Files
readest/apps/readest-app/src/services/sync/replicaTransferIntegration.ts
T
Huang Xin 981579c255 feat(sync): cross-device custom font sync (#4077)
* refactor(sync): kind-agnostic replica primitives

Extract dict-only sync into shared primitives (registry, pull/apply
orchestrator, persist env, schema allowlist) so other kinds can plug
in. Companion changes: per-replica Storage Manager grouping,
useReplicaPull boot-race recovery, manifest=null reconciliation on
every boot pull, copyFile takes explicit srcBase + dstBase, settled-
event helpers, lenient webDownload Content-Length (R2/S3 signed URLs
commonly omit it), and generic "File" transfer toast copy any replica
kind can share.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(sync): cross-device custom font sync

Plug the font replica adapter into the kind-agnostic primitives:
font store gains replica wiring, custom font import publishes the
replica row + queues a binary upload, and bootstrap registers the
font adapter and download-complete handler.

Includes legacy flat-path migration so pre-existing fonts sync
without re-import, full @font-face activation on auto-download
(load + mount the rule, mirroring manual import), and a fix to
createCustomFont so contentId / bundleDir / byteSize survive the
trip through addFont — otherwise import-time publish silently
no-oped on missing contentId.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 08:28:44 +02:00

123 lines
4.3 KiB
TypeScript

import { eventDispatcher } from '@/utils/event';
import { partialMD5 } from '@/utils/md5';
import { getReplicaAdapter } from './replicaRegistry';
import { publishReplicaManifest } from './replicaPublish';
import type { AppService } from '@/types/system';
import type { ClosableFile } from '@/utils/file';
import type { ReplicaTransferFile } from '@/store/transferStore';
interface ReplicaTransferCompleteDetail {
kind: string;
replicaId: string;
reincarnation?: string;
type: 'upload' | 'download' | 'delete';
files?: ReplicaTransferFile[];
filenames?: string[];
}
type DownloadHandler = (replicaId: string) => void;
const downloadHandlers = new Map<string, DownloadHandler>();
/**
* Per-kind download-completion handler. Called when binary downloads
* for a replica row finish landing on disk. Each store that participates
* in replica sync registers one; the dictionary store clears its
* `unavailable` flag, the font store will activate its FontFace, etc.
*
* Calling twice with the same kind throws — defensive against doubly-
* imported store modules during dev hot-reload.
*/
export const registerReplicaDownloadHandler = (kind: string, handler: DownloadHandler): void => {
if (downloadHandlers.has(kind)) {
throw new Error(`Replica download handler for kind="${kind}" is already registered`);
}
downloadHandlers.set(kind, handler);
};
let started = false;
let appServiceRef: AppService | null = null;
let listener: ((event: CustomEvent) => Promise<void>) | null = null;
const handleReplicaUpload = async (detail: ReplicaTransferCompleteDetail): Promise<void> => {
if (!detail.files || detail.files.length === 0) return;
if (!appServiceRef) return;
const adapter = getReplicaAdapter(detail.kind);
if (!adapter?.binary) return;
const base = adapter.binary.localBaseDir;
try {
const manifestFiles = await Promise.all(
detail.files.map(async (f) => {
const file = await appServiceRef!.openFile(f.lfp, base);
const partialMd5 = await partialMD5(file);
const closable = file as ClosableFile;
if (closable && closable.close) await closable.close();
return { filename: f.logical, byteSize: f.byteSize, partialMd5 };
}),
);
await publishReplicaManifest(
detail.kind,
detail.replicaId,
manifestFiles,
detail.reincarnation,
);
} catch (err) {
console.warn('replica-transfer-complete upload handler failed', err);
}
};
const handleReplicaDownload = (detail: ReplicaTransferCompleteDetail): void => {
// Per-kind handler clears the `unavailable` placeholder flag now that
// binaries are on disk. Stores register at boot via
// registerReplicaDownloadHandler.
const handler = downloadHandlers.get(detail.kind);
if (!handler) return;
try {
handler(detail.replicaId);
} catch (err) {
console.warn('replica-transfer-complete download handler failed', err);
}
};
const handleReplicaTransferComplete = async (event: CustomEvent): Promise<void> => {
const detail = event.detail as ReplicaTransferCompleteDetail | undefined;
if (!detail) return;
if (!getReplicaAdapter(detail.kind)) return;
if (detail.type === 'upload') {
await handleReplicaUpload(detail);
} else if (detail.type === 'download') {
handleReplicaDownload(detail);
}
// 'delete' is fire-and-forget; no follow-up needed.
};
/**
* Wires the long-lived `replica-transfer-complete` listener that turns
* a finished binary upload into a manifest commit (per the upload state
* machine: binaries first, manifest LAST). Called once from EnvContext
* after appService boots; idempotent.
*
* Callers that subsequently sign in / out shouldn't re-call this — the
* listener doesn't need to know auth state, and publishReplicaManifest
* already gates on the user being authenticated.
*/
export const startReplicaTransferIntegration = (appService: AppService): void => {
appServiceRef = appService;
if (started) return;
started = true;
listener = handleReplicaTransferComplete;
eventDispatcher.on('replica-transfer-complete', listener);
};
export const __resetReplicaTransferIntegrationForTests = (): void => {
if (listener) {
eventDispatcher.off('replica-transfer-complete', listener);
listener = null;
}
started = false;
appServiceRef = null;
downloadHandlers.clear();
};