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/20] 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/20] 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/20] 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/20] 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 631c59aa202267276f4018be346a54a6860373b5 Mon Sep 17 00:00:00 2001 From: k6G52m4Dz75W <74605402+k6G52m4Dz75W@users.noreply.github.com> Date: Sat, 19 Sep 2026 00:36:49 +0800 Subject: [PATCH 18/20] feat(desktop): query the real WebView build from the Tauri runtime MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirror readest#6275: Chromium's UA-Reduction freezes the User-Agent to a stub on Windows WebView2, and the UA carries frozen fallback tokens for the WebKit engines — so replace the whole User-Agent Client Hints mechanism (brand table + getHighEntropyValues round-trip) with a single get_webview_version command backed by tauri::webview_version(), which returns the real build on every desktop platform (WebView2 via GetAvailableCoreWebView2BrowserVersionString, WebKit via the framework bundle, WebKitGTK via webkit_get_*_version). The engine label stays with the UA parse (the runtime query has no brand); the label falls back to the UA-derived version when the command is unavailable — plain vite dev in a browser or a failed invoke. Side effect beyond the simplification: the macOS and Linux labels stop degrading to an engine-only string — the runtime query fills in the framework build the UA could never provide. The mobile probe pipeline (RN has no runtime-query equivalent) is untouched. --- packages/app/src-tauri/src/lib.rs | 31 +++++++++++++ packages/app/src/lib/webview-info.ts | 69 +++++++++++----------------- 2 files changed, 57 insertions(+), 43 deletions(-) diff --git a/packages/app/src-tauri/src/lib.rs b/packages/app/src-tauri/src/lib.rs index a782cb4bd..acf9caa07 100644 --- a/packages/app/src-tauri/src/lib.rs +++ b/packages/app/src-tauri/src/lib.rs @@ -8,6 +8,36 @@ use std::sync::Mutex; use tauri::Manager; use vector::VectorDBState; +#[derive(serde::Serialize)] +struct WebViewInfo { + engine: String, + version: String, +} + +/// The WebView runtime's real build number for Settings → About and the +/// feedback device info. The User-Agent is reduced to a stub on Windows +/// WebView2 (UA Reduction) and carries frozen fallback tokens for the WebKit +/// engines, so the runtime's own query is the only reliable source on every +/// desktop platform. The engine label stays with the frontend's UA parse — +/// the runtime query has no brand. +#[tauri::command] +fn get_webview_version() -> Option { + let engine = match std::env::consts::OS { + "windows" => "WebView2", + "macos" => "WebKit", + "linux" => "WebKitGTK", + _ => return None, + }; + let version = tauri::webview_version().ok()?.trim().to_string(); + if version.is_empty() { + return None; + } + Some(WebViewInfo { + engine: engine.to_string(), + version, + }) +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() @@ -46,6 +76,7 @@ pub fn run() { vector::vector_reinit, vector::vector_shutdown, readany_cli::readany_cli_run, + get_webview_version, ]) .setup(|app| { let app_handle = app.handle().clone(); diff --git a/packages/app/src/lib/webview-info.ts b/packages/app/src/lib/webview-info.ts index 88641271e..af86f6a9e 100644 --- a/packages/app/src/lib/webview-info.ts +++ b/packages/app/src/lib/webview-info.ts @@ -2,13 +2,21 @@ * Web-engine detection for the desktop app (Settings → About). The parsing * itself lives in core (packages/core/src/utils/webview-info.ts) so the mobile * app can parse the reader WebView's UA with identical results; this wrapper - * only adds the Tauri-runtime check and the Chromium Client Hints lookup. + * adds the Tauri-runtime check and the runtime version query. + * + * The VERSION comes from the Tauri runtime (`tauri::webview_version()`): + * the User-Agent is reduced to a stub on Windows WebView2 (UA Reduction, + * e.g. Edg/152.0.0.0 on a 152.0.4191.62 runtime) and carries frozen fallback + * tokens for the WebKit engines, while the runtime query returns the real + * build on every desktop platform. Only the ENGINE label stays with the UA + * parse — the runtime query has no brand. * * Version floors differ per engine (e.g. :has() needs WebView2 ≥ 105 / * WebKitGTK ≥ 2.36), which is exactly why the exact build matters. Detection * is display-only diagnostics, not a security boundary. */ +import { invoke } from "@tauri-apps/api/core"; import { formatWebviewInfo, parseWebviewInfo } from "@readany/core/utils/webview-info"; import type { WebviewInfo } from "@readany/core/utils/webview-info"; @@ -22,50 +30,25 @@ export function getWebviewInfo(ua: string = navigator.userAgent): WebviewInfo { } /** - * 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). WebView2 - * reports a distinct brand of its own — match loosely (the mobile probe in - * app-expo/src/components/common/UAProbe.tsx keeps the same list in sync). - */ -const CLIENT_HINT_BRANDS: Record = { - WebView2: /Microsoft Edge/i, - Edge: /Microsoft Edge/i, - "Android WebView": /Android WebView/i, - Chrome: /Google Chrome/i, -}; - -async function getFullVersionFromClientHints(engine: string): Promise { - const brandPattern = CLIENT_HINT_BRANDS[engine]; - if (!brandPattern) 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) => brandPattern.test(entry.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. + * Display label for Settings → About, async because the real build number + * needs a round-trip to the Rust runtime. Falls back to the UA-parsed version + * (reduced on WebView2) when the query is unavailable — plain `vite` dev in a + * browser, or a failed command. */ export async function getWebviewLabel(): Promise { const { engine, version } = getWebviewInfo(); if (!engine) return ""; - const fullVersion = (await getFullVersionFromClientHints(engine)) || version; - return formatWebviewInfo({ engine, version: fullVersion }); + if (isTauriRuntime()) { + try { + const native = await invoke<{ engine: string; version: string } | null>( + "get_webview_version", + ); + if (native?.version) { + return formatWebviewInfo({ engine, version: native.version }); + } + } catch (error) { + console.warn("[webview-info] get_webview_version failed, falling back to UA:", error); + } + } + return formatWebviewInfo({ engine, version }); } From 4f929c649f6ae8b2280aa424abfecb2a8114a4ac Mon Sep 17 00:00:00 2001 From: k6G52m4Dz75W <74605402+k6G52m4Dz75W@users.noreply.github.com> Date: Sat, 19 Sep 2026 10:26:56 +0800 Subject: [PATCH 19/20] =?UTF-8?q?fix(desktop):=20OCR=20follow-ups=20?= =?UTF-8?q?=E2=80=94=20diagnosable=20fallbacks,=20single=20engine=20author?= =?UTF-8?q?ity?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rust: log webview_version() failures and empty results before returning None — a resolved null at the IPC boundary was otherwise indistinguishable from an unsupported platform, silently degrading the label to the UA-reduced version (the frontend only logs on invoke rejection). - Frontend: prefer the runtime's engine label over the UA parse so the two sources can never disagree on the same machine; reuse the shared WebviewInfo type for the invoke result instead of a diverging structural copy. --- packages/app/src-tauri/src/lib.rs | 13 ++++++++++++- packages/app/src/lib/webview-info.ts | 10 +++++----- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/packages/app/src-tauri/src/lib.rs b/packages/app/src-tauri/src/lib.rs index acf9caa07..24c36b60f 100644 --- a/packages/app/src-tauri/src/lib.rs +++ b/packages/app/src-tauri/src/lib.rs @@ -28,8 +28,19 @@ fn get_webview_version() -> Option { "linux" => "WebKitGTK", _ => return None, }; - let version = tauri::webview_version().ok()?.trim().to_string(); + let version = match tauri::webview_version() { + Ok(v) => v.trim().to_string(), + // A genuine desktop query failure must stay distinguishable from an + // unsupported platform: the frontend only logs on invoke rejection, + // so a resolved None with no trace would silently degrade to the + // UA-reduced version. + Err(e) => { + eprintln!("[webview-info] webview_version() failed: {e}"); + return None; + } + }; if version.is_empty() { + eprintln!("[webview-info] webview_version() returned an empty string"); return None; } Some(WebViewInfo { diff --git a/packages/app/src/lib/webview-info.ts b/packages/app/src/lib/webview-info.ts index af86f6a9e..fcc23e6d8 100644 --- a/packages/app/src/lib/webview-info.ts +++ b/packages/app/src/lib/webview-info.ts @@ -40,11 +40,11 @@ export async function getWebviewLabel(): Promise { if (!engine) return ""; if (isTauriRuntime()) { try { - const native = await invoke<{ engine: string; version: string } | null>( - "get_webview_version", - ); - if (native?.version) { - return formatWebviewInfo({ engine, version: native.version }); + const native = await invoke("get_webview_version"); + // The runtime's OS→engine mapping is the desktop authority — prefer it + // over the UA parse so the two can never disagree on the same machine. + if (native?.engine && native.version) { + return formatWebviewInfo({ engine: native.engine, version: native.version }); } } catch (error) { console.warn("[webview-info] get_webview_version failed, falling back to UA:", error); From ca8b032ce0ffed95bfc0d7f244c2b6e8360ad509 Mon Sep 17 00:00:00 2001 From: k6G52m4Dz75W <74605402+k6G52m4Dz75W@users.noreply.github.com> Date: Sat, 19 Sep 2026 10:31:40 +0800 Subject: [PATCH 20/20] fix(desktop): let the runtime query answer even when the UA parse is empty MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-2 OCR: the 'if (!engine) return ""' guard ran BEFORE the native attempt, so an unknown/unsupported UA still short-circuited to an empty label — exactly the UA-owns-the-engine behavior the previous commit documented abandoning. Reorder to try the runtime query first; the UA parse now serves only the fallback path (vite dev in a browser, failed invoke), where formatWebviewInfo already collapses a missing engine to an empty string. Also align the module header, the getWebviewLabel JSDoc, and the Rust command doc with the new contract (runtime is the desktop authority for BOTH engine and version) — they still carried the stale 'the engine label stays with the UA parse' claim. --- packages/app/src-tauri/src/lib.rs | 7 +++---- packages/app/src/lib/webview-info.ts | 24 +++++++++++++----------- 2 files changed, 16 insertions(+), 15 deletions(-) diff --git a/packages/app/src-tauri/src/lib.rs b/packages/app/src-tauri/src/lib.rs index 24c36b60f..77a458448 100644 --- a/packages/app/src-tauri/src/lib.rs +++ b/packages/app/src-tauri/src/lib.rs @@ -14,12 +14,11 @@ struct WebViewInfo { version: String, } -/// The WebView runtime's real build number for Settings → About and the +/// The WebView engine label + real build number for Settings → About and the /// feedback device info. The User-Agent is reduced to a stub on Windows /// WebView2 (UA Reduction) and carries frozen fallback tokens for the WebKit -/// engines, so the runtime's own query is the only reliable source on every -/// desktop platform. The engine label stays with the frontend's UA parse — -/// the runtime query has no brand. +/// engines, so this runtime query is the desktop authority for BOTH fields; +/// the frontend falls back to its UA parse when this returns None. #[tauri::command] fn get_webview_version() -> Option { let engine = match std::env::consts::OS { diff --git a/packages/app/src/lib/webview-info.ts b/packages/app/src/lib/webview-info.ts index fcc23e6d8..5542f3a67 100644 --- a/packages/app/src/lib/webview-info.ts +++ b/packages/app/src/lib/webview-info.ts @@ -4,12 +4,13 @@ * app can parse the reader WebView's UA with identical results; this wrapper * adds the Tauri-runtime check and the runtime version query. * - * The VERSION comes from the Tauri runtime (`tauri::webview_version()`): - * the User-Agent is reduced to a stub on Windows WebView2 (UA Reduction, - * e.g. Edg/152.0.0.0 on a 152.0.4191.62 runtime) and carries frozen fallback - * tokens for the WebKit engines, while the runtime query returns the real - * build on every desktop platform. Only the ENGINE label stays with the UA - * parse — the runtime query has no brand. + * Inside the Tauri runtime, BOTH the engine label and the version come from + * the runtime query (`tauri::webview_version()` + an OS→engine mapping): + * the User-Agent is reduced to a stub on Windows WebView2 (UA Reduction) and + * carries frozen fallback tokens for the WebKit engines, so the runtime is + * the only reliable source on every desktop platform. The UA parse is the + * fallback path — plain `vite` dev in a browser or a failed command — where + * the version is reduced on WebView2 and frozen on WebKit. * * Version floors differ per engine (e.g. :has() needs WebView2 ≥ 105 / * WebKitGTK ≥ 2.36), which is exactly why the exact build matters. Detection @@ -31,13 +32,13 @@ export function getWebviewInfo(ua: string = navigator.userAgent): WebviewInfo { /** * Display label for Settings → About, async because the real build number - * needs a round-trip to the Rust runtime. Falls back to the UA-parsed version - * (reduced on WebView2) when the query is unavailable — plain `vite` dev in a - * browser, or a failed command. + * needs a round-trip to the Rust runtime. Inside the Tauri runtime the + * command's OS→engine mapping + runtime build is the authority (it answers + * even when the UA parse comes up empty); the UA parse is the fallback when + * the command is unavailable — plain `vite` dev in a browser, or a failed + * invoke — with the version reduced on WebView2 in that path. */ export async function getWebviewLabel(): Promise { - const { engine, version } = getWebviewInfo(); - if (!engine) return ""; if (isTauriRuntime()) { try { const native = await invoke("get_webview_version"); @@ -50,5 +51,6 @@ export async function getWebviewLabel(): Promise { console.warn("[webview-info] get_webview_version failed, falling back to UA:", error); } } + const { engine, version } = getWebviewInfo(); return formatWebviewInfo({ engine, version }); }