From d4b9ee5cabc9580b79f485a3fd1e178f0d2f24c8 Mon Sep 17 00:00:00 2001 From: k6G52m4Dz75W <74605402+k6G52m4Dz75W@users.noreply.github.com> Date: Wed, 9 Sep 2026 22:00:30 +0800 Subject: [PATCH 01/22] feat(settings): show web engine and version on the About page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reader features vary per engine (@layer/:has floors, execCommand, clipboard behavior), so bug reports need the engine name and build, not just the app version. Add a Web-engine row under the app version in Settings → About, parsed from the UA per Tauri platform: - Windows: WebView2 (Edg/ token = real Evergreen build) - Android: system WebView (Chrome/ token, ; wv) marker) - macOS/iOS: WebKit via the Version/ token (follows the system release, unlike readest's frozen AppleWebKit/605.1.15 parse) - Linux: WebKitGTK without a version — the UA carries only frozen tokens; the real one is the system libwebkit2gtk package - plain vite dev in a browser: generic Chrome/Edge/Firefox/Safari labels Labels localized for all 7 locales (settings.webviewEngine). --- .../src/components/settings/AboutSettings.tsx | 7 ++ packages/app/src/lib/webview-info.ts | 87 +++++++++++++++++++ .../core/src/i18n/locales/en/settings.json | 1 + .../core/src/i18n/locales/es/settings.json | 1 + .../core/src/i18n/locales/fr/settings.json | 1 + .../core/src/i18n/locales/ja/settings.json | 1 + .../core/src/i18n/locales/ko/settings.json | 1 + .../core/src/i18n/locales/zh-TW/settings.json | 1 + .../core/src/i18n/locales/zh/settings.json | 1 + 9 files changed, 101 insertions(+) create mode 100644 packages/app/src/lib/webview-info.ts diff --git a/packages/app/src/components/settings/AboutSettings.tsx b/packages/app/src/components/settings/AboutSettings.tsx index 8407de6e2..fd2d4268e 100644 --- a/packages/app/src/components/settings/AboutSettings.tsx +++ b/packages/app/src/components/settings/AboutSettings.tsx @@ -18,6 +18,7 @@ import { resetStatus, subscribeToUpdates, } from "@/lib/updater"; +import { formatWebviewInfo } from "@/lib/webview-info"; import { getVersion } from "@tauri-apps/api/app"; import { AlertCircle, @@ -37,6 +38,8 @@ import { import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; +const WEBVIEW_LABEL = formatWebviewInfo(); + const TECH_STACK = [ { name: "Tauri", descKey: "settings.techStackTauri", icon: Shield }, { name: "React", descKey: "settings.techStackReact", icon: Code2 }, @@ -147,6 +150,10 @@ export function AboutSettings() { +
+ {t("settings.webviewEngine")} + {WEBVIEW_LABEL || "—"} +
{/* Download Progress */} diff --git a/packages/app/src/lib/webview-info.ts b/packages/app/src/lib/webview-info.ts new file mode 100644 index 000000000..f109ae254 --- /dev/null +++ b/packages/app/src/lib/webview-info.ts @@ -0,0 +1,87 @@ +/** + * Detect the web engine (and its version) the app is running in, for display + * in Settings → About. This mirrors the engine axis our reader features vary + * on (@layer/:has/execCommand/clipboard all behave differently per engine), + * so bug reports can name the engine instead of "it doesn't work". + * + * Version floors differ per engine (e.g. :has() needs WebView2 ≥ 105 / + * WebKitGTK ≥ 2.36), which is exactly why the exact build matters. + * + * Detection is user-agent based. UA strings are not a security boundary here — + * this is display-only diagnostics. + * + * UA reference per platform (Tauri v2): + * - Windows (WebView2): `... Windows NT 10.0 ... AppleWebKit/537.36 ... Chrome/138.0.0.0 Safari/537.36 Edg/138.0.3351.65` + * - macOS (WKWebView): `... Macintosh ... AppleWebKit/605.1.15 ... Version/17.4 Safari/605.1.15` + * - iOS (WKWebView): `... iPhone ... Version/17.4 Mobile/15E148 Safari/604.1` + * - Android (WebView): `... Android 14; ...; wv) ... Chrome/138.0.0.0 ... Version/4.0 ...` + * - Linux (WebKitGTK): `... X11; Linux x86_64 ... AppleWebKit/605.1.15 ...` + * + * Note the frozen `605.1.15` on WebKit builds: the AppleWebKit token does NOT + * track the real WebKit version there, so Linux falls back to a versionless + * label (the real version lives in the system package, not the UA). + */ + +export interface WebviewInfo { + /** Engine/brand name, e.g. "WebView2", "WebKit", "Android WebView". */ + engine: string; + /** Full version string, or "" when the UA cannot provide a reliable one. */ + version: string; +} + +const match = (ua: string, pattern: RegExp): string => pattern.exec(ua)?.[1] ?? ""; + +/** True when running inside a Tauri webview (vs. plain `vite` dev in a browser). */ +export function isTauriRuntime(): boolean { + return typeof window !== "undefined" && "__TAURI_INTERNALS__" in window; +} + +export function getWebviewInfo(ua: string = navigator.userAgent): WebviewInfo { + const inTauri = isTauriRuntime(); + + // ── Tauri desktop/mobile shells ───────────────────────────────────────── + if (inTauri) { + // Windows: WebView2 is Edge-based; `Edg/` carries the real runtime version. + if (/Windows NT/.test(ua) && /Edg\//.test(ua)) { + return { engine: "WebView2", version: match(ua, /Edg\/([0-9.]+)/) }; + } + // Android: the system WebView identifies as Chrome with the `; wv)` token. + if (/Android/.test(ua) && /;\s*wv\)/.test(ua)) { + return { engine: "Android WebView", version: match(ua, /Chrome\/([0-9.]+)/) }; + } + // iOS WKWebView: `Version/` tracks the system WebKit (unlike macOS' frozen + // AppleWebKit token) — e.g. Version/17.4. + if (/iPhone|iPad|iPod/.test(ua)) { + const version = match(ua, /Version\/([0-9.]+)/); + return { engine: "WebKit", version }; + } + // macOS WKWebView: `Version/` follows the system WebKit release + // (e.g. Version/17.4); the AppleWebKit token is frozen at 605.1.15. + if (/Macintosh/.test(ua)) { + return { engine: "WebKit", version: match(ua, /Version\/([0-9.]+)/) }; + } + // Linux WebKitGTK: the UA carries no reliable version (frozen tokens), so + // report the engine without one — the real version is the system's + // libwebkit2gtk package. + if (/Linux|X11/.test(ua)) { + return { engine: "WebKitGTK", version: "" }; + } + } + + // ── Generic browsers (plain `vite` dev in a desktop browser) ──────────── + if (/Edg\//.test(ua)) return { engine: "Edge", version: match(ua, /Edg\/([0-9.]+)/) }; + if (/OPR\//.test(ua)) return { engine: "Opera", version: match(ua, /OPR\/([0-9.]+)/) }; + if (/Firefox\//.test(ua)) return { engine: "Firefox", version: match(ua, /Firefox\/([0-9.]+)/) }; + if (/CriOS\//.test(ua)) return { engine: "Chrome iOS", version: match(ua, /CriOS\/([0-9.]+)/) }; + if (/Chrome\//.test(ua)) return { engine: "Chrome", version: match(ua, /Chrome\/([0-9.]+)/) }; + if (/Safari\//.test(ua)) { + return { engine: "Safari", version: match(ua, /Version\/([0-9.]+)/) }; + } + return { engine: "", version: "" }; +} + +/** "WebView2 138.0.3351.65" / "WebKit 17.4" / "WebKitGTK" — "" when unknown. */ +export function formatWebviewInfo(ua: string = navigator.userAgent): string { + const { engine, version } = getWebviewInfo(ua); + return engine ? (version ? `${engine} ${version}` : engine) : ""; +} diff --git a/packages/core/src/i18n/locales/en/settings.json b/packages/core/src/i18n/locales/en/settings.json index 7c62a68bc..65ed86412 100644 --- a/packages/core/src/i18n/locales/en/settings.json +++ b/packages/core/src/i18n/locales/en/settings.json @@ -13,6 +13,7 @@ "other": "More", "aboutDesc": "Read Any, Understand More", "version": "Version", + "webviewEngine": "Web engine", "techStack": "Tech Stack", "techStackTauri": "Cross-platform desktop framework", "techStackReact": "UI component library", diff --git a/packages/core/src/i18n/locales/es/settings.json b/packages/core/src/i18n/locales/es/settings.json index 1169f171d..a0ae8f4e6 100644 --- a/packages/core/src/i18n/locales/es/settings.json +++ b/packages/core/src/i18n/locales/es/settings.json @@ -13,6 +13,7 @@ "other": "Más", "aboutDesc": "Lee cualquier cosa, comprende más", "version": "Versión", + "webviewEngine": "Motor web", "techStack": "Tecnologías", "techStackTauri": "Framework de escritorio multiplataforma", "techStackReact": "Librería de componentes UI", diff --git a/packages/core/src/i18n/locales/fr/settings.json b/packages/core/src/i18n/locales/fr/settings.json index 64bcde715..8ef4e33d9 100644 --- a/packages/core/src/i18n/locales/fr/settings.json +++ b/packages/core/src/i18n/locales/fr/settings.json @@ -13,6 +13,7 @@ "other": "Plus", "aboutDesc": "Lisez tout, comprenez davantage", "version": "Version", + "webviewEngine": "Moteur Web", "techStack": "Stack technique", "techStackTauri": "Framework bureau multiplateforme", "techStackReact": "Bibliothèque de composants UI", diff --git a/packages/core/src/i18n/locales/ja/settings.json b/packages/core/src/i18n/locales/ja/settings.json index c89c5832a..60ed1f14e 100644 --- a/packages/core/src/i18n/locales/ja/settings.json +++ b/packages/core/src/i18n/locales/ja/settings.json @@ -13,6 +13,7 @@ "other": "その他", "aboutDesc": "Read Any, Understand More", "version": "バージョン", + "webviewEngine": "Web エンジン", "techStack": "技術スタック", "techStackTauri": "クロスプラットフォームデスクトップフレームワーク", "techStackReact": "UIコンポーネントライブラリ", diff --git a/packages/core/src/i18n/locales/ko/settings.json b/packages/core/src/i18n/locales/ko/settings.json index 18b715456..80a5eb8a7 100644 --- a/packages/core/src/i18n/locales/ko/settings.json +++ b/packages/core/src/i18n/locales/ko/settings.json @@ -13,6 +13,7 @@ "other": "기타", "aboutDesc": "Read Any, Understand More", "version": "버전", + "webviewEngine": "웹 엔진", "techStack": "기술 스택", "techStackTauri": "크로스 플랫폼 데스크톱 프레임워크", "techStackReact": "UI 컴포넌트 라이브러리", diff --git a/packages/core/src/i18n/locales/zh-TW/settings.json b/packages/core/src/i18n/locales/zh-TW/settings.json index 1cb61a0ad..a60fb7c15 100644 --- a/packages/core/src/i18n/locales/zh-TW/settings.json +++ b/packages/core/src/i18n/locales/zh-TW/settings.json @@ -13,6 +13,7 @@ "other": "更多", "aboutDesc": "閱讀無界,理解無限", "version": "版本", + "webviewEngine": "Web 引擎", "techStack": "技術棧", "techStackTauri": "跨平台桌面框架", "techStackReact": "UI 元件庫", diff --git a/packages/core/src/i18n/locales/zh/settings.json b/packages/core/src/i18n/locales/zh/settings.json index 508744bc6..5e7d70111 100644 --- a/packages/core/src/i18n/locales/zh/settings.json +++ b/packages/core/src/i18n/locales/zh/settings.json @@ -13,6 +13,7 @@ "other": "更多", "aboutDesc": "阅读无界,理解无限", "version": "版本", + "webviewEngine": "Web 引擎", "techStack": "技术栈", "techStackTauri": "跨平台桌面框架", "techStackReact": "UI 组件库", From b080d2ee9eb095ed24dcc3514a0a8753944a948a Mon Sep 17 00:00:00 2001 From: k6G52m4Dz75W <74605402+k6G52m4Dz75W@users.noreply.github.com> Date: Wed, 9 Sep 2026 22:10:20 +0800 Subject: [PATCH 02/22] fix(settings): real WebView build via Client Hints; plain WebView-version label The UA string is reduced (Edg/152.0.0.0 on a 152.0.4191.62 WebView2 runtime), so the UA-parsed version showed zeros after the major. Fetch fullVersionList via User-Agent Client Hints for Chromium-family engines (WebView2 -> Microsoft Edge, Android WebView -> Android WebView, Chrome -> Google Chrome) and fall back to the UA value elsewhere. Rename the About row label to the plainer settings.webviewVersion in all locales. --- .../src/components/settings/AboutSettings.tsx | 23 +++++++-- packages/app/src/lib/webview-info.ts | 50 +++++++++++++++++++ .../core/src/i18n/locales/en/settings.json | 2 +- .../core/src/i18n/locales/es/settings.json | 2 +- .../core/src/i18n/locales/fr/settings.json | 2 +- .../core/src/i18n/locales/ja/settings.json | 2 +- .../core/src/i18n/locales/ko/settings.json | 2 +- .../core/src/i18n/locales/zh-TW/settings.json | 2 +- .../core/src/i18n/locales/zh/settings.json | 2 +- 9 files changed, 75 insertions(+), 12 deletions(-) diff --git a/packages/app/src/components/settings/AboutSettings.tsx b/packages/app/src/components/settings/AboutSettings.tsx index fd2d4268e..b9f6cbf7e 100644 --- a/packages/app/src/components/settings/AboutSettings.tsx +++ b/packages/app/src/components/settings/AboutSettings.tsx @@ -18,7 +18,7 @@ import { resetStatus, subscribeToUpdates, } from "@/lib/updater"; -import { formatWebviewInfo } from "@/lib/webview-info"; +import { getWebviewLabel } from "@/lib/webview-info"; import { getVersion } from "@tauri-apps/api/app"; import { AlertCircle, @@ -38,8 +38,6 @@ import { import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; -const WEBVIEW_LABEL = formatWebviewInfo(); - const TECH_STACK = [ { name: "Tauri", descKey: "settings.techStackTauri", icon: Shield }, { name: "React", descKey: "settings.techStackReact", icon: Code2 }, @@ -59,11 +57,26 @@ export function AboutSettings() { const [isChecking, setIsChecking] = useState(false); const [isRelaunching, setIsRelaunching] = useState(false); const [appVersion, setAppVersion] = useState(""); + const [webviewLabel, setWebviewLabel] = useState(""); useEffect(() => { getVersion().then(setAppVersion).catch(console.error); }, []); + useEffect(() => { + // Async: the full WebView2/Chrome build needs a Client Hints round-trip + // (the UA string itself is reduced to x.0.0.0). + let mounted = true; + getWebviewLabel() + .then((label) => { + if (mounted && label) setWebviewLabel(label); + }) + .catch(() => {}); + return () => { + mounted = false; + }; + }, []); + useEffect(() => { return subscribeToUpdates((s, u, p, e) => { setStatus(s); @@ -151,8 +164,8 @@ export function AboutSettings() {
- {t("settings.webviewEngine")} - {WEBVIEW_LABEL || "—"} + {t("settings.webviewVersion")} + {webviewLabel}
diff --git a/packages/app/src/lib/webview-info.ts b/packages/app/src/lib/webview-info.ts index f109ae254..8ac11de91 100644 --- a/packages/app/src/lib/webview-info.ts +++ b/packages/app/src/lib/webview-info.ts @@ -85,3 +85,53 @@ export function formatWebviewInfo(ua: string = navigator.userAgent): string { const { engine, version } = getWebviewInfo(ua); return engine ? (version ? `${engine} ${version}` : engine) : ""; } + +/** + * Chromium's UA Reduction freezes the minor/build/patch numbers in the UA + * string (Edg/152.0.0.0 on a 152.0.4191.62 runtime), so the UA-parsed version + * is incomplete on WebView2/Chrome/Android WebView. The real build is only in + * the User-Agent Client Hints `fullVersionList` (high-entropy), per brand: + * WebView2 reports "Microsoft Edge", Android WebView reports "Android + * WebView". Safari/Firefox/WebKitGTK have no client hints and keep the UA + * value (or none at all for WebKitGTK). + */ +const CLIENT_HINT_BRANDS: Record = { + WebView2: "Microsoft Edge", + Edge: "Microsoft Edge", + "Android WebView": "Android WebView", + Chrome: "Google Chrome", +}; + +async function getFullVersionFromClientHints(engine: string): Promise { + const brand = CLIENT_HINT_BRANDS[engine]; + if (!brand) return null; + try { + const uaData = ( + navigator as unknown as { + userAgentData?: { + getHighEntropyValues?: ( + hints: string[], + ) => Promise<{ fullVersionList?: { brand: string; version: string }[] }>; + }; + } + ).userAgentData; + const getHighEntropyValues = uaData?.getHighEntropyValues; + if (typeof getHighEntropyValues !== "function") return null; + const { fullVersionList } = await getHighEntropyValues.call(uaData, ["fullVersionList"]); + return fullVersionList?.find((entry) => entry.brand === brand)?.version ?? null; + } catch { + return null; + } +} + +/** + * Display label for Settings → About, async because the full version needs a + * round-trip through the Client Hints API on Chromium engines. Falls back to + * the UA-parsed (reduced) version when Client Hints are unavailable. + */ +export async function getWebviewLabel(): Promise { + const { engine, version } = getWebviewInfo(); + if (!engine) return ""; + const fullVersion = (await getFullVersionFromClientHints(engine)) || version; + return fullVersion ? `${engine} ${fullVersion}` : engine; +} diff --git a/packages/core/src/i18n/locales/en/settings.json b/packages/core/src/i18n/locales/en/settings.json index 65ed86412..aa6762ccd 100644 --- a/packages/core/src/i18n/locales/en/settings.json +++ b/packages/core/src/i18n/locales/en/settings.json @@ -13,7 +13,7 @@ "other": "More", "aboutDesc": "Read Any, Understand More", "version": "Version", - "webviewEngine": "Web engine", + "webviewVersion": "WebView version", "techStack": "Tech Stack", "techStackTauri": "Cross-platform desktop framework", "techStackReact": "UI component library", diff --git a/packages/core/src/i18n/locales/es/settings.json b/packages/core/src/i18n/locales/es/settings.json index a0ae8f4e6..184ab505b 100644 --- a/packages/core/src/i18n/locales/es/settings.json +++ b/packages/core/src/i18n/locales/es/settings.json @@ -13,7 +13,7 @@ "other": "Más", "aboutDesc": "Lee cualquier cosa, comprende más", "version": "Versión", - "webviewEngine": "Motor web", + "webviewVersion": "Versión de WebView", "techStack": "Tecnologías", "techStackTauri": "Framework de escritorio multiplataforma", "techStackReact": "Librería de componentes UI", diff --git a/packages/core/src/i18n/locales/fr/settings.json b/packages/core/src/i18n/locales/fr/settings.json index 8ef4e33d9..74f1eae53 100644 --- a/packages/core/src/i18n/locales/fr/settings.json +++ b/packages/core/src/i18n/locales/fr/settings.json @@ -13,7 +13,7 @@ "other": "Plus", "aboutDesc": "Lisez tout, comprenez davantage", "version": "Version", - "webviewEngine": "Moteur Web", + "webviewVersion": "Version WebView", "techStack": "Stack technique", "techStackTauri": "Framework bureau multiplateforme", "techStackReact": "Bibliothèque de composants UI", diff --git a/packages/core/src/i18n/locales/ja/settings.json b/packages/core/src/i18n/locales/ja/settings.json index 60ed1f14e..cda5bebc6 100644 --- a/packages/core/src/i18n/locales/ja/settings.json +++ b/packages/core/src/i18n/locales/ja/settings.json @@ -13,7 +13,7 @@ "other": "その他", "aboutDesc": "Read Any, Understand More", "version": "バージョン", - "webviewEngine": "Web エンジン", + "webviewVersion": "WebView バージョン", "techStack": "技術スタック", "techStackTauri": "クロスプラットフォームデスクトップフレームワーク", "techStackReact": "UIコンポーネントライブラリ", diff --git a/packages/core/src/i18n/locales/ko/settings.json b/packages/core/src/i18n/locales/ko/settings.json index 80a5eb8a7..c30316978 100644 --- a/packages/core/src/i18n/locales/ko/settings.json +++ b/packages/core/src/i18n/locales/ko/settings.json @@ -13,7 +13,7 @@ "other": "기타", "aboutDesc": "Read Any, Understand More", "version": "버전", - "webviewEngine": "웹 엔진", + "webviewVersion": "WebView 버전", "techStack": "기술 스택", "techStackTauri": "크로스 플랫폼 데스크톱 프레임워크", "techStackReact": "UI 컴포넌트 라이브러리", diff --git a/packages/core/src/i18n/locales/zh-TW/settings.json b/packages/core/src/i18n/locales/zh-TW/settings.json index a60fb7c15..97e0fa8cd 100644 --- a/packages/core/src/i18n/locales/zh-TW/settings.json +++ b/packages/core/src/i18n/locales/zh-TW/settings.json @@ -13,7 +13,7 @@ "other": "更多", "aboutDesc": "閱讀無界,理解無限", "version": "版本", - "webviewEngine": "Web 引擎", + "webviewVersion": "WebView 版本", "techStack": "技術棧", "techStackTauri": "跨平台桌面框架", "techStackReact": "UI 元件庫", diff --git a/packages/core/src/i18n/locales/zh/settings.json b/packages/core/src/i18n/locales/zh/settings.json index 5e7d70111..87a4a5b87 100644 --- a/packages/core/src/i18n/locales/zh/settings.json +++ b/packages/core/src/i18n/locales/zh/settings.json @@ -13,7 +13,7 @@ "other": "更多", "aboutDesc": "阅读无界,理解无限", "version": "版本", - "webviewEngine": "Web 引擎", + "webviewVersion": "WebView 版本", "techStack": "技术栈", "techStackTauri": "跨平台桌面框架", "techStackReact": "UI 组件库", From ede34705ab7cfdae109a2910fcc22da334c637bd Mon Sep 17 00:00:00 2001 From: k6G52m4Dz75W <74605402+k6G52m4Dz75W@users.noreply.github.com> Date: Wed, 9 Sep 2026 22:17:52 +0800 Subject: [PATCH 03/22] feat(settings): copy app + webview versions from the About card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hovering the version card reveals a copy button (readest's About window has the same affordance — mobile users can't select the version string for bug reports). Copies both lines at once: ReadAny 1.3.5 WebView2 152.0.4191.62 Feedback via icon swap + toast (common.copied). Uses the same navigator.clipboard.writeText as the chat/markdown copy buttons. --- .../src/components/settings/AboutSettings.tsx | 30 +++++++++++++++++-- .../core/src/i18n/locales/en/settings.json | 1 + .../core/src/i18n/locales/es/settings.json | 1 + .../core/src/i18n/locales/fr/settings.json | 1 + .../core/src/i18n/locales/ja/settings.json | 1 + .../core/src/i18n/locales/ko/settings.json | 1 + .../core/src/i18n/locales/zh-TW/settings.json | 1 + .../core/src/i18n/locales/zh/settings.json | 1 + 8 files changed, 35 insertions(+), 2 deletions(-) diff --git a/packages/app/src/components/settings/AboutSettings.tsx b/packages/app/src/components/settings/AboutSettings.tsx index b9f6cbf7e..712f3f586 100644 --- a/packages/app/src/components/settings/AboutSettings.tsx +++ b/packages/app/src/components/settings/AboutSettings.tsx @@ -25,6 +25,7 @@ import { BookOpen, Check, Code2, + Copy, Download, ExternalLink, Github, @@ -37,6 +38,7 @@ import { */ import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; +import { toast } from "sonner"; const TECH_STACK = [ { name: "Tauri", descKey: "settings.techStackTauri", icon: Shield }, @@ -58,6 +60,7 @@ export function AboutSettings() { const [isRelaunching, setIsRelaunching] = useState(false); const [appVersion, setAppVersion] = useState(""); const [webviewLabel, setWebviewLabel] = useState(""); + const [copied, setCopied] = useState(false); useEffect(() => { getVersion().then(setAppVersion).catch(console.error); @@ -102,6 +105,20 @@ export function AboutSettings() { checkForUpdate(); }; + // Both version lines at once — the pair is what a bug report needs (see the + // justify engine-fallback work: features vary per WebView build). + const handleCopyVersion = async () => { + const versionInfo = [`ReadAny ${appVersion}`, webviewLabel].filter(Boolean).join("\n"); + try { + await navigator.clipboard.writeText(versionInfo); + setCopied(true); + toast.success(t("common.copied")); + window.setTimeout(() => setCopied(false), 1500); + } catch (error) { + console.error("[AboutSettings] Copy version info failed:", error); + } + }; + const handleDownload = () => { setDialogType("none"); downloadAndInstall(); @@ -145,14 +162,23 @@ export function AboutSettings() {

{t("settings.aboutDesc")}

- {/* Version Card */} -
+ {/* Version Card — hover reveals a copy button; click copies the app + version and the web engine together for bug reports. */} +
{t("settings.version")}
{appVersion || "..."} +
- {/* Version Card — hover reveals a copy button; click copies the app - version and the web engine together for bug reports. */} -
+ {/* Version Card — the copy button copies the app version and the web + engine together for bug reports. */} +
{t("settings.version")}
@@ -173,7 +173,7 @@ export function AboutSettings() {
{t("settings.webviewVersion")} - {webviewLabel} + {webviewLabel || "..."}
diff --git a/packages/app/src/components/settings/FeedbackSettings.tsx b/packages/app/src/components/settings/FeedbackSettings.tsx index c16b4e91a..824895219 100644 --- a/packages/app/src/components/settings/FeedbackSettings.tsx +++ b/packages/app/src/components/settings/FeedbackSettings.tsx @@ -104,7 +104,7 @@ export function FeedbackSettings() { webview: webview || undefined, locale: i18n.language || navigator.language, }); - }, [appVersion, webview]); + }, [appVersion, webview, i18n.language]); const loadRecords = useCallback(async (refreshStatus = false) => { const history = await getFeedbackHistory(); diff --git a/packages/app/src/lib/webview-info.ts b/packages/app/src/lib/webview-info.ts index ab328b6c4..88641271e 100644 --- a/packages/app/src/lib/webview-info.ts +++ b/packages/app/src/lib/webview-info.ts @@ -67,5 +67,5 @@ export async function getWebviewLabel(): Promise { const { engine, version } = getWebviewInfo(); if (!engine) return ""; const fullVersion = (await getFullVersionFromClientHints(engine)) || version; - return formatWebviewInfo({ engine, version: fullVersion || version }); + return formatWebviewInfo({ engine, version: fullVersion }); } diff --git a/packages/core/src/utils/webview-info.ts b/packages/core/src/utils/webview-info.ts index 3e7992980..4e8145dc7 100644 --- a/packages/core/src/utils/webview-info.ts +++ b/packages/core/src/utils/webview-info.ts @@ -89,3 +89,12 @@ export function parseWebviewInfo(ua: string, inAppShell = true): WebviewInfo { export function formatWebviewInfo(info: WebviewInfo): string { return info.engine ? (info.version ? `${info.engine} ${info.version}` : info.engine) : ""; } + +/** + * The two-line version info pasted into bug reports — the pair (app version + + * web engine build) is what the issue template needs. Shared by the desktop + * About card and the mobile About screen so the format cannot drift. + */ +export function buildVersionInfo(appVersion: string, webviewLabel: string): string { + return [`ReadAny ${appVersion}`, webviewLabel].filter(Boolean).join("\n"); +} diff --git a/packages/feedback-worker/src/index.ts b/packages/feedback-worker/src/index.ts index 90a25cf40..5517672ff 100644 --- a/packages/feedback-worker/src/index.ts +++ b/packages/feedback-worker/src/index.ts @@ -299,7 +299,7 @@ function buildIssueBody( const clean = (value: unknown): string => String(value ?? "unknown") .replace(/[\r\n\t]+/g, " ") - .replace(/[`[\]<>!]/g, "") + .replace(/[`[\]<>!@]/g, "") .slice(0, 300); const details = [ `### Type\n${TYPE_LABELS[payload.type]}`, From 54e4048e7ce058a3ff2cef3f9e0664baf0752e53 Mon Sep 17 00:00:00 2001 From: k6G52m4Dz75W <74605402+k6G52m4Dz75W@users.noreply.github.com> Date: Fri, 11 Sep 2026 20:58:33 +0800 Subject: [PATCH 17/22] chore(mobile-dev): regenerate reader.html (justify engine + fullVersion UA report) --- packages/app-expo/assets/reader/reader.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/app-expo/assets/reader/reader.html b/packages/app-expo/assets/reader/reader.html index 43b01a6a8..c3e15b765 100644 --- a/packages/app-expo/assets/reader/reader.html +++ b/packages/app-expo/assets/reader/reader.html @@ -5631,7 +5631,7 @@ 0% { opacity: 1; } 100% { opacity: 0; } } - `,C.head.appendChild(B)}a&&r>0&&setTimeout(()=>{u.classList.add("foliate-arrow-fadeout"),setTimeout(()=>{u.parentNode&&u.parentNode.removeChild(u)},1e3)},r)}return c.append(u),c}static copyImage([e],t={}){let{src:A}=t,s=Ii("image"),{left:n,top:a,height:r,width:o}=e;return s.setAttribute("href",A),s.setAttribute("x",n),s.setAttribute("y",a),s.setAttribute("height",r),s.setAttribute("width",o),s}};Dc=new WeakMap,Od=new WeakMap,Vo=new WeakMap,Rs=new WeakMap,Wd=new WeakMap,ka=new WeakMap;var tU=i=>{let e=0,t=A=>{if(A.id=e++,A.subitems)for(let s of A.subitems)t(s)};for(let A of i)t(A);return i},ek=i=>i.flatMap(e=>e.subitems?.length?[e,ek(e.subitems)].flat():e),Vd=class{async init({toc:e,ids:t,splitHref:A,getFragment:s}){tU(e);let n=ek(e),a=new Map;for(let[o,c]of n.entries()){let[g,l]=await A(c?.href)??[],I={fragment:l,item:c};a.has(g)?a.get(g).items.push(I):a.set(g,{prev:n[o-1],items:[I]})}let r=new Map;for(let[o,c]of t.entries())a.has(c)?r.set(c,a.get(c)):r.set(c,r.get(t[o-1]));this.ids=t,this.map=r,this.getFragment=s}getProgress(e,t){if(!this.ids)return;let A=this.ids[e],s=this.map.get(A);if(!s)return null;let{prev:n,items:a}=s;if(!a)return n;if(!t||a.length===1&&!a[0].fragment)return a[0].item;let r=t.startContainer.getRootNode();for(let[o,{fragment:c}]of a.entries()){let g=this.getFragment(r,c);if(g&&t.comparePoint(g,0)>0)return a[o-1]?.item??n}return a[a.length-1].item}},W1,tk,O1=class{constructor(e,t,A){D(this,W1);this.sizes=e.map(s=>s.linear!="no"&&s.size>0?s.size:0),this.sizePerLoc=t,this.sizePerTimeUnit=A,this.sizeTotal=this.sizes.reduce((s,n)=>s+n,0),this.sectionFractions=x(this,W1,tk).call(this)}getProgress(e,t,A=0){let{sizes:s,sizePerLoc:n,sizePerTimeUnit:a,sizeTotal:r}=this,o=s[e]??0,g=s.slice(0,e).reduce((u,f)=>u+f,0)+t*o,l=g+A*o,I=r-g,d=(1-t)*o;return{fraction:l/r,section:{current:e,total:s.length},location:{current:Math.floor(g/n),next:Math.floor(l/n),total:Math.ceil(r/n)},time:{section:d/a,total:I/a}}}getSection(e){if(e<=0)return[0,0];if(e>=1)return[this.sizes.length-1,1];e=e+Number.EPSILON;let{sizeTotal:t}=this,A=this.sectionFractions.findIndex(n=>n>e)-1;if(A<0)return[0,0];for(;!this.sizes[A];)A++;let s=(e-this.sectionFractions[A])/(this.sizes[A]/t);return[A,s]}};W1=new WeakSet,tk=function(){let{sizeTotal:e}=this,t=[0],A=0;for(let s of this.sizes)t.push((A+=s)/e);return t};var iU=(i,e)=>{let t=[];for(let A=e.currentNode;A;A=e.nextNode()){let s=i.comparePoint(A,0);if(s===0)t.push(A);else if(s>0)break}return t},AU=(i,e)=>{let t=[];for(let A=e.nextNode();A;A=e.nextNode())t.push(A);return t},sU=NodeFilter.SHOW_ELEMENT|NodeFilter.SHOW_TEXT|NodeFilter.SHOW_CDATA_SECTION,nU=i=>{if(i.nodeType===1){let e=i.tagName.toLowerCase();return e==="script"||e==="style"||e==="rt"||e==="rp"?NodeFilter.FILTER_REJECT:NodeFilter.FILTER_SKIP}return NodeFilter.FILTER_ACCEPT},tm=function*(i,e,t){let A=i.commonAncestorContainer??i.body??i,s=document.createTreeWalker(A,sU,{acceptNode:t||nU}),a=(i.commonAncestorContainer?iU:AU)(i,s),r=a.map(c=>c.nodeValue),o=(c,g,l,I)=>{let d=document.createRange();return d.setStart(a[c],g),d.setEnd(a[l],I),d};for(let c of e(r,o))yield c};var M1="foliate-search:",hL="foliate-tts:",HK=async i=>{let e=new Uint8Array(await i.slice(0,4).arrayBuffer());return e[0]===80&&e[1]===75&&e[2]===3&&e[3]===4},JK=async i=>{let e=new Uint8Array(await i.slice(0,5).arrayBuffer());return e[0]===37&&e[1]===80&&e[2]===68&&e[3]===70&&e[4]===45},YK=({name:i,type:e})=>e==="application/vnd.comicbook+zip"||i.endsWith(".cbz"),PK=({name:i,type:e})=>e==="application/x-fictionbook+xml"||i.endsWith(".fb2"),_K=({name:i,type:e})=>e==="application/x-zip-compressed-fb2"||i.endsWith(".fb2.zip")||i.endsWith(".fbz"),KK=async i=>{let{configure:e,ZipReader:t,BlobReader:A,TextWriter:s,BlobWriter:n}=await Promise.resolve().then(()=>(wm(),tF));e({useWebWorkers:!1});let r=await new t(new A(i)).getEntries(),o=new Map(r.map(d=>[d.filename,d])),c=d=>(u,...f)=>o.has(u)?d(o.get(u),...f):null,g=c(d=>d.getData(new s)),l=c((d,u)=>d.getData(new n(u)));return{entries:r,loadText:g,loadBlob:l,getSize:d=>o.get(d)?.uncompressedSize??0}},IL=async i=>i.isFile?i:(await Promise.all(Array.from(await new Promise((e,t)=>i.createReader().readEntries(A=>e(A),A=>t(A))),IL))).flat(),qK=async i=>{let e=await IL(i),t=await Promise.all(e.map(g=>new Promise((l,I)=>g.file(d=>l([d,g.fullPath]),d=>I(d))))),A=new Map(t.map(([g,l])=>[l.replace(`${i.fullPath}/`,""),g])),s=new TextDecoder,n=g=>g?s.decode(g):null,a=g=>A.get(g)?.arrayBuffer()??null;return{loadText:async g=>n(await a(g)),loadBlob:g=>A.get(g),getSize:g=>A.get(g)?.size??0}},MS=class extends Error{},GS=class extends Error{},vS=class extends Error{},OK=async i=>{let e=await fetch(i);if(!e.ok)throw new MS(`${e.status} ${e.statusText}`,{cause:e});return new File([await e.blob()],new URL(e.url).pathname)},TS=async i=>{typeof i=="string"&&(i=await OK(i));let e;if(i.isDirectory){let t=await qK(i),{EPUB:A}=await Promise.resolve().then(()=>(IE(),Gm));e=await new A(t).init()}else if(i.size)if(await HK(i)){let t=await KK(i);if(YK(i)){let{makeComicBook:A}=await Promise.resolve().then(()=>(lF(),gF));e=A(t,i)}else if(_K(i)){let{makeFB2:A}=await Promise.resolve().then(()=>(Tm(),Um)),{entries:s}=t,n=s.find(r=>r.filename.endsWith(".fb2")),a=await t.loadBlob((n??s[0]).filename);e=await A(a)}else{let{EPUB:A}=await Promise.resolve().then(()=>(IE(),Gm));e=await new A(t).init()}}else if(await JK(i)){let{makePDF:t}=await Promise.resolve().then(()=>($D(),nv));e=await t(i)}else{let{isMOBI:t,MOBI:A}=await Promise.resolve().then(()=>(Cv(),fv));if(await t(i)){let s=await Promise.resolve().then(()=>(bv(),wv));e=await new A({unzlib:s.unzlibSync}).open(i)}else if(PK(i)){let{makeFB2:s}=await Promise.resolve().then(()=>(Tm(),Um));e=await s(i)}}else throw new GS("File not found");if(!e)throw new vS("File type not supported");return e},Jd,Vl,v1,Kn,HS=class HS{constructor(e,t,A={}){D(this,Jd);D(this,Vl);D(this,v1);D(this,Kn);m(this,Vl,e),m(this,v1,t),m(this,Kn,A),h(this,Kn).hidden&&this.hide(),h(this,Vl).addEventListener("mousemove",({screenX:s,screenY:n})=>{s===h(this,Kn).x&&n===h(this,Kn).y||(h(this,Kn).x=s,h(this,Kn).y=n,this.show(),h(this,Jd)&&clearTimeout(h(this,Jd)),t()&&m(this,Jd,setTimeout(this.hide.bind(this),1e3)))},!1)}cloneFor(e){return new HS(e,h(this,v1),h(this,Kn))}hide(){h(this,Vl).style.cursor="none",h(this,Kn).hidden=!0}show(){h(this,Vl).style.removeProperty("cursor"),h(this,Kn).hidden=!1}};Jd=new WeakMap,Vl=new WeakMap,v1=new WeakMap,Kn=new WeakMap;var LS=HS,qn,Fs,US=class extends EventTarget{constructor(){super(...arguments);D(this,qn,[]);D(this,Fs,-1)}pushState(t){let A=h(this,qn)[h(this,Fs)];A===t||A?.fraction&&A.fraction===t.fraction||(h(this,qn)[++hi(this,Fs)._]=t,h(this,qn).length=h(this,Fs)+1,this.dispatchEvent(new Event("index-change")))}replaceState(t){let A=h(this,Fs);h(this,qn)[A]=t}back(){let t=h(this,Fs);if(t<=0)return;let A={state:h(this,qn)[t-1]};m(this,Fs,t-1),this.dispatchEvent(new CustomEvent("popstate",{detail:A})),this.dispatchEvent(new Event("index-change"))}forward(){let t=h(this,Fs);if(t>=h(this,qn).length-1)return;let A={state:h(this,qn)[t+1]};m(this,Fs,t+1),this.dispatchEvent(new CustomEvent("popstate",{detail:A})),this.dispatchEvent(new Event("index-change"))}get canGoBack(){return h(this,Fs)>0}get canGoForward(){return h(this,Fs){if(!i)return{};try{let e=Intl.getCanonicalLocales(i)[0],t=new Intl.Locale(e),A=["zh","ja","kr"].includes(t.language),s=(t.getTextInfo?.()??t.textInfo)?.direction;return{canonical:e,locale:t,isCJK:A,direction:s}}catch(e){return console.warn(e),{}}},Px,qo,Da,bc,Oo,mr,_x,Yt,xr,dL,uL,fL,Yx,CL,BL,EL,G1=class extends HTMLElement{constructor(){super();D(this,Yt);D(this,Px,this.attachShadow({mode:"closed"}));D(this,qo);D(this,Da);D(this,bc);D(this,Oo,new Map);D(this,mr,{type:"outline",options:{}});D(this,_x,new LS(this,()=>this.hasAttribute("autohide-cursor")));pe(this,"isFixedLayout",!1);pe(this,"lastLocation");pe(this,"history",new US);this.history.addEventListener("popstate",({detail:t})=>{let A=this.resolveNavigation(t.state);this.renderer.goTo(A)})}async open(t){if((typeof t=="string"||typeof t.arrayBuffer=="function"||t.isDirectory)&&(t=await TS(t)),this.book=t,this.language=WK(t.metadata?.language),t.splitTOCHref&&t.getTOCFragment){let A=t.sections.map(a=>a.id);m(this,qo,new O1(t.sections,1500,1600));let s=t.splitTOCHref.bind(t),n=t.getTOCFragment.bind(t);m(this,Da,new Vd),await h(this,Da).init({toc:t.toc??[],ids:A,splitHref:s,getFragment:n}),m(this,bc,new Vd),await h(this,bc).init({toc:t.pageList??[],ids:A,splitHref:s,getFragment:n})}if(this.isFixedLayout=this.book.rendition?.layout==="pre-paginated",this.isFixedLayout?(await Promise.resolve().then(()=>(Fv(),kv)),this.renderer=document.createElement("foliate-fxl")):(await Promise.resolve().then(()=>(zv(),Zv)),this.renderer=document.createElement("foliate-paginator")),this.renderer.setAttribute("exportparts","head,foot,filter"),this.renderer.addEventListener("load",A=>x(this,Yt,uL).call(this,A.detail)),this.renderer.addEventListener("relocate",A=>x(this,Yt,dL).call(this,A.detail)),this.renderer.addEventListener("create-overlayer",A=>A.detail.attach(x(this,Yt,CL).call(this,A.detail))),this.renderer.open(t),h(this,Px).append(this.renderer),t.sections.some(A=>A.mediaOverlay)){let A=t.media.activeClass,s=t.media.playbackActiveClass;this.mediaOverlay=t.getMediaOverlay();let n;this.mediaOverlay.addEventListener("highlight",a=>{let r=this.resolveNavigation(a.detail.text);this.renderer.goTo(r).then(()=>{let{doc:o}=this.renderer.getContents().find(g=>g.index=r.index),c=r.anchor(o);c.classList.add(A),s&&c.ownerDocument.documentElement.classList.add(s),n=new WeakRef(c)})}),this.mediaOverlay.addEventListener("unhighlight",()=>{let a=n?.deref();a&&(a.classList.remove(A),s&&a.ownerDocument.documentElement.classList.remove(s))})}}close(){this.renderer?.destroy(),this.renderer?.remove(),m(this,qo,null),m(this,Da,null),m(this,bc,null),m(this,Oo,new Map),this.lastLocation=null,this.history.clear(),this.tts=null,this.mediaOverlay=null}goToTextStart(){return this.goTo(this.book.landmarks?.find(t=>t.type.includes("bodymatter")||t.type.includes("text"))?.href??this.book.sections.findIndex(t=>t.linear!=="no"))}async init({lastLocation:t,showTextStart:A}){let s=t?this.resolveNavigation(t):null;s?(await this.renderer.goTo(s),this.history.pushState(t)):A?await this.goToTextStart():(this.history.pushState(0),await this.next())}async addAnnotation(t,A){let{value:s,indicatorType:n="outline",indicatorOptions:a={}}=t;if(s.startsWith(M1)){let I=s.replace(M1,""),{index:d,anchor:u}=await this.resolveNavigation(I),f=x(this,Yt,Yx).call(this,d);if(f){let{overlayer:C,doc:B}=f;if(A){C.remove(s),C.remove(`${s}::underline`),C.remove(`${s}::tooltip`);return}let E=B?u(B):u,Q;n==="arrow"?Q=jo.arrow:Q=jo.outline,C.add(s,E,Q,a)}return}let r=s.startsWith(hL)?s.replace(hL,""):s,{index:o,anchor:c}=await this.resolveNavigation(r),g=x(this,Yt,Yx).call(this,o);if(g){let{overlayer:I,doc:d}=g;if(I.remove(s),I.remove(`${s}::underline`),I.remove(`${s}::tooltip`),A&&x(this,Yt,xr).call(this,"delete-annotation",{value:s,doc:d}),!A){let u=d?c(d):c,f=(C,B,E)=>{let Q=E?`${s}::${E}`:s;I.add(Q,u,C,B)};x(this,Yt,xr).call(this,"draw-annotation",{draw:f,annotation:t,doc:d,range:u})}}let l=h(this,Da).getProgress(o)?.label??"";return{index:o,label:l}}deleteAnnotation(t){return this.addAnnotation(t,!0)}async showAnnotation(t){let{value:A}=t,s=await this.goTo(A);if(s){let{index:n,anchor:a}=s,{doc:r}=x(this,Yt,Yx).call(this,n),o=a(r);x(this,Yt,xr).call(this,"show-annotation",{value:A,index:n,range:o})}}getCFI(t,A){let s=this.book.sections[t].cfi??Kd.fromIndex(t);return A?Zx(s,$x(A)):s}resolveCFI(t){if(this.book.resolveCFI)return this.book.resolveCFI(t);let A=Vn(t);return{index:Kd.toIndex((A.parent??A).shift()),anchor:a=>_d(a,A)}}resolveNavigation(t){try{if(typeof t=="number")return{index:t};if(typeof t.fraction=="number"){let[A,s]=h(this,qo).getSection(t.fraction);return{index:A,anchor:s}}return jl.test(t)?this.resolveCFI(t):this.book.resolveHref(t)}catch(A){console.error(A),console.error(`Could not resolve target ${t}`)}}async goTo(t){t=decodeURIComponent(t);let A=this.resolveNavigation(t);try{return await this.renderer.goTo(A),this.history.pushState(t),A}catch(s){console.error(s),console.error(`Could not go to ${t}`)}}async goToFraction(t){let[A,s]=h(this,qo).getSection(t);await this.renderer.goTo({index:A,anchor:s}),this.history.pushState({fraction:t})}async select(t){try{let A=await this.resolveNavigation(t);await this.renderer.goTo({...A,select:!0}),this.history.pushState(t)}catch(A){console.error(A),console.error(`Could not go to ${t}`)}}deselect(){for(let{doc:t}of this.renderer.getContents())t.defaultView.getSelection().removeAllRanges()}getSectionFractions(){return(h(this,qo)?.sectionFractions??[]).map(t=>t+Number.EPSILON)}getProgressOf(t,A){let s=h(this,Da)?.getProgress(t,A),n=h(this,bc)?.getProgress(t,A);return{tocItem:s,pageItem:n}}async getTOCItemOf(t){try{let{index:A,anchor:s}=await this.resolveNavigation(t),n=await this.book.sections[A].createDocument(),a=s(n),r=a instanceof Range,o=r?a:n.createRange();return r||o.selectNodeContents(a),h(this,Da).getProgress(A,o)}catch(A){console.error(A),console.error(`Could not get ${t}`)}}async prev(t){await this.renderer.prev(t)}async next(t){await this.renderer.next(t)}goLeft(){return this.book.dir==="rtl"?this.next():this.prev()}goRight(){return this.book.dir==="rtl"?this.prev():this.next()}async*search(t){this.clearSearch();let{searchMatcher:A}=await Promise.resolve().then(()=>(AL(),iL)),{query:s,index:n}=t,a=A(tm,{defaultLocale:this.language,...t}),r=n!=null?x(this,Yt,BL).call(this,a,s,n):x(this,Yt,EL).call(this,a,s),o=[];h(this,Oo).set(n,o);for await(let c of r)if(c.subitems){let g=c.subitems.map(({cfi:l})=>({value:M1+l}));h(this,Oo).set(c.index,g);for(let l of g){let I={...l,indicatorType:h(this,mr).type,indicatorOptions:h(this,mr).options};this.addAnnotation(I)}yield{label:h(this,Da).getProgress(c.index)?.label??"",subitems:c.subitems}}else{if(c.cfi){let g={value:M1+c.cfi};o.push(g);let l={...g,indicatorType:h(this,mr).type,indicatorOptions:h(this,mr).options};this.addAnnotation(l)}yield c}yield"done"}clearSearch(){for(let t of h(this,Oo).values())for(let A of t)this.deleteAnnotation(A);h(this,Oo).clear()}setSearchIndicator(t="outline",A={}){m(this,mr,{type:t,options:A})}async initTTS(t="word",A,s){let n=this.renderer.getContents(),a=this.renderer.primaryIndex,r=n.find(l=>l.index===a)??n[0],o=r?.doc,c=r?.index??0;if(!o)return;if(this.tts&&this.tts.doc===o){A&&(this.tts.highlight=A);return}let{TTS:g}=await Promise.resolve().then(()=>(lL(),gL));this.tts=new g(o,tm,s||null,A||(l=>this.renderer.scrollToAnchor(l,!0)),l=>this.getCFI(c,l),t)}startMediaOverlay(){let{index:t}=this.renderer.getContents()[0];return this.mediaOverlay.start(t)}};Px=new WeakMap,qo=new WeakMap,Da=new WeakMap,bc=new WeakMap,Oo=new WeakMap,mr=new WeakMap,_x=new WeakMap,Yt=new WeakSet,xr=function(t,A,s){return this.dispatchEvent(new CustomEvent(t,{detail:A,cancelable:s}))},dL=function({reason:t,range:A,index:s,fraction:n,size:a}){let r=h(this,qo)?.getProgress(s,n,a)??{},o=h(this,Da)?.getProgress(s,A),c=h(this,bc)?.getProgress(s,A),g=this.getCFI(s,A);this.lastLocation={...r,tocItem:o,pageItem:c,cfi:g,range:A},(t==="snap"||t==="page"||t==="scroll")&&this.history.replaceState(g),x(this,Yt,xr).call(this,"relocate",this.lastLocation)},uL=function({doc:t,index:A}){var s,n;(s=t.documentElement).lang||(s.lang=this.language.canonical??""),this.language.isCJK||(n=t.documentElement).dir||(n.dir=this.language.direction??""),x(this,Yt,fL).call(this,t,A),h(this,_x).cloneFor(t.documentElement),x(this,Yt,xr).call(this,"load",{doc:t,index:A})},fL=function(t,A){let{book:s}=this,n=s.sections[A];t.addEventListener("click",a=>{let r=a.target.closest("a[href]");if(!r)return;a.preventDefault();let o=r.getAttribute("href"),c=n?.resolveHref?.(o)??o;s?.isExternal?.(c)?Promise.resolve(x(this,Yt,xr).call(this,"external-link",{a:r,href:c},!0)).then(g=>g?globalThis.open(c,"_blank"):null).catch(g=>console.error(g)):Promise.resolve(x(this,Yt,xr).call(this,"link",{a:r,href:c},!0)).then(g=>g?this.goTo(c):null).catch(g=>console.error(g))})},Yx=function(t){return this.renderer.getContents().find(A=>A.index===t&&A.overlayer)},CL=function({doc:t,index:A}){let s=new jo;t.addEventListener("click",a=>{let[r,o]=s.hitTest(a);r&&!r.startsWith(M1)&&x(this,Yt,xr).call(this,"show-annotation",{value:r,index:A,range:o})},!1);let n=h(this,Oo).get(A);if(n)for(let a of n){let r={...a,indicatorType:h(this,mr).type,indicatorOptions:h(this,mr).options};this.addAnnotation(r)}return x(this,Yt,xr).call(this,"create-overlay",{index:A}),s},BL=async function*(t,A,s){let n=await this.book.sections[s].createDocument();for(let{range:a,excerpt:r}of t(n,A))yield{cfi:this.getCFI(s,a),excerpt:r}},EL=async function*(t,A){let{sections:s}=this.book;for(let[n,{createDocument:a}]of s.entries()){if(!a)continue;let r=await a(),o=Array.from(t(r,A),({range:g,excerpt:l})=>({cfi:this.getCFI(n,g),excerpt:l}));yield{progress:(n+1)/s.length},o.length&&(yield{index:n,subitems:o})}};customElements.get("foliate-view")||customElements.define("foliate-view",G1);q1();wm();IE();$D();window.makeBook=TS;window.Overlayer=jo;window.CFI=qd;window._zipJs={configure:aE,ZipReader:Au,BlobReader:th,TextWriter:iu,BlobWriter:ih};window._EPUB=nu;window._makePDFFromURL=ZD;window._extractPDFChapters=zD;customElements.get("foliate-view")||customElements.define("foliate-view",G1);window.ReactNativeWebView&&window.ReactNativeWebView.postMessage(JSON.stringify({type:"foliate-loaded"}));})(); + `,C.head.appendChild(B)}a&&r>0&&setTimeout(()=>{u.classList.add("foliate-arrow-fadeout"),setTimeout(()=>{u.parentNode&&u.parentNode.removeChild(u)},1e3)},r)}return c.append(u),c}static copyImage([e],t={}){let{src:A}=t,s=Ii("image"),{left:n,top:a,height:r,width:o}=e;return s.setAttribute("href",A),s.setAttribute("x",n),s.setAttribute("y",a),s.setAttribute("height",r),s.setAttribute("width",o),s}};Dc=new WeakMap,Od=new WeakMap,Vo=new WeakMap,Rs=new WeakMap,Wd=new WeakMap,ka=new WeakMap;var tU=i=>{let e=0,t=A=>{if(A.id=e++,A.subitems)for(let s of A.subitems)t(s)};for(let A of i)t(A);return i},ek=i=>i.flatMap(e=>e.subitems?.length?[e,ek(e.subitems)].flat():e),Vd=class{async init({toc:e,ids:t,splitHref:A,getFragment:s}){tU(e);let n=ek(e),a=new Map;for(let[o,c]of n.entries()){let[g,l]=await A(c?.href)??[],I={fragment:l,item:c};a.has(g)?a.get(g).items.push(I):a.set(g,{prev:n[o-1],items:[I]})}let r=new Map;for(let[o,c]of t.entries())a.has(c)?r.set(c,a.get(c)):r.set(c,r.get(t[o-1]));this.ids=t,this.map=r,this.getFragment=s}getProgress(e,t){if(!this.ids)return;let A=this.ids[e],s=this.map.get(A);if(!s)return null;let{prev:n,items:a}=s;if(!a)return n;if(!t||a.length===1&&!a[0].fragment)return a[0].item;let r=t.startContainer.getRootNode();for(let[o,{fragment:c}]of a.entries()){let g=this.getFragment(r,c);if(g&&t.comparePoint(g,0)>0)return a[o-1]?.item??n}return a[a.length-1].item}},W1,tk,O1=class{constructor(e,t,A){D(this,W1);this.sizes=e.map(s=>s.linear!="no"&&s.size>0?s.size:0),this.sizePerLoc=t,this.sizePerTimeUnit=A,this.sizeTotal=this.sizes.reduce((s,n)=>s+n,0),this.sectionFractions=x(this,W1,tk).call(this)}getProgress(e,t,A=0){let{sizes:s,sizePerLoc:n,sizePerTimeUnit:a,sizeTotal:r}=this,o=s[e]??0,g=s.slice(0,e).reduce((u,f)=>u+f,0)+t*o,l=g+A*o,I=r-g,d=(1-t)*o;return{fraction:l/r,section:{current:e,total:s.length},location:{current:Math.floor(g/n),next:Math.floor(l/n),total:Math.ceil(r/n)},time:{section:d/a,total:I/a}}}getSection(e){if(e<=0)return[0,0];if(e>=1)return[this.sizes.length-1,1];e=e+Number.EPSILON;let{sizeTotal:t}=this,A=this.sectionFractions.findIndex(n=>n>e)-1;if(A<0)return[0,0];for(;!this.sizes[A];)A++;let s=(e-this.sectionFractions[A])/(this.sizes[A]/t);return[A,s]}};W1=new WeakSet,tk=function(){let{sizeTotal:e}=this,t=[0],A=0;for(let s of this.sizes)t.push((A+=s)/e);return t};var iU=(i,e)=>{let t=[];for(let A=e.currentNode;A;A=e.nextNode()){let s=i.comparePoint(A,0);if(s===0)t.push(A);else if(s>0)break}return t},AU=(i,e)=>{let t=[];for(let A=e.nextNode();A;A=e.nextNode())t.push(A);return t},sU=NodeFilter.SHOW_ELEMENT|NodeFilter.SHOW_TEXT|NodeFilter.SHOW_CDATA_SECTION,nU=i=>{if(i.nodeType===1){let e=i.tagName.toLowerCase();return e==="script"||e==="style"||e==="rt"||e==="rp"?NodeFilter.FILTER_REJECT:NodeFilter.FILTER_SKIP}return NodeFilter.FILTER_ACCEPT},tm=function*(i,e,t){let A=i.commonAncestorContainer??i.body??i,s=document.createTreeWalker(A,sU,{acceptNode:t||nU}),a=(i.commonAncestorContainer?iU:AU)(i,s),r=a.map(c=>c.nodeValue),o=(c,g,l,I)=>{let d=document.createRange();return d.setStart(a[c],g),d.setEnd(a[l],I),d};for(let c of e(r,o))yield c};var M1="foliate-search:",hL="foliate-tts:",HK=async i=>{let e=new Uint8Array(await i.slice(0,4).arrayBuffer());return e[0]===80&&e[1]===75&&e[2]===3&&e[3]===4},JK=async i=>{let e=new Uint8Array(await i.slice(0,5).arrayBuffer());return e[0]===37&&e[1]===80&&e[2]===68&&e[3]===70&&e[4]===45},YK=({name:i,type:e})=>e==="application/vnd.comicbook+zip"||i.endsWith(".cbz"),PK=({name:i,type:e})=>e==="application/x-fictionbook+xml"||i.endsWith(".fb2"),_K=({name:i,type:e})=>e==="application/x-zip-compressed-fb2"||i.endsWith(".fb2.zip")||i.endsWith(".fbz"),KK=async i=>{let{configure:e,ZipReader:t,BlobReader:A,TextWriter:s,BlobWriter:n}=await Promise.resolve().then(()=>(wm(),tF));e({useWebWorkers:!1});let r=await new t(new A(i)).getEntries(),o=new Map(r.map(d=>[d.filename,d])),c=d=>(u,...f)=>o.has(u)?d(o.get(u),...f):null,g=c(d=>d.getData(new s)),l=c((d,u)=>d.getData(new n(u)));return{entries:r,loadText:g,loadBlob:l,getSize:d=>o.get(d)?.uncompressedSize??0}},IL=async i=>i.isFile?i:(await Promise.all(Array.from(await new Promise((e,t)=>i.createReader().readEntries(A=>e(A),A=>t(A))),IL))).flat(),qK=async i=>{let e=await IL(i),t=await Promise.all(e.map(g=>new Promise((l,I)=>g.file(d=>l([d,g.fullPath]),d=>I(d))))),A=new Map(t.map(([g,l])=>[l.replace(`${i.fullPath}/`,""),g])),s=new TextDecoder,n=g=>g?s.decode(g):null,a=g=>A.get(g)?.arrayBuffer()??null;return{loadText:async g=>n(await a(g)),loadBlob:g=>A.get(g),getSize:g=>A.get(g)?.size??0}},MS=class extends Error{},GS=class extends Error{},vS=class extends Error{},OK=async i=>{let e=await fetch(i);if(!e.ok)throw new MS(`${e.status} ${e.statusText}`,{cause:e});return new File([await e.blob()],new URL(e.url).pathname)},TS=async i=>{typeof i=="string"&&(i=await OK(i));let e;if(i.isDirectory){let t=await qK(i),{EPUB:A}=await Promise.resolve().then(()=>(IE(),Gm));e=await new A(t).init()}else if(i.size)if(await HK(i)){let t=await KK(i);if(YK(i)){let{makeComicBook:A}=await Promise.resolve().then(()=>(lF(),gF));e=A(t,i)}else if(_K(i)){let{makeFB2:A}=await Promise.resolve().then(()=>(Tm(),Um)),{entries:s}=t,n=s.find(r=>r.filename.endsWith(".fb2")),a=await t.loadBlob((n??s[0]).filename);e=await A(a)}else{let{EPUB:A}=await Promise.resolve().then(()=>(IE(),Gm));e=await new A(t).init()}}else if(await JK(i)){let{makePDF:t}=await Promise.resolve().then(()=>($D(),nv));e=await t(i)}else{let{isMOBI:t,MOBI:A}=await Promise.resolve().then(()=>(Cv(),fv));if(await t(i)){let s=await Promise.resolve().then(()=>(bv(),wv));e=await new A({unzlib:s.unzlibSync}).open(i)}else if(PK(i)){let{makeFB2:s}=await Promise.resolve().then(()=>(Tm(),Um));e=await s(i)}}else throw new GS("File not found");if(!e)throw new vS("File type not supported");return e},Jd,Vl,v1,Kn,HS=class HS{constructor(e,t,A={}){D(this,Jd);D(this,Vl);D(this,v1);D(this,Kn);m(this,Vl,e),m(this,v1,t),m(this,Kn,A),h(this,Kn).hidden&&this.hide(),h(this,Vl).addEventListener("mousemove",({screenX:s,screenY:n})=>{s===h(this,Kn).x&&n===h(this,Kn).y||(h(this,Kn).x=s,h(this,Kn).y=n,this.show(),h(this,Jd)&&clearTimeout(h(this,Jd)),t()&&m(this,Jd,setTimeout(this.hide.bind(this),1e3)))},!1)}cloneFor(e){return new HS(e,h(this,v1),h(this,Kn))}hide(){h(this,Vl).style.cursor="none",h(this,Kn).hidden=!0}show(){h(this,Vl).style.removeProperty("cursor"),h(this,Kn).hidden=!1}};Jd=new WeakMap,Vl=new WeakMap,v1=new WeakMap,Kn=new WeakMap;var LS=HS,qn,Fs,US=class extends EventTarget{constructor(){super(...arguments);D(this,qn,[]);D(this,Fs,-1)}pushState(t){let A=h(this,qn)[h(this,Fs)];A===t||A?.fraction&&A.fraction===t.fraction||(h(this,qn)[++hi(this,Fs)._]=t,h(this,qn).length=h(this,Fs)+1,this.dispatchEvent(new Event("index-change")))}replaceState(t){let A=h(this,Fs);h(this,qn)[A]=t}back(){let t=h(this,Fs);if(t<=0)return;let A={state:h(this,qn)[t-1]};m(this,Fs,t-1),this.dispatchEvent(new CustomEvent("popstate",{detail:A})),this.dispatchEvent(new Event("index-change"))}forward(){let t=h(this,Fs);if(t>=h(this,qn).length-1)return;let A={state:h(this,qn)[t+1]};m(this,Fs,t+1),this.dispatchEvent(new CustomEvent("popstate",{detail:A})),this.dispatchEvent(new Event("index-change"))}get canGoBack(){return h(this,Fs)>0}get canGoForward(){return h(this,Fs){if(!i)return{};try{let e=Intl.getCanonicalLocales(i)[0],t=new Intl.Locale(e),A=["zh","ja","kr"].includes(t.language),s=(t.getTextInfo?.()??t.textInfo)?.direction;return{canonical:e,locale:t,isCJK:A,direction:s}}catch(e){return console.warn(e),{}}},Px,qo,Da,bc,Oo,mr,_x,Yt,xr,dL,uL,fL,Yx,CL,BL,EL,G1=class extends HTMLElement{constructor(){super();D(this,Yt);D(this,Px,this.attachShadow({mode:"closed"}));D(this,qo);D(this,Da);D(this,bc);D(this,Oo,new Map);D(this,mr,{type:"outline",options:{}});D(this,_x,new LS(this,()=>this.hasAttribute("autohide-cursor")));pe(this,"isFixedLayout",!1);pe(this,"lastLocation");pe(this,"history",new US);this.history.addEventListener("popstate",({detail:t})=>{let A=this.resolveNavigation(t.state);this.renderer.goTo(A)})}async open(t){if((typeof t=="string"||typeof t.arrayBuffer=="function"||t.isDirectory)&&(t=await TS(t)),this.book=t,this.language=WK(t.metadata?.language),t.splitTOCHref&&t.getTOCFragment){let A=t.sections.map(a=>a.id);m(this,qo,new O1(t.sections,1500,1600));let s=t.splitTOCHref.bind(t),n=t.getTOCFragment.bind(t);m(this,Da,new Vd),await h(this,Da).init({toc:t.toc??[],ids:A,splitHref:s,getFragment:n}),m(this,bc,new Vd),await h(this,bc).init({toc:t.pageList??[],ids:A,splitHref:s,getFragment:n})}if(this.isFixedLayout=this.book.rendition?.layout==="pre-paginated",this.isFixedLayout?(await Promise.resolve().then(()=>(Fv(),kv)),this.renderer=document.createElement("foliate-fxl")):(await Promise.resolve().then(()=>(zv(),Zv)),this.renderer=document.createElement("foliate-paginator")),this.renderer.setAttribute("exportparts","head,foot,filter"),this.renderer.addEventListener("load",A=>x(this,Yt,uL).call(this,A.detail)),this.renderer.addEventListener("relocate",A=>x(this,Yt,dL).call(this,A.detail)),this.renderer.addEventListener("create-overlayer",A=>A.detail.attach(x(this,Yt,CL).call(this,A.detail))),this.renderer.open(t),h(this,Px).append(this.renderer),t.sections.some(A=>A.mediaOverlay)){let A=t.media.activeClass,s=t.media.playbackActiveClass;this.mediaOverlay=t.getMediaOverlay();let n;this.mediaOverlay.addEventListener("highlight",a=>{let r=this.resolveNavigation(a.detail.text);this.renderer.goTo(r).then(()=>{let{doc:o}=this.renderer.getContents().find(g=>g.index=r.index),c=r.anchor(o);c.classList.add(A),s&&c.ownerDocument.documentElement.classList.add(s),n=new WeakRef(c)})}),this.mediaOverlay.addEventListener("unhighlight",()=>{let a=n?.deref();a&&(a.classList.remove(A),s&&a.ownerDocument.documentElement.classList.remove(s))})}}close(){this.renderer?.destroy(),this.renderer?.remove(),m(this,qo,null),m(this,Da,null),m(this,bc,null),m(this,Oo,new Map),this.lastLocation=null,this.history.clear(),this.tts=null,this.mediaOverlay=null}goToTextStart(){return this.goTo(this.book.landmarks?.find(t=>t.type.includes("bodymatter")||t.type.includes("text"))?.href??this.book.sections.findIndex(t=>t.linear!=="no"))}async init({lastLocation:t,showTextStart:A}){let s=t?this.resolveNavigation(t):null;s?(await this.renderer.goTo(s),this.history.pushState(t)):A?await this.goToTextStart():(this.history.pushState(0),await this.next())}async addAnnotation(t,A){let{value:s,indicatorType:n="outline",indicatorOptions:a={}}=t;if(s.startsWith(M1)){let I=s.replace(M1,""),{index:d,anchor:u}=await this.resolveNavigation(I),f=x(this,Yt,Yx).call(this,d);if(f){let{overlayer:C,doc:B}=f;if(A){C.remove(s),C.remove(`${s}::underline`),C.remove(`${s}::tooltip`);return}let E=B?u(B):u,Q;n==="arrow"?Q=jo.arrow:Q=jo.outline,C.add(s,E,Q,a)}return}let r=s.startsWith(hL)?s.replace(hL,""):s,{index:o,anchor:c}=await this.resolveNavigation(r),g=x(this,Yt,Yx).call(this,o);if(g){let{overlayer:I,doc:d}=g;if(I.remove(s),I.remove(`${s}::underline`),I.remove(`${s}::tooltip`),A&&x(this,Yt,xr).call(this,"delete-annotation",{value:s,doc:d}),!A){let u=d?c(d):c,f=(C,B,E)=>{let Q=E?`${s}::${E}`:s;I.add(Q,u,C,B)};x(this,Yt,xr).call(this,"draw-annotation",{draw:f,annotation:t,doc:d,range:u})}}let l=h(this,Da).getProgress(o)?.label??"";return{index:o,label:l}}deleteAnnotation(t){return this.addAnnotation(t,!0)}async showAnnotation(t){let{value:A}=t,s=await this.goTo(A);if(s){let{index:n,anchor:a}=s,{doc:r}=x(this,Yt,Yx).call(this,n),o=a(r);x(this,Yt,xr).call(this,"show-annotation",{value:A,index:n,range:o})}}getCFI(t,A){let s=this.book.sections[t].cfi??Kd.fromIndex(t);return A?Zx(s,$x(A)):s}resolveCFI(t){if(this.book.resolveCFI)return this.book.resolveCFI(t);let A=Vn(t);return{index:Kd.toIndex((A.parent??A).shift()),anchor:a=>_d(a,A)}}resolveNavigation(t){try{if(typeof t=="number")return{index:t};if(typeof t.fraction=="number"){let[A,s]=h(this,qo).getSection(t.fraction);return{index:A,anchor:s}}return jl.test(t)?this.resolveCFI(t):this.book.resolveHref(t)}catch(A){console.error(A),console.error(`Could not resolve target ${t}`)}}async goTo(t){t=decodeURIComponent(t);let A=this.resolveNavigation(t);try{return await this.renderer.goTo(A),this.history.pushState(t),A}catch(s){console.error(s),console.error(`Could not go to ${t}`)}}async goToFraction(t){let[A,s]=h(this,qo).getSection(t);await this.renderer.goTo({index:A,anchor:s}),this.history.pushState({fraction:t})}async select(t){try{let A=await this.resolveNavigation(t);await this.renderer.goTo({...A,select:!0}),this.history.pushState(t)}catch(A){console.error(A),console.error(`Could not go to ${t}`)}}deselect(){for(let{doc:t}of this.renderer.getContents())t.defaultView.getSelection().removeAllRanges()}getSectionFractions(){return(h(this,qo)?.sectionFractions??[]).map(t=>t+Number.EPSILON)}getProgressOf(t,A){let s=h(this,Da)?.getProgress(t,A),n=h(this,bc)?.getProgress(t,A);return{tocItem:s,pageItem:n}}async getTOCItemOf(t){try{let{index:A,anchor:s}=await this.resolveNavigation(t),n=await this.book.sections[A].createDocument(),a=s(n),r=a instanceof Range,o=r?a:n.createRange();return r||o.selectNodeContents(a),h(this,Da).getProgress(A,o)}catch(A){console.error(A),console.error(`Could not get ${t}`)}}async prev(t){await this.renderer.prev(t)}async next(t){await this.renderer.next(t)}goLeft(){return this.book.dir==="rtl"?this.next():this.prev()}goRight(){return this.book.dir==="rtl"?this.prev():this.next()}async*search(t){this.clearSearch();let{searchMatcher:A}=await Promise.resolve().then(()=>(AL(),iL)),{query:s,index:n}=t,a=A(tm,{defaultLocale:this.language,...t}),r=n!=null?x(this,Yt,BL).call(this,a,s,n):x(this,Yt,EL).call(this,a,s),o=[];h(this,Oo).set(n,o);for await(let c of r)if(c.subitems){let g=c.subitems.map(({cfi:l})=>({value:M1+l}));h(this,Oo).set(c.index,g);for(let l of g){let I={...l,indicatorType:h(this,mr).type,indicatorOptions:h(this,mr).options};this.addAnnotation(I)}yield{label:h(this,Da).getProgress(c.index)?.label??"",subitems:c.subitems}}else{if(c.cfi){let g={value:M1+c.cfi};o.push(g);let l={...g,indicatorType:h(this,mr).type,indicatorOptions:h(this,mr).options};this.addAnnotation(l)}yield c}yield"done"}clearSearch(){for(let t of h(this,Oo).values())for(let A of t)this.deleteAnnotation(A);h(this,Oo).clear()}setSearchIndicator(t="outline",A={}){m(this,mr,{type:t,options:A})}async initTTS(t="word",A,s){let n=this.renderer.getContents(),a=this.renderer.primaryIndex,r=n.find(l=>l.index===a)??n[0],o=r?.doc,c=r?.index??0;if(!o)return;if(this.tts&&this.tts.doc===o){A&&(this.tts.highlight=A);return}let{TTS:g}=await Promise.resolve().then(()=>(lL(),gL));this.tts=new g(o,tm,s||null,A||(l=>this.renderer.scrollToAnchor(l,!0)),l=>this.getCFI(c,l),t)}startMediaOverlay(){let{index:t}=this.renderer.getContents()[0];return this.mediaOverlay.start(t)}};Px=new WeakMap,qo=new WeakMap,Da=new WeakMap,bc=new WeakMap,Oo=new WeakMap,mr=new WeakMap,_x=new WeakMap,Yt=new WeakSet,xr=function(t,A,s){return this.dispatchEvent(new CustomEvent(t,{detail:A,cancelable:s}))},dL=function({reason:t,range:A,index:s,fraction:n,size:a}){let r=h(this,qo)?.getProgress(s,n,a)??{},o=h(this,Da)?.getProgress(s,A),c=h(this,bc)?.getProgress(s,A),g=this.getCFI(s,A);this.lastLocation={...r,tocItem:o,pageItem:c,cfi:g,range:A},(t==="snap"||t==="page"||t==="scroll")&&this.history.replaceState(g),x(this,Yt,xr).call(this,"relocate",this.lastLocation)},uL=function({doc:t,index:A}){var s,n;(s=t.documentElement).lang||(s.lang=this.language.canonical??""),this.language.isCJK||(n=t.documentElement).dir||(n.dir=this.language.direction??""),x(this,Yt,fL).call(this,t,A),h(this,_x).cloneFor(t.documentElement),x(this,Yt,xr).call(this,"load",{doc:t,index:A})},fL=function(t,A){let{book:s}=this,n=s.sections[A];t.addEventListener("click",a=>{let r=a.target.closest("a[href]");if(!r)return;a.preventDefault();let o=r.getAttribute("href"),c=n?.resolveHref?.(o)??o;s?.isExternal?.(c)?Promise.resolve(x(this,Yt,xr).call(this,"external-link",{a:r,href:c},!0)).then(g=>g?globalThis.open(c,"_blank"):null).catch(g=>console.error(g)):Promise.resolve(x(this,Yt,xr).call(this,"link",{a:r,href:c},!0)).then(g=>g?this.goTo(c):null).catch(g=>console.error(g))})},Yx=function(t){return this.renderer.getContents().find(A=>A.index===t&&A.overlayer)},CL=function({doc:t,index:A}){let s=new jo;t.addEventListener("click",a=>{let[r,o]=s.hitTest(a);r&&!r.startsWith(M1)&&x(this,Yt,xr).call(this,"show-annotation",{value:r,index:A,range:o})},!1);let n=h(this,Oo).get(A);if(n)for(let a of n){let r={...a,indicatorType:h(this,mr).type,indicatorOptions:h(this,mr).options};this.addAnnotation(r)}return x(this,Yt,xr).call(this,"create-overlay",{index:A}),s},BL=async function*(t,A,s){let n=await this.book.sections[s].createDocument();for(let{range:a,excerpt:r}of t(n,A))yield{cfi:this.getCFI(s,a),excerpt:r}},EL=async function*(t,A){let{sections:s}=this.book;for(let[n,{createDocument:a}]of s.entries()){if(!a)continue;let r=await a(),o=Array.from(t(r,A),({range:g,excerpt:l})=>({cfi:this.getCFI(n,g),excerpt:l}));yield{progress:(n+1)/s.length},o.length&&(yield{index:n,subitems:o})}};customElements.get("foliate-view")||customElements.define("foliate-view",G1);q1();wm();IE();$D();(async()=>{let i=null;try{let e=navigator.userAgentData;if(e&&typeof e.getHighEntropyValues=="function"){let{fullVersionList:t}=await e.getHighEntropyValues(["fullVersionList"]),A=(t||[]).find(s=>/Android WebView|Microsoft Edge/i.test(s.brand));A&&(i=A.version)}}catch{}window.ReactNativeWebView&&window.ReactNativeWebView.postMessage(JSON.stringify({type:"readany-ua",ua:navigator.userAgent,fullVersion:i}))})();window.makeBook=TS;window.Overlayer=jo;window.CFI=qd;window._zipJs={configure:aE,ZipReader:Au,BlobReader:th,TextWriter:iu,BlobWriter:ih};window._EPUB=nu;window._makePDFFromURL=ZD;window._extractPDFChapters=zD;customElements.get("foliate-view")||customElements.define("foliate-view",G1);window.ReactNativeWebView&&window.ReactNativeWebView.postMessage(JSON.stringify({type:"foliate-loaded"}));})(); From b361cc02533fb3c487c9dafc2157cc11f74ff0e7 Mon Sep 17 00:00:00 2001 From: k6G52m4Dz75W <74605402+k6G52m4Dz75W@users.noreply.github.com> Date: Fri, 11 Sep 2026 22:30:43 +0800 Subject: [PATCH 18/22] feat(settings): real toolchain versions on the About tech-stack cards The Tauri/React/TypeScript cards now show the versions the app actually runs on, read from sources that cannot drift: - Tauri: injected at build time by vite define, parsed from the src-tauri/Cargo.lock "tauri" package entry (2.10.2). @tauri-apps/api releases in lockstep but can differ by a patch, and its exports map blocks package.json imports, so the lockfile is the honest source. - React: React.version at runtime (19.1.0). - TypeScript: the resolved typescript/package.json import. Foliate has no meaningful version (vendored, 0.0.0) so it stays version-less; missing versions degrade to the plain name. --- .../src/components/settings/AboutSettings.tsx | 31 ++++++++++++++----- packages/app/src/vite-env.d.ts | 4 +++ packages/app/vite.config.ts | 17 ++++++++++ 3 files changed, 45 insertions(+), 7 deletions(-) diff --git a/packages/app/src/components/settings/AboutSettings.tsx b/packages/app/src/components/settings/AboutSettings.tsx index 995e964cb..07dce3e7e 100644 --- a/packages/app/src/components/settings/AboutSettings.tsx +++ b/packages/app/src/components/settings/AboutSettings.tsx @@ -33,19 +33,31 @@ import { RefreshCw, Shield, Zap, + type LucideIcon, } from "lucide-react"; /** * AboutSettings — 关于页面 */ -import { useEffect, useRef, useState } from "react"; +import React, { useEffect, useRef, useState } from "react"; // Timer cleanup for the copy feedback flag (unmount-safe). import { useTranslation } from "react-i18next"; import { toast } from "sonner"; +import tsPkg from "typescript/package.json"; -const TECH_STACK = [ - { name: "Tauri", descKey: "settings.techStackTauri", icon: Shield }, - { name: "React", descKey: "settings.techStackReact", icon: Code2 }, - { name: "TypeScript", descKey: "settings.techStackTypeScript", icon: Zap }, +type TechStackItem = { + name: string; + version?: string; + descKey: string; + icon: LucideIcon; +}; + +// Versions come from the real installed sources (runtime React, the resolved +// lockfile, the toolchain package) so the cards cannot drift the way a +// hardcoded version label does. +const TECH_STACK: TechStackItem[] = [ + { name: "Tauri", version: __TAURI_VERSION__, descKey: "settings.techStackTauri", icon: Shield }, + { name: "React", version: React.version, descKey: "settings.techStackReact", icon: Code2 }, + { name: "TypeScript", version: tsPkg.version, descKey: "settings.techStackTypeScript", icon: Zap }, { name: "Foliate", descKey: "settings.techStackFoliate", icon: BookOpen }, ]; @@ -305,13 +317,18 @@ export function AboutSettings() {

{t("settings.techStack")}

- {TECH_STACK.map(({ name, descKey, icon: Icon }) => ( + {TECH_STACK.map(({ name, version, descKey, icon: Icon }) => (
-
{name}
+
+ {name} + {version ? ( + {version} + ) : null} +
{t(descKey)}
diff --git a/packages/app/src/vite-env.d.ts b/packages/app/src/vite-env.d.ts index 5ce2949ac..11f26d94b 100644 --- a/packages/app/src/vite-env.d.ts +++ b/packages/app/src/vite-env.d.ts @@ -1,5 +1,9 @@ /// +// Injected by vite.config.ts from src-tauri/Cargo.lock — the real Tauri +// framework version shown on the About tech-stack cards. +declare const __TAURI_VERSION__: string; + declare interface PromiseConstructor { withResolvers(): { promise: Promise; diff --git a/packages/app/vite.config.ts b/packages/app/vite.config.ts index d17093a73..605298bbb 100644 --- a/packages/app/vite.config.ts +++ b/packages/app/vite.config.ts @@ -1,3 +1,4 @@ +import fs from "node:fs"; import path from "node:path"; import tailwindcss from "@tailwindcss/vite"; import react from "@vitejs/plugin-react"; @@ -7,8 +8,24 @@ import { defineConfig } from "vite"; const host = process.env.TAURI_DEV_HOST; const pdfjsDist = path.resolve(__dirname, "../../node_modules/pdfjs-dist"); +// The About dialog shows the Tauri framework version. @tauri-apps/api releases +// in lockstep but can differ by a patch, and its package.json is not importable +// (exports map), so read the version the app actually builds against from the +// lockfile. Empty string = the card renders without a version. +function readTauriVersion(): string { + try { + const lock = fs.readFileSync(path.resolve(__dirname, "src-tauri/Cargo.lock"), "utf-8"); + return lock.match(/^name = "tauri"\r?\nversion = "([^"]+)"/m)?.[1] ?? ""; + } catch { + return ""; + } +} + // https://vite.dev/config/ export default defineConfig(async () => ({ + define: { + __TAURI_VERSION__: JSON.stringify(readTauriVersion()), + }, plugins: [react(), tailwindcss()], worker: { format: "es", From 9e1710318a6f2373c8f9addf648d7f4d2f1f3ea2 Mon Sep 17 00:00:00 2001 From: k6G52m4Dz75W <74605402+k6G52m4Dz75W@users.noreply.github.com> Date: Fri, 11 Sep 2026 22:30:55 +0800 Subject: [PATCH 19/22] feat(mobile): derive the About tech-stack labels from installed versions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The About screen hardcoded "Expo SDK 55" while the installed expo is 54.0.33 (the upstream pin is ~54.0.33 too) — the label had drifted. Derive it from the expo package's major (== SDK number since SDK 51), and take React Native from Platform.constants.reactNativeVersion (0.81.5) instead of an unnamed label. Also drop the hardcoded SDK number from the expo-platform-service doc comment so it cannot go stale again. --- .../src/lib/platform/expo-platform-service.ts | 3 ++- .../app-expo/src/screens/settings/AboutScreen.tsx | 13 +++++++++++-- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/packages/app-expo/src/lib/platform/expo-platform-service.ts b/packages/app-expo/src/lib/platform/expo-platform-service.ts index 6b20d9daa..fef13a3ad 100644 --- a/packages/app-expo/src/lib/platform/expo-platform-service.ts +++ b/packages/app-expo/src/lib/platform/expo-platform-service.ts @@ -2,7 +2,8 @@ import i18n from "@readany/core/i18n"; /** * ExpoPlatformService — IPlatformService implementation for Expo / React Native. * - * Uses Expo SDK 55+ modules: + * Uses Expo SDK modules (the actual SDK number lives in package.json — + * hardcoded numbers here went stale before): * - expo-file-system (new File/Directory/Paths API) for FS operations * - expo-sqlite for database * - expo-secure-store for KV storage diff --git a/packages/app-expo/src/screens/settings/AboutScreen.tsx b/packages/app-expo/src/screens/settings/AboutScreen.tsx index 7f78889b9..7a5310ced 100644 --- a/packages/app-expo/src/screens/settings/AboutScreen.tsx +++ b/packages/app-expo/src/screens/settings/AboutScreen.tsx @@ -4,6 +4,7 @@ import { useWebviewLabel } from "@/stores/webview-info-store"; import { getPlatformService } from "@readany/core/services"; import { checkForUpdate } from "@readany/core/update"; import * as Clipboard from "expo-clipboard"; +import expoPkg from "expo/package.json"; import { useCallback, useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { @@ -11,6 +12,7 @@ import { Alert, Image, Linking, + Platform, ScrollView, StyleSheet, Text, @@ -29,9 +31,16 @@ import { } from "../../styles/theme"; import { SettingsHeader } from "./SettingsHeader"; +// The expo package's major version has matched the SDK number since SDK 51, +// so derive the label instead of hardcoding it (a hardcoded "55" went stale +// while the installed expo is 54.x). RN comes from the runtime's own constant. +const EXPO_SDK_LABEL = `Expo SDK ${expoPkg.version.split(".")[0]}`; +const rnVersion = Platform.constants.reactNativeVersion; +const REACT_NATIVE_VERSION = `${rnVersion.major}.${rnVersion.minor}.${rnVersion.patch}`; + const TECH_STACK = [ - { label: "Expo SDK 55", descKey: "about.nativeContainer" }, - { label: "React Native", descKey: "about.uiFramework" }, + { label: EXPO_SDK_LABEL, descKey: "about.nativeContainer" }, + { label: `React Native ${REACT_NATIVE_VERSION}`, descKey: "about.uiFramework" }, { label: "Foliate.js", descKey: "about.ebookRenderer" }, { label: "SQLite", descKey: "about.localDatabase" }, ]; From 04ca4533aaef29855805a68fa4f162c6dff3f694 Mon Sep 17 00:00:00 2001 From: k6G52m4Dz75W <74605402+k6G52m4Dz75W@users.noreply.github.com> Date: Fri, 11 Sep 2026 22:48:05 +0800 Subject: [PATCH 20/22] docs(mobile): drop another stale version number OCR flagged MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The section comment said "expo-file-system v55" while the installed expo-file-system is 19.0.21 — same drift class the previous commit removed from the header comment; this one slipped through. --- packages/app-expo/src/lib/platform/expo-platform-service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/app-expo/src/lib/platform/expo-platform-service.ts b/packages/app-expo/src/lib/platform/expo-platform-service.ts index fef13a3ad..6ff523ed4 100644 --- a/packages/app-expo/src/lib/platform/expo-platform-service.ts +++ b/packages/app-expo/src/lib/platform/expo-platform-service.ts @@ -38,7 +38,7 @@ export class ExpoPlatformService implements IPlatformService { readonly isMobile = true; readonly isDesktop = false; - // ---- File system (expo-file-system v55 — File/Directory/Paths API) ---- + // ---- File system (expo-file-system — File/Directory/Paths API) ---- async readFile(path: string): Promise { try { From df0a3d817b70db9c03323f9dc0e634b67c7e25fe Mon Sep 17 00:00:00 2001 From: k6G52m4Dz75W <74605402+k6G52m4Dz75W@users.noreply.github.com> Date: Fri, 11 Sep 2026 22:55:42 +0800 Subject: [PATCH 21/22] refactor(settings): inject the TypeScript version like the Tauri one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Application source no longer imports a dev-only package's manifest for one field. vite.config.ts reads typescript/package.json through createRequire (so hoisting cannot break the path) and defines __TS_VERSION__ next to __TAURI_VERSION__ — same source pattern, same empty-string fallback, and the TypeScript manifest stays out of the client bundle (OCR review follow-up). --- .../app/src/components/settings/AboutSettings.tsx | 9 ++++----- packages/app/src/vite-env.d.ts | 4 ++++ packages/app/vite.config.ts | 14 ++++++++++++++ 3 files changed, 22 insertions(+), 5 deletions(-) diff --git a/packages/app/src/components/settings/AboutSettings.tsx b/packages/app/src/components/settings/AboutSettings.tsx index 07dce3e7e..238d5aad8 100644 --- a/packages/app/src/components/settings/AboutSettings.tsx +++ b/packages/app/src/components/settings/AboutSettings.tsx @@ -42,7 +42,6 @@ import React, { useEffect, useRef, useState } from "react"; // Timer cleanup for the copy feedback flag (unmount-safe). import { useTranslation } from "react-i18next"; import { toast } from "sonner"; -import tsPkg from "typescript/package.json"; type TechStackItem = { name: string; @@ -51,13 +50,13 @@ type TechStackItem = { icon: LucideIcon; }; -// Versions come from the real installed sources (runtime React, the resolved -// lockfile, the toolchain package) so the cards cannot drift the way a -// hardcoded version label does. +// Versions come from the real installed sources (runtime React, build-time +// defines read from the lockfile and the resolved toolchain) so the cards +// cannot drift the way a hardcoded version label does. const TECH_STACK: TechStackItem[] = [ { name: "Tauri", version: __TAURI_VERSION__, descKey: "settings.techStackTauri", icon: Shield }, { name: "React", version: React.version, descKey: "settings.techStackReact", icon: Code2 }, - { name: "TypeScript", version: tsPkg.version, descKey: "settings.techStackTypeScript", icon: Zap }, + { name: "TypeScript", version: __TS_VERSION__, descKey: "settings.techStackTypeScript", icon: Zap }, { name: "Foliate", descKey: "settings.techStackFoliate", icon: BookOpen }, ]; diff --git a/packages/app/src/vite-env.d.ts b/packages/app/src/vite-env.d.ts index 11f26d94b..858b3cbcb 100644 --- a/packages/app/src/vite-env.d.ts +++ b/packages/app/src/vite-env.d.ts @@ -4,6 +4,10 @@ // framework version shown on the About tech-stack cards. declare const __TAURI_VERSION__: string; +// Injected by vite.config.ts from the resolved typescript/package.json — +// same pattern, keeps a dev-only manifest out of the client bundle. +declare const __TS_VERSION__: string; + declare interface PromiseConstructor { withResolvers(): { promise: Promise; diff --git a/packages/app/vite.config.ts b/packages/app/vite.config.ts index 605298bbb..f8c5b3025 100644 --- a/packages/app/vite.config.ts +++ b/packages/app/vite.config.ts @@ -1,3 +1,4 @@ +import { createRequire } from "node:module"; import fs from "node:fs"; import path from "node:path"; import tailwindcss from "@tailwindcss/vite"; @@ -21,10 +22,23 @@ function readTauriVersion(): string { } } +// Same pattern for the TypeScript toolchain version: application source must +// not import a dev-only package's manifest for one field. Resolve through +// Node so hoisting cannot break the path. +function readTypeScriptVersion(): string { + try { + const require = createRequire(path.resolve(__dirname, "package.json")); + return require("typescript/package.json").version ?? ""; + } catch { + return ""; + } +} + // https://vite.dev/config/ export default defineConfig(async () => ({ define: { __TAURI_VERSION__: JSON.stringify(readTauriVersion()), + __TS_VERSION__: JSON.stringify(readTypeScriptVersion()), }, plugins: [react(), tailwindcss()], worker: { From 7f39c949e2f4b8684cdca48559c95bbc651e1024 Mon Sep 17 00:00:00 2001 From: k6G52m4Dz75W <74605402+k6G52m4Dz75W@users.noreply.github.com> Date: Fri, 11 Sep 2026 23:18:20 +0800 Subject: [PATCH 22/22] fix(mobile): show the full expo package version on the tech-stack card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Expo SDK 54" only tracked the SDK release line; an in-SDK patch bump (54.0.33 → 54.0.35) was invisible — the version display's whole purpose is environment reproduction. "Expo 54.0.33" matches the granularity of the other cards (Tauri 2.10.2, React Native 0.81.5) and loses nothing: the major IS the SDK number. --- .../app-expo/src/screens/settings/AboutScreen.tsx | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/app-expo/src/screens/settings/AboutScreen.tsx b/packages/app-expo/src/screens/settings/AboutScreen.tsx index 7a5310ced..91e6e3c3b 100644 --- a/packages/app-expo/src/screens/settings/AboutScreen.tsx +++ b/packages/app-expo/src/screens/settings/AboutScreen.tsx @@ -31,15 +31,16 @@ import { } from "../../styles/theme"; import { SettingsHeader } from "./SettingsHeader"; -// The expo package's major version has matched the SDK number since SDK 51, -// so derive the label instead of hardcoding it (a hardcoded "55" went stale -// while the installed expo is 54.x). RN comes from the runtime's own constant. -const EXPO_SDK_LABEL = `Expo SDK ${expoPkg.version.split(".")[0]}`; +// The full expo package version, same granularity as the Tauri and React +// Native labels: an in-SDK patch bump (54.0.33 → 54.0.35) is exactly the +// kind of difference a bug report needs. The major IS the SDK number +// (54 here), so no information is lost versus the "Expo SDK 54" phrasing. +const EXPO_LABEL = `Expo ${expoPkg.version}`; const rnVersion = Platform.constants.reactNativeVersion; const REACT_NATIVE_VERSION = `${rnVersion.major}.${rnVersion.minor}.${rnVersion.patch}`; const TECH_STACK = [ - { label: EXPO_SDK_LABEL, descKey: "about.nativeContainer" }, + { label: EXPO_LABEL, descKey: "about.nativeContainer" }, { label: `React Native ${REACT_NATIVE_VERSION}`, descKey: "about.uiFramework" }, { label: "Foliate.js", descKey: "about.ebookRenderer" }, { label: "SQLite", descKey: "about.localDatabase" },