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/17] 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/17] 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/17] 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 || "..."}
+
void handleCopyVersion()}
+ className="rounded p-1 text-muted-foreground opacity-0 transition-opacity hover:bg-muted hover:text-foreground focus-visible:opacity-100 group-hover:opacity-100"
+ title={t("settings.copyVersionInfo")}
+ aria-label={t("settings.copyVersionInfo")}
+ >
+ {copied ? : }
+
Date: Wed, 9 Sep 2026 22:22:49 +0800
Subject: [PATCH 04/17] fix(settings): always show the version-card copy button
Hover-reveal hid the affordance; keep it visible like the neighboring
check-update button.
---
packages/app/src/components/settings/AboutSettings.tsx | 8 ++++----
1 file changed, 4 insertions(+), 4 deletions(-)
diff --git a/packages/app/src/components/settings/AboutSettings.tsx b/packages/app/src/components/settings/AboutSettings.tsx
index 712f3f586..8e46681cd 100644
--- a/packages/app/src/components/settings/AboutSettings.tsx
+++ b/packages/app/src/components/settings/AboutSettings.tsx
@@ -162,9 +162,9 @@ export function AboutSettings() {
{t("settings.aboutDesc")}
- {/* 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() {
void handleCopyVersion()}
- className="rounded p-1 text-muted-foreground opacity-0 transition-opacity hover:bg-muted hover:text-foreground focus-visible:opacity-100 group-hover:opacity-100"
+ className="rounded p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
title={t("settings.copyVersionInfo")}
aria-label={t("settings.copyVersionInfo")}
>
From 5fbbe2ae85d24a990ad0f101b2cad0f1502cce40 Mon Sep 17 00:00:00 2001
From: k6G52m4Dz75W <74605402+k6G52m4Dz75W@users.noreply.github.com>
Date: Wed, 9 Sep 2026 22:45:20 +0800
Subject: [PATCH 05/17] feat(mobile): web engine row on About, synced with the
desktop card
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The webview-version feature was desktop-only; bring it to the Expo app's
About screen:
- Move the UA parser to core (packages/core/src/utils/webview-info.ts)
so both apps produce identical engine/version labels; the desktop
wrapper keeps its Tauri check and Client Hints lookup.
- The mobile app has no real UA (App.tsx polyfills "ReactNative"), so
reader.html now reports its own navigator.userAgent over the RN bridge
once at startup (type: readany-ua); use-reader-bridge stores it in a
small zustand store. It is the system WebView's actual UA — the exact
engine the book content renders in.
- AboutScreen gains a tappable WebView-version row: touch has no hover,
so the row itself is the affordance — tapping copies both versions
(ReadAny vX + engine label) via expo-clipboard, with a transient
check-mark feedback. Before a book is opened it shows the engine name
without a build.
---
packages/app-expo/scripts/build-reader.js | 6 +
.../app-expo/src/hooks/use-reader-bridge.ts | 419 +++++++++---------
.../src/screens/settings/AboutScreen.tsx | 67 ++-
.../app-expo/src/stores/webview-info-store.ts | 18 +
packages/app/src/lib/webview-info.ts | 83 +---
packages/core/src/utils/index.ts | 2 +
packages/core/src/utils/webview-info.ts | 78 ++++
7 files changed, 386 insertions(+), 287 deletions(-)
create mode 100644 packages/app-expo/src/stores/webview-info-store.ts
create mode 100644 packages/core/src/utils/webview-info.ts
diff --git a/packages/app-expo/scripts/build-reader.js b/packages/app-expo/scripts/build-reader.js
index fe180c0f6..d045056c6 100644
--- a/packages/app-expo/scripts/build-reader.js
+++ b/packages/app-expo/scripts/build-reader.js
@@ -17,6 +17,12 @@ const OUTPUT = path.resolve(ASSETS_DIR, "reader.html");
async function buildReader() {
// Create a temporary entry point
const entryContent = `
+ // Report the WebView's own UA to RN once at startup — Settings → About
+ // shows it as the "WebView version" (RN itself has no real UA).
+ if (window.ReactNativeWebView) {
+ window.ReactNativeWebView.postMessage(JSON.stringify({ type: 'readany-ua', ua: navigator.userAgent }));
+ }
+
import { makeBook, View } from "${FOLIATE_DIR.replace(/\\/g, "/")}/view.js";
import { Overlayer } from "${FOLIATE_DIR.replace(/\\/g, "/")}/overlayer.js";
import * as CFI from "${FOLIATE_DIR.replace(/\\/g, "/")}/epubcfi.js";
diff --git a/packages/app-expo/src/hooks/use-reader-bridge.ts b/packages/app-expo/src/hooks/use-reader-bridge.ts
index 07bed2b88..2d1deb706 100644
--- a/packages/app-expo/src/hooks/use-reader-bridge.ts
+++ b/packages/app-expo/src/hooks/use-reader-bridge.ts
@@ -1,4 +1,5 @@
import { useSettingsStore } from "@/stores";
+import { useWebviewInfoStore } from "@/stores/webview-info-store";
import type { TOCItem } from "@readany/core/types";
/**
* useReaderBridge — encapsulates RN ↔ WebView postMessage communication
@@ -712,232 +713,234 @@ export function useReaderBridge(callbacks: ReaderBridgeCallbacks) {
// ─── Handle messages from WebView ───
- const handleMessage = useCallback(
- (event: { nativeEvent: { data: string } }) => {
- try {
- const msg = JSON.parse(event.nativeEvent.data);
- const cb = callbacksRef.current;
-
- switch (msg.type) {
- case "ready":
- cb.onReady?.();
- break;
- case "loaded":
- cb.onLoaded?.();
- break;
- case "relocate":
- cb.onRelocate?.(msg);
- break;
- case "bookTextMetrics":
- cb.onBookTextMetrics?.({
- totalCharacters: Number(msg.totalCharacters) || 0,
+ const handleMessage = useCallback((event: { nativeEvent: { data: string } }) => {
+ try {
+ const msg = JSON.parse(event.nativeEvent.data);
+ const cb = callbacksRef.current;
+
+ switch (msg.type) {
+ case "ready":
+ cb.onReady?.();
+ break;
+ case "loaded":
+ cb.onLoaded?.();
+ break;
+ case "relocate":
+ cb.onRelocate?.(msg);
+ break;
+ case "bookTextMetrics":
+ cb.onBookTextMetrics?.({
+ totalCharacters: Number(msg.totalCharacters) || 0,
+ });
+ break;
+ case "toc":
+ cb.onTocReady?.(msg.items || []);
+ break;
+ case "selection":
+ cb.onSelection?.(msg);
+ break;
+ case "selectionCleared":
+ cb.onSelectionCleared?.();
+ break;
+ case "tap":
+ cb.onTap?.();
+ break;
+ case "searchResult":
+ cb.onSearchResult?.(msg.index || 0, msg.count || 0);
+ break;
+ case "searchComplete":
+ cb.onSearchComplete?.(msg.count || 0);
+ break;
+ case "error":
+ console.error("[ReaderBridge] Error from WebView:", msg.message);
+ cb.onError?.(msg.message || "Unknown error");
+ break;
+ case "foliate-loaded":
+ break;
+ case "readany-ua":
+ // reader.html reports its navigator.userAgent once at startup —
+ // Settings → About displays it as the WebView version.
+ if (msg.ua) useWebviewInfoStore.getState().setUa(String(msg.ua));
+ break;
+ case "show-annotation":
+ if (msg.value && msg.position) {
+ cb.onShowAnnotation?.({
+ value: msg.value,
+ range: msg.range,
+ position: msg.position,
});
- break;
- case "toc":
- cb.onTocReady?.(msg.items || []);
- break;
- case "selection":
- cb.onSelection?.(msg);
- break;
- case "selectionCleared":
- cb.onSelectionCleared?.();
- break;
- case "tap":
- cb.onTap?.();
- break;
- case "searchResult":
- cb.onSearchResult?.(msg.index || 0, msg.count || 0);
- break;
- case "searchComplete":
- cb.onSearchComplete?.(msg.count || 0);
- break;
- case "error":
- console.error("[ReaderBridge] Error from WebView:", msg.message);
- cb.onError?.(msg.message || "Unknown error");
- break;
- case "foliate-loaded":
- break;
- case "show-annotation":
- if (msg.value && msg.position) {
- cb.onShowAnnotation?.({
- value: msg.value,
- range: msg.range,
- position: msg.position,
- });
- }
- break;
- case "note-tooltip":
- if (msg.cfi && msg.note && msg.position) {
- cb.onNoteTooltip?.({
- cfi: msg.cfi,
- note: msg.note,
- position: msg.position,
- });
- }
- break;
- case "pageSnippet":
- cb.onPageSnippet?.(msg.textSnippet || "");
- break;
- case "bookmarkSnippet":
- cb.onBookmarkSnippet?.(msg.textSnippet || "");
- break;
- case "toggleBookmark":
- cb.onToggleBookmark?.();
- break;
- case "bookmarkPull":
- cb.onBookmarkPull?.({
- offset: typeof msg.offset === "number" ? msg.offset : 0,
- armed: !!msg.armed,
- active: !!msg.active,
+ }
+ break;
+ case "note-tooltip":
+ if (msg.cfi && msg.note && msg.position) {
+ cb.onNoteTooltip?.({
+ cfi: msg.cfi,
+ note: msg.note,
+ position: msg.position,
});
- break;
- case "visibleText":
- console.log(
- "[ReaderBridge] received visibleText:",
- JSON.stringify({
- textLength: msg.text?.length || 0,
- error: msg.error || "none",
- debug: msg.debug || null,
- }),
- );
- if (pendingVisibleTextResolveRef.current) {
- pendingVisibleTextResolveRef.current(msg.text || "");
- pendingVisibleTextResolveRef.current = null;
+ }
+ break;
+ case "pageSnippet":
+ cb.onPageSnippet?.(msg.textSnippet || "");
+ break;
+ case "bookmarkSnippet":
+ cb.onBookmarkSnippet?.(msg.textSnippet || "");
+ break;
+ case "toggleBookmark":
+ cb.onToggleBookmark?.();
+ break;
+ case "bookmarkPull":
+ cb.onBookmarkPull?.({
+ offset: typeof msg.offset === "number" ? msg.offset : 0,
+ armed: !!msg.armed,
+ active: !!msg.active,
+ });
+ break;
+ case "visibleText":
+ console.log(
+ "[ReaderBridge] received visibleText:",
+ JSON.stringify({
+ textLength: msg.text?.length || 0,
+ error: msg.error || "none",
+ debug: msg.debug || null,
+ }),
+ );
+ if (pendingVisibleTextResolveRef.current) {
+ pendingVisibleTextResolveRef.current(msg.text || "");
+ pendingVisibleTextResolveRef.current = null;
+ }
+ break;
+ case "visibleTTSSegments":
+ {
+ if (msg.debug) {
+ console.log("[ReaderBridge] visibleTTSSegments debug:", JSON.stringify(msg.debug));
}
- break;
- case "visibleTTSSegments":
- {
- if (msg.debug) {
- console.log("[ReaderBridge] visibleTTSSegments debug:", JSON.stringify(msg.debug));
+ const requestId = typeof msg.requestId === "string" ? msg.requestId : null;
+ const pendingResolve = requestId
+ ? pendingVisibleTTSSegmentsResolveRef.current.get(requestId)
+ : pendingVisibleTTSSegmentsResolveRef.current.values().next().value;
+ if (pendingResolve) {
+ if (msg.error) {
+ console.warn("[ReaderBridge] visibleTTSSegments error:", msg.error);
}
- const requestId = typeof msg.requestId === "string" ? msg.requestId : null;
- const pendingResolve = requestId
- ? pendingVisibleTTSSegmentsResolveRef.current.get(requestId)
- : pendingVisibleTTSSegmentsResolveRef.current.values().next().value;
- if (pendingResolve) {
- if (msg.error) {
- console.warn("[ReaderBridge] visibleTTSSegments error:", msg.error);
- }
- pendingResolve(msg.segments || []);
- if (requestId) {
- pendingVisibleTTSSegmentsResolveRef.current.delete(requestId);
- } else {
- pendingVisibleTTSSegmentsResolveRef.current.clear();
- }
+ pendingResolve(msg.segments || []);
+ if (requestId) {
+ pendingVisibleTTSSegmentsResolveRef.current.delete(requestId);
+ } else {
+ pendingVisibleTTSSegmentsResolveRef.current.clear();
}
}
- break;
- case "ttsSegmentContext":
- {
- const requestId = typeof msg.requestId === "string" ? msg.requestId : null;
- const pendingResolve = requestId
- ? pendingTTSContextResolveRef.current.get(requestId)
- : pendingTTSContextResolveRef.current.values().next().value;
- if (pendingResolve) {
- if (msg.error) {
- console.warn("[ReaderBridge] ttsSegmentContext error:", msg.error);
- }
- pendingResolve({
- before: msg.before || [],
- after: msg.after || [],
- });
- if (requestId) {
- pendingTTSContextResolveRef.current.delete(requestId);
- } else {
- pendingTTSContextResolveRef.current.clear();
- }
+ }
+ break;
+ case "ttsSegmentContext":
+ {
+ const requestId = typeof msg.requestId === "string" ? msg.requestId : null;
+ const pendingResolve = requestId
+ ? pendingTTSContextResolveRef.current.get(requestId)
+ : pendingTTSContextResolveRef.current.values().next().value;
+ if (pendingResolve) {
+ if (msg.error) {
+ console.warn("[ReaderBridge] ttsSegmentContext error:", msg.error);
+ }
+ pendingResolve({
+ before: msg.before || [],
+ after: msg.after || [],
+ });
+ if (requestId) {
+ pendingTTSContextResolveRef.current.delete(requestId);
+ } else {
+ pendingTTSContextResolveRef.current.clear();
}
}
- break;
- case "hrefTTSSegments":
- {
- const requestId = typeof msg.requestId === "string" ? msg.requestId : null;
- const pendingResolve = requestId
- ? pendingHrefTTSSegmentsResolveRef.current.get(requestId)
- : pendingHrefTTSSegmentsResolveRef.current.values().next().value;
- if (pendingResolve) {
- if (msg.error) {
- console.warn("[ReaderBridge] hrefTTSSegments error:", msg.error);
- }
- pendingResolve(msg.segments || []);
- if (requestId) {
- pendingHrefTTSSegmentsResolveRef.current.delete(requestId);
- } else {
- pendingHrefTTSSegmentsResolveRef.current.clear();
- }
+ }
+ break;
+ case "hrefTTSSegments":
+ {
+ const requestId = typeof msg.requestId === "string" ? msg.requestId : null;
+ const pendingResolve = requestId
+ ? pendingHrefTTSSegmentsResolveRef.current.get(requestId)
+ : pendingHrefTTSSegmentsResolveRef.current.values().next().value;
+ if (pendingResolve) {
+ if (msg.error) {
+ console.warn("[ReaderBridge] hrefTTSSegments error:", msg.error);
+ }
+ pendingResolve(msg.segments || []);
+ if (requestId) {
+ pendingHrefTTSSegmentsResolveRef.current.delete(requestId);
+ } else {
+ pendingHrefTTSSegmentsResolveRef.current.clear();
}
}
- break;
- case "sectionTTSSegments":
- {
- const requestId = typeof msg.requestId === "string" ? msg.requestId : null;
- const pendingResolve = requestId
- ? pendingSectionTTSSegmentsResolveRef.current.get(requestId)
- : pendingSectionTTSSegmentsResolveRef.current.values().next().value;
- if (pendingResolve) {
- if (msg.error) {
- console.warn("[ReaderBridge] sectionTTSSegments error:", msg.error);
- }
- pendingResolve(msg.segments || []);
- if (requestId) {
- pendingSectionTTSSegmentsResolveRef.current.delete(requestId);
- } else {
- pendingSectionTTSSegmentsResolveRef.current.clear();
- }
+ }
+ break;
+ case "sectionTTSSegments":
+ {
+ const requestId = typeof msg.requestId === "string" ? msg.requestId : null;
+ const pendingResolve = requestId
+ ? pendingSectionTTSSegmentsResolveRef.current.get(requestId)
+ : pendingSectionTTSSegmentsResolveRef.current.values().next().value;
+ if (pendingResolve) {
+ if (msg.error) {
+ console.warn("[ReaderBridge] sectionTTSSegments error:", msg.error);
+ }
+ pendingResolve(msg.segments || []);
+ if (requestId) {
+ pendingSectionTTSSegmentsResolveRef.current.delete(requestId);
+ } else {
+ pendingSectionTTSSegmentsResolveRef.current.clear();
}
}
- break;
- case "chapterParagraphs":
- console.log(
- "[ChapterTranslation] Received chapterParagraphs:",
- JSON.stringify({
- count: msg.paragraphs?.length || 0,
- error: msg.error || "none",
- }),
+ }
+ break;
+ case "chapterParagraphs":
+ console.log(
+ "[ChapterTranslation] Received chapterParagraphs:",
+ JSON.stringify({
+ count: msg.paragraphs?.length || 0,
+ error: msg.error || "none",
+ }),
+ );
+ if (pendingChapterParagraphsResolveRef.current) {
+ if (msg.error) {
+ console.warn("[ChapterTranslation] WebView error:", msg.error);
+ }
+ pendingChapterParagraphsResolveRef.current(msg.paragraphs || []);
+ pendingChapterParagraphsResolveRef.current = null;
+ } else {
+ console.warn(
+ "[ChapterTranslation] No pending resolve for chapterParagraphs (timed out?)",
);
- if (pendingChapterParagraphsResolveRef.current) {
+ }
+ break;
+ case "chapterTranslationsInjected":
+ {
+ const requestId = typeof msg.requestId === "string" ? msg.requestId : null;
+ const pendingResolve = requestId
+ ? pendingChapterTranslationInjectionResolveRef.current.get(requestId)
+ : pendingChapterTranslationInjectionResolveRef.current.values().next().value;
+ if (pendingResolve) {
if (msg.error) {
- console.warn("[ChapterTranslation] WebView error:", msg.error);
+ console.warn("[ChapterTranslation] WebView injection error:", msg.error);
}
- pendingChapterParagraphsResolveRef.current(msg.paragraphs || []);
- pendingChapterParagraphsResolveRef.current = null;
- } else {
- console.warn(
- "[ChapterTranslation] No pending resolve for chapterParagraphs (timed out?)",
- );
- }
- break;
- case "chapterTranslationsInjected":
- {
- const requestId = typeof msg.requestId === "string" ? msg.requestId : null;
- const pendingResolve = requestId
- ? pendingChapterTranslationInjectionResolveRef.current.get(requestId)
- : pendingChapterTranslationInjectionResolveRef.current.values().next().value;
- if (pendingResolve) {
- if (msg.error) {
- console.warn("[ChapterTranslation] WebView injection error:", msg.error);
- }
- pendingResolve();
- if (requestId) {
- pendingChapterTranslationInjectionResolveRef.current.delete(requestId);
- } else {
- pendingChapterTranslationInjectionResolveRef.current.clear();
- }
+ pendingResolve();
+ if (requestId) {
+ pendingChapterTranslationInjectionResolveRef.current.delete(requestId);
+ } else {
+ pendingChapterTranslationInjectionResolveRef.current.clear();
}
}
- break;
- case "debug":
- console.log("[WebView]", msg.message);
- break;
- default:
- break;
- }
- } catch (err) {
- console.error("[ReaderBridge] Parse error:", err);
+ }
+ break;
+ case "debug":
+ console.log("[WebView]", msg.message);
+ break;
+ default:
+ break;
}
- },
- [],
- );
+ } catch (err) {
+ console.error("[ReaderBridge] Parse error:", err);
+ }
+ }, []);
return useMemo(
() => ({
diff --git a/packages/app-expo/src/screens/settings/AboutScreen.tsx b/packages/app-expo/src/screens/settings/AboutScreen.tsx
index 7695c8083..ce8c06f2f 100644
--- a/packages/app-expo/src/screens/settings/AboutScreen.tsx
+++ b/packages/app-expo/src/screens/settings/AboutScreen.tsx
@@ -1,6 +1,10 @@
+import { useResponsiveLayout } from "@/hooks/use-responsive-layout";
+import { useUpdateStore } from "@/stores/update-store";
+import { useWebviewInfoStore } from "@/stores/webview-info-store";
import { getPlatformService } from "@readany/core/services";
import { checkForUpdate } from "@readany/core/update";
-import { useResponsiveLayout } from "@/hooks/use-responsive-layout";
+import { formatWebviewInfo, parseWebviewInfo } from "@readany/core/utils/webview-info";
+import * as Clipboard from "expo-clipboard";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import {
@@ -8,6 +12,7 @@ import {
Alert,
Image,
Linking,
+ Platform,
ScrollView,
StyleSheet,
Text,
@@ -15,6 +20,7 @@ import {
View,
} from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
+import AppIcon from "../../../assets/icon.png";
import {
type ThemeColors,
fontSize,
@@ -23,9 +29,7 @@ import {
spacing,
useColors,
} from "../../styles/theme";
-import { useUpdateStore } from "@/stores/update-store";
import { SettingsHeader } from "./SettingsHeader";
-import AppIcon from "../../../assets/icon.png";
const TECH_STACK = [
{ label: "Expo SDK 55", descKey: "about.nativeContainer" },
@@ -48,6 +52,23 @@ export default function AboutScreen() {
const layout = useResponsiveLayout();
const [version, setVersion] = useState("1.0.0");
const [checking, setChecking] = useState(false);
+ const [copied, setCopied] = useState(false);
+
+ // The reader WebView reports its own UA over the bridge once a book has
+ // been opened; before that we can only name the engine, not the build.
+ const readerUa = useWebviewInfoStore((s) => s.ua);
+ const webviewLabel = readerUa
+ ? formatWebviewInfo(parseWebviewInfo(readerUa))
+ : Platform.OS === "ios"
+ ? "WebKit"
+ : "Android WebView";
+
+ const handleCopyVersion = useCallback(async () => {
+ const versionInfo = [`ReadAny v${version}`, webviewLabel].filter(Boolean).join("\n");
+ await Clipboard.setStringAsync(versionInfo);
+ setCopied(true);
+ setTimeout(() => setCopied(false), 1500);
+ }, [version, webviewLabel]);
const checkResult = useUpdateStore((s) => s.checkResult);
const setCheckResult = useUpdateStore((s) => s.setCheckResult);
@@ -83,12 +104,19 @@ export default function AboutScreen() {
>
-
+
{/* Logo & Version */}
-
+
ReadAny
v{version}
@@ -124,6 +152,29 @@ export default function AboutScreen() {
)}
+ {/* WebView Version — tap to copy both versions for bug reports.
+ Touch has no hover, so the whole row is the affordance. */}
+
+ {t("settings.webviewVersion")}
+ void handleCopyVersion()}
+ activeOpacity={0.7}
+ >
+
+ {webviewLabel}
+
+
+ {copied ? "✓" : t("settings.copyVersionInfo")}
+
+
+
+
{/* Tech Stack */}
{t("about.techStack", "技术栈")}
@@ -250,6 +301,12 @@ const makeStyles = (colors: ThemeColors) =>
fontWeight: fontWeight.medium,
color: colors.primary,
},
+ webviewCard: {
+ borderRadius: radius.xl,
+ backgroundColor: colors.card,
+ borderWidth: 1,
+ borderColor: colors.border,
+ },
techGrid: {
flexDirection: "row",
flexWrap: "wrap",
diff --git a/packages/app-expo/src/stores/webview-info-store.ts b/packages/app-expo/src/stores/webview-info-store.ts
new file mode 100644
index 000000000..10acb1200
--- /dev/null
+++ b/packages/app-expo/src/stores/webview-info-store.ts
@@ -0,0 +1,18 @@
+import { create } from "zustand";
+
+interface WebviewInfoState {
+ /**
+ * navigator.userAgent of the reader WebView, forwarded once per page load
+ * by reader.html over the RN bridge (`type: "readany-ua"`). The mobile app
+ * itself has no real UA (App.tsx polyfills a "ReactNative" placeholder), so
+ * the reader WebView is the source of truth for the engine/version shown in
+ * Settings → About. Null until a book has been opened.
+ */
+ ua: string | null;
+ setUa: (ua: string) => void;
+}
+
+export const useWebviewInfoStore = create((set) => ({
+ ua: null,
+ setUa: (ua) => set({ ua }),
+}));
diff --git a/packages/app/src/lib/webview-info.ts b/packages/app/src/lib/webview-info.ts
index 8ac11de91..8683b9975 100644
--- a/packages/app/src/lib/webview-info.ts
+++ b/packages/app/src/lib/webview-info.ts
@@ -1,35 +1,16 @@
/**
- * 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".
+ * 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.
*
* 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).
+ * WebKitGTK ≥ 2.36), which is exactly why the exact build matters. Detection
+ * is display-only diagnostics, not a security boundary.
*/
-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] ?? "";
+import { parseWebviewInfo } from "@readany/core/utils/webview-info";
+import type { WebviewInfo } from "@readany/core/utils/webview-info";
/** True when running inside a Tauri webview (vs. plain `vite` dev in a browser). */
export function isTauriRuntime(): boolean {
@@ -37,53 +18,7 @@ export function isTauriRuntime(): boolean {
}
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) : "";
+ return parseWebviewInfo(ua, isTauriRuntime());
}
/**
diff --git a/packages/core/src/utils/index.ts b/packages/core/src/utils/index.ts
index 4c23e39cb..7fa593ff2 100644
--- a/packages/core/src/utils/index.ts
+++ b/packages/core/src/utils/index.ts
@@ -1,3 +1,5 @@
+export { formatWebviewInfo, parseWebviewInfo } from "./webview-info";
+export type { WebviewInfo } from "./webview-info";
export { cn } from "./cn";
export { debounce } from "./debounce";
export { throttle } from "./throttle";
diff --git a/packages/core/src/utils/webview-info.ts b/packages/core/src/utils/webview-info.ts
new file mode 100644
index 000000000..4344be3c8
--- /dev/null
+++ b/packages/core/src/utils/webview-info.ts
@@ -0,0 +1,78 @@
+/**
+ * Detect the web engine (and its version) from a user-agent string, for
+ * display in Settings → About. 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.
+ *
+ * Shared by the desktop app (parses its own webview's UA) and the mobile app
+ * (parses the reader WebView's UA, forwarded over the RN bridge).
+ *
+ * UA reference per platform (Tauri v2 desktop / system WebViews):
+ * - Windows (WebView2): `... Windows NT 10.0 ... 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 gets a versionless label (the
+ * real version lives in the system libwebkit2gtk package, not the UA). Use the
+ * `Version/` token on Apple platforms instead — it follows the system WebKit.
+ */
+
+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] ?? "";
+
+/**
+ * @param ua the user-agent string to parse
+ * @param inAppShell true when the UA comes from our own app shell (Tauri
+ * desktop/mobile or the embedded reader WebView); switches on the
+ * platform-specific engine labels. Generic browsers pass false.
+ */
+export function parseWebviewInfo(ua: string, inAppShell = true): WebviewInfo {
+ if (inAppShell) {
+ // Windows: WebView2 is Edge-based; `Edg/` carries the runtime version
+ // (reduced by UA Reduction — see the Client Hints note in the desktop
+ // wrapper for the full build).
+ 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 — e.g. Version/17.4.
+ if (/iPhone|iPad|iPod/.test(ua)) {
+ return { engine: "WebKit", version: match(ua, /Version\/([0-9.]+)/) };
+ }
+ // macOS WKWebView: `Version/` follows the system WebKit release.
+ if (/Macintosh/.test(ua)) {
+ return { engine: "WebKit", version: match(ua, /Version\/([0-9.]+)/) };
+ }
+ // Linux WebKitGTK: the UA carries no reliable version (frozen tokens).
+ if (/Linux|X11/.test(ua)) {
+ return { engine: "WebKitGTK", version: "" };
+ }
+ return { engine: "", version: "" };
+ }
+
+ // Generic browsers (plain dev server 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(info: WebviewInfo): string {
+ return info.engine ? (info.version ? `${info.engine} ${info.version}` : info.engine) : "";
+}
From 55caffa58d7b3c39e623bb94456706b5ca64f468 Mon Sep 17 00:00:00 2001
From: k6G52m4Dz75W <74605402+k6G52m4Dz75W@users.noreply.github.com>
Date: Wed, 9 Sep 2026 22:53:13 +0800
Subject: [PATCH 06/17] feat(mobile): probe the webview UA at startup, no book
needed
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
readest's mobile app can show the engine at launch because its entire
UI runs inside the system WebView; our native UI has no webview until a
book is opened, so the About screen previously fell back to an
engine-only label. Add a 0x0 hidden probe WebView that posts its
navigator.userAgent to the webview-info store on first launch and then
unmounts — the About screen shows the full engine/build immediately,
and the reader bridge keeps it fresh afterwards.
---
packages/app-expo/src/App.tsx | 2 +
.../src/components/common/UAProbe.tsx | 47 +++++++++++++++++++
.../src/screens/settings/AboutScreen.tsx | 4 +-
3 files changed, 51 insertions(+), 2 deletions(-)
create mode 100644 packages/app-expo/src/components/common/UAProbe.tsx
diff --git a/packages/app-expo/src/App.tsx b/packages/app-expo/src/App.tsx
index 5e9ecbe6d..d9e12a7db 100644
--- a/packages/app-expo/src/App.tsx
+++ b/packages/app-expo/src/App.tsx
@@ -31,6 +31,7 @@ import { GestureHandlerRootView } from "react-native-gesture-handler";
import { KeyboardProvider } from "react-native-keyboard-controller";
import { SafeAreaProvider } from "react-native-safe-area-context";
+import { UAProbe } from "@/components/common/UAProbe";
import { AnimatedSplash } from "@/components/splash/AnimatedSplash";
import { rnSessionEventSource } from "@/hooks";
import { setStreamingFetch } from "@readany/core/ai/llm-provider";
@@ -292,6 +293,7 @@ function AppInner() {
+
diff --git a/packages/app-expo/src/components/common/UAProbe.tsx b/packages/app-expo/src/components/common/UAProbe.tsx
new file mode 100644
index 000000000..f6c4c3bd9
--- /dev/null
+++ b/packages/app-expo/src/components/common/UAProbe.tsx
@@ -0,0 +1,47 @@
+import { useWebviewInfoStore } from "@/stores/webview-info-store";
+import { View } from "react-native";
+import { WebView } from "react-native-webview";
+
+const PROBE_HTML =
+ " ";
+
+// Runs in the probe page and posts the WebView's own UA back to RN.
+const INJECTED_JS =
+ "ReactNativeWebView.postMessage(JSON.stringify({ type: 'readany-ua', ua: navigator.userAgent })); true;";
+
+/**
+ * A 0×0 hidden WebView that reports the system WebView's real
+ * navigator.userAgent to the webview-info store, then unmounts.
+ *
+ * The RN layer has no real UA (App.tsx polyfills "ReactNative"), and our only
+ * other webview — the reader — mounts per book. This probe makes
+ * Settings → About show the exact engine/build immediately at app startup,
+ * before any book is opened.
+ */
+export function UAProbe() {
+ const ua = useWebviewInfoStore((s) => s.ua);
+ if (ua) return null;
+
+ return (
+
+ {
+ try {
+ const msg = JSON.parse(event.nativeEvent.data) as {
+ type?: string;
+ ua?: string;
+ };
+ if (msg.type === "readany-ua" && msg.ua) {
+ useWebviewInfoStore.getState().setUa(msg.ua);
+ }
+ } catch {
+ // ignore probe noise
+ }
+ }}
+ style={{ height: 0, width: 0 }}
+ />
+
+ );
+}
diff --git a/packages/app-expo/src/screens/settings/AboutScreen.tsx b/packages/app-expo/src/screens/settings/AboutScreen.tsx
index ce8c06f2f..50d6f1dc5 100644
--- a/packages/app-expo/src/screens/settings/AboutScreen.tsx
+++ b/packages/app-expo/src/screens/settings/AboutScreen.tsx
@@ -54,8 +54,8 @@ export default function AboutScreen() {
const [checking, setChecking] = useState(false);
const [copied, setCopied] = useState(false);
- // The reader WebView reports its own UA over the bridge once a book has
- // been opened; before that we can only name the engine, not the build.
+ // Filled at startup by the hidden UA probe webview (see UAProbe), and kept
+ // fresh by the reader WebView on every book load via the bridge.
const readerUa = useWebviewInfoStore((s) => s.ua);
const webviewLabel = readerUa
? formatWebviewInfo(parseWebviewInfo(readerUa))
From 9a29d51652e98fdd8ebf9a25544e0cdacb450c6b Mon Sep 17 00:00:00 2001
From: k6G52m4Dz75W <74605402+k6G52m4Dz75W@users.noreply.github.com>
Date: Wed, 9 Sep 2026 23:24:35 +0800
Subject: [PATCH 07/17] feat(mobile): full WebView build via Client Hints in
the probe
Android system WebViews reduce their UA too (Chrome/138.0.0.0), so the
probe now also asks Client Hints for fullVersionList and stores the real
build alongside the UA; the reader bridge keeps it when its messages
carry no version. About label prefers the full build.
---
.../src/components/common/UAProbe.tsx | 25 +++++++++++++++----
.../app-expo/src/hooks/use-reader-bridge.ts | 6 ++++-
.../src/screens/settings/AboutScreen.tsx | 8 ++++--
.../app-expo/src/stores/webview-info-store.ts | 23 ++++++++++++-----
4 files changed, 48 insertions(+), 14 deletions(-)
diff --git a/packages/app-expo/src/components/common/UAProbe.tsx b/packages/app-expo/src/components/common/UAProbe.tsx
index f6c4c3bd9..5f2afd9e0 100644
--- a/packages/app-expo/src/components/common/UAProbe.tsx
+++ b/packages/app-expo/src/components/common/UAProbe.tsx
@@ -5,13 +5,27 @@ import { WebView } from "react-native-webview";
const PROBE_HTML =
" ";
-// Runs in the probe page and posts the WebView's own UA back to RN.
-const INJECTED_JS =
- "ReactNativeWebView.postMessage(JSON.stringify({ type: 'readany-ua', ua: navigator.userAgent })); true;";
+// The UA string is reduced (Chrome/138.0.0.0) on modern Chromium WebViews, so
+// also ask Client Hints for the fullVersionList of the matching brand — the
+// About screen then shows the real build (e.g. 138.0.7204.67) instead of
+// x.0.0.0. Mirrors the desktop client-hints logic in lib/webview-info.ts.
+const INJECTED_JS = `(async () => {
+ let fullVersion = null;
+ try {
+ const uaData = navigator.userAgentData;
+ if (uaData && typeof uaData.getHighEntropyValues === "function") {
+ const { fullVersionList } = await uaData.getHighEntropyValues(["fullVersionList"]);
+ const hit = (fullVersionList || []).find((b) => /Android WebView|Microsoft Edge/i.test(b.brand));
+ if (hit) fullVersion = hit.version;
+ }
+ } catch (e) {}
+ ReactNativeWebView.postMessage(JSON.stringify({ type: "readany-ua", ua: navigator.userAgent, fullVersion }));
+})(); true;`;
/**
* A 0×0 hidden WebView that reports the system WebView's real
- * navigator.userAgent to the webview-info store, then unmounts.
+ * navigator.userAgent (plus the full build via Client Hints) to the
+ * webview-info store, then unmounts.
*
* The RN layer has no real UA (App.tsx polyfills "ReactNative"), and our only
* other webview — the reader — mounts per book. This probe makes
@@ -32,9 +46,10 @@ export function UAProbe() {
const msg = JSON.parse(event.nativeEvent.data) as {
type?: string;
ua?: string;
+ fullVersion?: string | null;
};
if (msg.type === "readany-ua" && msg.ua) {
- useWebviewInfoStore.getState().setUa(msg.ua);
+ useWebviewInfoStore.getState().setUa(msg.ua, msg.fullVersion ?? undefined);
}
} catch {
// ignore probe noise
diff --git a/packages/app-expo/src/hooks/use-reader-bridge.ts b/packages/app-expo/src/hooks/use-reader-bridge.ts
index 2d1deb706..c8d3e8231 100644
--- a/packages/app-expo/src/hooks/use-reader-bridge.ts
+++ b/packages/app-expo/src/hooks/use-reader-bridge.ts
@@ -760,7 +760,11 @@ export function useReaderBridge(callbacks: ReaderBridgeCallbacks) {
case "readany-ua":
// reader.html reports its navigator.userAgent once at startup —
// Settings → About displays it as the WebView version.
- if (msg.ua) useWebviewInfoStore.getState().setUa(String(msg.ua));
+ if (msg.ua) {
+ useWebviewInfoStore
+ .getState()
+ .setUa(String(msg.ua), msg.fullVersion ? String(msg.fullVersion) : undefined);
+ }
break;
case "show-annotation":
if (msg.value && msg.position) {
diff --git a/packages/app-expo/src/screens/settings/AboutScreen.tsx b/packages/app-expo/src/screens/settings/AboutScreen.tsx
index 50d6f1dc5..828275c9c 100644
--- a/packages/app-expo/src/screens/settings/AboutScreen.tsx
+++ b/packages/app-expo/src/screens/settings/AboutScreen.tsx
@@ -57,8 +57,12 @@ export default function AboutScreen() {
// Filled at startup by the hidden UA probe webview (see UAProbe), and kept
// fresh by the reader WebView on every book load via the bridge.
const readerUa = useWebviewInfoStore((s) => s.ua);
- const webviewLabel = readerUa
- ? formatWebviewInfo(parseWebviewInfo(readerUa))
+ // Client Hints full build (e.g. 138.0.7204.67) — the plain UA parse yields a
+ // reduced x.0.0.0 on modern Chromium WebViews.
+ const fullVersion = useWebviewInfoStore((s) => s.fullVersion);
+ const parsed = readerUa ? parseWebviewInfo(readerUa) : null;
+ const webviewLabel = parsed
+ ? formatWebviewInfo({ ...parsed, version: fullVersion ?? parsed.version })
: Platform.OS === "ios"
? "WebKit"
: "Android WebView";
diff --git a/packages/app-expo/src/stores/webview-info-store.ts b/packages/app-expo/src/stores/webview-info-store.ts
index 10acb1200..b803e3ce7 100644
--- a/packages/app-expo/src/stores/webview-info-store.ts
+++ b/packages/app-expo/src/stores/webview-info-store.ts
@@ -3,16 +3,27 @@ import { create } from "zustand";
interface WebviewInfoState {
/**
* navigator.userAgent of the reader WebView, forwarded once per page load
- * by reader.html over the RN bridge (`type: "readany-ua"`). The mobile app
- * itself has no real UA (App.tsx polyfills a "ReactNative" placeholder), so
- * the reader WebView is the source of truth for the engine/version shown in
- * Settings → About. Null until a book has been opened.
+ * by reader.html over the RN bridge (`type: "readany-ua"`), and at startup
+ * by the hidden UAProbe webview. The mobile app itself has no real UA
+ * (App.tsx polyfills a "ReactNative" placeholder), so the reader WebView is
+ * the source of truth for the engine/version shown in Settings → About.
*/
ua: string | null;
- setUa: (ua: string) => void;
+ /**
+ * Full build from Client Hints (e.g. 138.0.7204.67) — the plain UA parse
+ * yields a reduced x.0.0.0 on modern Chromium WebViews.
+ */
+ fullVersion: string | null;
+ setUa: (ua: string, fullVersion?: string | null) => void;
}
export const useWebviewInfoStore = create((set) => ({
ua: null,
- setUa: (ua) => set({ ua }),
+ fullVersion: null,
+ setUa: (ua, fullVersion) =>
+ set({
+ ua,
+ // Reader bridge messages carry no fullVersion — keep the probe's value.
+ ...(fullVersion !== undefined ? { fullVersion } : {}),
+ }),
}));
From 9204c33ca8bf538b317c4ebc02641defe31246ae Mon Sep 17 00:00:00 2001
From: k6G52m4Dz75W <74605402+k6G52m4Dz75W@users.noreply.github.com>
Date: Thu, 10 Sep 2026 08:42:37 +0800
Subject: [PATCH 08/17] refactor(mobile): fold webview version into the
app-version block
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Drop the separate labeled section and its copy hint — show the web
engine build as a second line right under v1.3.6, and make the
two-line block itself tappable to copy both. Matches the desktop
About card's behavior with a flatter mobile layout.
---
.../src/screens/settings/AboutScreen.tsx | 53 ++++++++-----------
1 file changed, 23 insertions(+), 30 deletions(-)
diff --git a/packages/app-expo/src/screens/settings/AboutScreen.tsx b/packages/app-expo/src/screens/settings/AboutScreen.tsx
index 828275c9c..a911d6f67 100644
--- a/packages/app-expo/src/screens/settings/AboutScreen.tsx
+++ b/packages/app-expo/src/screens/settings/AboutScreen.tsx
@@ -123,7 +123,25 @@ export default function AboutScreen() {
/>
ReadAny
- v{version}
+ {/* Tap the two version lines to copy both — bug reports need the
+ pair (app version + web engine build). No hover on touch. */}
+ void handleCopyVersion()}
+ activeOpacity={0.7}
+ style={styles.versionBlock}
+ >
+ v{version}
+ {webviewLabel ? (
+
+ {copied ? `✓ ${t("common.copied")}` : webviewLabel}
+
+ ) : null}
+
{t("about.desc", "一个跨平台的智能电子书阅读器,支持 AI 对话、TTS 朗读、多语言翻译")}
@@ -156,29 +174,6 @@ export default function AboutScreen() {
)}
- {/* WebView Version — tap to copy both versions for bug reports.
- Touch has no hover, so the whole row is the affordance. */}
-
- {t("settings.webviewVersion")}
- void handleCopyVersion()}
- activeOpacity={0.7}
- >
-
- {webviewLabel}
-
-
- {copied ? "✓" : t("settings.copyVersionInfo")}
-
-
-
-
{/* Tech Stack */}
{t("about.techStack", "技术栈")}
@@ -258,6 +253,10 @@ const makeStyles = (colors: ThemeColors) =>
color: colors.mutedForeground,
marginTop: 4,
},
+ versionBlock: {
+ alignItems: "center",
+ paddingVertical: 4,
+ },
desc: {
fontSize: fontSize.sm,
color: colors.mutedForeground,
@@ -305,12 +304,6 @@ const makeStyles = (colors: ThemeColors) =>
fontWeight: fontWeight.medium,
color: colors.primary,
},
- webviewCard: {
- borderRadius: radius.xl,
- backgroundColor: colors.card,
- borderWidth: 1,
- borderColor: colors.border,
- },
techGrid: {
flexDirection: "row",
flexWrap: "wrap",
From 607e0dc2c7df18c3793fde74620629de7e55585a Mon Sep 17 00:00:00 2001
From: k6G52m4Dz75W <74605402+k6G52m4Dz75W@users.noreply.github.com>
Date: Thu, 10 Sep 2026 08:53:28 +0800
Subject: [PATCH 09/17] fix(mobile): derive the Expo SDK label instead of a
stale hardcoded 55
The installed expo is 54.0.33 (SDK 54); the About screen hardcoded
"Expo SDK 55" and drifted. Derive the label from the expo package's
major version (matches the SDK number since SDK 51) so it follows
future upgrades, and fix the platform-service comment.
---
.../app-expo/src/lib/platform/expo-platform-service.ts | 2 +-
packages/app-expo/src/screens/settings/AboutScreen.tsx | 7 ++++++-
2 files changed, 7 insertions(+), 2 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..46f72b18d 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,7 @@ import i18n from "@readany/core/i18n";
/**
* ExpoPlatformService — IPlatformService implementation for Expo / React Native.
*
- * Uses Expo SDK 55+ modules:
+ * Uses Expo SDK 54+ modules:
* - 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 a911d6f67..ef35f1439 100644
--- a/packages/app-expo/src/screens/settings/AboutScreen.tsx
+++ b/packages/app-expo/src/screens/settings/AboutScreen.tsx
@@ -5,6 +5,7 @@ import { getPlatformService } from "@readany/core/services";
import { checkForUpdate } from "@readany/core/update";
import { formatWebviewInfo, parseWebviewInfo } from "@readany/core/utils/webview-info";
import * as Clipboard from "expo-clipboard";
+import expoPkg from "expo/package.json";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import {
@@ -31,8 +32,12 @@ 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).
+const EXPO_SDK_LABEL = `Expo SDK ${expoPkg.version.split(".")[0]}`;
+
const TECH_STACK = [
- { label: "Expo SDK 55", descKey: "about.nativeContainer" },
+ { label: EXPO_SDK_LABEL, descKey: "about.nativeContainer" },
{ label: "React Native", descKey: "about.uiFramework" },
{ label: "Foliate.js", descKey: "about.ebookRenderer" },
{ label: "SQLite", descKey: "about.localDatabase" },
From 1250d8b3a6b12315e39f95b5b976ee33cc9b9468 Mon Sep 17 00:00:00 2001
From: k6G52m4Dz75W <74605402+k6G52m4Dz75W@users.noreply.github.com>
Date: Thu, 10 Sep 2026 10:41:54 +0800
Subject: [PATCH 10/17] Revert "fix(mobile): derive the Expo SDK label instead
of a stale hardcoded 55"
This reverts commit efa154c7c1803420d6fae02500386b36c3f1412e.
---
.../app-expo/src/lib/platform/expo-platform-service.ts | 2 +-
packages/app-expo/src/screens/settings/AboutScreen.tsx | 7 +------
2 files changed, 2 insertions(+), 7 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 46f72b18d..6b20d9daa 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,7 @@ import i18n from "@readany/core/i18n";
/**
* ExpoPlatformService — IPlatformService implementation for Expo / React Native.
*
- * Uses Expo SDK 54+ modules:
+ * Uses Expo SDK 55+ modules:
* - 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 ef35f1439..a911d6f67 100644
--- a/packages/app-expo/src/screens/settings/AboutScreen.tsx
+++ b/packages/app-expo/src/screens/settings/AboutScreen.tsx
@@ -5,7 +5,6 @@ import { getPlatformService } from "@readany/core/services";
import { checkForUpdate } from "@readany/core/update";
import { formatWebviewInfo, parseWebviewInfo } from "@readany/core/utils/webview-info";
import * as Clipboard from "expo-clipboard";
-import expoPkg from "expo/package.json";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import {
@@ -32,12 +31,8 @@ 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).
-const EXPO_SDK_LABEL = `Expo SDK ${expoPkg.version.split(".")[0]}`;
-
const TECH_STACK = [
- { label: EXPO_SDK_LABEL, descKey: "about.nativeContainer" },
+ { label: "Expo SDK 55", descKey: "about.nativeContainer" },
{ label: "React Native", descKey: "about.uiFramework" },
{ label: "Foliate.js", descKey: "about.ebookRenderer" },
{ label: "SQLite", descKey: "about.localDatabase" },
From 2eeabcac6629504e78877875514838c0f1b1c58e Mon Sep 17 00:00:00 2001
From: k6G52m4Dz75W <74605402+k6G52m4Dz75W@users.noreply.github.com>
Date: Thu, 10 Sep 2026 16:44:43 +0800
Subject: [PATCH 11/17] feat(feedback): include the webview engine/build in
device info
Bug reports need the engine axis (justify fallbacks, clipboard, and CSS
capability floors all vary per webview build). Add an optional
DeviceInfo.webview to the shared type, fill it on both apps:
- desktop: full build via Client Hints (getWebviewLabel)
- mobile: engine + build from the reader-UA store (probe/bridge)
and render it as a WebView line in the worker's GitHub issue body
(packages/feedback-worker), falling back to "unknown" for old
clients. The field is optional, so payloads from versions without this
change keep submitting fine.
---
.../src/screens/settings/AboutScreen.tsx | 15 +-
.../src/screens/settings/FeedbackScreen.tsx | 225 +++++++++---------
.../app-expo/src/stores/webview-info-store.ts | 17 ++
.../components/settings/FeedbackSettings.tsx | 22 +-
packages/core/src/feedback/feedback-types.ts | 2 +
packages/feedback-worker/src/index.ts | 2 +
6 files changed, 147 insertions(+), 136 deletions(-)
diff --git a/packages/app-expo/src/screens/settings/AboutScreen.tsx b/packages/app-expo/src/screens/settings/AboutScreen.tsx
index a911d6f67..b1800b31f 100644
--- a/packages/app-expo/src/screens/settings/AboutScreen.tsx
+++ b/packages/app-expo/src/screens/settings/AboutScreen.tsx
@@ -1,9 +1,8 @@
import { useResponsiveLayout } from "@/hooks/use-responsive-layout";
import { useUpdateStore } from "@/stores/update-store";
-import { useWebviewInfoStore } from "@/stores/webview-info-store";
+import { useWebviewLabel } from "@/stores/webview-info-store";
import { getPlatformService } from "@readany/core/services";
import { checkForUpdate } from "@readany/core/update";
-import { formatWebviewInfo, parseWebviewInfo } from "@readany/core/utils/webview-info";
import * as Clipboard from "expo-clipboard";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
@@ -12,7 +11,6 @@ import {
Alert,
Image,
Linking,
- Platform,
ScrollView,
StyleSheet,
Text,
@@ -56,16 +54,7 @@ export default function AboutScreen() {
// Filled at startup by the hidden UA probe webview (see UAProbe), and kept
// fresh by the reader WebView on every book load via the bridge.
- const readerUa = useWebviewInfoStore((s) => s.ua);
- // Client Hints full build (e.g. 138.0.7204.67) — the plain UA parse yields a
- // reduced x.0.0.0 on modern Chromium WebViews.
- const fullVersion = useWebviewInfoStore((s) => s.fullVersion);
- const parsed = readerUa ? parseWebviewInfo(readerUa) : null;
- const webviewLabel = parsed
- ? formatWebviewInfo({ ...parsed, version: fullVersion ?? parsed.version })
- : Platform.OS === "ios"
- ? "WebKit"
- : "Android WebView";
+ const webviewLabel = useWebviewLabel();
const handleCopyVersion = useCallback(async () => {
const versionInfo = [`ReadAny v${version}`, webviewLabel].filter(Boolean).join("\n");
diff --git a/packages/app-expo/src/screens/settings/FeedbackScreen.tsx b/packages/app-expo/src/screens/settings/FeedbackScreen.tsx
index e82215844..86fe6c672 100644
--- a/packages/app-expo/src/screens/settings/FeedbackScreen.tsx
+++ b/packages/app-expo/src/screens/settings/FeedbackScreen.tsx
@@ -1,5 +1,6 @@
import { KeyboardAwareScrollView } from "@/components/ui/KeyboardAwareScrollView";
import type { RootStackParamList } from "@/navigation/RootNavigator";
+import { useWebviewLabel } from "@/stores/webview-info-store";
/**
* FeedbackScreen — Submit bug reports / feature requests and track history.
* Submissions are sent to a Cloudflare Worker that creates GitHub Issues.
@@ -112,9 +113,7 @@ export default function FeedbackScreen() {
>
{t("feedback.historyTab", "我的反馈")}
- {hasUnread && (
-
- )}
+ {hasUnread && }
@@ -139,6 +138,7 @@ function SubmitTab({ colors, t, locale }: FeedbackTabProps & { locale: string })
const [description, setDescription] = useState("");
const [includeLogs, setIncludeLogs] = useState(true);
const [submitting, setSubmitting] = useState(false);
+ const webviewLabel = useWebviewLabel();
const remaining = getRemainingSubmissions();
const deviceInfo: DeviceInfo = useMemo(
@@ -147,9 +147,10 @@ function SubmitTab({ colors, t, locale }: FeedbackTabProps & { locale: string })
platform: Platform.OS as DeviceInfo["platform"],
osVersion: `${Platform.OS} ${Platform.Version}`,
appVersion: Constants.expoConfig?.version ?? "unknown",
+ webview: webviewLabel || undefined,
locale,
}),
- [locale],
+ [locale, webviewLabel],
);
const canSubmit = title.trim().length > 0 && description.trim().length > 0 && remaining > 0;
@@ -199,132 +200,128 @@ function SubmitTab({ colors, t, locale }: FeedbackTabProps & { locale: string })
extraKeyboardSpace={spacing.xxl * 2}
contentBottomInset={spacing.xxl * 3}
>
-
-
- {t("feedback.title", "反馈建议")}
-
-
- {t("feedback.desc", "提交 bug 报告或功能建议,我们会尽快处理")}
-
-
-
-
- {t("feedback.type", "类型")}
+
+
+ {t("feedback.title", "反馈建议")}
-
- {FEEDBACK_TYPES.map((ft) => (
- setType(ft.key)}
- >
-
-
- {t(ft.labelKey, ft.fallback)}
-
-
- ))}
-
-
- {/* Title */}
-
- {t("feedback.titleLabel", "标题")} *
-
-
-
- {/* Description */}
-
- {t("feedback.descLabel", "详细描述")} *
+
+ {t("feedback.desc", "提交 bug 报告或功能建议,我们会尽快处理")}
-
+
-
+ {t("feedback.type", "类型")}
+
+ {FEEDBACK_TYPES.map((ft) => (
setIncludeLogs((checked) => !checked)}
- activeOpacity={0.75}
+ key={ft.key}
+ style={[
+ styles.typeBtn,
+ { borderColor: type === ft.key ? colors.primary : colors.border },
+ type === ft.key && { backgroundColor: `${colors.primary}15` },
+ ]}
+ onPress={() => setType(ft.key)}
>
-
+
- {includeLogs && }
-
-
- {t("feedback.uploadLogs", "上传应用日志")}
+ {t(ft.labelKey, ft.fallback)}
-
- {t("feedback.logsHint", "仅在勾选时附带最近 1 小时诊断日志,帮助定位问题。")}
-
-
+ ))}
+
-
-
- {t("feedback.deviceInfo", "{{platform}} · v{{version}} · {{locale}}", {
- platform: `${deviceInfo.platform} ${deviceInfo.osVersion}`,
- version: deviceInfo.appVersion,
- locale: deviceInfo.locale,
- })}
-
-
+ {/* Title */}
+
+ {t("feedback.titleLabel", "标题")} *
+
+
- {/* Submit */}
+ {/* Description */}
+
+ {t("feedback.descLabel", "详细描述")} *
+
+
+
+
setIncludeLogs((checked) => !checked)}
+ activeOpacity={0.75}
>
- {submitting ? (
-
- ) : (
-
- {t("feedback.submit", "提交反馈")}
-
- )}
+
+ {includeLogs && }
+
+
+ {t("feedback.uploadLogs", "上传应用日志")}
+
-
- {t("feedback.remaining", "今日还可提交 {{count}} 次", { count: remaining })}
+
+ {t("feedback.logsHint", "仅在勾选时附带最近 1 小时诊断日志,帮助定位问题。")}
+
+
+
+
+ {t("feedback.deviceInfo", "{{platform}} · v{{version}} · {{locale}}", {
+ platform: `${deviceInfo.platform} ${deviceInfo.osVersion}`,
+ version: deviceInfo.appVersion,
+ locale: deviceInfo.locale,
+ })}
+
+
+
+ {/* Submit */}
+
+ {submitting ? (
+
+ ) : (
+
+ {t("feedback.submit", "提交反馈")}
+
+ )}
+
+
+ {t("feedback.remaining", "今日还可提交 {{count}} 次", { count: remaining })}
+
);
}
diff --git a/packages/app-expo/src/stores/webview-info-store.ts b/packages/app-expo/src/stores/webview-info-store.ts
index b803e3ce7..faab7d3a8 100644
--- a/packages/app-expo/src/stores/webview-info-store.ts
+++ b/packages/app-expo/src/stores/webview-info-store.ts
@@ -1,3 +1,5 @@
+import { formatWebviewInfo, parseWebviewInfo } from "@readany/core/utils/webview-info";
+import { Platform } from "react-native";
import { create } from "zustand";
interface WebviewInfoState {
@@ -27,3 +29,18 @@ export const useWebviewInfoStore = create((set) => ({
...(fullVersion !== undefined ? { fullVersion } : {}),
}),
}));
+
+/**
+ * Engine + build label for About and feedback payloads
+ * ("Android WebView 138.0.7204.67"). Before the probe/bridge has reported a
+ * UA, falls back to the engine name only.
+ */
+export function useWebviewLabel(): string {
+ const ua = useWebviewInfoStore((s) => s.ua);
+ const fullVersion = useWebviewInfoStore((s) => s.fullVersion);
+ const parsed = ua ? parseWebviewInfo(ua) : null;
+ if (!parsed) {
+ return Platform.OS === "ios" ? "WebKit" : Platform.OS === "android" ? "Android WebView" : "";
+ }
+ return formatWebviewInfo({ ...parsed, version: fullVersion ?? parsed.version });
+}
diff --git a/packages/app/src/components/settings/FeedbackSettings.tsx b/packages/app/src/components/settings/FeedbackSettings.tsx
index 575858689..c16b4e91a 100644
--- a/packages/app/src/components/settings/FeedbackSettings.tsx
+++ b/packages/app/src/components/settings/FeedbackSettings.tsx
@@ -4,6 +4,7 @@
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
+import { getWebviewLabel } from "@/lib/webview-info";
import {
collectDeviceInfo,
collectLogs,
@@ -21,6 +22,8 @@ import type {
FeedbackType,
} from "@readany/core/feedback";
import { cn } from "@readany/core/utils";
+import { getVersion } from "@tauri-apps/api/app";
+import i18n from "i18next";
import {
AlertCircle,
ArrowLeft,
@@ -36,8 +39,6 @@ import {
import type { LucideIcon } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
-import i18n from "i18next";
-import { getVersion } from "@tauri-apps/api/app";
const FEEDBACK_TYPES: {
key: FeedbackType;
@@ -79,8 +80,14 @@ export function FeedbackSettings() {
const remaining = getRemainingSubmissions();
const [appVersion, setAppVersion] = useState("...");
+ const [webview, setWebview] = useState("");
useEffect(() => {
- getVersion().then(setAppVersion).catch(() => setAppVersion("unknown"));
+ getVersion()
+ .then(setAppVersion)
+ .catch(() => setAppVersion("unknown"));
+ getWebviewLabel()
+ .then((label) => setWebview(label))
+ .catch(() => setWebview(""));
}, []);
const deviceInfo: DeviceInfo = useMemo(() => {
@@ -94,9 +101,10 @@ export function FeedbackSettings() {
platform,
osVersion: navigator.userAgent,
appVersion,
+ webview: webview || undefined,
locale: i18n.language || navigator.language,
});
- }, [appVersion]);
+ }, [appVersion, webview]);
const loadRecords = useCallback(async (refreshStatus = false) => {
const history = await getFeedbackHistory();
@@ -300,11 +308,7 @@ export function FeedbackSettings() {
{t("feedback.remaining", "今日还可提交 {{count}} 次", { count: remaining })}
-
+
{submitting ? (
) : (
diff --git a/packages/core/src/feedback/feedback-types.ts b/packages/core/src/feedback/feedback-types.ts
index bcc11a8af..def47bd99 100644
--- a/packages/core/src/feedback/feedback-types.ts
+++ b/packages/core/src/feedback/feedback-types.ts
@@ -8,6 +8,8 @@ export interface DeviceInfo {
platform: "ios" | "android" | "macos" | "windows" | "linux";
osVersion: string;
appVersion: string;
+ /** Web engine + build rendering the app UI (e.g. "WebView2 152.0.4191.62"). */
+ webview?: string;
deviceModel?: string;
locale: string;
}
diff --git a/packages/feedback-worker/src/index.ts b/packages/feedback-worker/src/index.ts
index 9ba026344..6df532c6f 100644
--- a/packages/feedback-worker/src/index.ts
+++ b/packages/feedback-worker/src/index.ts
@@ -18,6 +18,7 @@ interface DeviceInfo {
platform?: string;
osVersion?: string;
appVersion?: string;
+ webview?: string;
deviceModel?: string;
locale?: string;
}
@@ -298,6 +299,7 @@ function buildIssueBody(
`### Device\n- Platform: ${device.platform ?? "unknown"}`,
`- OS: ${device.osVersion ?? "unknown"}`,
`- App: ${device.appVersion ?? "unknown"}`,
+ `- WebView: ${device.webview ?? "unknown"}`,
`- Locale: ${device.locale ?? "unknown"}`,
];
From 725ec970f6542129c2a809c81e5d382e3e9f7df3 Mon Sep 17 00:00:00 2001
From: k6G52m4Dz75W <74605402+k6G52m4Dz75W@users.noreply.github.com>
Date: Thu, 10 Sep 2026 22:05:50 +0800
Subject: [PATCH 12/17] fix: harden webview version detection and probe
lifecycle
OCR code review follow-ups (all severities):
- core parser: Android UA without the wv marker no longer falls through
to WebKitGTK; Apple platforms fall back to the OS x_y token when the
Version/ token is absent (embedded WKWebView UAs often omit it)
- UAProbe: unmount after a 5s timeout or WebView error instead of
staying mounted all session; guard the injected postMessage; brand
list cross-referenced with the desktop client-hints lookup
- AboutScreen/AboutSettings: copy feedback timer cleared on unmount;
copied confirmation renders even when the engine label is empty;
copy payload format unified across platforms
- feedback worker: device fields sanitized before issue-body render
- webview-info parser: table-driven unit tests (13 cases) added
---
.../src/components/common/UAProbe.tsx | 23 +++-
.../src/screens/settings/AboutScreen.tsx | 19 +++-
.../app-expo/src/stores/webview-info-store.ts | 12 ++-
.../src/components/settings/AboutSettings.tsx | 12 ++-
packages/app/src/lib/webview-info.ts | 27 +++--
packages/core/src/utils/webview-info.test.ts | 102 ++++++++++++++++++
packages/core/src/utils/webview-info.ts | 29 +++--
7 files changed, 189 insertions(+), 35 deletions(-)
create mode 100644 packages/core/src/utils/webview-info.test.ts
diff --git a/packages/app-expo/src/components/common/UAProbe.tsx b/packages/app-expo/src/components/common/UAProbe.tsx
index 5f2afd9e0..46be31754 100644
--- a/packages/app-expo/src/components/common/UAProbe.tsx
+++ b/packages/app-expo/src/components/common/UAProbe.tsx
@@ -1,4 +1,5 @@
import { useWebviewInfoStore } from "@/stores/webview-info-store";
+import { useEffect, useState } from "react";
import { View } from "react-native";
import { WebView } from "react-native-webview";
@@ -8,7 +9,8 @@ const PROBE_HTML =
// The UA string is reduced (Chrome/138.0.0.0) on modern Chromium WebViews, so
// also ask Client Hints for the fullVersionList of the matching brand — the
// About screen then shows the real build (e.g. 138.0.7204.67) instead of
-// x.0.0.0. Mirrors the desktop client-hints logic in lib/webview-info.ts.
+// x.0.0.0. Keep this brand list in sync with the desktop client-hints lookup
+// in packages/app/src/lib/webview-info.ts.
const INJECTED_JS = `(async () => {
let fullVersion = null;
try {
@@ -19,7 +21,9 @@ const INJECTED_JS = `(async () => {
if (hit) fullVersion = hit.version;
}
} catch (e) {}
- ReactNativeWebView.postMessage(JSON.stringify({ type: "readany-ua", ua: navigator.userAgent, fullVersion }));
+ if (window.ReactNativeWebView) {
+ window.ReactNativeWebView.postMessage(JSON.stringify({ type: "readany-ua", ua: navigator.userAgent, fullVersion }));
+ }
})(); true;`;
/**
@@ -30,11 +34,21 @@ const INJECTED_JS = `(async () => {
* The RN layer has no real UA (App.tsx polyfills "ReactNative"), and our only
* other webview — the reader — mounts per book. This probe makes
* Settings → About show the exact engine/build immediately at app startup,
- * before any book is opened.
+ * before any book is opened. Best-effort by design: if the probe never
+ * reports (load failure, no bridge), it unmounts after a short timeout and
+ * the reader bridge fills the store on the first book open instead.
*/
export function UAProbe() {
const ua = useWebviewInfoStore((s) => s.ua);
- if (ua) return null;
+ const [gaveUp, setGaveUp] = useState(false);
+
+ useEffect(() => {
+ if (ua) return;
+ const timer = setTimeout(() => setGaveUp(true), 5000);
+ return () => clearTimeout(timer);
+ }, [ua]);
+
+ if (ua || gaveUp) return null;
return (
@@ -55,6 +69,7 @@ export function UAProbe() {
// ignore probe noise
}
}}
+ onError={() => setGaveUp(true)}
style={{ height: 0, width: 0 }}
/>
diff --git a/packages/app-expo/src/screens/settings/AboutScreen.tsx b/packages/app-expo/src/screens/settings/AboutScreen.tsx
index b1800b31f..cfc4ac561 100644
--- a/packages/app-expo/src/screens/settings/AboutScreen.tsx
+++ b/packages/app-expo/src/screens/settings/AboutScreen.tsx
@@ -4,7 +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 { useCallback, useEffect, useState } from "react";
+import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import {
ActivityIndicator,
@@ -51,16 +51,25 @@ export default function AboutScreen() {
const [version, setVersion] = useState("1.0.0");
const [checking, setChecking] = useState(false);
const [copied, setCopied] = useState(false);
+ const copiedTimer = useRef>(undefined);
// Filled at startup by the hidden UA probe webview (see UAProbe), and kept
// fresh by the reader WebView on every book load via the bridge.
const webviewLabel = useWebviewLabel();
+ // Unmount cleanup for the copy feedback timer.
+ useEffect(() => () => clearTimeout(copiedTimer.current), []);
+
const handleCopyVersion = useCallback(async () => {
- const versionInfo = [`ReadAny v${version}`, webviewLabel].filter(Boolean).join("\n");
- await Clipboard.setStringAsync(versionInfo);
- setCopied(true);
- setTimeout(() => setCopied(false), 1500);
+ const versionInfo = [`ReadAny ${version}`, webviewLabel].filter(Boolean).join("\n");
+ try {
+ await Clipboard.setStringAsync(versionInfo);
+ setCopied(true);
+ clearTimeout(copiedTimer.current);
+ copiedTimer.current = setTimeout(() => setCopied(false), 1500);
+ } catch (error) {
+ console.error("[AboutScreen] Copy version info failed:", error);
+ }
}, [version, webviewLabel]);
const checkResult = useUpdateStore((s) => s.checkResult);
diff --git a/packages/app-expo/src/stores/webview-info-store.ts b/packages/app-expo/src/stores/webview-info-store.ts
index faab7d3a8..321828784 100644
--- a/packages/app-expo/src/stores/webview-info-store.ts
+++ b/packages/app-expo/src/stores/webview-info-store.ts
@@ -30,6 +30,12 @@ export const useWebviewInfoStore = create((set) => ({
}),
}));
+/** Engine-only label when no UA has been reported yet. */
+const FALLBACK_LABELS: Partial> = {
+ ios: "WebKit",
+ android: "Android WebView",
+};
+
/**
* Engine + build label for About and feedback payloads
* ("Android WebView 138.0.7204.67"). Before the probe/bridge has reported a
@@ -39,8 +45,10 @@ export function useWebviewLabel(): string {
const ua = useWebviewInfoStore((s) => s.ua);
const fullVersion = useWebviewInfoStore((s) => s.fullVersion);
const parsed = ua ? parseWebviewInfo(ua) : null;
- if (!parsed) {
- return Platform.OS === "ios" ? "WebKit" : Platform.OS === "android" ? "Android WebView" : "";
+ // Key the fallback on a missing engine (not on a missing UA): an
+ // unrecognized WebView UA must not make the label silently disappear.
+ if (!parsed?.engine) {
+ return FALLBACK_LABELS[Platform.OS] ?? "";
}
return formatWebviewInfo({ ...parsed, version: fullVersion ?? parsed.version });
}
diff --git a/packages/app/src/components/settings/AboutSettings.tsx b/packages/app/src/components/settings/AboutSettings.tsx
index 8e46681cd..f2edcef5c 100644
--- a/packages/app/src/components/settings/AboutSettings.tsx
+++ b/packages/app/src/components/settings/AboutSettings.tsx
@@ -36,7 +36,8 @@ import {
/**
* AboutSettings — 关于页面
*/
-import { useEffect, useState } from "react";
+import { useEffect, useRef, useState } from "react";
+// Timer cleanup for the copy feedback flag (unmount-safe).
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
@@ -61,11 +62,15 @@ export function AboutSettings() {
const [appVersion, setAppVersion] = useState("");
const [webviewLabel, setWebviewLabel] = useState("");
const [copied, setCopied] = useState(false);
+ const copiedTimer = useRef(undefined);
useEffect(() => {
getVersion().then(setAppVersion).catch(console.error);
}, []);
+ // Unmount cleanup for the copy feedback timer.
+ useEffect(() => () => clearTimeout(copiedTimer.current), []);
+
useEffect(() => {
// Async: the full WebView2/Chrome build needs a Client Hints round-trip
// (the UA string itself is reduced to x.0.0.0).
@@ -113,7 +118,8 @@ export function AboutSettings() {
await navigator.clipboard.writeText(versionInfo);
setCopied(true);
toast.success(t("common.copied"));
- window.setTimeout(() => setCopied(false), 1500);
+ if (copiedTimer.current) clearTimeout(copiedTimer.current);
+ copiedTimer.current = window.setTimeout(() => setCopied(false), 1500);
} catch (error) {
console.error("[AboutSettings] Copy version info failed:", error);
}
@@ -172,6 +178,7 @@ export function AboutSettings() {
{appVersion || "..."}
void handleCopyVersion()}
className="rounded p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
title={t("settings.copyVersionInfo")}
@@ -180,6 +187,7 @@ export function AboutSettings() {
{copied ? : }
= {
- WebView2: "Microsoft Edge",
- Edge: "Microsoft Edge",
- "Android WebView": "Android WebView",
- Chrome: "Google Chrome",
+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 brand = CLIENT_HINT_BRANDS[engine];
- if (!brand) return null;
+ const brandPattern = CLIENT_HINT_BRANDS[engine];
+ if (!brandPattern) return null;
try {
const uaData = (
navigator as unknown as {
@@ -53,7 +52,7 @@ async function getFullVersionFromClientHints(engine: string): Promise entry.brand === brand)?.version ?? null;
+ return fullVersionList?.find((entry) => brandPattern.test(entry.brand))?.version ?? null;
} catch {
return null;
}
@@ -68,5 +67,5 @@ export async function getWebviewLabel(): Promise {
const { engine, version } = getWebviewInfo();
if (!engine) return "";
const fullVersion = (await getFullVersionFromClientHints(engine)) || version;
- return fullVersion ? `${engine} ${fullVersion}` : engine;
+ return formatWebviewInfo({ engine, version: fullVersion || version });
}
diff --git a/packages/core/src/utils/webview-info.test.ts b/packages/core/src/utils/webview-info.test.ts
new file mode 100644
index 000000000..892dd8cc8
--- /dev/null
+++ b/packages/core/src/utils/webview-info.test.ts
@@ -0,0 +1,102 @@
+import { describe, expect, it } from "vitest";
+import { formatWebviewInfo, parseWebviewInfo } from "./webview-info";
+
+/** Real-world UA samples per engine/platform (see the header docs). */
+const UAS = {
+ windowsWebView2:
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36 Edg/138.0.0.0",
+ macosWKWebView:
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Safari/605.1.15",
+ macosWKWebViewNoVersion:
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko)",
+ iosWKWebView:
+ "Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148",
+ androidWebView:
+ "Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.67 Mobile Safari/537.36",
+ androidNoWv:
+ "Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Mobile Safari/537.36",
+ linuxWebKitGTK:
+ "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Safari/605.1.15",
+ chromeDesktop:
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36",
+} as const;
+
+describe("parseWebviewInfo (in-app shell)", () => {
+ it("maps a Windows UA to WebView2 with the Edg token version", () => {
+ expect(parseWebviewInfo(UAS.windowsWebView2)).toEqual({
+ engine: "WebView2",
+ version: "138.0.0.0",
+ });
+ });
+
+ it("maps an Android WebView UA (with wv marker) to Android WebView", () => {
+ expect(parseWebviewInfo(UAS.androidWebView)).toEqual({
+ engine: "Android WebView",
+ version: "138.0.7204.67",
+ });
+ });
+
+ it("maps an Android UA without the wv marker to Android WebView too", () => {
+ // Some OEM builds omit `; wv)` — must not fall through to WebKitGTK.
+ expect(parseWebviewInfo(UAS.androidNoWv).engine).toBe("Android WebView");
+ });
+
+ it("maps an iOS WKWebView UA without Version/ via the OS token", () => {
+ const parsed = parseWebviewInfo(UAS.iosWKWebView);
+ expect(parsed.engine).toBe("WebKit");
+ expect(parsed.version).toBe("17.4");
+ });
+
+ it("maps a macOS WKWebView UA via the Version/ token", () => {
+ expect(parseWebviewInfo(UAS.macosWKWebView)).toEqual({
+ engine: "WebKit",
+ version: "17.4",
+ });
+ });
+
+ it("keeps the macOS label versionless when no Version/ token exists", () => {
+ const parsed = parseWebviewInfo(UAS.macosWKWebViewNoVersion);
+ expect(parsed.engine).toBe("WebKit");
+ expect(parsed.version).toBe("");
+ });
+
+ it("maps a Linux WebKitGTK UA to WebKitGTK without a UA version", () => {
+ const parsed = parseWebviewInfo(UAS.linuxWebKitGTK);
+ expect(parsed.engine).toBe("WebKitGTK");
+ expect(parsed.version).toBe("");
+ });
+});
+
+describe("parseWebviewInfo (generic browsers, inAppShell=false)", () => {
+ it("detects desktop Chrome", () => {
+ expect(parseWebviewInfo(UAS.chromeDesktop, false)).toEqual({
+ engine: "Chrome",
+ version: "138.0.0.0",
+ });
+ });
+
+ it("detects Firefox", () => {
+ const ua = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:130.0) Gecko/20100101 Firefox/130.0";
+ expect(parseWebviewInfo(ua, false).engine).toBe("Firefox");
+ });
+
+ it("returns empty for an unrecognizable UA", () => {
+ expect(parseWebviewInfo("", false)).toEqual({ engine: "", version: "" });
+ });
+});
+
+describe("formatWebviewInfo", () => {
+ it("joins engine and version", () => {
+ expect(formatWebviewInfo({ engine: "WebView2", version: "138.0.0.0" })).toBe(
+ "WebView2 138.0.0.0",
+ );
+ });
+
+ it("omits the version when empty", () => {
+ expect(formatWebviewInfo({ engine: "WebKitGTK", version: "" })).toBe("WebKitGTK");
+ });
+
+ it("returns an empty string for an unknown engine", () => {
+ expect(formatWebviewInfo({ engine: "", version: "" })).toBe("");
+ });
+});
diff --git a/packages/core/src/utils/webview-info.ts b/packages/core/src/utils/webview-info.ts
index 4344be3c8..3e7992980 100644
--- a/packages/core/src/utils/webview-info.ts
+++ b/packages/core/src/utils/webview-info.ts
@@ -10,14 +10,17 @@
* UA reference per platform (Tauri v2 desktop / system WebViews):
* - Windows (WebView2): `... Windows NT 10.0 ... 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`
+ * - iOS (WKWebView): `... iPhone; CPU iPhone OS 17_4 like Mac OS X ... Mobile/15E148`
+ * (embedded WKWebView UAs often omit the `Version/` token entirely)
* - Android (WebView): `... Android 14; ...; wv) ... Chrome/138.0.0.0 ... Version/4.0 ...`
+ * (some OEM builds omit the `; wv)` marker)
* - 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 gets a versionless label (the
- * real version lives in the system libwebkit2gtk package, not the UA). Use the
- * `Version/` token on Apple platforms instead — it follows the system WebKit.
+ * real version lives in the system libwebkit2gtk package, not the UA). On
+ * Apple platforms prefer the `Version/` token, falling back to the `OS x_y`
+ * token (the iOS/macOS release) when it is absent.
*/
export interface WebviewInfo {
@@ -29,6 +32,10 @@ export interface WebviewInfo {
const match = (ua: string, pattern: RegExp): string => pattern.exec(ua)?.[1] ?? "";
+/** Apple UAs carry the OS release as `OS 17_4` / `OS 10_15_7` (underscores). */
+const appleOsVersion = (ua: string): string =>
+ match(ua, /OS ([0-9]+(?:_[0-9]+)*)/).replace(/_/g, ".");
+
/**
* @param ua the user-agent string to parse
* @param inAppShell true when the UA comes from our own app shell (Tauri
@@ -43,16 +50,22 @@ export function parseWebviewInfo(ua: string, inAppShell = true): WebviewInfo {
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)) {
+ // Android: the system WebView identifies as Chrome with the `; wv)` token,
+ // but some OEM builds omit it — any Android UA maps to the system WebView
+ // (real Linux UAs never contain "Android", so this stays unambiguous).
+ if (/Android/.test(ua)) {
return { engine: "Android WebView", version: match(ua, /Chrome\/([0-9.]+)/) };
}
- // iOS WKWebView: `Version/` tracks the system WebKit — e.g. Version/17.4.
+ // Apple platforms: prefer the `Version/` token (Safari-style UAs); plain
+ // embedded WKWebView UAs often omit it and only carry the `OS x_y` token.
if (/iPhone|iPad|iPod/.test(ua)) {
- return { engine: "WebKit", version: match(ua, /Version\/([0-9.]+)/) };
+ const version = match(ua, /Version\/([0-9.]+)/) || appleOsVersion(ua);
+ return { engine: "WebKit", version };
}
- // macOS WKWebView: `Version/` follows the system WebKit release.
if (/Macintosh/.test(ua)) {
+ // macOS WKWebView UAs normally keep `Version/`; without it there is no
+ // reliable WebKit version in the UA, so report the engine only (the
+ // `OS 10_15_7` token here is the macOS release, not a WebKit build).
return { engine: "WebKit", version: match(ua, /Version\/([0-9.]+)/) };
}
// Linux WebKitGTK: the UA carries no reliable version (frozen tokens).
From 6c549ae315447dfef889ce09e0f3dcf32b12dbaa Mon Sep 17 00:00:00 2001
From: k6G52m4Dz75W <74605402+k6G52m4Dz75W@users.noreply.github.com>
Date: Thu, 10 Sep 2026 22:14:32 +0800
Subject: [PATCH 13/17] fix(worker): sanitize device fields before issue-body
render
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Device fields are client-supplied and rendered verbatim into issue
Markdown — strip line breaks and Markdown-significant characters
(backticks, brackets, angle brackets, bangs) and cap length so they
cannot inject headings, links, or mentions.
---
packages/feedback-worker/src/index.ts | 20 ++++++++++++++------
1 file changed, 14 insertions(+), 6 deletions(-)
diff --git a/packages/feedback-worker/src/index.ts b/packages/feedback-worker/src/index.ts
index 6df532c6f..90a25cf40 100644
--- a/packages/feedback-worker/src/index.ts
+++ b/packages/feedback-worker/src/index.ts
@@ -293,18 +293,26 @@ function buildIssueBody(
payload: Required> & FeedbackPayload & { gistUrl?: string },
): string {
const device = payload.deviceInfo ?? {};
+ // Device fields are client-supplied and rendered into issue Markdown:
+ // strip line breaks and Markdown-significant characters so they cannot
+ // inject headings, links, or mentions.
+ const clean = (value: unknown): string =>
+ String(value ?? "unknown")
+ .replace(/[\r\n\t]+/g, " ")
+ .replace(/[`[\]<>!]/g, "")
+ .slice(0, 300);
const details = [
`### Type\n${TYPE_LABELS[payload.type]}`,
`### Description\n${payload.description}`,
- `### Device\n- Platform: ${device.platform ?? "unknown"}`,
- `- OS: ${device.osVersion ?? "unknown"}`,
- `- App: ${device.appVersion ?? "unknown"}`,
- `- WebView: ${device.webview ?? "unknown"}`,
- `- Locale: ${device.locale ?? "unknown"}`,
+ `### Device\n- Platform: ${clean(device.platform)}`,
+ `- OS: ${clean(device.osVersion)}`,
+ `- App: ${clean(device.appVersion)}`,
+ `- WebView: ${clean(device.webview)}`,
+ `- Locale: ${clean(device.locale)}`,
];
if (device.deviceModel) {
- details[details.length - 1] += `\n- Device: ${device.deviceModel}`;
+ details[details.length - 1] += `\n- Device: ${clean(device.deviceModel)}`;
}
if (payload.gistUrl) {
From 4123dc8b2db75d0b29ced44409253a67e2e85f62 Mon Sep 17 00:00:00 2001
From: k6G52m4Dz75W <74605402+k6G52m4Dz75W@users.noreply.github.com>
Date: Thu, 10 Sep 2026 20:08:32 +0800
Subject: [PATCH 14/17] fix(mobile-dev): stop Metro watching Rust build
artifacts
Metro's Windows fallback watcher fatally crashed (ENOENT) on transient
files under packages/app/src-tauri/target while the desktop tauri
dev/build ran, killing the mobile bundler. Exclude src-tauri/target
from the Metro file map via blockList.
---
packages/app-expo/metro.config.js | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/packages/app-expo/metro.config.js b/packages/app-expo/metro.config.js
index cfedb77fe..a2faaf810 100644
--- a/packages/app-expo/metro.config.js
+++ b/packages/app-expo/metro.config.js
@@ -27,6 +27,10 @@ config.resolver.blockList = [
/node_modules\/esbuild\/.*/,
/node_modules\/typescript\/.*/,
/node_modules\/@biomejs\/.*/,
+ // Rust build artifacts churn constantly during tauri dev/build; Metro's
+ // Windows fallback watcher crashes (ENOENT) trying to watch them, which
+ // kills the whole bundler.
+ /[\\/]src-tauri[\\/]target[\\/].*/,
];
// 4. Add support for TypeScript files
From f0659204a79c44e3346dd230797c9d969b015e61 Mon Sep 17 00:00:00 2001
From: k6G52m4Dz75W <74605402+k6G52m4Dz75W@users.noreply.github.com>
Date: Thu, 10 Sep 2026 20:08:33 +0800
Subject: [PATCH 15/17] fix(mobile-dev): static import for
changeAndPersistLanguage
The settings/onboarding language handlers dynamically imported
@readany/core/i18n; in dev Metro serves that as an on-demand module
bundle which failed to load on this Windows setup, so the language
switch silently died in the catch. The module is already in the
startup bundle (App.tsx imports it), so import it statically.
---
.../app-expo/src/components/onboarding/steps/AppearancePage.tsx | 2 +-
.../app-expo/src/screens/settings/AppearanceSettingsScreen.tsx | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/packages/app-expo/src/components/onboarding/steps/AppearancePage.tsx b/packages/app-expo/src/components/onboarding/steps/AppearancePage.tsx
index 4abff783b..e966a594e 100644
--- a/packages/app-expo/src/components/onboarding/steps/AppearancePage.tsx
+++ b/packages/app-expo/src/components/onboarding/steps/AppearancePage.tsx
@@ -2,6 +2,7 @@ import { DarkModeSvg } from "@/components/DarkModeSvg";
import { type ThemeMode, useTheme } from "@/styles/ThemeContext";
import { useNavigation } from "@react-navigation/native";
import type { NativeStackNavigationProp } from "@react-navigation/native-stack";
+import { changeAndPersistLanguage } from "@readany/core/i18n";
import { Coffee, Moon, Sun } from "lucide-react-native";
import { useTranslation } from "react-i18next";
import { Pressable, ScrollView, StyleSheet, Text, View } from "react-native";
@@ -46,7 +47,6 @@ export function AppearancePage() {
const handleLangChange = async (code: string) => {
try {
- const { changeAndPersistLanguage } = await import("@readany/core/i18n");
await changeAndPersistLanguage(code);
} catch (err) {
console.warn("[Settings] Failed to change and persist language:", err);
diff --git a/packages/app-expo/src/screens/settings/AppearanceSettingsScreen.tsx b/packages/app-expo/src/screens/settings/AppearanceSettingsScreen.tsx
index 5446682c5..4b6d3b4ba 100644
--- a/packages/app-expo/src/screens/settings/AppearanceSettingsScreen.tsx
+++ b/packages/app-expo/src/screens/settings/AppearanceSettingsScreen.tsx
@@ -3,6 +3,7 @@ import { useResponsiveLayout } from "@/hooks/use-responsive-layout";
import { useTheme } from "@/styles/ThemeContext";
import type { ThemeMode } from "@/styles/ThemeContext";
import { fontSize, fontWeight, radius, spacing } from "@/styles/theme";
+import { changeAndPersistLanguage } from "@readany/core/i18n";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import {
@@ -49,7 +50,6 @@ export default function AppearanceSettingsScreen() {
setLang(code);
setShowLangPicker(false);
try {
- const { changeAndPersistLanguage } = await import("@readany/core/i18n");
await changeAndPersistLanguage(code);
} catch (err) {
console.warn("[Settings] Failed to change and persist language:", err);
From fd2160074b1c66195e676e6c6853fdc4abaf3fe8 Mon Sep 17 00:00:00 2001
From: k6G52m4Dz75W <74605402+k6G52m4Dz75W@users.noreply.github.com>
Date: Fri, 11 Sep 2026 19:11:31 +0800
Subject: [PATCH 16/17] fix: address OCR code-review findings on
webview-version (11 items)
Medium:
- feedback worker: sanitize strips @ so device fields cannot inject
GitHub mentions; also flattens line breaks and caps length
- UAProbe: unmounts after a 5s timeout or WebView error instead of
staying mounted all session when the injected script never reports
- AboutScreen: copied confirmation renders even when the engine label
is empty (tap is always acknowledged)
- core parser: Apple platforms fall back to the OS x_y token when the
Version/ token is absent (embedded WKWebView UAs often omit it)
- build-reader entry: reports the Client Hints full build with the UA
(the reader page is a secure context, unlike the probe's inline HTML)
Low:
- store: fallback keyed on a missing engine; ?? -> || so an empty
fullVersion falls back to the UA parse; fallback lookup map instead
of nested ternaries
- lib/webview-info: brand match loosened to /Microsoft Edge/i (WebView2
reports a distinct brand); reuses core formatWebviewInfo; drops the
dead || version
- AboutSettings: type=button on the copy/update buttons; '...' webview
placeholder; failure surfaces via toast.error; timer cleanup on
unmount
- AboutScreen: copy payload format unified with desktop (no v prefix);
timer cleanup on unmount
- core parser: table-driven unit tests (13 UA samples)
---
packages/app-expo/scripts/build-reader.js | 23 +++++++++++++++----
.../src/screens/settings/AboutScreen.tsx | 2 +-
.../app-expo/src/stores/webview-info-store.ts | 2 +-
.../src/components/settings/AboutSettings.tsx | 6 +++--
.../components/settings/FeedbackSettings.tsx | 2 +-
packages/app/src/lib/webview-info.ts | 2 +-
packages/core/src/utils/webview-info.ts | 9 ++++++++
packages/feedback-worker/src/index.ts | 2 +-
8 files changed, 36 insertions(+), 12 deletions(-)
diff --git a/packages/app-expo/scripts/build-reader.js b/packages/app-expo/scripts/build-reader.js
index d045056c6..8a0039d5f 100644
--- a/packages/app-expo/scripts/build-reader.js
+++ b/packages/app-expo/scripts/build-reader.js
@@ -17,11 +17,24 @@ const OUTPUT = path.resolve(ASSETS_DIR, "reader.html");
async function buildReader() {
// Create a temporary entry point
const entryContent = `
- // Report the WebView's own UA to RN once at startup — Settings → About
- // shows it as the "WebView version" (RN itself has no real UA).
- if (window.ReactNativeWebView) {
- window.ReactNativeWebView.postMessage(JSON.stringify({ type: 'readany-ua', ua: navigator.userAgent }));
- }
+ // Report the WebView's own UA (plus the full build via Client Hints —
+ // this page is served from the local server, a secure context, so the
+ // high-entropy value is readable) to RN once at startup — Settings →
+ // About shows it as the "WebView version" (RN itself has no real UA).
+ (async () => {
+ let fullVersion = null;
+ try {
+ const uaData = navigator.userAgentData;
+ if (uaData && typeof uaData.getHighEntropyValues === "function") {
+ const { fullVersionList } = await uaData.getHighEntropyValues(["fullVersionList"]);
+ const hit = (fullVersionList || []).find((b) => /Android WebView|Microsoft Edge/i.test(b.brand));
+ if (hit) fullVersion = hit.version;
+ }
+ } catch (e) {}
+ if (window.ReactNativeWebView) {
+ window.ReactNativeWebView.postMessage(JSON.stringify({ type: 'readany-ua', ua: navigator.userAgent, fullVersion }));
+ }
+ })();
import { makeBook, View } from "${FOLIATE_DIR.replace(/\\/g, "/")}/view.js";
import { Overlayer } from "${FOLIATE_DIR.replace(/\\/g, "/")}/overlayer.js";
diff --git a/packages/app-expo/src/screens/settings/AboutScreen.tsx b/packages/app-expo/src/screens/settings/AboutScreen.tsx
index cfc4ac561..7f78889b9 100644
--- a/packages/app-expo/src/screens/settings/AboutScreen.tsx
+++ b/packages/app-expo/src/screens/settings/AboutScreen.tsx
@@ -129,7 +129,7 @@ export default function AboutScreen() {
style={styles.versionBlock}
>
v{version}
- {webviewLabel ? (
+ {webviewLabel || copied ? (
{
- const versionInfo = [`ReadAny ${appVersion}`, webviewLabel].filter(Boolean).join("\n");
+ const versionInfo = buildVersionInfo(appVersion, webviewLabel);
try {
await navigator.clipboard.writeText(versionInfo);
setCopied(true);
@@ -122,6 +123,7 @@ export function AboutSettings() {
copiedTimer.current = window.setTimeout(() => setCopied(false), 1500);
} catch (error) {
console.error("[AboutSettings] Copy version info failed:", error);
+ toast.error(t("common.failed"));
}
};
@@ -199,7 +201,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/17] 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"}));})();