a1279a65ce
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>
110 lines
3.4 KiB
TypeScript
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;
|
|
}
|