import { useEffect } from 'react'; import { useAuth } from '@/context/AuthContext'; import { useEnv } from '@/context/EnvContext'; import { useCustomDictionaryStore, findDictionaryByContentId } from '@/store/customDictionaryStore'; import { useCustomFontStore, findFontByContentId, migrateLegacyFonts, } from '@/store/customFontStore'; import { useCustomTextureStore, findTextureByContentId, migrateLegacyTextures, } from '@/store/customTextureStore'; import { useCustomOPDSStore, findOPDSCatalogByContentId } from '@/store/customOPDSStore'; import { transferManager } from '@/services/transferManager'; import { getReplicaSync, subscribeReplicaSyncReady } from '@/services/sync/replicaSync'; import { dictionaryAdapter } from '@/services/sync/adapters/dictionary'; import { fontAdapter } from '@/services/sync/adapters/font'; import { textureAdapter } from '@/services/sync/adapters/texture'; import { opdsCatalogAdapter } from '@/services/sync/adapters/opdsCatalog'; import { settingsAdapter, type SettingsRemoteRecord } from '@/services/sync/adapters/settings'; import { applyRemoteSettings, clearStoredEncryptedHashes, getStoredLastSeenCipher, publishSettingsIfChanged, } from '@/services/sync/replicaSettingsSync'; import { useSettingsStore } from '@/store/settingsStore'; import { queueReplicaBinaryUpload } from '@/services/sync/replicaBinaryUpload'; import { replicaPullAndApply, type PullAndApplyDeps, type ReplicaLocalRecord, } from '@/services/sync/replicaPullAndApply'; import type { ReplicaAdapter } from '@/services/sync/replicaRegistry'; import { getAccessToken } from '@/utils/access'; import { isSyncCategoryEnabled } from '@/services/sync/syncCategories'; import { uniqueId } from '@/utils/misc'; import type { EnvConfigType } from '@/services/environment'; import type { AppService, BaseDir } from '@/types/system'; import type { ReplicaSyncManager } from '@/services/sync/replicaSyncManager'; import type { ImportedDictionary } from '@/services/dictionaries/types'; import type { CustomFont } from '@/styles/fonts'; import type { CustomTexture } from '@/styles/textures'; import type { OPDSCatalog } from '@/types/opds'; import type { Hlc, ReplicaRow } from '@/types/replica'; import type { SystemSettings } from '@/types/settings'; export type ReplicaKind = 'dictionary' | 'font' | 'texture' | 'opds_catalog' | 'settings'; export interface UseReplicaPullOpts { /** Replica kinds this page wants pulled. */ kinds: readonly ReplicaKind[]; /** Delay before firing the pull. Defaults to 5s — keeps the boot * critical path clean and lets feature mounts hydrate first. */ delayMs?: number; } const REPLICA_PULL_DEFAULT_DELAY_MS = 5_000; /** Periodic incremental-pull cadence for long-lived foreground tabs. */ const REPLICA_PULL_PERIODIC_INTERVAL_MS = 5 * 60 * 1000; /** * Minimum gap between focus-triggered pulls. iOS Tauri WKWebView fires * `focus` twice during a single foreground transition (~8ms apart) and * `visibilitychange` an additional ~400ms later — the throttle * collapses that burst to one pull. Rapid alt-tab cycles on desktop * are handled by the same gate. `online` and the periodic timer * bypass this — they're independent signals. */ const REPLICA_PULL_FOREGROUND_THROTTLE_MS = 10_000; // Module-level dedup so navigating between pages (library → reader → …) // doesn't fire a fresh boot pull every time. Once a kind has had its // boot pull initiated (or completed) it stays in this set until the // page unloads. Subsequent re-syncs for the same kind go through the // incremental auto-pull path below. const pulledKinds = new Set(); // Per-kind in-flight gate covering BOTH boot and incremental pulls. // Prevents concurrent pulls (e.g. visibilitychange firing while the // boot pull is still resolving, or two triggers landing in the same // tick). const pullInFlight = new Set(); // Union of every kind any mount has ever asked us to keep in sync. // Drives the visibility / online / periodic-tick fan-out, which fires // for the lifetime of the tab regardless of which page is currently // mounted. const registeredKinds = new Set(); // Latest service + envConfig captured by a hook mount; the auto-sync // triggers read this so the listeners (installed once) don't capture // stale references when the env hot-reloads. let autoSyncContext: { service: AppService; envConfig: EnvConfigType } | null = null; let autoSyncListenersInstalled = false; let periodicTimer: ReturnType | null = null; let lastForegroundPullAt = 0; // Module-level mirror of `useAuth().user`. The auto-sync listeners run // at module scope and have no React access; they consult this flag to // short-circuit when there is no signed-in user (no point hitting // /api/sync — it would 401 anyway). Kept in sync by the hook. let hasCurrentUser = false; // Shared promise for the boot-time settings pull. All other kinds' // boot pulls await this so applyRemoteSettings has a chance to seed // `lastPublishedFields` with server-authoritative values before // dict/font/texture/opds_catalog auto-saves fire — without that // ordering, those auto-saves diff against `undefined` on a fresh // boot and republish the local default (e.g. // `dictionarySettings.providerOrder`) with a fresh HLC, clobbering // cross-device state under per-field LWW. let settingsBootPullPromise: Promise | null = null; /** * Per-kind config consumed by `buildReplicaPullDeps`. The factory fills * in everything that is structurally identical across kinds (creating * bundle dirs, queueing downloads, checking files exist, queueing * upload of the local copy, gating on auth). Per-kind logic stays * here: the adapter, base dir, find/hydrate/apply/soft-delete store * accessors. */ interface ReplicaPullConfig { kind: ReplicaKind; /** Required for binary-bearing kinds; omitted for metadata-only kinds. */ baseDir?: BaseDir; adapter: ReplicaAdapter; findByContentId: (id: string) => T | undefined; hydrateLocalStore?: (envConfig: EnvConfigType) => Promise; applyRemote: (record: T) => void; softDeleteByContentId: (id: string) => void; /** Forwarded to PullAndApplyDeps; see that field for semantics. */ silentDecrypt?: boolean; /** Forwarded to PullAndApplyDeps; see that field for semantics. */ onSaltNotFound?: (paths: readonly string[]) => void; } /** * Build the deps the orchestrator hands to `replicaPullAndApply`. * * - Boot path: omits `pullOverride`, so `pull` calls `manager.pull(kind)` * per kind (network round-trip per kind). The boot path also keeps * the `isAuthenticated` gate, which doubles as the per-kind sync- * category gate. * - Incremental path: passes a `pullOverride` returning rows the * batched `manager.pullMany` already fetched. `isAuthenticated` is * skipped because the orchestrator pre-filtered by * `isSyncCategoryEnabled` before issuing the batched request. */ const buildReplicaPullDeps = ( manager: ReplicaSyncManager, service: AppService, envConfig: EnvConfigType, config: ReplicaPullConfig, pullOpts?: { since?: Hlc | null }, pullOverride?: () => Promise, ): PullAndApplyDeps => ({ adapter: config.adapter, // Boot path passes { since: null } so we always re-fetch and apply // locally, ignoring any previously-advanced cursor. The incremental // path passes pullOverride so a single batched `manager.pullMany` // result is fanned out to per-kind apply without re-hitting the wire. pull: pullOverride ?? (() => manager.pull(config.kind, pullOpts)), findByContentId: config.findByContentId, hydrateLocalStore: config.hydrateLocalStore ? () => config.hydrateLocalStore!(envConfig) : undefined, applyRemote: config.applyRemote, softDeleteByContentId: config.softDeleteByContentId, silentDecrypt: config.silentDecrypt, onSaltNotFound: config.onSaltNotFound, // The bundle / binary callbacks below are only reached when the // adapter declares a `binary` capability — replicaPullAndApply // short-circuits metadata-only kinds before invoking them. The // non-null assertion on baseDir is therefore safe in the binary // path; metadata-only kinds (opds_catalog) leave config.baseDir // unset and never hit these. createBundleDir: async () => { const id = uniqueId(); await service.createDir(id, config.baseDir!, true); return id; }, queueReplicaDownload: (contentId, displayTitle, files, _bundleDir, base) => transferManager.queueReplicaDownload(config.kind, contentId, displayTitle, files, base), filesExist: async (bundleDir, filenames) => { for (const filename of filenames) { const exists = await service.exists(`${bundleDir}/${filename}`, config.baseDir!); if (!exists) return false; } return true; }, queueLocalBinaryUpload: async (record) => { await queueReplicaBinaryUpload(config.kind, record, service); }, // The pull skips when this resolves false. We piggyback the // user-facing category gate here so disabling a kind in // `User → Manage Sync` no-ops the pull (no HTTP, no warnings) // alongside the auth precheck — same effect, half the wiring. // Skipped on the incremental path: the orchestrator pre-filters // by category before dispatching the batched request, so this // would just re-check what's already been gated. isAuthenticated: pullOverride ? undefined : async () => { if (!isSyncCategoryEnabled(config.kind)) return false; return !!(await getAccessToken()); }, }); const dictionaryPullConfig: ReplicaPullConfig = { kind: 'dictionary', baseDir: 'Dictionaries', adapter: dictionaryAdapter, // Page may mount before loadCustomDictionaries has hydrated the // in-memory store, so the dedup helper falls back to settings. findByContentId: findDictionaryByContentId, // Pull-side relies on the in-memory dict store reflecting persisted // state — without this, the auto-persist fired by applyRemoteDictionary // would write back only the just-applied rows and clobber every // persisted dict that hadn't been hydrated by an Annotator/Settings // mount. Library-page refreshes were the visible victim. hydrateLocalStore: (envConfig) => useCustomDictionaryStore.getState().loadCustomDictionaries(envConfig), applyRemote: (dict) => useCustomDictionaryStore.getState().applyRemoteDictionary(dict), softDeleteByContentId: (id) => useCustomDictionaryStore.getState().softDeleteByContentId(id), }; const fontPullConfig: ReplicaPullConfig = { kind: 'font', baseDir: 'Fonts', adapter: fontAdapter, findByContentId: findFontByContentId, hydrateLocalStore: async (envConfig) => { await useCustomFontStore.getState().loadCustomFonts(envConfig); // Rehash legacy flat-path fonts so the user doesn't have to // re-import them by hand to get them onto other devices. await migrateLegacyFonts(envConfig); }, applyRemote: (font) => useCustomFontStore.getState().applyRemoteFont(font), softDeleteByContentId: (id) => useCustomFontStore.getState().softDeleteByContentId(id), }; const texturePullConfig: ReplicaPullConfig = { kind: 'texture', baseDir: 'Images', adapter: textureAdapter, findByContentId: findTextureByContentId, hydrateLocalStore: async (envConfig) => { await useCustomTextureStore.getState().loadCustomTextures(envConfig); // Rehash legacy flat-path textures so the user doesn't have to // re-import them by hand to get them onto other devices. await migrateLegacyTextures(envConfig); }, applyRemote: (texture) => useCustomTextureStore.getState().applyRemoteTexture(texture), softDeleteByContentId: (id) => useCustomTextureStore.getState().softDeleteByContentId(id), }; const opdsCatalogPullConfig: ReplicaPullConfig = { kind: 'opds_catalog', // metadata-only — no baseDir adapter: opdsCatalogAdapter, findByContentId: findOPDSCatalogByContentId, hydrateLocalStore: (envConfig) => useCustomOPDSStore.getState().loadCustomOPDSCatalogs(envConfig), applyRemote: (catalog) => useCustomOPDSStore.getState().applyRemoteCatalog(catalog), softDeleteByContentId: (id) => useCustomOPDSStore.getState().softDeleteByContentId(id), }; const settingsPullConfig = (envConfig: EnvConfigType): ReplicaPullConfig => ({ kind: 'settings', // metadata-only — no baseDir adapter: settingsAdapter, // Synthesize a "local" record carrying the persisted cipher // fingerprint so the orchestrator's cipher-fingerprint comparison // works for settings the same way it does for OPDS: // * fingerprint matches → skip prompt (already-decrypted ciphers // unchanged); no spam on refresh // * fingerprint differs (rotation / fresh device / new device A // just set credentials) → prompt fires for the user to enter // the passphrase // The empty patch is fine: applyRow re-applies metadata-only kinds // unconditionally for the actual data. findByContentId: () => ({ name: 'singleton' as const, patch: {} as Partial, lastSeenCipher: getStoredLastSeenCipher(), }), applyRemote: (record) => applyRemoteSettings(envConfig, record), // Settings is a singleton — never tombstoned. The server-side // forget-passphrase wipe doesn't touch this row. softDeleteByContentId: () => {}, // Auto-recovery for the orphan-cipher case: clear the persisted // "already-published" hash so the next save re-encrypts under the // current salt and overwrites the orphan. Then trigger an // immediate re-publish so the user doesn't have to touch settings // before the server heals itself. onSaltNotFound: (paths) => { clearStoredEncryptedHashes(paths); const settings = useSettingsStore.getState().settings; if (settings) void publishSettingsIfChanged(settings); }, }); /** * Per-kind dispatch for both the boot pull (one HTTP per kind) and the * incremental apply (rows already fetched in a batch). Keeping the * switch keeps the generic record type sound — collapsing the configs * into a Record> would force a * contravariant cast. */ const runPullForKind = async ( kind: ReplicaKind, service: AppService, envConfig: EnvConfigType, pullOpts?: { since?: Hlc | null }, pullOverride?: () => Promise, ): Promise => { const ctx = getReplicaSync(); if (!ctx) return; switch (kind) { case 'dictionary': await replicaPullAndApply( buildReplicaPullDeps( ctx.manager, service, envConfig, dictionaryPullConfig, pullOpts, pullOverride, ), ); return; case 'font': await replicaPullAndApply( buildReplicaPullDeps( ctx.manager, service, envConfig, fontPullConfig, pullOpts, pullOverride, ), ); return; case 'texture': await replicaPullAndApply( buildReplicaPullDeps( ctx.manager, service, envConfig, texturePullConfig, pullOpts, pullOverride, ), ); return; case 'opds_catalog': await replicaPullAndApply( buildReplicaPullDeps( ctx.manager, service, envConfig, opdsCatalogPullConfig, pullOpts, pullOverride, ), ); return; case 'settings': await replicaPullAndApply( buildReplicaPullDeps( ctx.manager, service, envConfig, settingsPullConfig(envConfig), pullOpts, pullOverride, ), ); return; } }; /** * Cursor-based incremental pull. One batched HTTP round-trip for every * registered kind that has had its boot pull initiated and whose sync * category is enabled. Concurrent triggers (focus + online firing in * the same tick, periodic timer racing with a focus event) collapse * via the per-kind `pullInFlight` set: if any of the kinds we'd batch * is already in flight, we skip the redundant call entirely. */ const triggerIncrementalPullAll = (): void => { if (!hasCurrentUser) return; if (!autoSyncContext) return; const ctx = getReplicaSync(); if (!ctx) return; const { service, envConfig } = autoSyncContext; const kindsToPull: ReplicaKind[] = []; for (const kind of registeredKinds) { // Skip until the kind's boot pull has at least been initiated. Boot // does the full re-fetch (since=null); incremental builds on whatever // cursor that pull advanced. if (!pulledKinds.has(kind)) continue; if (pullInFlight.has(kind)) continue; if (!isSyncCategoryEnabled(kind)) continue; kindsToPull.push(kind); } if (kindsToPull.length === 0) return; for (const kind of kindsToPull) pullInFlight.add(kind); void (async () => { try { // ONE network round-trip for every eligible kind. Per-kind apply // runs in parallel below — adapters are independent, and one // kind's apply error doesn't poison the others. let rowsByKind: Map; try { rowsByKind = await ctx.manager.pullMany(kindsToPull); } catch (err) { console.warn('replica batch pull failed', err); return; } await Promise.allSettled( kindsToPull.map(async (kind) => { const rows = rowsByKind.get(kind) ?? []; try { await runPullForKind(kind, service, envConfig, undefined, async () => rows); } catch (err) { console.warn(`replica ${kind} incremental apply failed`, err); } }), ); } finally { for (const kind of kindsToPull) pullInFlight.delete(kind); } })(); }; // Two foreground signals, sharing one throttle: // Listening to both with one throttle catches every transition without // double-pulling. Some debug logs are kept on purpose: foreground-sync // regressions have historically been hard to reproduce. const onForegroundReturn = (source: 'focus' | 'visibilitychange'): void => { // Visibility events fire both directions; only act on the visible side. if (source === 'visibilitychange' && typeof document !== 'undefined') { if (document.visibilityState !== 'visible') return; } const now = Date.now(); if (now - lastForegroundPullAt < REPLICA_PULL_FOREGROUND_THROTTLE_MS) return; lastForegroundPullAt = now; triggerIncrementalPullAll(); }; const onFocus = (): void => onForegroundReturn('focus'); const onVisibilityChange = (): void => onForegroundReturn('visibilitychange'); const onOnline = (): void => { triggerIncrementalPullAll(); }; const onPeriodicTick = (): void => { // Don't burn battery on a backgrounded tab — the focus listener // will fire a catch-up pull when the window returns to foreground. if (typeof document !== 'undefined' && document.visibilityState === 'hidden') return; triggerIncrementalPullAll(); }; /** * Idempotent settings boot pull. First caller starts the pull; every * subsequent caller (other kinds awaiting the seed, additional mounts) * receives the same promise. Settings is also added to * `registeredKinds` so the auto-pull triggers fan it out alongside the * caller's requested kinds. */ const ensureSettingsBootPulled = (service: AppService, envConfig: EnvConfigType): Promise => { if (settingsBootPullPromise) return settingsBootPullPromise; registeredKinds.add('settings'); pulledKinds.add('settings'); pullInFlight.add('settings'); settingsBootPullPromise = runPullForKind('settings', service, envConfig, { since: null }) .catch((err) => { console.warn('replica settings pull failed', err); pulledKinds.delete('settings'); }) .finally(() => { pullInFlight.delete('settings'); }); return settingsBootPullPromise; }; /** * Install document/window listeners + periodic interval for incremental * pulls. Idempotent — first call wires everything, subsequent calls * just refresh `autoSyncContext` (so listeners always see the latest * appService / envConfig). Listeners stay attached for the lifetime of * the page; in production this runs exactly once. */ const installAutoSyncListeners = (service: AppService, envConfig: EnvConfigType): void => { autoSyncContext = { service, envConfig }; if (autoSyncListenersInstalled) return; autoSyncListenersInstalled = true; if (typeof document !== 'undefined') { document.addEventListener('visibilitychange', onVisibilityChange); } if (typeof window !== 'undefined') { window.addEventListener('focus', onFocus); window.addEventListener('online', onOnline); } periodicTimer = setInterval(onPeriodicTick, REPLICA_PULL_PERIODIC_INTERVAL_MS); }; /** * Schedules a deferred replica pull for the requested kinds. Mount this * on a page that wants those kinds present (library, reader, etc.). * * Two pull lifecycles share this hook: * * 1. Boot pull — one-shot per session (dedup'd by `pulledKinds`), * fired `delayMs` after first mount. Uses `since=null` for a full * re-fetch that recovers from any prior cursor-advanced-but-not- * applied gaps. The `settings` kind is implicitly pulled FIRST * (regardless of whether the caller requested it) so the rest of * the kinds' auto-saves don't republish stale local providerOrder / * providerEnabled with a fresh HLC. * 2. Incremental auto-pull — runs for the lifetime of the tab via * module-level visibility / online listeners + a 5-minute interval. * Uses the persisted cursor for cheap delta fetches. Concurrent * triggers collapse via `pullInFlight`. * * Listeners install once on the first mount that has a ready replica- * sync singleton, and stay attached after subsequent unmounts so a * long-lived tab keeps catching up across other devices' edits. */ export const useReplicaPull = ({ kinds, delayMs = REPLICA_PULL_DEFAULT_DELAY_MS, }: UseReplicaPullOpts): void => { const { envConfig, appService } = useEnv(); const { user } = useAuth(); // Stable cache key so the effect doesn't re-run when the caller // passes a freshly-allocated array literal each render. const kindsKey = kinds.join(','); // Mirror the React-side auth state into the module-level flag the // auto-sync listeners read. Kept in its own effect so a user ref // change (Supabase TOKEN_REFRESHED reissues the user object on // foreground / refresh) doesn't tear down the boot-pull effect. useEffect(() => { hasCurrentUser = !!user; }, [user]); useEffect(() => { if (!appService) return; if (!user) return; for (const kind of kinds) registeredKinds.add(kind); let timer: ReturnType | null = null; let unsubscribe: (() => void) | null = null; const schedule = () => { installAutoSyncListeners(appService, envConfig); if (timer) return; // Settings is implicitly pulled first regardless of whether the // caller asked for it (e.g. the reader page only requests // dictionary/font/texture). Without that seeding, applyRemote // for a binary kind on a fresh device appends the new id to the // local default `dictionarySettings.providerOrder` and the // ensuing auto-save publishes that order with a fresh HLC, // overwriting the cross-device order set on another device. const otherPending = kinds.filter((k) => k !== 'settings' && !pulledKinds.has(k)); const needsSettings = !pulledKinds.has('settings'); if (otherPending.length === 0 && !needsSettings) return; timer = setTimeout(() => { void (async () => { // Await the settings boot pull before dispatching the others. // Subsequent mounts share `settingsBootPullPromise` so the // pull only happens once per session. await ensureSettingsBootPulled(appService, envConfig); // Boot path skips disabled kinds: enabling a category later // re-fires `triggerIncrementalPullAll` from a focus event, // which will fetch the missed rows. This keeps boot bandwidth // proportional to what the user actually sync's. const eligible = otherPending.filter( (k) => !pulledKinds.has(k) && isSyncCategoryEnabled(k), ); if (eligible.length === 0) return; // Claim both slots up front so a concurrently-scheduled mount // (e.g., library + reader mounting back-to-back) doesn't // double-pull, and so a focus / online trigger landing // mid-boot doesn't fire a second pull alongside this one. // On failure we release `pulledKinds` so a subsequent // navigation can retry; `pullInFlight` is always cleared. for (const kind of eligible) { pulledKinds.add(kind); pullInFlight.add(kind); } // ONE batched HTTP round-trip for all non-settings kinds at // boot, with `since=null` so each kind does a full re-fetch // (mirrors the old per-kind `runPullForKind(kind, …, {since: null})` // semantics). Per-kind apply runs in parallel afterwards. const ctx = getReplicaSync(); if (!ctx) { for (const kind of eligible) { pulledKinds.delete(kind); pullInFlight.delete(kind); } return; } let rowsByKind: Map; try { rowsByKind = await ctx.manager.pullMany(eligible, { since: null }); } catch (err) { console.warn('replica boot batch pull failed', err); for (const kind of eligible) { pulledKinds.delete(kind); pullInFlight.delete(kind); } return; } await Promise.allSettled( eligible.map(async (kind) => { try { const rows = rowsByKind.get(kind) ?? []; await runPullForKind(kind, appService, envConfig, undefined, async () => rows); } catch (err) { console.warn(`replica ${kind} boot apply failed`, err); pulledKinds.delete(kind); } finally { pullInFlight.delete(kind); } }), ); })(); }, delayMs); }; if (getReplicaSync()) { schedule(); } else { // Hard-refresh race: appService resolved before // EnvContext.initReplicaSync finished (loadSettings is async, // setAppService runs first). Wait for the ready signal so the // pull still fires once the singleton lands. unsubscribe = subscribeReplicaSyncReady(schedule); } return () => { if (timer) clearTimeout(timer); if (unsubscribe) unsubscribe(); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [kindsKey, appService, envConfig, delayMs, user]); }; /** Test seam — clear all module-level state and tear down listeners. */ export const __resetReplicaPullForTests = (): void => { pulledKinds.clear(); pullInFlight.clear(); registeredKinds.clear(); autoSyncContext = null; if (typeof document !== 'undefined') { document.removeEventListener('visibilitychange', onVisibilityChange); } if (typeof window !== 'undefined') { window.removeEventListener('focus', onFocus); window.removeEventListener('online', onOnline); } if (periodicTimer) { clearInterval(periodicTimer); periodicTimer = null; } autoSyncListenersInstalled = false; lastForegroundPullAt = 0; settingsBootPullPromise = null; hasCurrentUser = false; };