Files
readest/apps/readest-app/src/services/send/conversion/conversionWorker.ts
T
Huang Xin a1279a65ce feat(send): clip web URLs into self-contained EPUBs via Tauri webview (#4241)
Builds the URL-clipping path of the "Send to Readest" feature: paste a
link, the renderer ingests the rendered page, and a self-contained EPUB
lands in the library. No server proxy, no external CDN refs left in the
EPUB once it's saved.

Architecture

- New Rust `clip_url` command spawns a hidden Tauri WebviewWindow at the
  target URL with a real Chrome UA + WebKit fingerprint mask, so TLS-
  fingerprint and JS-challenge walls (Cloudflare, Medium, X, WeChat MP)
  resolve naturally instead of bouncing the server proxy.
- Capture transport is URL-payload navigation to a one-shot
  127.0.0.1:RANDOM_PORT/clip/{token}?d={url-safe-base64} listener.
  Top-level navigation isn't governed by CSP connect-src / form-action /
  WebKit Private Network Access — the four earlier transports
  (fetch, <form>, custom URI scheme, window.name) were each blocked by
  one of those.
- Page-to-EPUB bundler (`assetBundler`) walks <img>/<picture> with
  src → data-src → data-original → data-srcset → srcset fallback so lazy-
  loading sites don't ship a 60px LQIP; fetches assets in parallel with a
  per-asset timeout + per-asset/total caps; failed images degrade to alt-
  text placeholders. A per-site rules table (seeded with WeChat MP) + a
  selector fallback catches articles Readability misextracts. Builder
  prepends the article <h1> + byline so the EPUB has a proper opening.
- Nested EPUB TOC built from h1–h6.

UI surfaces

- "From Web URL" entry in the library Import menu, gated to Tauri; web
  build hides the URL field and points at the browser extension.
- `ImportFromUrlDialog` with auto-height (overrides Dialog's `sm:h-[65%]`
  default) and a dim placeholder for the URL field.
- Clip webview window styled to match Readest's main window — macOS
  decorations + overlay title bar; other desktops decorationless with a
  drop shadow; native background + in-page loading overlay pick up the
  caller's `themeCode.bg`/`fg` so light/dark/eink/custom themes all
  render correctly. Title localised, all five overlay/title strings
  translated across 33 locales.

Notes

- Gates the macOS traffic-light positioner to main/reader-* windows so
  the decorationless clip window no longer null-derefs in
  `position_traffic_lights`.
- Stricter validation across the path: schemes restricted to http/https,
  hex-color parsing rejects malformed values, server endpoint returns
  400 on missing/invalid base64.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 19:48:09 +02:00

110 lines
3.4 KiB
TypeScript

import { convertToEpub, type ConvertInput } from './convertToEpub';
import type { ConvertedBook } from './types';
import type {
ConversionWorkerRequest,
ConversionWorkerResponse,
} from './conversion-worker-protocol';
const DEFAULT_TIMEOUT_MS = 120_000;
async function convertInWorker(input: ConvertInput, timeoutMs: number): Promise<ConvertedBook> {
return await new Promise<ConvertedBook>((resolve, reject) => {
const worker = new Worker(
new URL('../../../workers/send-conversion.worker.ts', import.meta.url),
{
type: 'module',
},
);
const cleanup = () => {
worker.onmessage = null;
worker.onerror = null;
worker.onmessageerror = null;
worker.terminate();
};
const timer = setTimeout(() => {
cleanup();
reject(new Error(`Conversion worker timed out after ${timeoutMs}ms`));
}, timeoutMs);
worker.onmessage = (event: MessageEvent<ConversionWorkerResponse>) => {
clearTimeout(timer);
if (event.data.type === 'error') {
cleanup();
reject(new Error(event.data.payload.message));
return;
}
const { epubBuffer, name, title, author } = event.data.payload;
cleanup();
resolve({
file: new File([epubBuffer], name, { type: 'application/epub+zip' }),
title,
author,
});
};
worker.onerror = () => {
clearTimeout(timer);
cleanup();
reject(new Error('Conversion worker failed'));
};
worker.onmessageerror = () => {
clearTimeout(timer);
cleanup();
reject(new Error('Conversion worker message deserialization failed'));
};
const request: ConversionWorkerRequest = { type: 'convert', payload: input };
worker.postMessage(request);
});
}
/**
* Convert a document to EPUB off the main thread, falling back to in-thread
* conversion when Web Workers are unavailable or the worker fails.
*
* `kind: 'page'` is the exception — its asset bundler uses the
* `@tauri-apps/plugin-http` fetch to bypass CORS, and that binding lives on
* `window.__TAURI_INTERNALS__` which a Web Worker can't reach. So page
* clips always run on the main thread.
*/
export async function convertToEpubWithWorker(
input: ConvertInput,
timeoutMs: number = DEFAULT_TIMEOUT_MS,
): Promise<ConvertedBook> {
if (input.kind === 'page' || typeof Worker === 'undefined') {
return convertToEpub(input);
}
try {
return await convertInWorker(input, timeoutMs);
} catch (error) {
console.warn('Conversion worker failed, falling back to main thread:', error);
return convertToEpub(input);
}
}
const CONVERTIBLE_EXT: Record<string, 'docx' | 'rtf' | 'html' | 'txt'> = {
docx: 'docx',
rtf: 'rtf',
html: 'html',
htm: 'html',
txt: 'txt',
};
/**
* If a file is a document Readest can't read natively, convert it to EPUB;
* otherwise return it unchanged. Shared by the `/send` page and the inbox
* drainer so both channels convert identically.
*/
export async function convertFileIfNeeded(file: File): Promise<File> {
const ext = file.name.includes('.') ? file.name.split('.').pop()!.toLowerCase() : '';
const kind = CONVERTIBLE_EXT[ext];
if (!kind) return file;
if (kind === 'txt') {
return (await convertToEpubWithWorker({ kind: 'txt', file })).file;
}
const bytes = await file.arrayBuffer();
return (await convertToEpubWithWorker({ kind, bytes, fileName: file.name })).file;
}