forked from akai/readest
981579c255
* 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>
152 lines
3.9 KiB
TypeScript
152 lines
3.9 KiB
TypeScript
import { invoke, Channel } from '@tauri-apps/api/core';
|
|
|
|
export type UploadMethod = 'POST' | 'PUT';
|
|
|
|
export const enum UploadFileError {
|
|
Unauthorized = 'Unauthorized access',
|
|
DownloadFailed = 'File download failed',
|
|
}
|
|
|
|
export interface ProgressPayload {
|
|
progress: number;
|
|
total: number;
|
|
transferSpeed: number;
|
|
}
|
|
|
|
export type ProgressHandler = (progress: ProgressPayload) => void;
|
|
|
|
export const webUpload = (file: File, uploadUrl: string, onProgress?: ProgressHandler) => {
|
|
return new Promise<void>((resolve, reject) => {
|
|
const startTime = Date.now();
|
|
const xhr = new XMLHttpRequest();
|
|
xhr.open('PUT', uploadUrl, true);
|
|
|
|
xhr.upload.onprogress = (event) => {
|
|
if (onProgress && event.lengthComputable) {
|
|
onProgress({
|
|
progress: event.loaded,
|
|
total: event.total,
|
|
transferSpeed: event.loaded / ((Date.now() - startTime) / 1000),
|
|
});
|
|
}
|
|
};
|
|
|
|
xhr.onload = () => {
|
|
if (xhr.status >= 200 && xhr.status < 300) {
|
|
resolve();
|
|
} else {
|
|
reject(new Error(`Upload failed with status ${xhr.status}`));
|
|
}
|
|
};
|
|
|
|
xhr.onerror = () => reject(new Error('Upload failed'));
|
|
|
|
xhr.send(file);
|
|
});
|
|
};
|
|
|
|
export const webDownload = async (
|
|
downloadUrl: string,
|
|
onProgress?: ProgressHandler,
|
|
headers?: Record<string, string>,
|
|
) => {
|
|
const response = await fetch(downloadUrl, {
|
|
method: 'GET',
|
|
headers: headers ? headers : undefined,
|
|
});
|
|
if (!response.ok) {
|
|
if (response.status === 401 || response.status === 403) {
|
|
throw new Error(UploadFileError.Unauthorized);
|
|
}
|
|
throw new Error(UploadFileError.DownloadFailed);
|
|
}
|
|
|
|
const responseHeaders = Object.fromEntries(response.headers.entries());
|
|
const contentLength =
|
|
response.headers.get('Content-Length') || response.headers.get('X-Content-Length');
|
|
// R2/S3 signed URLs frequently don't expose Content-Length over CORS, so
|
|
// missing length is common in the wild. Fall back to indeterminate
|
|
// progress (total=0) instead of failing the download. UI callers already
|
|
// guard `total === 0` to skip percentage updates.
|
|
const totalSize = parseInt(contentLength || '0', 10);
|
|
let receivedSize = 0;
|
|
const reader = response.body!.getReader();
|
|
const chunks: Uint8Array[] = [];
|
|
|
|
const startTime = Date.now();
|
|
while (true) {
|
|
const { done, value } = await reader.read();
|
|
if (done) break;
|
|
|
|
chunks.push(value);
|
|
receivedSize += value.length;
|
|
|
|
if (onProgress) {
|
|
onProgress({
|
|
progress: receivedSize,
|
|
total: totalSize,
|
|
transferSpeed: receivedSize / ((Date.now() - startTime) / 1000),
|
|
});
|
|
}
|
|
}
|
|
|
|
return { headers: responseHeaders, blob: new Blob(chunks as BlobPart[]) };
|
|
};
|
|
|
|
export const tauriUpload = async (
|
|
url: string,
|
|
filePath: string,
|
|
method: UploadMethod,
|
|
progressHandler?: ProgressHandler,
|
|
headers?: Map<string, string>,
|
|
): Promise<string> => {
|
|
const ids = new Uint32Array(1);
|
|
window.crypto.getRandomValues(ids);
|
|
const id = ids[0];
|
|
|
|
const onProgress = new Channel<ProgressPayload>();
|
|
if (progressHandler) {
|
|
onProgress.onmessage = progressHandler;
|
|
}
|
|
|
|
return await invoke('upload_file', {
|
|
id,
|
|
url,
|
|
filePath,
|
|
method,
|
|
headers: headers ?? {},
|
|
onProgress,
|
|
});
|
|
};
|
|
|
|
export const tauriDownload = async (
|
|
url: string,
|
|
filePath: string,
|
|
progressHandler?: ProgressHandler,
|
|
headers?: Record<string, string>,
|
|
body?: string,
|
|
singleThreaded?: boolean,
|
|
skipSslVerification?: boolean,
|
|
): Promise<Record<string, string>> => {
|
|
const ids = new Uint32Array(1);
|
|
window.crypto.getRandomValues(ids);
|
|
const id = ids[0];
|
|
|
|
const onProgress = new Channel<ProgressPayload>();
|
|
if (progressHandler) {
|
|
onProgress.onmessage = progressHandler;
|
|
}
|
|
|
|
const responseHeaders = await invoke<Record<string, string>>('download_file', {
|
|
id,
|
|
url,
|
|
filePath,
|
|
headers: headers ?? {},
|
|
onProgress,
|
|
body,
|
|
singleThreaded,
|
|
skipSslVerification,
|
|
});
|
|
return responseHeaders;
|
|
};
|