Compare commits

...

10 Commits

Author SHA1 Message Date
Huang Xin 54d54791b0 release: version 0.11.12 (#4682) 2026-06-20 06:41:25 +02:00
Huang Xin 7185dca1a2 feat(reader): add save/share button to image gallery toolbar (#4680)
* feat(reader): add save/share button to image gallery toolbar

Add a button to the top-right toolbar of the fullscreen image viewer
that saves the currently viewed image to the device. It uses the native
or web Share flow where available (iOS/Android/macOS, navigator.share)
and falls back to a save dialog or browser download otherwise, reusing
the existing export path via appService.saveFile.

The button icon and label reflect the active flow (share vs save).
Adds dataUrlToBytes/imageExtensionFromMime helpers, unit and component
tests, and translations for the new strings across all locales.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(share): write shareable file to a Temp subdirectory to avoid 0-byte share

On Android, Tauri's Temp dir is the app cache dir, and the sharekit plugin
copies the shared file to <cacheDir>/<name> before firing the share intent.
When saveFile wrote the shareable file to the Temp root, that copy became a
copy onto itself whose output stream truncated the source to 0 bytes, so the
shared image (and any shared export) arrived as a 0 KB file. Write the file
to a Temp subdirectory instead so the plugin's copy has a distinct source.

Verified on a Xiaomi device: sharing a file in the Temp root truncated it to
0 bytes, while sharing from the subdirectory produced a real, non-empty copy.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(reader): save image to system gallery on Android

The Android share sheet cannot save an image to a file (no file manager
registers as an ACTION_SEND target), so the Save Image button now writes
the image straight into the system photo gallery via MediaStore. It lands
in Pictures/Readest, visible in Gallery and the Files app, with no picker
and no storage permission on Android 10+.

Adds a save_image_to_gallery command to the native-bridge plugin (Rust +
Kotlin MediaStore insert) and an appService.saveImageToGallery method. On
Android the Save button uses it; iOS/macOS/desktop/web keep the existing
share/export flow, and the button label/icon reflect the actual action.

Also includes local agent memory notes that were staged alongside.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 06:28:08 +02:00
Huang Xin a9526377a2 fix(reader): stretch Duokan fullscreen cover to fill the page (#4679)
Bump foliate-js so paginated Duokan full-page covers
(data-duokan-page-fullscreen) render with object-fit: fill instead of
contain. The cover now fills the whole page, distorting to fit when the
aspect ratio differs, matching Duokan's native full-page render. Adds a
browser test asserting the fullscreen cover computes object-fit: fill.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 05:33:47 +02:00
Huang Xin f7e1bddda6 fix(sync): stop re-pinning statusless books to the top of the library after every sync (#4677)
The sync POST handler rewrote books.updated_at = now() whenever a pushed
book's resolved reading_status differed from the server row's. A book that
was imported locally and never given a status sends reading_status:
undefined, while the server stores null, so `undefined !== null` reported a
spurious status change. The 1-day re-sync window re-pushes every recently
touched book on each sync, so the server stamped those books with a fresh,
batch-identical timestamp every cycle, floating them to the top of the
date-sorted library (and above a book the user had just read).

Normalize nullish reading_status values before comparing so a statusless
book never registers as a status change. Verified on-device via CDP:
PUSH_SENT carried the old timestamp while PUSH_RETURNED came back with a
fresh now() for exactly the statusless books.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 05:17:27 +02:00
Huang Xin 0ab8f6042f fix(reader): keep cover background-image visible under a texture (#4675)
Bump foliate-js to the textureAwareBackground fix and add a regression
test. A cover page that paints its image via a body background-image
leaves background-color transparent, so the computed background shorthand
starts with "rgba(0, 0, 0, 0)" even though a real image follows. The
paginator misclassified it as transparent and, with a background texture
active (e.g. parchment), dropped the page background so the texture showed
on the first page instead of the cover. Verified on Android WebView.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 04:13:28 +02:00
Huang Xin 5f561504e3 fix(sync): keep view settings device-local and exclude them from sync (#4672) 2026-06-20 03:13:03 +02:00
Huang Xin b9a3ee725f fix(opds): make saved catalog card hover distinct from dialog background (#4673)
The saved catalog cards used hover:bg-base-200/40. Since base-200 is only
~5% off base-100, applying it at 40% alpha shifted the background by roughly
2%, and with the dialog itself sitting at base-200 the hover collapsed into
the dialog color, making the hovered card blend in.

Use hover:bg-base-300 (~12% off base-100) so the hover state is clearly
separated from both the resting card (base-100) and the dialog (base-200).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 03:12:10 +02:00
Huang Xin 23d1ef6f13 fix(rsvp): restore in-flow control bar layout reverted by #4589 (#4671)
* fix(rsvp): restore in-flow control bar layout reverted by #4589

PR #4585 fixed the mobile RSVP control bar overlap by laying the audio
toggle and settings gear in a single in-flow flex row flanking the
centered transport. PR #4589 branched from main about five minutes
before #4585 merged and merged about ten hours later without rebasing,
so its squash carried the stale pre-#4585 file and reverted the entire
fix, including the regression test #4585 had added.

On narrow phones (360px) the audio and settings icons again overlapped
the right end of the transport, hiding the "skip forward 15" control.

Restore the #4585 layout and re-add a structural guard test asserting
the audio toggle and settings share the transport row and live in no
absolutely positioned cluster. Verified on a Xiaomi 13 (360px) via
on-device CDP: no overlap, play button stays centered.

Also stage the project-memory note for this regression.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(rsvp): hide Faster/Slower buttons at 350px or below

On very narrow phones (width 350px or less) the control row has no room
for every control. Collapse the Faster/Slower speed buttons via a
max-[350px]:hidden variant (matching the existing 350px tightening tier)
so the transport, audio toggle and settings never overflow. Speed stays
adjustable from the WPM dropdown.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 02:58:47 +02:00
Huang Xin 6e9faaa874 fix(pdf): throttle PDF range reads to fix large-file OOM on Android/iOS (#3470) (#4670)
Large PDFs (50 MB+) crashed on import/open. pdf.js requests hundreds of
byte ranges in a burst while parsing the document structure, and
foliate-js makePDF dispatched them all concurrently. On Android each read
is served through the WebView's rangefile custom scheme
(shouldInterceptRequest); the flood of simultaneous native requests
exhausts the WebView's Java heap (OutOfMemoryError in handleRequest).

Bump foliate-js to cap in-flight range reads at 6 (the implicit per-host
limit a real HTTP transport already gets), and add a regression test
asserting makePDF keeps at most 6 range reads in flight.

Verified live on a Xiaomi 13 (Android 16 / WebView 147) via CDP: max
concurrent range reads drop from 753 to 6 with no change in open time.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 02:00:44 +02:00
Huang Xin d5c640996d fix(opds): show Add Catalog dialog above Settings on mobile (#4669)
The "Add OPDS Catalog" dialog (a ModalPortal opened from inside
Settings > Integrations > OPDS Catalogs) rendered behind the Settings
sheet on mobile, so the form could not be reached or filled in.

Root cause: PR #3235 raised the Settings dialog to z-[10050] to clear
the full-screen RSVP overlay (z-[10000]) for in-overlay dictionary
management. That also jumped Settings above the ModalPortal layer
(z-[100]), so any modal opened from inside Settings was buried. The bug
is mobile-only because on desktop the rounded-window frame
(.window-border, z-99) traps the inline-rendered Settings dialog in its
own stacking context, while ModalPortal escapes to document.body and
wins there.

Redesign the overlay z-index into a compact scale (no four-digit
values), each layer clearing the z-99 page frame:

  100 RSVP overlay
  101 RSVP controls (start dialog, lookup chip)
  110 Settings dialog
  120 modal / command palette
  130 toast / alert
  200 app lock

Lock the ordering with a static test that reads the values from source
and would have caught the #3235 regression. Documented in DESIGN.md.

Verified on a Xiaomi device via CDP: elementFromPoint at the dialog
center now resolves inside the Add Catalog form instead of Settings.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 01:41:16 +02:00
90 changed files with 1464 additions and 145 deletions
@@ -39,6 +39,7 @@
- [Custom fonts disappear on cloud sync (#4410)](custom-fonts-reincarnation-4410.md) — CRDT remove-wins: re-import-after-delete needs a `reincarnation` token or the pull re-applies the tombstone; `addFont`/`addTexture` minted none; fix mirrors dictionary (both cases) + OPDS token style; coverage matrix per kind
- [koplugin note deletion sync](koplugin-note-deletion-sync.md) — koplugin push only walked LIVE annotations so deletions never reached the server; fix = `recordDeletion` persists a `deletedAt` tombstone to `doc_settings.readest_sync.deleted_notes`, `push` folds+clears them; deletion signal in `onAnnotationsModified` is `items.index_modified < 0`
- [koplugin stats sync (#4666)](koplugin-stats-sync.md) — reading-stats sync (pull on open / push on close, whole statistics.sqlite3 delta, cursor-based); 3-bug chain: plain-table-not-LuaSettings `settings:readSetting` crash; missing required books/notes/configs; statBooks/statPages need `optional_params` (Spore expected=requiredoptional, `payload`≠accepted); large-backlog UI-stall + silent-retry risk unfixed
- [Statusless books re-pinned to top (#4677)](sync-statusless-book-rebump-4677.md) — never-statused (locally-imported, never-pulled) books send `reading_status:undefined` vs server `null`; server POST `statusChanged` (`undefined!==null`) rewrites `updated_at=now()` every push → batch-identical ts pins them top of date-sort; 1-day re-sync window amplifies; fix = `(a??null)!==(b??null)`. On-device CDP PUSH_SENT-vs-RETURNED proof recipe
## Testing
- [Nightly updater Android E2E](nightly-updater-android-e2e.md) — real Xiaomi/HyperOS test of #4577 self-updater; `pnpm dev-android` (--features devtools) for CDP, raw-socket CDP discovery, nightly>stable comparator, MIUI 单次安装授权 install gates
@@ -50,6 +51,7 @@
- [TTS sync paragraph+RSVP (#3235, PR #4576)](tts-sync-paragraph-rsvp-3235.md) — TTS-is-clock follow: canonical `tts-position{cfi,kind:word|sentence,sectionIndex,sequence}`; in-mode 🔊 audio toggle (`build{Paragraph,Rsvp}TtsSpeakDetail`, live-range gate); **current word/sentence highlight painted on the overlay CLONE via CSS Custom Highlight API** (no DOM mutation, spans inline; offsets relative to para-start map 1:1 to clone, `getTextSubRange` reuse, index-tagged vs stale); kind-gating `decideParagraphTtsHighlight` (Edge word wins, skip coarse sentence); `::highlight()` from `ttsHighlightOptions`
## Build & Vendoring
- [fastlane Apple App Store submission](fastlane-apple-appstore-submission.md) — `release_ios`/`release_macos` lanes (App Store review + TestFlight on the altool-uploaded build); gotchas = Tauri auto-notarizes if `APPLE_API_KEY_PATH` is in the macOS build env (keep it OUT, derive from key id), fastlane runs lanes from `./fastlane` so anchor paths via `repo_path`, npm-dotenv-cli vs Ruby-gem shadowing + `cd ../..`
- [Turbopack build-cache OOM + gated Docker standalone (#4619)](turbopack-build-cache-oom-docker-standalone.md) — interrupted-build partial `turbopackFileSystemCacheForBuild` cache → 42 workers/18GB-swap freeze (clean build=~6.5GB); disabled the flag; `output:'standalone'` gated on `BUILD_STANDALONE` (Docker-only); tauri CI uses `next dev` (config-independent)
- [Deps/security override workflow](deps-security-overrides-workflow.md) — fix transitive npm Dependabot alerts: main monorepo overrides live in `pnpm-workspace.yaml` (NOT root package.json); `packages/tauri-plugins` is a SEPARATE submodule project w/ own lockfile + `minimumReleaseAge` (main workspace has no age gate); bound 0.x overrides like `vite`; verify via test+lint+build-web. PR #4618 (esbuild 0.28.1, vitest 4.1.9)
- [R2 rclone CreateBucket 403 (#4588)](r2-rclone-createbucket-403.md) — single-file `rclone copyto`/`moveto` probes CreateBucket → 403 on object-scoped R2 token; use a directory `rclone copy` (or `no_check_bucket=true`); broke nightly assemble, not the release flow
@@ -63,8 +65,10 @@
- [Android Open-with intent flow (#4521)](android-open-with-intent-flow.md) — "Open with"/"Send to" pipeline: `NativeBridgePlugin.kt::handleIntent``shared-intent``useAppUrlIngress``useOpenWithBooks` (VIEW=transient→reader, SEND=library+upload). Telegram fails where file-manager works on TWO axes: cold-start delivery (fixed by #4527, on dev NOT released v0.11.4) + foreign-private-file read (Telegram FileProvider non-persistable grant vs shared-storage FUSE real-path). adb MediaStore VIEW repro tests pipeline but CANNOT reproduce the read axis (MANAGE_EXTERNAL_STORAGE bypasses grant)
- [Dict lookup → OEM browser hijack (#4559)](dict-lookup-browser-hijack-4559.md) — VIVO system-dict lookup opened the browser not Eudic. PRIMARY: no `<queries>` for `ACTION_PROCESS_TEXT` under targetSdk36 → dictionary apps invisible, only auto-visible browser returned (fix = add `<queries>` to plugin manifest). SECONDARY: browser registers PROCESS_TEXT + is default → filter browsers in pure `decideLookupDispatch` (explicit/chooser/unavailable). Remember-the-pick via `IntentSender`+`EXTRA_CHOSEN_COMPONENT``LookupChoiceReceiver`→SharedPreferences (`ACTION_CHOOSER` has no native Always); reset row in `CustomDictionaries.tsx`
- [Android sideload same versionCode](android-sideload-same-versioncode.md) — sideloaded APK reinstall allows EQUAL versionCode (only strictly-lower blocked); Play Store's increment rule does NOT apply to sideload. Nightly APKs share base versionCode and still install. Corrects a plausible-but-wrong review claim
- [Large-PDF OOM range flood (#3470)](pdf-oom-range-flood-3470.md) — 50MB+ PDF import/open crash = foliate makePDF firing ALL pdf.js range reads un-awaited (753 concurrent fetch→shouldInterceptRequest→Java byte[] → 512MB heap OOM), NOT whole-file load; official viewer survives via browser ~6-conn/host cap; fix = MAX_CONCURRENT_RANGES=6 queue in makePDF; on-device CDP recipe; Xiaomi13/WV147 won't OOM but flood 753→6 verified
## Feature Notes
- [Save image to gallery (Android, #4680)](save-image-to-gallery-android.md) — image-viewer Save button → MediaStore on Android (share sheet can't save-to-file: ACTION_SEND has no file-manager target); sharekit 0-byte self-copy bug (Temp==cacheDir); tsgo misses abstract-class conformance (real tsc catches); on-device CDP verify recipe
- [Webtoon Mode (#3647)](webtoon-mode-3647.md) — seamless no-gap scrolled reading for image books (PRs #4662 + foliate-js#30); fixed-layout scroll mode is fit-width by construction (ignores `zoom`, only `scale-factor`); `scroll-gap` attr→`--scroll-page-gap` var; clear-on-leave in BOTH ViewMenu effect AND Shift+J; worktree submodule has local-path origin (push SHA direct to fork)
- [Biometric app-lock (#4645)](biometric-app-lock-4645.md) — fingerprint/Face ID startup unlock layered over PIN (mobile); gate must read flag from `appLockStore` not un-seeded `settingsStore` (race); `tauri-plugin-biometric` is `#![cfg(mobile)]` (desktop clippy skips it; pin in root Cargo.lock); scope i18n manually (en unscanned, full extract churns drift)
- [Tap to open image/table (#4600)](tap-to-open-image-table-4600.md) — single-tap opens gallery/table-zoom in **reflowable** EPUBs (long-press unchanged); `iframe-long-press` message renamed to `iframe-open-media`, hook `useLongPressEvent``useOpenMediaEvent`; shared `detectMediaTarget`; `handleClick` got `isFixedLayout`
@@ -92,6 +96,8 @@
- [Design system → DESIGN.md](feedback_design_system_doc.md) — codify recurring UI/UX rules in `apps/readest-app/DESIGN.md`; never `pl/pr/ml/mr/text-left/text-right` (RTL); §5 boxed list anatomy has uniform `min-h-14` rows and chromeless controls
## Reader UI Fixes
- [RSVP control bar overlap = REVERT](rsvp-control-bar-overlap-revert.md) — mobile RSVP TTS+settings overlapping transport was a REGRESSION: #4585 fixed it (in-flow `justify-between md:justify-center`), stale-branch #4589 (Word Wise) squash-reverted the whole fix incl. its guard test; re-fixed + on-device CDP recipe (Shift+V to enter RSVP, live-DOM preview)
- [Overlay z-index scale](zindex-overlay-scale.md) — compact global scale (RSVP 100/101, Settings 110, ModalPortal/CmdPalette 120, toast 130, app-lock 200) replacing insane 10000s; Add-Catalog-behind-Settings was MOBILE-ONLY (desktop `.window-border` z-99 traps inline Settings; ModalPortal portals to body→wins); static invariant test `zIndexScale.test.ts`; on-device CDP verify via `pnpm dev-android` devtools build
- [Search excerpt no context for styled words (#4594)](search-excerpt-context-4594.md) — RESOLVED (foliate-js#25 + readest#4631). italic/`<i>` word = own `strs[]` text node; `makeExcerpt` read context only WITHIN the node → empty pre/post; fix = `collectBefore/After` walk neighbour nodes (+2 latent multi-node match bugs: string-index `slice`, `start===end`)
- [Global annotation page-turn lag (#4575)](global-annotation-pageturn-perf-4575.md) — highlighting recurring names = `global` highlights re-fanned-out (TreeWalker + getCFI/occurrence + SVG churn) EVERY page turn (~25-45ms desktop, ×mobile); fix = `WeakMap<Document,...>` memo in `globalAnnotations.ts` skips already-expanded sections; live-profiled via dev-web foliate-view; GBK-TXT synthetic-drop import recipe
- [Overlayer splitRange by text nodes](overlayer-splitrange-textnodes.md) — highlight SVG missed bullet-list text when range also touched a `<p>`: `#splitRangeByParagraph`'s `'p,h1-h4'` selector dropped `li` (3rd whack-a-mole after f087826/920676b); fix = walk text nodes + `img,svg` in overlayer.js, never block-tag selectors; jsdom test stubs `Range.prototype.getClientRects`
@@ -0,0 +1,47 @@
---
name: cover-bg-image-texture-suppression
description: Cover painted via body background-image vanished under an active bg texture (parchment) because textureAwareBackground misclassified it as transparent
metadata:
node_type: memory
type: project
originSessionId: 9d32520c-53be-4871-9104-d93617736e30
---
EPUB cover pages that paint the cover via a `<body>` CSS `background-image`
(EPUB sets `background-color` transparent + `background-size:100% 100%`, no
`<img>` — e.g. Sigil/duokan样书《商梯》) showed the **background texture instead
of the cover** on the first page. Reported "Xiaomi only" but it's
texture-only, not Android-only.
Root cause (verified on-device via adb+CDP, Xiaomi 13 WV147): foliate
`packages/foliate-js/paginator.js` `textureAwareBackground(resolved, hasTexture)`.
foliate captures the body bg into `view.docBackground` via
`getComputedStyle(body).background` (the SHORTHAND), which always serializes the
transparent background-*color* first: `rgba(0, 0, 0, 0) url("blob:…") no-repeat
fixed 50% 50% / 100% 100% …`. The old `isTransparent` regex
`/^\s*(transparent|rgba\(0,\s*0,\s*0,\s*0\))/` matched that prefix → under an
active texture (`--bg-texture-id` != none) it returned `''` → no bg segment in
the host `#background` → texture (`.foliate-viewer::before`) showed through. With
no texture it worked (returns the cover bg unchanged), which is why desktop/
default looked fine.
Fix: a bg that carries an image is NOT transparent. Add `hasImage =
/\burl\(/i.test(resolved)` and gate `isTransparent` on `!hasImage`. A full-page
cover should occlude the texture; plain `none` transparent pages still drop so
the texture shows through. Helps scrolled (line ~1464) and paginated (~1482)
callers alike. Test: `paginator-background-segments.test.ts` (added the
url()-keeps case; kept the existing `none`-drops case).
NOT the bug (ruled out on-device): Rust `parse_epub_metadata` cover EXTRACTION
(library thumbnail was correct), shorthand serialization (WV147 emits the url
fine), the cover blob URL (loads 1200x1800 fine), `background-attachment:fixed`
(Android falls back to scroll but the segment sets `background-attachment:
initial` anyway). Related: [[paginated-texture-occlusion-4399]],
[[dark-mode-texture-body-bg-4446]], [[paginator-swipe-bg-flash]].
CDP verify recipe: pid changes per app restart — re-derive socket from
`/proc/net/unix` (`webview_devtools_remote_<pid>`), `adb forward tcp:9333
localabstract:…`; curl mishandles WV HTTP framing → raw-socket fetch `/json`;
pure-python WS client (omit Origin for M111+); paint a 50%-width test segment
with the cover blob bg into `#background` + `Page.captureScreenshot` to see
cover-vs-texture side by side.
@@ -0,0 +1,47 @@
---
name: pdf-oom-range-flood-3470
description: "Android/iOS large-PDF import/open OOM (#3470) = unthrottled pdf.js range-request flood, not whole-file load; fix = concurrency cap in foliate makePDF"
metadata:
node_type: memory
type: project
originSessionId: 1f5ecad5-076c-4170-939a-c80438c37f64
---
# Large-PDF OOM on Android/iOS (#3470)
**Symptom:** importing/opening a 50 MB+ PDF crashes (no message) with
`java.lang.OutOfMemoryError ... target footprint 536870912` (512 MB Java heap)
at `RustWebViewClient.handleRequest``shouldInterceptRequest`. Same file is
fine in the official pdf.js viewer on Android Chrome. Repro file: `100个句子记完7000个雅思单词.pdf` (67 MB, 970 pages).
**Root cause (NOT whole-file load):** opening the PDF makes pdf.js fire ~759
small **64 KB** range reads to parse scattered xref/object streams. foliate-js
`makePDF` fulfilled every `requestDataRange` with an **un-awaited**
`file.slice(begin,end).arrayBuffer()` → all dispatched at once (measured
**maxInFlight 753**). On Android each read is a `fetch()` to the `rangefile`
scheme → `shouldInterceptRequest` allocates a Rust `Vec<u8>` + a Java `byte[]`
per request; ~750 simultaneous intercepted requests exhaust the 512 MB Java
heap. The official pdf.js viewer survives because the **browser caps ~6
connections/host**; the custom `rangefile` (and iOS native-file) scheme has no
such cap. Explains "50 MB+" (bigger PDF → more scattered objects → bigger
flood) and "crashes on some devices only" (heap/WebView threshold).
**Fix (RESOLVED — foliate-js#31 squash `e098bc3` + readest#4670, both merged):** `packages/foliate-js/pdf.js` `makePDF` — queue + pump bounding
range reads to `MAX_CONCURRENT_RANGES = 6` (mimics the browser's per-host
limit). One spot covers Android `RemoteFile`, iOS `NativeFile`, web `File`.
Throttling is **free** on speed (6 parallel fetches saturate throughput). foliate-js
is a **git submodule** → commit + push to readest fork, then bump pointer.
Test: `src/__tests__/foliate-pdf-range-concurrency.test.ts``vi.mock('@pdfjs/pdf.min.mjs')` installs a fake `globalThis.pdfjsLib` whose `getDocument` fires a 200-call flood; asserts `maxInFlight ≤ 6` and all served. Fails (200) before, passes after.
## On-device CDP verification recipe (no rebuild)
Release Readest 0.11.10 ships a debuggable WebView (socket
`webview_devtools_remote_<pid>`), so CDP attaches without `run-as`.
- `adb forward tcp:9222 localabstract:webview_devtools_remote_$PID`; page WS from `curl :9222/json`.
- Push file where asset scope allows: `/sdcard/Readest/Books/` matches scope glob `**/Readest/**/*`; app has MANAGE_EXTERNAL_STORAGE → readable. Canonical path `/storage/emulated/0/Readest/Books/x.pdf`.
- rangefile URL: `http://rangefile.localhost/?path=<encodeURIComponent(abs)>&start=&end=` (end **inclusive**, omit=EOF, 8 MB cap, returns 200 + `X-Total-Size`).
- Faithfully replicate `makePDF`: `await import('http://tauri.localhost/vendor/pdfjs/pdf.min.mjs')` (sets `globalThis.pdfjsLib`, same vendored 5.7.284), a file-like `{size, slice(b,e)→{arrayBuffer:()=>fetchRangePart(b,e-1)}}`, `new pdfjsLib.PDFDataRangeTransport(size,[])`, instrument `requestDataRange`, `getDocument({range,wasmUrl:'/vendor/pdfjs/',cMapUrl,standardFontDataUrl,isEvalSupported:false})` then `getPage(1)/getViewport/getMetadata`.
- Java heap via `adb shell dumpsys meminfo com.bilingify.readest` (Dalvik Heap line).
**Verified on Xiaomi 13 (fuxi) / Android 16 / WebView 147 / 8 GB:** this device does NOT OOM (newer WebView; Dalvik only +9 MB) but the flood reproduces: **753 → 6** concurrent, open time **1446 → 1479 ms** (no penalty), 970 pages/title/viewport identical. Gotcha: package installs (`installPackageLI` in logcat) kill the app mid-session → re-discover the devtools socket PID. The makePDF flood alone did NOT crash this device — can't get a live OOM here; rely on the user's WebView-145 log + the bounded-concurrency proof.
Related: [[android-nativefile-remotefile-io]] (rangefile vs asset-protocol Range bug), [[webtoon-mode-3647]] (foliate-js submodule fork-push).
@@ -0,0 +1,18 @@
---
name: rsvp-control-bar-overlap-revert
description: RSVP mobile control bar overlap was a REGRESSION — PR
metadata:
node_type: memory
type: project
originSessionId: cc658a96-fce5-4922-b924-361173c57e2a
---
RSVP (Speed Reading) overlay control bar: on narrow phones (Xiaomi 13 = 360px CSS width) the audio (TTS 🔊) toggle + settings ⚙ gear overlapped the right end of the centered transport row, hiding the "skip forward 15" label.
**This was a regression, not a new bug.** PR #4585 (`51fede1a0`, merged 2026-06-14 19:18Z) already fixed it: replaced the `absolute end-0` cluster with a single in-flow `flex items-center justify-between md:justify-center` row — audio toggle far-left, settings far-right, flanking the centered play button; secondary buttons tightened to `h-8 w-8 shrink-0 md:h-9 md:w-9`, skip buttons `px-1.5 md:px-2`. At 360px the 9 controls pack ~340px into a 336px row with zero gaps but **no overlap** (verified on-device).
**Reverted by PR #4589** (`490824504`, `feat/word-wise`, Word Wise inline vocab). Branch created 19:13Z — 5 min BEFORE #4585 merged — and merged ~10.5h later WITHOUT rebasing. The squash carried the stale pre-#4585 `RSVPOverlay.tsx`, so its diff is an exact mirror revert: #4585 = +53/51 src & +29 test; #4589 = +51/53 src & 29 test. #4589 added ZERO Word Wise code to RSVPOverlay — those hunks were purely the revert. It also deleted #4585's guard test (`audioBtn.closest('.absolute')).toBeNull()`), so CI couldn't catch the reintroduced overlap.
**Re-fix (this session):** restored the #4585 block byte-for-byte + re-added a guard test (`audio/settings share the play button's parent`, parent `className` has no `absolute`) in `rsvp-overlay-context.test.tsx`. Pattern to watch: a stale feature branch squash-merged after an intervening fix silently reverts it — see [[security-advisories-web-2026-06]] (shared-target worktree build-cache pollution) for the same stale-branch family.
**On-device verify recipe:** app running → `adb forward tcp:9222 localabstract:webview_devtools_remote_<pid>` → enter RSVP by dispatching synthetic `KeyboardEvent('keydown',{key:'v',shiftKey:true})` on `window` (Shift+V shortcut, listener on window; blur activeElement first) → click "From Current Page" → CDP-mutate the live DOM to preview a layout fix before rebuilding (apply exact source classes + reparent, then `getBoundingClientRect` overlap check + `adb exec-out screencap`). See [[cdp-android-webview-profiling]].
@@ -0,0 +1,23 @@
---
name: save-image-to-gallery-android
description: Image-viewer Save button → Android MediaStore (not share); sharekit 0-byte self-copy bug; tsgo misses abstract conformance
metadata:
node_type: memory
type: project
originSessionId: d72184f1-0e4c-412b-9dc9-fb384e189427
---
PR #4680 — image gallery "Save Image" button (`ImageViewer.tsx` + `ZoomControls.tsx`).
**Routing (the button reflects the actual action):** `canShare = !isAndroidApp && canShareText(appService)`.
- Android → `appService.saveImageToGallery(filename, bytes, mimeType)` = new native-bridge command `save_image_to_gallery` (Kotlin `MediaStore.Images` insert into `Pictures/Readest`, scoped-storage = NO permission on API 29+; pre-29 best-effort). Writes a Temp `shared/<name>` staging file, passes its path, removes it after.
- iOS/macOS / web-with-`navigator.share``saveFile({share:true})`.
- desktop / web-no-share → saveDialog / download.
**WHY Android does NOT use the share sheet to "save to file":** Android `ACTION_SEND` only lists apps that *consume* content; NO file manager registers for it. Verified on device: `adb shell cmd package query-activities -a android.intent.action.SEND -t image/png` → 34 apps (Bluetooth/Gmail/WPS/Telegram/Xiaomi-Drive…), zero file managers. "Save to a folder" is `ACTION_CREATE_DOCUMENT` (system `com.google.android.documentsui`), which never appears in a share sheet. So on MIUI the share flow genuinely can't save-to-file.
**sharekit 0-byte self-copy bug (separate fix commit on #4680):** `@choochmeque/tauri-plugin-sharekit` (rust `tauri-plugin-sharekit 0.3`) `shareFile` copies src → `File(activity.cacheDir, sourceFile.name)` BEFORE `ACTION_SEND`. Tauri `Temp` dir IS `activity.cacheDir` = `/data/user/0/<pkg>/cache` (verified `invoke('plugin:path|resolve_directory',{directory:12})`). Writing the shared file to the Temp ROOT makes that a copy onto itself → `FileOutputStream` truncates the source to 0 before `copyTo` reads it → **0 KB shared file**. Fix = write to a Temp `shared/` SUBDIR in `nativeAppService.saveFile`. Also fixed the same latent 0-byte bug in annotation/markdown export.
**tsgo gap (bit me):** `pnpm lint` (tsgo) does NOT flag abstract-class interface conformance — adding a method to the `AppService` interface compiled clean under tsgo but the production Next `tsc` failed (`BaseAppService` missing abstract member). When extending `AppService`: add `abstract` decl in `BaseAppService` (appService.ts) + impls in native/web/**node**AppService + the 2 test stub classes (`app-service.test.ts`, `import-metahash.test.ts`). Run real `npx tsc --noEmit -p tsconfig.json` to catch.
**On-device verify recipe (no run-as on release APK):** `pnpm dev-android` (devtools APK) → CDP invoke `plugin:native-bridge|save_image_to_gallery` with a PNG staged via `plugin:fs|write_file` (body = Uint8Array 2nd arg, `headers:{path:encodeURIComponent(p),options:'{}'}`) → confirm with `adb shell content query --uri content://media/external/images/media --projection _display_name:relative_path:_size --where "relative_path='Pictures/Readest/'"`. Real 252 KB JPEGs from the live UI landed correctly. See [[android-cdp-e2e-lane]], [[cdp-android-webview-profiling]].
@@ -0,0 +1,20 @@
---
name: sync-statusless-book-rebump-4677
description: Books with no reading status get re-pinned to top of library after every sync (updated_at rebump); PR
metadata:
node_type: memory
type: project
originSessionId: f943703d-f8c5-4ad9-9c2c-fc2c02d8b62c
---
# Statusless books re-pinned to top of library after every sync (PR #4677)
**Symptom:** a fixed set of books stayed pinned at the top of a `updatedAt`-desc ("date read") library. Reading/closing another book moved it to front, but the next cloud sync floated those books back above it. Gone after logout → caused by the sync round-trip.
**Root cause** (`src/pages/api/sync.ts` POST handler, books branch ~line 422): when a pushed book is NOT newer than the server (`clientIsNewer` false), it rewrites `updated_at = new Date().toISOString()` if `statusChanged`. The check was `status.reading_status !== serverBook.reading_status`. A locally-imported book that never got a status sends `reading_status: undefined` (dropped by `JSON.stringify`); the server stores `null`. `undefined !== null` ⇒ true ⇒ spurious rewrite. The rewrite re-writes `undefined` (→ stays `null`), so it NEVER converges. Discriminator is purely client-side: books that round-tripped through a PULL have `readingStatus: null` (set by `transformBookFromDB`) and don't trigger it; never-pulled imports keep `undefined`.
**Amplifiers:** (1) the 1-day re-sync window (`useSync.ts:98` `lastSyncedAtBooks = stored - ONE_DAY_IN_MS`) re-pushes every recently-touched book each sync. (2) the rewrite runs in one batch `upsert` transaction → all affected rows get the SAME `now()` ⇒ identical-to-the-ms timestamps (the tell-tale signature).
**Fix:** `readingStatusChanged(a,b) = (a ?? null) !== (b ?? null)` — treat undefined/null both as "no status". Existing inflated timestamps age out naturally; no migration. NOT a DB trigger (Alice kept her config time, proving the app writes the value).
**CDP verification recipe (Xiaomi, on-device):** the `pnpm dev-android` build = release APK with `--features devtools` → WebView debugging on → `tauri.localhost` + `webview_devtools_remote_<pid>` socket. Discover socket from `/proc/net/unix`, `adb forward tcp:PORT localabstract:<sock>`, drive via Node 24 native `WebSocket` to `/json/list` page target. The page can call `fetch('https://web.readest.com/api/sync?since=0&type=books', {Authorization: Bearer <localStorage token>})` directly to read cloud `updated_at` per book. Decisive evidence = compare in-app `PUSH_SENT` (client sends old ts) vs `PUSH_RETURNED` (server returns fresh identical `now()`) for the statusless books only. Console object args replay as `Array(N)` previews with stale objectIds — log pre-stringified JSON and/or stash into a `window.__SYNCDBG` ring buffer read via `Runtime.evaluate(returnByValue)`. Related: [[android-cdp-e2e-lane]], [[cdp-android-webview-profiling]]. Touches #4634 reading-status field-level merge.
@@ -0,0 +1,48 @@
---
name: zindex-overlay-scale
description: Global overlay z-index scale; why Add-Catalog-behind-Settings was mobile-only (window-border trap); RSVP de-escalated from 10000
metadata:
node_type: memory
type: project
originSessionId: e7590344-aa6d-4bec-9b6d-6f3b93b18c87
---
RESOLVED — PR #4669 (merged 2026-06-19). Redesigned the overlay z-index scale (was: RSVP `z-[10000]`, Settings `!z-[10050]`,
ModalPortal `z-[100]`). Compact scale now, all clearing the desktop `.window-border`
page frame (`z-99` in `globals.css`):
- `100` RSVP immersive overlay (`RSVPOverlay.tsx`)
- `101` RSVP controls — start dialog + lookup chip (`RSVPStartDialog.tsx`, `RSVPOverlay.tsx`)
- `110` Settings dialog (`SettingsDialog.tsx`, `!z-[110]`)
- `120` modal / command palette (`ModalPortal.tsx`, `CommandPalette.tsx`)
- `130` toast (`Alert.tsx`)
- `200` app-lock (`AppLockScreen.tsx`, unchanged)
**Bug fixed:** "Add OPDS Catalog" dialog (a `ModalPortal`, opened via `CatalogManager
inSubPage` inside Settings → Integrations) rendered BEHIND the Settings sheet on mobile.
Root cause = regression from #3235, which raised Settings to `!z-[10050]` (to beat the
RSVP `z-[10000]` overlay for in-overlay dictionary mgmt) — that jumped Settings above the
`ModalPortal` layer (`z-[100]`), so any modal opened from inside Settings was buried.
**Why MOBILE-ONLY (non-obvious):** `Dialog` does NOT portal — `SettingsDialog` renders
inline inside `.reader-page` (`ReaderContent.tsx:273`). On desktop rounded-window,
`.reader-page` has `.window-border` (`z-99`, `position:relative`) = a stacking context
that TRAPS Settings at the `z-99` layer. `ModalPortal` uses `createPortal(document.body)`
→ escapes to body `z-100+` → already wins on desktop. On mobile `hasRoundedWindow` is
false → no `.window-border` → Settings' `z-10050` competes at body level and buries the
modal. So RSVP must stay **≥100** to cover the `z-99` frame on desktop (an early `z-[70]`
idea would have broken desktop RSVP).
**Invariant lock:** `src/__tests__/styles/zIndexScale.test.ts` reads the z values straight
from source and asserts MODAL>SETTINGS>RSVP_CTRL>RSVP>99, APP_LOCK>MODAL, and all <1000.
This static test would have caught #3235. Scale also documented in `DESIGN.md` §6 and a
comment block in `ModalPortal.tsx`.
**On-device verify recipe (Xiaomi fuxi):** release build has WebView debugging OFF (no CDP
socket). `pnpm dev-android` builds the RELEASE-signed APK with `--features devtools` and
`adb install -r` — same signing key (`65:2D:..`), replaces in place, KEEPS data, enables
`@webview_devtools_remote_<pid>`. Proof of the bug = `document.elementFromPoint(cx,cy)` at
the Add-Catalog modal-box center returns a Settings `<P>` (topInsideSettings:true); after
fix returns the Add-Catalog `FORM`. Drove UI via `adb shell input tap` (logical*3 = physical
on this 1080×2400/360×800 device) + stdlib-only CDP ws client at `/tmp/cdp.py`.
Related: [[android-cdp-e2e-lane]], [[cdp-android-webview-profiling]], [[tts-sync-paragraph-rsvp-3235]].
+23
View File
@@ -628,6 +628,29 @@ the closest analog.
- Drag handle at top (the small horizontal pill) is mandatory if the sheet supports
swipe-to-dismiss.
#### Stacking order (z-index scale)
Full-screen and body-portaled overlays share **one global stacking scale**. Keep it
compact — never reach for four-digit z-indexes. Every layer must clear the desktop
rounded-window page frame (`.window-border`, `z-99` in `globals.css`), then layer:
| z-index | Layer | Where |
| ------- | ----- | ----- |
| `99` | Desktop window-border page frame | `globals.css` |
| `100` | RSVP immersive reading overlay | `RSVPOverlay` |
| `101` | RSVP immersive controls (start dialog, lookup chip) | `RSVPStartDialog`, `RSVPOverlay` |
| `110` | Settings app dialog (above RSVP for in-overlay dictionary mgmt) | `SettingsDialog` |
| `120` | Modal / command palette | `ModalPortal`, `CommandPalette` |
| `130` | Toast / alert | `Alert` |
| `200` | Security lock screen | `AppLockScreen` |
The non-obvious invariant: **`ModalPortal` (120) must stay above `SettingsDialog`
(110)** so a modal opened _from inside_ Settings (e.g. Add OPDS Catalog) isn't buried.
This bites only on mobile — desktop traps `SettingsDialog` inside the `z-99`
`.window-border` stacking context, so the body-portaled modal already wins there.
The ordering is locked by `src/__tests__/styles/zIndexScale.test.ts`; update both
together.
---
### 7. Motion + a11y
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@readest/readest-app",
"version": "0.11.10",
"version": "0.11.12",
"private": true,
"type": "module",
"scripts": {
@@ -1907,5 +1907,10 @@
"Show a short native-language hint above difficult words. Words above your level get a hint.": "أظهر تلميحًا قصيرًا بلغتك الأم فوق الكلمات الصعبة. الكلمات التي تفوق مستواك تحصل على تلميح.",
"Level": "المستوى",
"CEFR level": "مستوى CEFR",
"Webtoon Mode": "وضع الويبتون"
"Webtoon Mode": "وضع الويبتون",
"Image saved successfully": "تم حفظ الصورة بنجاح",
"Failed to save the image": "فشل حفظ الصورة",
"Share Image": "مشاركة الصورة",
"Save Image": "حفظ الصورة",
"Image saved to gallery": "تم حفظ الصورة في المعرض"
}
@@ -1775,5 +1775,10 @@
"Show a short native-language hint above difficult words. Words above your level get a hint.": "কঠিন শব্দের উপরে আপনার মাতৃভাষায় একটি সংক্ষিপ্ত ইঙ্গিত দেখান। আপনার স্তরের উপরের শব্দগুলো একটি ইঙ্গিত পায়।",
"Level": "স্তর",
"CEFR level": "CEFR স্তর",
"Webtoon Mode": "ওয়েবটুন মোড"
"Webtoon Mode": "ওয়েবটুন মোড",
"Image saved successfully": "ছবি সফলভাবে সংরক্ষিত হয়েছে",
"Failed to save the image": "ছবি সংরক্ষণ করা যায়নি",
"Share Image": "ছবি শেয়ার করুন",
"Save Image": "ছবি সংরক্ষণ করুন",
"Image saved to gallery": "ছবি গ্যালারিতে সংরক্ষিত হয়েছে"
}
@@ -1742,5 +1742,10 @@
"Show a short native-language hint above difficult words. Words above your level get a hint.": "ཚིག་དཀའ་མོའི་སྟེང་དུ་རང་གི་སྐད་ཡིག་ཐོག་ནས་བརྡ་སྟོན་ཐུང་ངུ་ཞིག་སྟོན། ཁྱེད་ཀྱི་རིམ་པ་ལས་མཐོ་བའི་ཚིག་ལ་བརྡ་སྟོན་ཐོབ།",
"Level": "རིམ་པ།",
"CEFR level": "CEFR རིམ་པ།",
"Webtoon Mode": "Webtoon སྤྱོད་ཚུལ།"
"Webtoon Mode": "Webtoon སྤྱོད་ཚུལ།",
"Image saved successfully": "པར་རིས་ཉར་ཚགས་ལེགས་གྲུབ་བྱུང་",
"Failed to save the image": "པར་རིས་ཉར་ཚགས་བྱས་མ་ཐུབ",
"Share Image": "པར་རིས་མཉམ་སྤྱོད།",
"Save Image": "པར་རིས་ཉར་ཚགས།",
"Image saved to gallery": "པར་རིས་པར་མཛོད་ནང་ཉར་ཚགས་བྱས་ཟིན"
}
@@ -1775,5 +1775,10 @@
"Show a short native-language hint above difficult words. Words above your level get a hint.": "Zeigt einen kurzen Hinweis in deiner Muttersprache über schwierigen Wörtern an. Wörter über deinem Niveau erhalten einen Hinweis.",
"Level": "Niveau",
"CEFR level": "CEFR-Niveau",
"Webtoon Mode": "Webtoon-Modus"
"Webtoon Mode": "Webtoon-Modus",
"Image saved successfully": "Bild erfolgreich gespeichert",
"Failed to save the image": "Bild konnte nicht gespeichert werden",
"Share Image": "Bild teilen",
"Save Image": "Bild speichern",
"Image saved to gallery": "Bild in der Galerie gespeichert"
}
@@ -1775,5 +1775,10 @@
"Show a short native-language hint above difficult words. Words above your level get a hint.": "Εμφανίζει μια σύντομη υπόδειξη στη μητρική σου γλώσσα πάνω από δύσκολες λέξεις. Οι λέξεις πάνω από το επίπεδό σου λαμβάνουν υπόδειξη.",
"Level": "Επίπεδο",
"CEFR level": "Επίπεδο CEFR",
"Webtoon Mode": "Λειτουργία webtoon"
"Webtoon Mode": "Λειτουργία webtoon",
"Image saved successfully": "Η εικόνα αποθηκεύτηκε με επιτυχία",
"Failed to save the image": "Αποτυχία αποθήκευσης της εικόνας",
"Share Image": "Κοινή χρήση εικόνας",
"Save Image": "Αποθήκευση εικόνας",
"Image saved to gallery": "Η εικόνα αποθηκεύτηκε στη συλλογή"
}
@@ -1808,5 +1808,10 @@
"Show a short native-language hint above difficult words. Words above your level get a hint.": "Muestra una breve pista en tu idioma nativo encima de las palabras difíciles. Las palabras por encima de tu nivel reciben una pista.",
"Level": "Nivel",
"CEFR level": "Nivel CEFR",
"Webtoon Mode": "Modo webtoon"
"Webtoon Mode": "Modo webtoon",
"Image saved successfully": "Imagen guardada correctamente",
"Failed to save the image": "No se pudo guardar la imagen",
"Share Image": "Compartir imagen",
"Save Image": "Guardar imagen",
"Image saved to gallery": "Imagen guardada en la galería"
}
@@ -1775,5 +1775,10 @@
"Show a short native-language hint above difficult words. Words above your level get a hint.": "یک راهنمای کوتاه به زبان مادری‌تان بالای واژه‌های دشوار نشان می‌دهد. واژه‌های بالاتر از سطح شما راهنما می‌گیرند.",
"Level": "سطح",
"CEFR level": "سطح CEFR",
"Webtoon Mode": "حالت وب‌تون"
"Webtoon Mode": "حالت وب‌تون",
"Image saved successfully": "تصویر با موفقیت ذخیره شد",
"Failed to save the image": "ذخیره تصویر ناموفق بود",
"Share Image": "اشتراک‌گذاری تصویر",
"Save Image": "ذخیره تصویر",
"Image saved to gallery": "تصویر در گالری ذخیره شد"
}
@@ -1808,5 +1808,10 @@
"Show a short native-language hint above difficult words. Words above your level get a hint.": "Affiche une courte indication dans votre langue maternelle au-dessus des mots difficiles. Les mots au-dessus de votre niveau reçoivent une indication.",
"Level": "Niveau",
"CEFR level": "Niveau CEFR",
"Webtoon Mode": "Mode webtoon"
"Webtoon Mode": "Mode webtoon",
"Image saved successfully": "Image enregistrée avec succès",
"Failed to save the image": "Échec de l'enregistrement de l'image",
"Share Image": "Partager l'image",
"Save Image": "Enregistrer l'image",
"Image saved to gallery": "Image enregistrée dans la galerie"
}
@@ -1808,5 +1808,10 @@
"Show a short native-language hint above difficult words. Words above your level get a hint.": "מציג רמז קצר בשפת האם שלך מעל מילים קשות. מילים מעל הרמה שלך מקבלות רמז.",
"Level": "רמה",
"CEFR level": "רמת CEFR",
"Webtoon Mode": "מצב ובטון"
"Webtoon Mode": "מצב ובטון",
"Image saved successfully": "התמונה נשמרה בהצלחה",
"Failed to save the image": "שמירת התמונה נכשלה",
"Share Image": "שיתוף התמונה",
"Save Image": "שמירת התמונה",
"Image saved to gallery": "התמונה נשמרה בגלריה"
}
@@ -1775,5 +1775,10 @@
"Show a short native-language hint above difficult words. Words above your level get a hint.": "कठिन शब्दों के ऊपर आपकी मातृभाषा में एक छोटा संकेत दिखाता है। आपके स्तर से ऊपर के शब्दों को संकेत मिलता है।",
"Level": "स्तर",
"CEFR level": "CEFR स्तर",
"Webtoon Mode": "वेबटून मोड"
"Webtoon Mode": "वेबटून मोड",
"Image saved successfully": "छवि सफलतापूर्वक सहेजी गई",
"Failed to save the image": "छवि सहेजने में विफल",
"Share Image": "छवि साझा करें",
"Save Image": "छवि सहेजें",
"Image saved to gallery": "छवि गैलरी में सहेजी गई"
}
@@ -1775,5 +1775,10 @@
"Show a short native-language hint above difficult words. Words above your level get a hint.": "Rövid anyanyelvi tippet jelenít meg a nehéz szavak fölött. A szintednél nehezebb szavak kapnak tippet.",
"Level": "Szint",
"CEFR level": "CEFR-szint",
"Webtoon Mode": "Webtoon mód"
"Webtoon Mode": "Webtoon mód",
"Image saved successfully": "A kép sikeresen mentve",
"Failed to save the image": "Nem sikerült menteni a képet",
"Share Image": "Kép megosztása",
"Save Image": "Kép mentése",
"Image saved to gallery": "A kép elmentve a galériába"
}
@@ -1742,5 +1742,10 @@
"Show a short native-language hint above difficult words. Words above your level get a hint.": "Menampilkan petunjuk singkat dalam bahasa ibu Anda di atas kata-kata sulit. Kata-kata di atas level Anda mendapat petunjuk.",
"Level": "Level",
"CEFR level": "Level CEFR",
"Webtoon Mode": "Mode webtoon"
"Webtoon Mode": "Mode webtoon",
"Image saved successfully": "Gambar berhasil disimpan",
"Failed to save the image": "Gagal menyimpan gambar",
"Share Image": "Bagikan Gambar",
"Save Image": "Simpan Gambar",
"Image saved to gallery": "Gambar disimpan ke galeri"
}
@@ -1808,5 +1808,10 @@
"Show a short native-language hint above difficult words. Words above your level get a hint.": "Mostra un breve suggerimento nella tua lingua madre sopra le parole difficili. Le parole sopra il tuo livello ricevono un suggerimento.",
"Level": "Livello",
"CEFR level": "Livello CEFR",
"Webtoon Mode": "Modalità webtoon"
"Webtoon Mode": "Modalità webtoon",
"Image saved successfully": "Immagine salvata correttamente",
"Failed to save the image": "Impossibile salvare l'immagine",
"Share Image": "Condividi immagine",
"Save Image": "Salva immagine",
"Image saved to gallery": "Immagine salvata nella galleria"
}
@@ -1742,5 +1742,10 @@
"Show a short native-language hint above difficult words. Words above your level get a hint.": "難しい単語の上に母語の短いヒントを表示します。あなたのレベルを超える単語にヒントが付きます。",
"Level": "レベル",
"CEFR level": "CEFRレベル",
"Webtoon Mode": "ウェブトゥーンモード"
"Webtoon Mode": "ウェブトゥーンモード",
"Image saved successfully": "画像を保存しました",
"Failed to save the image": "画像の保存に失敗しました",
"Share Image": "画像を共有",
"Save Image": "画像を保存",
"Image saved to gallery": "画像をギャラリーに保存しました"
}
@@ -1742,5 +1742,10 @@
"Show a short native-language hint above difficult words. Words above your level get a hint.": "어려운 단어 위에 모국어로 된 짧은 힌트를 표시합니다. 내 수준보다 높은 단어에 힌트가 표시됩니다.",
"Level": "수준",
"CEFR level": "CEFR 수준",
"Webtoon Mode": "웹툰 모드"
"Webtoon Mode": "웹툰 모드",
"Image saved successfully": "이미지를 저장했습니다",
"Failed to save the image": "이미지를 저장하지 못했습니다",
"Share Image": "이미지 공유",
"Save Image": "이미지 저장",
"Image saved to gallery": "이미지를 갤러리에 저장했습니다"
}
@@ -1742,5 +1742,10 @@
"Show a short native-language hint above difficult words. Words above your level get a hint.": "Menunjukkan petunjuk ringkas dalam bahasa ibunda anda di atas perkataan sukar. Perkataan melebihi tahap anda akan mendapat petunjuk.",
"Level": "Tahap",
"CEFR level": "Tahap CEFR",
"Webtoon Mode": "Mode webtoon"
"Webtoon Mode": "Mode webtoon",
"Image saved successfully": "Imej berjaya disimpan",
"Failed to save the image": "Gagal menyimpan imej",
"Share Image": "Kongsi Imej",
"Save Image": "Simpan Imej",
"Image saved to gallery": "Imej disimpan ke galeri"
}
@@ -1775,5 +1775,10 @@
"Show a short native-language hint above difficult words. Words above your level get a hint.": "Toont een korte hint in je moedertaal boven moeilijke woorden. Woorden boven jouw niveau krijgen een hint.",
"Level": "Niveau",
"CEFR level": "CEFR-niveau",
"Webtoon Mode": "Webtoon-modus"
"Webtoon Mode": "Webtoon-modus",
"Image saved successfully": "Afbeelding succesvol opgeslagen",
"Failed to save the image": "Kan de afbeelding niet opslaan",
"Share Image": "Afbeelding delen",
"Save Image": "Afbeelding opslaan",
"Image saved to gallery": "Afbeelding opgeslagen in de galerij"
}
@@ -1841,5 +1841,10 @@
"Show a short native-language hint above difficult words. Words above your level get a hint.": "Pokazuje krótką wskazówkę w Twoim języku ojczystym nad trudnymi słowami. Słowa powyżej Twojego poziomu otrzymują wskazówkę.",
"Level": "Poziom",
"CEFR level": "Poziom CEFR",
"Webtoon Mode": "Tryb webtoon"
"Webtoon Mode": "Tryb webtoon",
"Image saved successfully": "Obraz został zapisany pomyślnie",
"Failed to save the image": "Nie udało się zapisać obrazu",
"Share Image": "Udostępnij obraz",
"Save Image": "Zapisz obraz",
"Image saved to gallery": "Obraz zapisano w galerii"
}
@@ -1808,5 +1808,10 @@
"Show a short native-language hint above difficult words. Words above your level get a hint.": "Mostra uma breve dica no seu idioma nativo acima de palavras difíceis. Palavras acima do seu nível recebem uma dica.",
"Level": "Nível",
"CEFR level": "Nível CEFR",
"Webtoon Mode": "Modo webtoon"
"Webtoon Mode": "Modo webtoon",
"Image saved successfully": "Imagem salva com sucesso",
"Failed to save the image": "Falha ao salvar a imagem",
"Share Image": "Compartilhar imagem",
"Save Image": "Salvar imagem",
"Image saved to gallery": "Imagem salva na galeria"
}
@@ -1808,5 +1808,10 @@
"Show a short native-language hint above difficult words. Words above your level get a hint.": "Mostra uma breve dica na sua língua materna acima de palavras difíceis. Palavras acima do seu nível recebem uma dica.",
"Level": "Nível",
"CEFR level": "Nível CEFR",
"Webtoon Mode": "Modo webtoon"
"Webtoon Mode": "Modo webtoon",
"Image saved successfully": "Imagem salva com sucesso",
"Failed to save the image": "Falha ao salvar a imagem",
"Share Image": "Partilhar imagem",
"Save Image": "Salvar imagem",
"Image saved to gallery": "Imagem guardada na galeria"
}
@@ -1808,5 +1808,10 @@
"Show a short native-language hint above difficult words. Words above your level get a hint.": "Afișează un scurt indiciu în limba ta maternă deasupra cuvintelor dificile. Cuvintele peste nivelul tău primesc un indiciu.",
"Level": "Nivel",
"CEFR level": "Nivel CEFR",
"Webtoon Mode": "Mod webtoon"
"Webtoon Mode": "Mod webtoon",
"Image saved successfully": "Imagine salvată cu succes",
"Failed to save the image": "Salvarea imaginii a eșuat",
"Share Image": "Partajează imaginea",
"Save Image": "Salvează imaginea",
"Image saved to gallery": "Imagine salvată în galerie"
}
@@ -1841,5 +1841,10 @@
"Show a short native-language hint above difficult words. Words above your level get a hint.": "Показывает короткую подсказку на вашем родном языке над сложными словами. Слова выше вашего уровня получают подсказку.",
"Level": "Уровень",
"CEFR level": "Уровень CEFR",
"Webtoon Mode": "Режим вебтуна"
"Webtoon Mode": "Режим вебтуна",
"Image saved successfully": "Изображение успешно сохранено",
"Failed to save the image": "Не удалось сохранить изображение",
"Share Image": "Поделиться изображением",
"Save Image": "Сохранить изображение",
"Image saved to gallery": "Изображение сохранено в галерее"
}
@@ -1775,5 +1775,10 @@
"Show a short native-language hint above difficult words. Words above your level get a hint.": "අසීරු වචන මත ඔබේ මව් භාෂාවෙන් කෙටි ඉඟියක් පෙන්වයි. ඔබේ මට්ටමට වඩා ඉහළ වචනවලට ඉඟියක් ලැබේ.",
"Level": "මට්ටම",
"CEFR level": "CEFR මට්ටම",
"Webtoon Mode": "Webtoon ආකාරය"
"Webtoon Mode": "Webtoon ආකාරය",
"Image saved successfully": "රූපය සාර්ථකව සුරැකිණි",
"Failed to save the image": "රූපය සුරැකීමට අසමත් විය",
"Share Image": "රූපය බෙදාගන්න",
"Save Image": "රූපය සුරකින්න",
"Image saved to gallery": "රූපය ගැලරියට සුරැකිණි"
}
@@ -1841,5 +1841,10 @@
"Show a short native-language hint above difficult words. Words above your level get a hint.": "Prikaže kratek namig v tvojem maternem jeziku nad težkimi besedami. Besede nad tvojo ravnjo dobijo namig.",
"Level": "Raven",
"CEFR level": "Raven CEFR",
"Webtoon Mode": "Tryb webtoon"
"Webtoon Mode": "Tryb webtoon",
"Image saved successfully": "Slika je bila uspešno shranjena",
"Failed to save the image": "Slike ni bilo mogoče shraniti",
"Share Image": "Deli sliko",
"Save Image": "Shrani sliko",
"Image saved to gallery": "Slika je shranjena v galerijo"
}
@@ -1775,5 +1775,10 @@
"Show a short native-language hint above difficult words. Words above your level get a hint.": "Visar en kort ledtråd på ditt modersmål ovanför svåra ord. Ord över din nivå får en ledtråd.",
"Level": "Nivå",
"CEFR level": "CEFR-nivå",
"Webtoon Mode": "Webtoon-läge"
"Webtoon Mode": "Webtoon-läge",
"Image saved successfully": "Bilden har sparats",
"Failed to save the image": "Det gick inte att spara bilden",
"Share Image": "Dela bild",
"Save Image": "Spara bild",
"Image saved to gallery": "Bilden har sparats i galleriet"
}
@@ -1775,5 +1775,10 @@
"Show a short native-language hint above difficult words. Words above your level get a hint.": "கடினமான சொற்களுக்கு மேலே உங்கள் தாய்மொழியில் ஒரு சிறு குறிப்பைக் காட்டும். உங்கள் நிலைக்கு மேலான சொற்களுக்கு குறிப்பு கிடைக்கும்.",
"Level": "நிலை",
"CEFR level": "CEFR நிலை",
"Webtoon Mode": "வெப்டூன் பயன்முறை"
"Webtoon Mode": "வெப்டூன் பயன்முறை",
"Image saved successfully": "படம் வெற்றிகரமாகச் சேமிக்கப்பட்டது",
"Failed to save the image": "படத்தைச் சேமிக்க முடியவில்லை",
"Share Image": "படத்தைப் பகிர்",
"Save Image": "படத்தைச் சேமி",
"Image saved to gallery": "படம் கேலரியில் சேமிக்கப்பட்டது"
}
@@ -1742,5 +1742,10 @@
"Show a short native-language hint above difficult words. Words above your level get a hint.": "แสดงคำใบ้สั้น ๆ ในภาษาแม่ของคุณเหนือคำที่ยาก คำที่สูงกว่าระดับของคุณจะมีคำใบ้",
"Level": "ระดับ",
"CEFR level": "ระดับ CEFR",
"Webtoon Mode": "โหมดเว็บตูน"
"Webtoon Mode": "โหมดเว็บตูน",
"Image saved successfully": "บันทึกรูปภาพสำเร็จแล้ว",
"Failed to save the image": "ไม่สามารถบันทึกรูปภาพได้",
"Share Image": "แชร์รูปภาพ",
"Save Image": "บันทึกรูปภาพ",
"Image saved to gallery": "บันทึกรูปภาพไปยังแกลเลอรีแล้ว"
}
@@ -1775,5 +1775,10 @@
"Show a short native-language hint above difficult words. Words above your level get a hint.": "Zor kelimelerin üzerinde ana dilinizde kısa bir ipucu gösterir. Seviyenizin üzerindeki kelimeler ipucu alır.",
"Level": "Seviye",
"CEFR level": "CEFR seviyesi",
"Webtoon Mode": "Webtoon modu"
"Webtoon Mode": "Webtoon modu",
"Image saved successfully": "Görsel başarıyla kaydedildi",
"Failed to save the image": "Görsel kaydedilemedi",
"Share Image": "Görseli Paylaş",
"Save Image": "Görseli Kaydet",
"Image saved to gallery": "Görsel galeriye kaydedildi"
}
@@ -1841,5 +1841,10 @@
"Show a short native-language hint above difficult words. Words above your level get a hint.": "Показує коротку підказку вашою рідною мовою над складними словами. Слова, вищі за ваш рівень, отримують підказку.",
"Level": "Рівень",
"CEFR level": "Рівень CEFR",
"Webtoon Mode": "Режим вебтуна"
"Webtoon Mode": "Режим вебтуна",
"Image saved successfully": "Зображення успішно збережено",
"Failed to save the image": "Не вдалося зберегти зображення",
"Share Image": "Поділитися зображенням",
"Save Image": "Зберегти зображення",
"Image saved to gallery": "Зображення збережено в галереї"
}
@@ -1775,5 +1775,10 @@
"Show a short native-language hint above difficult words. Words above your level get a hint.": "Qiyin sozlar ustida ona tilingizda qisqa maslahat korsatadi. Darajangizdan yuqori sozlarga maslahat beriladi.",
"Level": "Daraja",
"CEFR level": "CEFR darajasi",
"Webtoon Mode": "Webtoon rejimi"
"Webtoon Mode": "Webtoon rejimi",
"Image saved successfully": "Rasm muvaffaqiyatli saqlandi",
"Failed to save the image": "Rasmni saqlab bo'lmadi",
"Share Image": "Rasmni ulashish",
"Save Image": "Rasmni saqlash",
"Image saved to gallery": "Rasm galereyaga saqlandi"
}
@@ -1742,5 +1742,10 @@
"Show a short native-language hint above difficult words. Words above your level get a hint.": "Hiển thị gợi ý ngắn bằng tiếng mẹ đẻ của bạn phía trên những từ khó. Những từ trên trình độ của bạn sẽ có gợi ý.",
"Level": "Cấp độ",
"CEFR level": "Cấp độ CEFR",
"Webtoon Mode": "Chế độ webtoon"
"Webtoon Mode": "Chế độ webtoon",
"Image saved successfully": "Đã lưu hình ảnh thành công",
"Failed to save the image": "Không thể lưu hình ảnh",
"Share Image": "Chia sẻ hình ảnh",
"Save Image": "Lưu hình ảnh",
"Image saved to gallery": "Đã lưu hình ảnh vào thư viện"
}
@@ -1742,5 +1742,10 @@
"Show a short native-language hint above difficult words. Words above your level get a hint.": "在生词上方显示一条母语简短提示。高于你水平的单词会显示提示。",
"Level": "级别",
"CEFR level": "CEFR 级别",
"Webtoon Mode": "条漫模式"
"Webtoon Mode": "条漫模式",
"Image saved successfully": "图片保存成功",
"Failed to save the image": "图片保存失败",
"Share Image": "分享图片",
"Save Image": "保存图片",
"Image saved to gallery": "图片已保存到相册"
}
@@ -1742,5 +1742,10 @@
"Show a short native-language hint above difficult words. Words above your level get a hint.": "在難詞上方顯示一則母語簡短提示。高於你程度的單字會顯示提示。",
"Level": "等級",
"CEFR level": "CEFR 等級",
"Webtoon Mode": "條漫模式"
"Webtoon Mode": "條漫模式",
"Image saved successfully": "圖片儲存成功",
"Failed to save the image": "圖片儲存失敗",
"Share Image": "分享圖片",
"Save Image": "儲存圖片",
"Image saved to gallery": "圖片已儲存至相簿"
}
+12
View File
@@ -1,5 +1,17 @@
{
"releases": {
"0.11.12": {
"date": "2026-06-20",
"notes": [
"Reading: Fixed full-page cover images and cover backgrounds that didn't display correctly under a page texture",
"Reading: Fixed a crash when opening large PDF files on mobile devices",
"Reading: You can now save or share an image straight from the image viewer",
"Speed Reading: Fixed the control bar overlapping the playback controls on mobile",
"Sync: Books without a reading status no longer jump back to the top after every sync",
"Sync: Your per-device reading view settings now stay local instead of syncing between devices",
"OPDS: Fixed the Add Catalog dialog appearing behind Settings on mobile"
]
},
"0.11.10": {
"date": "2026-06-19",
"notes": [
@@ -4,9 +4,11 @@ import android.Manifest
import android.app.Activity
import android.app.PendingIntent
import android.content.ComponentName
import android.content.ContentValues
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.provider.MediaStore
import android.util.Log
import android.os.Build
import android.os.Environment
@@ -55,6 +57,14 @@ class CopyURIRequestArgs {
var dst: String? = null
}
@InvokeArg
class SaveImageToGalleryRequestArgs {
var srcPath: String? = null
var fileName: String? = null
var mimeType: String? = null
var albumName: String? = null
}
@InvokeArg
class InstallPackageRequestArgs {
var path: String? = null
@@ -369,6 +379,77 @@ class NativeBridgePlugin(private val activity: Activity): Plugin(activity) {
}
}
@Command
fun save_image_to_gallery(invoke: Invoke) {
val args = invoke.parseArgs(SaveImageToGalleryRequestArgs::class.java)
pluginScope.launch {
val ret = withContext(Dispatchers.IO) {
val r = JSObject()
try {
val srcFile = File(args.srcPath ?: "")
if (!srcFile.exists()) {
r.put("success", false)
r.put("error", "Source file does not exist")
return@withContext r
}
val displayName = args.fileName ?: srcFile.name
val mimeType = args.mimeType ?: "image/*"
val album = args.albumName ?: "Readest"
val resolver = activity.contentResolver
val values = ContentValues().apply {
put(MediaStore.Images.Media.DISPLAY_NAME, displayName)
put(MediaStore.Images.Media.MIME_TYPE, mimeType)
// Scoped storage (Android 10+): place the image under the
// shared Pictures collection without any storage permission,
// and mark it pending until the bytes are fully written.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
put(
MediaStore.Images.Media.RELATIVE_PATH,
"${Environment.DIRECTORY_PICTURES}/$album"
)
put(MediaStore.Images.Media.IS_PENDING, 1)
}
}
val collection = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY)
} else {
MediaStore.Images.Media.EXTERNAL_CONTENT_URI
}
val itemUri = resolver.insert(collection, values)
if (itemUri == null) {
r.put("success", false)
r.put("error", "Failed to create MediaStore entry")
return@withContext r
}
resolver.openOutputStream(itemUri).use { output ->
if (output == null) {
throw IOException("Failed to open output stream")
}
srcFile.inputStream().use { input -> input.copyTo(output) }
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val pending = ContentValues().apply {
put(MediaStore.Images.Media.IS_PENDING, 0)
}
resolver.update(itemUri, pending, null, null)
}
r.put("success", true)
r.put("uri", itemUri.toString())
} catch (e: Exception) {
r.put("success", false)
r.put("error", e.message)
}
r
}
if (isActive) invoke.resolve(ret)
}
}
@Command
fun install_package(invoke: Invoke) {
val args = invoke.parseArgs(InstallPackageRequestArgs::class.java)
@@ -2,6 +2,7 @@ const COMMANDS: &[&str] = &[
"auth_with_safari",
"auth_with_custom_tab",
"copy_uri_to_path",
"save_image_to_gallery",
"use_background_audio",
"install_package",
"set_system_ui_visibility",
@@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-save-image-to-gallery"
description = "Enables the save_image_to_gallery command without any pre-configured scope."
commands.allow = ["save_image_to_gallery"]
[[permission]]
identifier = "deny-save-image-to-gallery"
description = "Denies the save_image_to_gallery command without any pre-configured scope."
commands.deny = ["save_image_to_gallery"]
@@ -7,6 +7,7 @@ Default permissions for the plugin
- `allow-auth-with-safari`
- `allow-auth-with-custom-tab`
- `allow-copy-uri-to-path`
- `allow-save-image-to-gallery`
- `allow-use-background-audio`
- `allow-install-package`
- `allow-set-system-ui-visibility`
@@ -938,6 +939,32 @@ Denies the request_permissions command without any pre-configured scope.
<tr>
<td>
`native-bridge:allow-save-image-to-gallery`
</td>
<td>
Enables the save_image_to_gallery command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-bridge:deny-save-image-to-gallery`
</td>
<td>
Denies the save_image_to_gallery command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`native-bridge:allow-select-directory`
</td>
@@ -4,6 +4,7 @@ permissions = [
"allow-auth-with-safari",
"allow-auth-with-custom-tab",
"allow-copy-uri-to-path",
"allow-save-image-to-gallery",
"allow-use-background-audio",
"allow-install-package",
"allow-set-system-ui-visibility",
@@ -702,6 +702,18 @@
"const": "deny-request-permissions",
"markdownDescription": "Denies the request_permissions command without any pre-configured scope."
},
{
"description": "Enables the save_image_to_gallery command without any pre-configured scope.",
"type": "string",
"const": "allow-save-image-to-gallery",
"markdownDescription": "Enables the save_image_to_gallery command without any pre-configured scope."
},
{
"description": "Denies the save_image_to_gallery command without any pre-configured scope.",
"type": "string",
"const": "deny-save-image-to-gallery",
"markdownDescription": "Denies the save_image_to_gallery command without any pre-configured scope."
},
{
"description": "Enables the select_directory command without any pre-configured scope.",
"type": "string",
@@ -775,10 +787,10 @@
"markdownDescription": "Denies the use_background_audio command without any pre-configured scope."
},
{
"description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-auth-with-safari`\n- `allow-auth-with-custom-tab`\n- `allow-copy-uri-to-path`\n- `allow-use-background-audio`\n- `allow-install-package`\n- `allow-set-system-ui-visibility`\n- `allow-get-status-bar-height`\n- `allow-get-sys-fonts-list`\n- `allow-intercept-keys`\n- `allow-lock-screen-orientation`\n- `allow-iap-is-available`\n- `allow-iap-initialize`\n- `allow-iap-fetch-products`\n- `allow-iap-purchase-product`\n- `allow-iap-restore-purchases`\n- `allow-get-system-color-scheme`\n- `allow-get-safe-area-insets`\n- `allow-get-screen-brightness`\n- `allow-set-screen-brightness`\n- `allow-get-external-sdcard-path`\n- `allow-open-external-url`\n- `allow-show-lookup-popover`\n- `allow-get-lookup-dictionary`\n- `allow-clear-lookup-dictionary`\n- `allow-select-directory`\n- `allow-get-storefront-region-code`\n- `allow-request-manage-storage-permission`\n- `allow-register-listener`\n- `allow-remove-listener`\n- `allow-check-permissions`\n- `allow-request-permissions`\n- `allow-checkPermissions`\n- `allow-requestPermissions`\n- `allow-set-sync-passphrase`\n- `allow-get-sync-passphrase`\n- `allow-clear-sync-passphrase`\n- `allow-is-sync-keychain-available`",
"description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-auth-with-safari`\n- `allow-auth-with-custom-tab`\n- `allow-copy-uri-to-path`\n- `allow-save-image-to-gallery`\n- `allow-use-background-audio`\n- `allow-install-package`\n- `allow-set-system-ui-visibility`\n- `allow-get-status-bar-height`\n- `allow-get-sys-fonts-list`\n- `allow-intercept-keys`\n- `allow-lock-screen-orientation`\n- `allow-iap-is-available`\n- `allow-iap-initialize`\n- `allow-iap-fetch-products`\n- `allow-iap-purchase-product`\n- `allow-iap-restore-purchases`\n- `allow-get-system-color-scheme`\n- `allow-get-safe-area-insets`\n- `allow-get-screen-brightness`\n- `allow-set-screen-brightness`\n- `allow-get-external-sdcard-path`\n- `allow-open-external-url`\n- `allow-show-lookup-popover`\n- `allow-get-lookup-dictionary`\n- `allow-clear-lookup-dictionary`\n- `allow-select-directory`\n- `allow-get-storefront-region-code`\n- `allow-request-manage-storage-permission`\n- `allow-register-listener`\n- `allow-remove-listener`\n- `allow-check-permissions`\n- `allow-request-permissions`\n- `allow-checkPermissions`\n- `allow-requestPermissions`\n- `allow-set-sync-passphrase`\n- `allow-get-sync-passphrase`\n- `allow-clear-sync-passphrase`\n- `allow-is-sync-keychain-available`",
"type": "string",
"const": "default",
"markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-auth-with-safari`\n- `allow-auth-with-custom-tab`\n- `allow-copy-uri-to-path`\n- `allow-use-background-audio`\n- `allow-install-package`\n- `allow-set-system-ui-visibility`\n- `allow-get-status-bar-height`\n- `allow-get-sys-fonts-list`\n- `allow-intercept-keys`\n- `allow-lock-screen-orientation`\n- `allow-iap-is-available`\n- `allow-iap-initialize`\n- `allow-iap-fetch-products`\n- `allow-iap-purchase-product`\n- `allow-iap-restore-purchases`\n- `allow-get-system-color-scheme`\n- `allow-get-safe-area-insets`\n- `allow-get-screen-brightness`\n- `allow-set-screen-brightness`\n- `allow-get-external-sdcard-path`\n- `allow-open-external-url`\n- `allow-show-lookup-popover`\n- `allow-get-lookup-dictionary`\n- `allow-clear-lookup-dictionary`\n- `allow-select-directory`\n- `allow-get-storefront-region-code`\n- `allow-request-manage-storage-permission`\n- `allow-register-listener`\n- `allow-remove-listener`\n- `allow-check-permissions`\n- `allow-request-permissions`\n- `allow-checkPermissions`\n- `allow-requestPermissions`\n- `allow-set-sync-passphrase`\n- `allow-get-sync-passphrase`\n- `allow-clear-sync-passphrase`\n- `allow-is-sync-keychain-available`"
"markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-auth-with-safari`\n- `allow-auth-with-custom-tab`\n- `allow-copy-uri-to-path`\n- `allow-save-image-to-gallery`\n- `allow-use-background-audio`\n- `allow-install-package`\n- `allow-set-system-ui-visibility`\n- `allow-get-status-bar-height`\n- `allow-get-sys-fonts-list`\n- `allow-intercept-keys`\n- `allow-lock-screen-orientation`\n- `allow-iap-is-available`\n- `allow-iap-initialize`\n- `allow-iap-fetch-products`\n- `allow-iap-purchase-product`\n- `allow-iap-restore-purchases`\n- `allow-get-system-color-scheme`\n- `allow-get-safe-area-insets`\n- `allow-get-screen-brightness`\n- `allow-set-screen-brightness`\n- `allow-get-external-sdcard-path`\n- `allow-open-external-url`\n- `allow-show-lookup-popover`\n- `allow-get-lookup-dictionary`\n- `allow-clear-lookup-dictionary`\n- `allow-select-directory`\n- `allow-get-storefront-region-code`\n- `allow-request-manage-storage-permission`\n- `allow-register-listener`\n- `allow-remove-listener`\n- `allow-check-permissions`\n- `allow-request-permissions`\n- `allow-checkPermissions`\n- `allow-requestPermissions`\n- `allow-set-sync-passphrase`\n- `allow-get-sync-passphrase`\n- `allow-clear-sync-passphrase`\n- `allow-is-sync-keychain-available`"
}
]
}
@@ -30,6 +30,14 @@ pub(crate) async fn copy_uri_to_path<R: Runtime>(
app.native_bridge().copy_uri_to_path(payload)
}
#[command]
pub(crate) async fn save_image_to_gallery<R: Runtime>(
app: AppHandle<R>,
payload: SaveImageToGalleryRequest,
) -> Result<SaveImageToGalleryResponse> {
app.native_bridge().save_image_to_gallery(payload)
}
#[command]
pub(crate) async fn use_background_audio<R: Runtime>(
app: AppHandle<R>,
@@ -60,6 +60,13 @@ impl<R: Runtime> NativeBridge<R> {
Err(crate::Error::UnsupportedPlatformError)
}
pub fn save_image_to_gallery(
&self,
_payload: SaveImageToGalleryRequest,
) -> crate::Result<SaveImageToGalleryResponse> {
Err(crate::Error::UnsupportedPlatformError)
}
pub fn use_background_audio(&self, _payload: UseBackgroundAudioRequest) -> crate::Result<()> {
Err(crate::Error::UnsupportedPlatformError)
}
@@ -58,6 +58,7 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
commands::auth_with_safari,
commands::auth_with_custom_tab,
commands::copy_uri_to_path,
commands::save_image_to_gallery,
commands::use_background_audio,
commands::install_package,
commands::set_system_ui_visibility,
@@ -48,6 +48,17 @@ impl<R: Runtime> NativeBridge<R> {
}
}
impl<R: Runtime> NativeBridge<R> {
pub fn save_image_to_gallery(
&self,
payload: SaveImageToGalleryRequest,
) -> crate::Result<SaveImageToGalleryResponse> {
self.0
.run_mobile_plugin("save_image_to_gallery", payload)
.map_err(Into::into)
}
}
impl<R: Runtime> NativeBridge<R> {
pub fn use_background_audio(&self, payload: UseBackgroundAudioRequest) -> crate::Result<()> {
self.0
@@ -27,6 +27,27 @@ pub struct CopyURIResponse {
pub error: Option<String>,
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SaveImageToGalleryRequest {
/// Absolute path of the source image file on disk.
pub src_path: String,
/// Display name for the saved image, e.g. `image.png`.
pub file_name: String,
pub mime_type: String,
/// Subfolder under the system Pictures collection. Defaults to `Readest`.
pub album_name: Option<String>,
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SaveImageToGalleryResponse {
pub success: bool,
/// MediaStore content URI of the saved image on success.
pub uri: Option<String>,
pub error: Option<String>,
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct UseBackgroundAudioRequest {
@@ -12,6 +12,11 @@ vi.mock('@/hooks/useKeyDownActions', () => ({
useKeyDownActions: () => {},
}));
// ImageViewer reads appService (for the save button) via useEnv; stub it.
vi.mock('@/context/EnvContext', () => ({
useEnv: () => ({ appService: null }),
}));
// ZoomControls reaches into the theme store and Tauri window APIs; stub it out.
vi.mock('@/app/reader/components/ZoomControls', () => ({
__esModule: true,
@@ -0,0 +1,120 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, cleanup, fireEvent, waitFor } from '@testing-library/react';
import ImageViewer from '@/app/reader/components/ImageViewer';
vi.mock('@/hooks/useTranslation', () => ({
useTranslation: () => (s: string) => s,
}));
vi.mock('@/hooks/useKeyDownActions', () => ({
useKeyDownActions: () => {},
}));
vi.mock('@/store/themeStore', () => ({
useThemeStore: () => ({ systemUIVisible: false, statusBarHeight: 0 }),
}));
const h = vi.hoisted(() => ({
appService: null as {
isMobileApp: boolean;
isMacOSApp: boolean;
isAndroidApp?: boolean;
saveFile: ReturnType<typeof vi.fn>;
saveImageToGallery?: ReturnType<typeof vi.fn>;
} | null,
dispatch: vi.fn(),
}));
vi.mock('@/context/EnvContext', () => ({
useEnv: () => ({ appService: h.appService }),
}));
vi.mock('@/utils/event', () => ({
eventDispatcher: { dispatch: h.dispatch },
}));
// "AAEC" base64 decodes to bytes [0, 1, 2].
const PNG_DATA_URL = 'data:image/png;base64,AAEC';
const gridInsets = { top: 0, right: 0, bottom: 0, left: 0 };
beforeEach(() => {
h.appService = null;
h.dispatch.mockReset();
});
afterEach(cleanup);
describe('ImageViewer save/share button', () => {
it('exports the image and toasts when sharing is unavailable', async () => {
const saveFile = vi.fn().mockResolvedValue(true);
h.appService = { isMobileApp: false, isMacOSApp: false, saveFile };
const { getByLabelText } = render(
<ImageViewer src={PNG_DATA_URL} onClose={vi.fn()} gridInsets={gridInsets} />,
);
// No native/web share → the affordance is the "Save Image" (export) button.
fireEvent.click(getByLabelText('Save Image'));
await waitFor(() => expect(saveFile).toHaveBeenCalledTimes(1));
const [filename, content, options] = saveFile.mock.calls[0]!;
expect(filename).toBe('image.png');
expect(content).toBeInstanceOf(ArrayBuffer);
expect(new Uint8Array(content as ArrayBuffer)).toEqual(new Uint8Array([0, 1, 2]));
expect(options).toMatchObject({ share: true, mimeType: 'image/png' });
expect(options.sharePosition).toMatchObject({ preferredEdge: 'bottom' });
// Export path confirms with a toast.
await waitFor(() =>
expect(h.dispatch).toHaveBeenCalledWith('toast', expect.objectContaining({ type: 'info' })),
);
});
it('shares via saveFile without a toast on a share-capable platform', async () => {
const saveFile = vi.fn().mockResolvedValue(true);
h.appService = { isMobileApp: true, isMacOSApp: false, saveFile };
const { getByLabelText } = render(
<ImageViewer src={PNG_DATA_URL} onClose={vi.fn()} gridInsets={gridInsets} />,
);
// Share-capable → the affordance is the "Share Image" button.
fireEvent.click(getByLabelText('Share Image'));
await waitFor(() => expect(saveFile).toHaveBeenCalledTimes(1));
expect(saveFile.mock.calls[0]![2]).toMatchObject({ share: true });
// The OS share sheet is its own feedback; no toast on the share path.
expect(h.dispatch).not.toHaveBeenCalled();
});
it('saves to the photo gallery on Android instead of sharing', async () => {
const saveImageToGallery = vi.fn().mockResolvedValue(true);
const saveFile = vi.fn().mockResolvedValue(true);
h.appService = {
isMobileApp: true,
isMacOSApp: false,
isAndroidApp: true,
saveFile,
saveImageToGallery,
};
const { getByLabelText } = render(
<ImageViewer src={PNG_DATA_URL} onClose={vi.fn()} gridInsets={gridInsets} />,
);
// Android saves to the gallery, so the affordance is "Save Image", not Share.
fireEvent.click(getByLabelText('Save Image'));
await waitFor(() => expect(saveImageToGallery).toHaveBeenCalledTimes(1));
const [filename, content, mime] = saveImageToGallery.mock.calls[0]!;
expect(filename).toBe('image.png');
expect(content).toBeInstanceOf(ArrayBuffer);
expect(mime).toBe('image/png');
// It must NOT fall through to the share sheet on Android.
expect(saveFile).not.toHaveBeenCalled();
await waitFor(() =>
expect(h.dispatch).toHaveBeenCalledWith('toast', expect.objectContaining({ type: 'info' })),
);
});
});
@@ -529,6 +529,75 @@ describe('RSVPOverlay — dictionary lookup (#4475)', () => {
});
});
describe('RSVPOverlay — playback control layout (#4585, regressed by #4589)', () => {
afterEach(() => cleanup());
// The audio (TTS) toggle and the settings gear must sit in the SAME flex row
// as the transport buttons, flanking the centered play button in normal flow.
// The earlier `absolute end-0` cluster overlaid them on top of the right end
// of the transport, hiding the audio button behind "skip forward 15" on narrow
// phones. Keeping all three as siblings of the play button is what prevents the
// overlap, so assert the structure here (jsdom can't measure the overlap).
test('audio toggle and settings flank the transport in the same flex row', () => {
const state = buildState({
words: [{ text: 'hello', orpIndex: 1, pauseMultiplier: 1 }],
currentIndex: 0,
});
const { container } = renderOverlay(state);
const audioButton = container.querySelector('[aria-label="Play audio"]') as HTMLElement;
const settingsButton = container.querySelector('[aria-label="Settings"]') as HTMLElement;
const playButton = container.querySelector('[aria-label="Play"]') as HTMLElement;
expect(audioButton).not.toBeNull();
expect(settingsButton).not.toBeNull();
expect(playButton).not.toBeNull();
// All three share the play button's parent (the single flex row) — the audio
// toggle and settings are not tucked into a separate absolute cluster.
expect(audioButton.parentElement).toBe(playButton.parentElement);
expect(settingsButton.parentElement).toBe(playButton.parentElement);
});
// On very narrow phones (< 350px) the row has no room for every control, so
// the Faster/Slower speed buttons collapse to save space (speed is still
// adjustable from the WPM dropdown). The core transport stays put.
test('hides the Faster/Slower buttons below 350px to save space', () => {
const state = buildState({
words: [{ text: 'hello', orpIndex: 1, pauseMultiplier: 1 }],
currentIndex: 0,
});
const { container } = renderOverlay(state);
const decrease = container.querySelector('[aria-label="Decrease speed"]') as HTMLElement;
const increase = container.querySelector('[aria-label="Increase speed"]') as HTMLElement;
const play = container.querySelector('[aria-label="Play"]') as HTMLElement;
const audio = container.querySelector('[aria-label="Play audio"]') as HTMLElement;
const settings = container.querySelector('[aria-label="Settings"]') as HTMLElement;
expect(decrease.className).toContain('max-[350px]:hidden');
expect(increase.className).toContain('max-[350px]:hidden');
// Transport, audio toggle and settings must remain visible at any width.
expect(play.className).not.toContain('max-[350px]:hidden');
expect(audio.className).not.toContain('max-[350px]:hidden');
expect(settings.className).not.toContain('max-[350px]:hidden');
});
// The previous fix relied on absolute positioning being gone; guard against it
// sneaking back into the row that holds the transport controls.
test('the playback control row is not absolutely positioned', () => {
const state = buildState({
words: [{ text: 'hello', orpIndex: 1, pauseMultiplier: 1 }],
currentIndex: 0,
});
const { container } = renderOverlay(state);
const playButton = container.querySelector('[aria-label="Play"]') as HTMLElement;
const audioButton = container.querySelector('[aria-label="Play audio"]') as HTMLElement;
expect(playButton.parentElement!.className).not.toContain('absolute');
expect(audioButton.parentElement!.className).not.toContain('absolute');
});
});
describe('RSVPOverlay — start delay setting (#4478)', () => {
afterEach(() => {
cleanup();
@@ -133,6 +133,30 @@ describe('textureAwareBackground', () => {
expect(textureAwareBackground('rgb(255, 255, 255)', true)).toBe('rgb(255, 255, 255)');
});
// Regression test for a cover page that paints its image via a body
// `background-image` (the EPUB sets `background-color` transparent and lets the
// image fill the page). `getComputedStyle(body).background` serializes the
// transparent colour FIRST, so the shorthand starts with `rgba(0, 0, 0, 0)`
// even though a real image follows. Such a page must NOT be treated as
// transparent — a full-page cover image should occlude the texture, not be
// dropped so the texture shows through. Verified on a Xiaomi (Android WebView)
// where the parchment texture replaced the book's cover on the first page.
it('keeps a background that carries an image even when a texture is active', () => {
// The real value captured from the cover iframe on-device.
expect(
textureAwareBackground(
'rgba(0, 0, 0, 0) url("blob:http://x/abc") no-repeat fixed 50% 50% / 100% 100% padding-box border-box',
true,
),
).toBe(
'rgba(0, 0, 0, 0) url("blob:http://x/abc") no-repeat fixed 50% 50% / 100% 100% padding-box border-box',
);
// A plain relative/absolute url() background (not a blob) is kept too.
expect(textureAwareBackground('transparent url(cover.jpg) center / cover', true)).toBe(
'transparent url(cover.jpg) center / cover',
);
});
it('never drops a background when no texture is active (no regression)', () => {
expect(textureAwareBackground('rgba(0, 0, 0, 0)', false)).toBe('rgba(0, 0, 0, 0)');
expect(textureAwareBackground('rgb(0, 0, 0)', false)).toBe('rgb(0, 0, 0)');
@@ -104,6 +104,25 @@ describe('Paginator Duokan fullscreen cover (#4379)', () => {
expect(img!.offsetHeight).toBeGreaterThan(0);
});
it('stretches the fullscreen cover to fill the page (object-fit: fill)', async () => {
paginator = createPaginator();
paginator.open(book);
const stabilized = waitForStabilized(paginator);
await paginator.goTo({ index: 0 });
await stabilized;
const img = getCoverImg(paginator);
expect(img).toBeTruthy();
await waitForImgLoaded(img!);
await waitForVisibleHeight(img!);
// The fullscreen treatment stretches the cover edge-to-edge, ignoring the
// image's aspect ratio, to match Duokan's native full-page rendering.
const cs = img!.ownerDocument.defaultView!.getComputedStyle(img!);
expect(cs.objectFit).toBe('fill');
});
it('shows the cover image in scrolled mode', async () => {
paginator = createPaginator();
paginator.open(book);
@@ -0,0 +1,113 @@
// Regression test for readest/readest issue #3470.
//
// Opening a large PDF makes pdf.js request hundreds of small byte ranges at
// once while it parses the cross-reference and object streams. foliate-js'
// `makePDF` used to fulfil every `requestDataRange` immediately via an
// un-awaited `file.slice(begin, end).arrayBuffer()`, so all of those reads ran
// concurrently. On Android each read is a `fetch()` to the custom `rangefile`
// scheme that the WebView serves through `shouldInterceptRequest`; firing
// hundreds at once floods that native handler and exhausts the 512 MB Java
// heap, crashing the app on 50 MB+ PDFs.
//
// A real HTTP transport is implicitly throttled by the browser's per-host
// connection limit (~6); the custom file scheme bypasses that. `makePDF` must
// therefore throttle the concurrent range reads itself. This test drives a
// flood of `requestDataRange` calls and asserts the number of simultaneous
// `file.slice()` reads stays bounded while every requested range is still
// served.
import { afterEach, describe, expect, it, vi } from 'vitest';
// The number of range requests pdf.js fires in the simulated parse burst.
const FLOOD = 200;
// Captured by the @pdfjs mock so the test can drive requestDataRange.
let rangeTransport: {
requestDataRange: (b: number, e: number) => void;
onDataRange: (b: number, c: ArrayBuffer) => void;
};
// Minimal stand-in for the vendored pdf.js build. foliate-js/pdf.js imports it
// only for the side effect of setting globalThis.pdfjsLib, then reads from
// that global — so the mock installs a controllable fake there.
vi.mock('@pdfjs/pdf.min.mjs', () => {
class PDFDataRangeTransport {
requestDataRange!: (begin: number, end: number) => void;
onDataRange = vi.fn();
constructor(
public length: number,
public initialData: unknown,
) {}
}
const fakePdf = {
numPages: 100,
getPage: vi.fn(async () => ({
getViewport: () => ({ width: 600, height: 800 }),
cleanup: vi.fn(),
})),
getMetadata: vi.fn(async () => ({ metadata: undefined, info: {} })),
getOutline: vi.fn(async () => null),
getDestination: vi.fn(),
getPageIndex: vi.fn(),
destroy: vi.fn(),
};
const getDocument = vi.fn(({ range }: { range: typeof rangeTransport }) => {
rangeTransport = range;
const promise = (async () => {
// pdf.js fires a burst of range requests as it parses the PDF structure.
for (let i = 0; i < FLOOD; i++) range.requestDataRange(i * 1000, i * 1000 + 999);
await new Promise((r) => setTimeout(r, 0));
return fakePdf;
})();
return { promise };
});
(globalThis as unknown as { pdfjsLib: unknown }).pdfjsLib = {
GlobalWorkerOptions: {},
PDFDataRangeTransport,
getDocument,
};
return {};
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('makePDF range-read concurrency (#3470)', () => {
it('bounds simultaneous range reads and still serves every request', async () => {
const { makePDF } = await import('foliate-js/pdf.js');
let inFlight = 0;
let maxInFlight = 0;
let served = 0;
const file = {
size: FLOOD * 1000,
slice(begin: number, end: number) {
return {
async arrayBuffer() {
inFlight++;
maxInFlight = Math.max(maxInFlight, inFlight);
await new Promise((r) => setTimeout(r, 5));
inFlight--;
served++;
return new ArrayBuffer(end - begin);
},
};
},
};
await makePDF(file as unknown as File);
// Wait for the throttled queue to drain every requested range.
const start = Date.now();
while (served < FLOOD && Date.now() - start < 5000) {
await new Promise((r) => setTimeout(r, 5));
}
// Every range pdf.js asked for must still be delivered…
expect(served).toBe(FLOOD);
// …but never more than a browser-like per-host connection limit at once.
expect(maxInFlight).toBeGreaterThan(0);
expect(maxInFlight).toBeLessThanOrEqual(6);
});
});
@@ -37,7 +37,6 @@ const h = vi.hoisted(() => {
user: { id: 'u1' },
syncConfigsMock: vi.fn(async () => {}),
syncBooksMock: vi.fn(async () => {}),
setConfigMock: vi.fn(),
cfiCompareMock: vi.fn((_a: string, _b: string) => 0),
view: { renderer: { getContents: () => [], primaryIndex: 0 }, goTo: vi.fn() },
state: {
@@ -67,7 +66,7 @@ vi.mock('@/hooks/useTranslation', () => ({
vi.mock('@/store/bookDataStore', () => ({
useBookDataStore: h.makeStore({
getConfig: () => h.config,
setConfig: h.setConfigMock,
setConfig: vi.fn(),
getBookData: () => ({ book: h.book }),
}),
}));
@@ -142,7 +141,6 @@ beforeEach(() => {
vi.useFakeTimers();
h.syncConfigsMock.mockClear();
h.syncBooksMock.mockClear();
h.setConfigMock.mockClear();
h.view.goTo.mockClear();
h.cfiCompareMock.mockReset();
h.cfiCompareMock.mockReturnValue(0);
@@ -247,11 +245,10 @@ describe('useProgressSync', () => {
renderHook(() => useProgressSync('h1-view1'));
await advance(0);
// Not navigated to, and not persisted into the local config (the local
// 'cfi-loc' is kept instead of the malformed remote value).
// The malformed remote location is discarded so it can't move the reader.
// (Config is no longer merged from sync — only reading progress drives
// navigation — so the local position is left untouched.)
expect(h.view.goTo).not.toHaveBeenCalled();
const persisted = h.setConfigMock.mock.calls.at(-1)?.[1] as { location?: string } | undefined;
expect(persisted?.location).toBe('cfi-loc');
});
test('navigates to a well-formed newer synced location', async () => {
@@ -1,8 +1,32 @@
import { describe, expect, it } from 'vitest';
import { resolveReadingStatusMerge } from '@/pages/api/sync';
import { readingStatusChanged, resolveReadingStatusMerge } from '@/pages/api/sync';
const iso = (ms: number) => new Date(ms).toISOString();
describe('readingStatusChanged', () => {
// The bug: a locally-imported book the client never gave a status to sends
// `reading_status: undefined`, while the server row stores `null`. Comparing
// them with `!==` reports a spurious change, so the server rewrites
// `updated_at = now()` on every push and the book floats to the top of the
// date-sorted library after every sync.
it('treats client undefined and server null as the same (no change)', () => {
expect(readingStatusChanged(undefined, null)).toBe(false);
expect(readingStatusChanged(null, undefined)).toBe(false);
expect(readingStatusChanged(null, null)).toBe(false);
expect(readingStatusChanged(undefined, undefined)).toBe(false);
});
it('reports a real status change', () => {
expect(readingStatusChanged('finished', null)).toBe(true);
expect(readingStatusChanged(undefined, 'reading')).toBe(true);
expect(readingStatusChanged('reading', 'finished')).toBe(true);
});
it('reports no change when both sides hold the same status', () => {
expect(readingStatusChanged('finished', 'finished')).toBe(false);
});
});
describe('resolveReadingStatusMerge', () => {
it('keeps the client status when its status timestamp is newer', () => {
const out = resolveReadingStatusMerge(
@@ -102,6 +102,10 @@ class TestAppService extends BaseAppService {
return true;
}
async saveImageToGallery(): Promise<boolean> {
return true;
}
async ask(): Promise<boolean> {
return true;
}
@@ -69,6 +69,9 @@ class TestAppService extends BaseAppService {
async saveFile() {
return false;
}
async saveImageToGallery() {
return false;
}
async ask() {
return false;
}
@@ -3,6 +3,7 @@ import { describe, test, expect, vi, beforeEach } from 'vitest';
const osTypeMock = vi.fn().mockReturnValue('macos');
const writeTextFileMock = vi.fn().mockResolvedValue(undefined);
const writeFileMock = vi.fn().mockResolvedValue(undefined);
const mkdirMock = vi.fn().mockResolvedValue(undefined);
const saveDialogMock = vi.fn().mockResolvedValue('/tmp/exported.md');
const shareFileMock = vi.fn().mockResolvedValue(undefined);
@@ -12,7 +13,7 @@ vi.mock('@tauri-apps/plugin-os', () => ({
vi.mock('@tauri-apps/plugin-fs', () => ({
exists: vi.fn().mockResolvedValue(false),
mkdir: vi.fn().mockResolvedValue(undefined),
mkdir: (...args: unknown[]) => mkdirMock(...args),
readTextFile: vi.fn().mockResolvedValue(''),
readFile: vi.fn().mockResolvedValue(new Uint8Array()),
writeTextFile: (...args: unknown[]) => writeTextFileMock(...args),
@@ -80,6 +81,7 @@ describe('NativeAppService.saveFile share gating', () => {
beforeEach(() => {
writeTextFileMock.mockClear();
writeFileMock.mockClear();
mkdirMock.mockClear();
saveDialogMock.mockClear();
shareFileMock.mockClear();
});
@@ -108,6 +110,27 @@ describe('NativeAppService.saveFile share gating', () => {
expect(saveDialogMock).toHaveBeenCalledTimes(1);
});
// Regression (#4680): Tauri's Temp dir IS the Android cache dir, and the
// sharekit plugin copies the shared file to `<cacheDir>/<name>` before
// sharing. Writing the shareable file to the Temp ROOT makes that copy a
// self-copy whose output stream truncates the source to 0 bytes (the shared
// image came out 0 KB). The shareable file must live in a Temp SUBDIRECTORY.
test('writes the shareable file to a Temp subdirectory to avoid self-copy truncation', async () => {
const service = await loadServiceWithOS('android');
const bytes = new Uint8Array([1, 2, 3]).buffer;
await service.saveFile('image.png', bytes, { share: true, mimeType: 'image/png' });
expect(shareFileMock).toHaveBeenCalledTimes(1);
const sharedPath = shareFileMock.mock.calls[0]![0] as string;
// Must NOT be `<tempDir>/image.png` — that collides with the plugin's
// `File(cacheDir, "image.png")` destination and truncates to 0 bytes.
expect(sharedPath).not.toBe('/tmp/image.png');
expect(sharedPath).toContain('/shared/');
expect(writeFileMock).toHaveBeenCalledWith(sharedPath, expect.any(Uint8Array));
// The subdirectory is created before writing.
expect(mkdirMock).toHaveBeenCalled();
});
// The book "Send" flow hands an already-on-disk file straight to the share
// sheet via `filePath` and passes `null` content so nothing gets re-buffered
// into memory. The file at `filePath` must be shared verbatim without any
@@ -0,0 +1,74 @@
import fs from 'node:fs';
import path from 'node:path';
import { describe, it, expect } from 'vitest';
/**
* Coordinated overlay z-index scale.
*
* Body-portaled / full-screen overlays share a single global stacking order.
* They must all clear the desktop rounded-window page frame (`.window-border`,
* z-index 99 in globals.css) and then layer in this order (low -> high):
*
* 100 RSVP immersive reading overlay
* 101 RSVP immersive controls (start dialog / hint chip)
* 110 Settings app dialog (raised above RSVP for dictionary management)
* 120 modal / command-palette layer (ModalPortal, CommandPalette)
* 130 toast / alert
* 200 security lock screen (AppLockScreen)
*
* This test reads the values straight from source so a future change that
* re-orders the layers fails loudly. It encodes the regression that buried the
* "Add OPDS Catalog" dialog behind Settings on mobile: Settings had been raised
* to z-[10050] (PR #3235) above the ModalPortal at z-[100], so any modal opened
* from inside Settings rendered behind it.
*/
const PAGE_FRAME = 99; // .window-border in src/styles/globals.css
const read = (rel: string) => fs.readFileSync(path.join(process.cwd(), rel), 'utf8');
const firstZ = (src: string, re: RegExp): number => {
const m = src.match(re);
expect(m, `expected to find z-index via ${re} in source`).not.toBeNull();
return Number(m![1]);
};
const MODAL = firstZ(read('src/components/ModalPortal.tsx'), /z-\[(\d+)\]/);
const SETTINGS = firstZ(read('src/components/settings/SettingsDialog.tsx'), /!z-\[(\d+)\]/);
const RSVP_OVERLAY = firstZ(
read('src/app/reader/components/rsvp/RSVPOverlay.tsx'),
/fixed inset-0 z-\[(\d+)\] flex select-none/,
);
const RSVP_CONTROLS = firstZ(
read('src/app/reader/components/rsvp/RSVPStartDialog.tsx'),
/z-\[(\d+)\]/,
);
const APP_LOCK = firstZ(read('src/components/AppLockScreen.tsx'), /z-\[(\d+)\]/);
describe('overlay z-index scale', () => {
it('renders a modal (e.g. Add OPDS Catalog) above the Settings dialog', () => {
// Regression: ModalPortal opened from inside Settings was buried (#add-catalog).
expect(MODAL).toBeGreaterThan(SETTINGS);
});
it('raises the Settings dialog above the RSVP immersive overlay', () => {
expect(SETTINGS).toBeGreaterThan(RSVP_OVERLAY);
});
it('keeps RSVP controls above the RSVP overlay', () => {
expect(RSVP_CONTROLS).toBeGreaterThan(RSVP_OVERLAY);
});
it('keeps the RSVP overlay above the desktop window-border page frame', () => {
expect(RSVP_OVERLAY).toBeGreaterThan(PAGE_FRAME);
});
it('keeps the security lock screen on top of every modal', () => {
expect(APP_LOCK).toBeGreaterThan(MODAL);
});
it('uses a compact scale with no four-digit z-index', () => {
for (const value of [RSVP_OVERLAY, RSVP_CONTROLS, SETTINGS, MODAL, APP_LOCK]) {
expect(value).toBeLessThan(1000);
}
});
});
@@ -0,0 +1,35 @@
import { describe, it, expect } from 'vitest';
import { dataUrlToBytes, imageExtensionFromMime } from '@/utils/image';
describe('dataUrlToBytes', () => {
it('decodes a base64 data URL into bytes and its MIME type', () => {
// base64 "AAEC" -> bytes [0, 1, 2]
const { bytes, mimeType } = dataUrlToBytes('data:image/png;base64,AAEC');
expect(mimeType).toBe('image/png');
expect(Array.from(bytes)).toEqual([0, 1, 2]);
});
it('decodes a non-base64 (percent-encoded) data URL', () => {
const { bytes, mimeType } = dataUrlToBytes('data:image/svg+xml,%3Csvg%2F%3E');
expect(mimeType).toBe('image/svg+xml');
expect(new TextDecoder().decode(bytes)).toBe('<svg/>');
});
it('throws on a value that is not a data URL', () => {
expect(() => dataUrlToBytes('blob:whatever')).toThrow();
});
});
describe('imageExtensionFromMime', () => {
it('maps common image MIME types to file extensions', () => {
expect(imageExtensionFromMime('image/png')).toBe('png');
expect(imageExtensionFromMime('image/jpeg')).toBe('jpg');
expect(imageExtensionFromMime('image/webp')).toBe('webp');
expect(imageExtensionFromMime('image/gif')).toBe('gif');
});
it('strips structured-syntax suffixes (svg+xml -> svg)', () => {
expect(imageExtensionFromMime('image/svg+xml')).toBe('svg');
});
});
@@ -459,7 +459,7 @@ export function CatalogManager({ inSubPage = false }: CatalogManagerProps = {})
'focus-visible:ring-base-content/15 focus-visible:outline-none focus-visible:ring-2',
catalog.disabled
? 'cursor-not-allowed opacity-60'
: 'hover:bg-base-200/40 cursor-pointer',
: 'hover:bg-base-300 cursor-pointer',
)}
>
<div className='flex flex-1 flex-col gap-2.5 px-4 pb-2 pt-4'>
@@ -3,7 +3,11 @@ import React, { useState, useRef, useEffect } from 'react';
import { IoChevronBack, IoChevronForward } from 'react-icons/io5';
import { useTranslation } from '@/hooks/useTranslation';
import { useKeyDownActions } from '@/hooks/useKeyDownActions';
import { useEnv } from '@/context/EnvContext';
import { Insets } from '@/types/misc';
import { eventDispatcher } from '@/utils/event';
import { canShareText } from '@/utils/share';
import { dataUrlToBytes, imageExtensionFromMime } from '@/utils/image';
import ZoomControls from './ZoomControls';
interface ImageViewerProps {
@@ -27,6 +31,12 @@ const ImageViewer: React.FC<ImageViewerProps> = ({
gridInsets,
}) => {
const _ = useTranslation();
const { appService } = useEnv();
// On Android the button saves straight to the photo gallery (the share sheet
// can't save to a file there); elsewhere it shares where supported, else
// exports. The affordance reflects the actual action.
const saveToGallery = appService?.isAndroidApp ?? false;
const canShare = !saveToGallery && canShareText(appService);
const [scale, setScale] = useState(1);
const [position, setPosition] = useState({ x: 0, y: 0 });
const [isDragging, setIsDragging] = useState(false);
@@ -325,6 +335,55 @@ const ImageViewer: React.FC<ImageViewerProps> = ({
hideZoomLabelAfterDelay();
};
// Save the currently viewed image to the device. `appService.saveFile`
// routes to the native/web Share sheet where available and falls back to a
// save dialog / browser download otherwise.
const handleSaveImage = async (e: React.MouseEvent<HTMLButtonElement>) => {
if (!src || !appService) return;
// Anchor the macOS / iPad share popover to the button rect (mirrors the
// annotation export flow); ignored on platforms that don't use it.
const rect = e.currentTarget.getBoundingClientRect();
const sharePosition = {
x: rect.left + rect.width / 2,
y: rect.top,
preferredEdge: 'bottom' as const,
};
try {
const { bytes, mimeType } = dataUrlToBytes(decodeURIComponent(src));
const filename = `image.${imageExtensionFromMime(mimeType)}`;
if (saveToGallery) {
const saved = await appService.saveImageToGallery(
filename,
bytes.buffer as ArrayBuffer,
mimeType,
);
eventDispatcher.dispatch('toast', {
type: saved ? 'info' : 'error',
message: saved ? _('Image saved to gallery') : _('Failed to save the image'),
});
return;
}
const saved = await appService.saveFile(filename, bytes.buffer as ArrayBuffer, {
share: true,
mimeType,
sharePosition,
});
// The Share sheet provides its own feedback; only confirm the export path.
if (!canShare) {
eventDispatcher.dispatch('toast', {
type: saved ? 'info' : 'error',
message: saved ? _('Image saved successfully') : _('Failed to save the image'),
});
}
} catch (error) {
console.error('Failed to save image:', error);
eventDispatcher.dispatch('toast', {
type: 'error',
message: _('Failed to save the image'),
});
}
};
const onDoubleClick = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
@@ -391,7 +450,7 @@ const ImageViewer: React.FC<ImageViewerProps> = ({
<div
role='button'
tabIndex={0}
className='not-eink:bg-black/50 eink:bg-base-100 not-eink:backdrop-blur-md absolute inset-0'
className='image-viewer-overlay not-eink:bg-black/50 eink:bg-base-100 not-eink:backdrop-blur-md absolute inset-0'
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
onClose();
@@ -400,7 +459,9 @@ const ImageViewer: React.FC<ImageViewerProps> = ({
/>
<ZoomControls
gridInsets={gridInsets}
canShare={canShare}
onClose={onClose}
onSave={handleSaveImage}
onZoomIn={handleZoomIn}
onZoomOut={handleZoomOut}
onReset={handleReset}
@@ -183,7 +183,7 @@ const TableViewer: React.FC<TableViewerProps> = ({ gridInsets, html, isDarkMode,
<div
role='button'
tabIndex={0}
className='absolute inset-0 bg-black/50 backdrop-blur-md'
className='table-viewer-overlay absolute inset-0 not-eink:bg-black/50 eink:bg-base-100 not-eink:backdrop-blur-md'
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
onClose();
@@ -1,12 +1,22 @@
import React from 'react';
import { IoClose, IoExpand, IoAdd, IoRemove } from 'react-icons/io5';
import {
IoClose,
IoExpand,
IoAdd,
IoRemove,
IoShareOutline,
IoDownloadOutline,
} from 'react-icons/io5';
import { useTranslation } from '@/hooks/useTranslation';
import { useThemeStore } from '@/store/themeStore';
import { Insets } from '@/types/misc';
interface ZoomControlsProps {
gridInsets: Insets;
// Save/Share is image-specific; omit `onSave` (e.g. the table viewer) to hide it.
canShare?: boolean;
onClose: () => void;
onSave?: (e: React.MouseEvent<HTMLButtonElement>) => void;
onZoomIn: () => void;
onZoomOut: () => void;
onReset: () => void;
@@ -14,7 +24,9 @@ interface ZoomControlsProps {
const ZoomControls: React.FC<ZoomControlsProps> = ({
gridInsets,
canShare,
onClose,
onSave,
onZoomIn,
onZoomOut,
onReset,
@@ -39,6 +51,21 @@ const ZoomControls: React.FC<ZoomControlsProps> = ({
<IoClose className='h-6 w-6' />
</button>
{onSave && (
<button
onClick={onSave}
className='eink-bordered flex h-10 w-10 cursor-pointer items-center justify-center rounded-full bg-black/50 transition-colors hover:bg-black/70'
aria-label={canShare ? _('Share Image') : _('Save Image')}
title={canShare ? _('Share Image') : _('Save Image')}
>
{canShare ? (
<IoShareOutline className='h-6 w-6' />
) : (
<IoDownloadOutline className='h-6 w-6' />
)}
</button>
)}
<button
onClick={onZoomIn}
className='eink-bordered flex h-10 w-10 cursor-pointer items-center justify-center rounded-full bg-black/50 transition-colors hover:bg-black/70'
@@ -1031,7 +1031,7 @@ const RSVPControl = forwardRef<RSVPControlHandle, RSVPControlProps>(function RSV
const dictionaryLang = bookData?.bookDoc?.metadata?.language as string | undefined;
const handleManageDictionary = useCallback(() => {
// Open dictionary management OVER the RSVP overlay (RSVP stays open). The
// settings dialog is raised above the overlay's z-[10000] (see SettingsDialog),
// settings dialog is raised above the overlay's z-[100] (see SettingsDialog),
// and RSVP's capture-phase keyboard handler bails while it's open so the
// settings inputs / Escape work (see RSVPOverlay).
setSettingsDialogBookKey(bookKey);
@@ -685,7 +685,7 @@ const RSVPOverlay: React.FC<RSVPOverlayProps> = ({
<div
data-testid='rsvp-overlay'
aria-label={_('Speed Reading')}
className='fixed inset-0 z-[10000] flex select-none flex-col'
className='fixed inset-0 z-[100] flex select-none flex-col'
style={{
paddingTop: `${gridInsets.top}px`,
paddingBottom: `${gridInsets.bottom * 0.33}px`,
@@ -1077,11 +1077,41 @@ const RSVPOverlay: React.FC<RSVPOverlayProps> = ({
</div>
</div>
{/* Playback controls */}
<div className='relative flex items-center justify-center gap-1 md:gap-2'>
{/* Playback controls a single full-width flex row on mobile so the
audio toggle (far left) and settings gear (far right) flank the
centered transport in normal flow instead of overlapping it from an
absolute corner; a centered cluster on md+ where there is room. */}
<div className='flex items-center justify-between md:justify-center md:gap-2'>
{/* Audio (TTS) read-along toggle starts TTS from the displayed word,
or stops it when engaged (decision 5, #3235). Far-left peer of the
transport so the centered play button stays centered and nothing
overlaps on mobile. Active state uses a filled glyph + eink-bordered
surface so it reads in e-ink without relying on color. */}
<button
aria-label={ttsActive ? _('Pause audio') : _('Play audio')}
className={clsx(
'touch-target flex h-8 w-8 shrink-0 cursor-pointer items-center justify-center rounded-full border-none transition-colors active:scale-95 md:h-9 md:w-9',
ttsActive
? 'eink-bordered bg-[color-mix(in_srgb,var(--rsvp-accent)_18%,transparent)]'
: 'bg-transparent hover:bg-gray-500/20',
)}
onClick={() => onToggleTtsAudio?.()}
title={ttsActive ? _('Pause audio') : _('Play audio')}
>
{ttsActive ? (
<IoVolumeHigh
className='h-4 w-4 md:h-5 md:w-5'
style={{ color: accentColor }}
aria-hidden='true'
/>
) : (
<IoVolumeMediumOutline className='h-4 w-4 md:h-5 md:w-5' aria-hidden='true' />
)}
</button>
<button
aria-label={_('Skip back 15 words')}
className='flex cursor-pointer items-center gap-0.5 rounded-full border-none bg-transparent px-2 py-1.5 transition-colors hover:bg-gray-500/20 active:scale-95'
className='flex shrink-0 cursor-pointer items-center gap-0.5 rounded-full border-none bg-transparent px-1.5 py-1.5 transition-colors hover:bg-gray-500/20 active:scale-95 md:px-2'
onClick={() => controller.skipBackward(15)}
title={_('Back 15 words (Shift+Left)')}
>
@@ -1089,9 +1119,11 @@ const RSVPOverlay: React.FC<RSVPOverlayProps> = ({
<IoPlaySkipBack className='h-5 w-5 md:h-6 md:w-6' />
</button>
{/* Faster/Slower collapse below 350px (speed is still adjustable from
the WPM dropdown) so the transport never overflows the row. */}
<button
aria-label={_('Decrease speed')}
className='flex h-9 w-9 cursor-pointer items-center justify-center rounded-full border-none bg-transparent transition-colors hover:bg-gray-500/20 active:scale-95'
className='flex h-8 w-8 shrink-0 cursor-pointer items-center justify-center rounded-full border-none bg-transparent transition-colors hover:bg-gray-500/20 active:scale-95 max-[350px]:hidden md:h-9 md:w-9'
onClick={() => controller.decreaseSpeed()}
title={_('Slower (Left/Down)')}
>
@@ -1100,7 +1132,7 @@ const RSVPOverlay: React.FC<RSVPOverlayProps> = ({
<button
aria-label={_('Previous word')}
className='flex h-9 w-9 cursor-pointer items-center justify-center rounded-full border-none bg-transparent transition-colors hover:bg-gray-500/20 active:scale-95'
className='flex h-8 w-8 shrink-0 cursor-pointer items-center justify-center rounded-full border-none bg-transparent transition-colors hover:bg-gray-500/20 active:scale-95 md:h-9 md:w-9'
onClick={() => controller.prevWord()}
title={_('Previous word (,)')}
>
@@ -1110,7 +1142,7 @@ const RSVPOverlay: React.FC<RSVPOverlayProps> = ({
<button
aria-label={transportPlaying ? _('Pause') : _('Play')}
className={clsx(
'flex h-14 w-14 cursor-pointer items-center justify-center rounded-full border-none bg-gray-500/15 transition-colors hover:bg-gray-500/25 active:scale-95 md:h-16 md:w-16',
'flex h-14 w-14 shrink-0 cursor-pointer items-center justify-center rounded-full border-none bg-gray-500/15 transition-colors hover:bg-gray-500/25 active:scale-95 md:h-16 md:w-16',
transportPlaying ? '' : 'ps-1',
)}
onClick={() => transportToggleRef.current()}
@@ -1125,7 +1157,7 @@ const RSVPOverlay: React.FC<RSVPOverlayProps> = ({
<button
aria-label={_('Next word')}
className='flex h-9 w-9 cursor-pointer items-center justify-center rounded-full border-none bg-transparent transition-colors hover:bg-gray-500/20 active:scale-95'
className='flex h-8 w-8 shrink-0 cursor-pointer items-center justify-center rounded-full border-none bg-transparent transition-colors hover:bg-gray-500/20 active:scale-95 md:h-9 md:w-9'
onClick={() => controller.nextWord()}
title={_('Next word (.)')}
>
@@ -1134,7 +1166,7 @@ const RSVPOverlay: React.FC<RSVPOverlayProps> = ({
<button
aria-label={_('Increase speed')}
className='flex h-9 w-9 cursor-pointer items-center justify-center rounded-full border-none bg-transparent transition-colors hover:bg-gray-500/20 active:scale-95'
className='flex h-8 w-8 shrink-0 cursor-pointer items-center justify-center rounded-full border-none bg-transparent transition-colors hover:bg-gray-500/20 active:scale-95 max-[350px]:hidden md:h-9 md:w-9'
onClick={() => controller.increaseSpeed()}
title={_('Faster (Right/Up)')}
>
@@ -1143,7 +1175,7 @@ const RSVPOverlay: React.FC<RSVPOverlayProps> = ({
<button
aria-label={_('Skip forward 15 words')}
className='flex cursor-pointer items-center gap-0.5 rounded-full border-none bg-transparent px-2 py-1.5 transition-colors hover:bg-gray-500/20 active:scale-95'
className='flex shrink-0 cursor-pointer items-center gap-0.5 rounded-full border-none bg-transparent px-1.5 py-1.5 transition-colors hover:bg-gray-500/20 active:scale-95 md:px-2'
onClick={() => controller.skipForward(15)}
title={_('Forward 15 words (Shift+Right)')}
>
@@ -1151,48 +1183,20 @@ const RSVPOverlay: React.FC<RSVPOverlayProps> = ({
<span className='text-xs font-semibold opacity-80'>15</span>
</button>
{/* Trailing cluster: audio (TTS) toggle + divider + settings gear.
The audio toggle starts TTS from the displayed word (or stops it
when engaged) never a second play triangle (decision 5). Active
state uses a filled glyph + eink-bordered surface so it reads in
e-ink without relying on color. */}
<div className='absolute end-0 flex items-center gap-1'>
<button
aria-label={ttsActive ? _('Pause audio') : _('Play audio')}
className={clsx(
'touch-target flex h-9 w-9 cursor-pointer items-center justify-center rounded-full border-none transition-colors active:scale-95',
ttsActive
? 'eink-bordered bg-[color-mix(in_srgb,var(--rsvp-accent)_18%,transparent)]'
: 'bg-transparent hover:bg-gray-500/20',
)}
onClick={() => onToggleTtsAudio?.()}
title={ttsActive ? _('Pause audio') : _('Play audio')}
>
{ttsActive ? (
<IoVolumeHigh
className='h-4 w-4 md:h-5 md:w-5'
style={{ color: accentColor }}
aria-hidden='true'
/>
) : (
<IoVolumeMediumOutline className='h-4 w-4 md:h-5 md:w-5' aria-hidden='true' />
)}
</button>
<span className='h-5 w-px bg-gray-500/30' aria-hidden='true' />
<button
aria-label={_('Settings')}
className={clsx(
'flex h-9 w-9 cursor-pointer items-center justify-center rounded-full border-none bg-transparent transition-colors hover:bg-gray-500/20 active:scale-95',
showSettings && 'bg-gray-500/15',
)}
onClick={() => setShowSettings((prev) => !prev)}
title={_('Settings')}
>
<IoSettingsSharp className='h-4 w-4 md:h-5 md:w-5' />
</button>
</div>
{/* Settings far-right peer mirroring the audio toggle on the left,
so both flank the centered transport in normal flow (decision 5,
#3235) without an absolute cluster overlapping it on mobile. */}
<button
aria-label={_('Settings')}
className={clsx(
'flex h-8 w-8 shrink-0 cursor-pointer items-center justify-center rounded-full border-none bg-transparent transition-colors hover:bg-gray-500/20 active:scale-95 md:h-9 md:w-9',
showSettings && 'bg-gray-500/15',
)}
onClick={() => setShowSettings((prev) => !prev)}
title={_('Settings')}
>
<IoSettingsSharp className='h-4 w-4 md:h-5 md:w-5' />
</button>
</div>
{/* Settings row (collapsible) */}
@@ -1323,7 +1327,7 @@ const RSVPOverlay: React.FC<RSVPOverlayProps> = ({
{lookup && (
<button
aria-label={_('Look up')}
className='eink-bordered fixed z-[10001] flex -translate-x-1/2 -translate-y-full items-center gap-1.5 rounded-full px-3 py-1.5 text-sm font-semibold shadow-lg'
className='eink-bordered fixed z-[101] flex -translate-x-1/2 -translate-y-full items-center gap-1.5 rounded-full px-3 py-1.5 text-sm font-semibold shadow-lg'
style={{
left: `${lookup.left}px`,
top: `${lookup.top}px`,
@@ -26,7 +26,7 @@ const RSVPStartDialog: React.FC<RSVPStartDialogProps> = ({ startChoice, onSelect
return (
<div
role='presentation'
className='fixed inset-0 z-[10001] flex items-center justify-center'
className='fixed inset-0 z-[101] flex items-center justify-center'
style={{ backgroundColor: backdropColor }}
onClick={onClose}
onKeyDown={(e) => e.key === 'Escape' && onClose()}
@@ -27,7 +27,6 @@ export const useProgressSync = (bookKey: string) => {
// to the WHOLE bookDataStore — saveConfig writes booksData on every
// throttled save and would otherwise re-render the entire reader subtree.
const getConfig = useBookDataStore((s) => s.getConfig);
const setConfig = useBookDataStore((s) => s.setConfig);
const getBookData = useBookDataStore((s) => s.getBookData);
const getView = useReaderStore((s) => s.getView);
const setHoveredBookKey = useReaderStore((s) => s.setHoveredBookKey);
@@ -238,14 +237,8 @@ export const useProgressSync = (bookKey: string) => {
remoteCFILocation = candidateCFI;
}
}
const filteredSyncedConfig = Object.fromEntries(
Object.entries(syncedConfig).filter(([_, value]) => value !== null && value !== undefined),
);
if (syncedConfig.updatedAt >= config.updatedAt) {
setConfig(bookKey, { ...config, ...filteredSyncedConfig });
} else {
setConfig(bookKey, { ...filteredSyncedConfig, ...config });
}
// Currently, only reading progress is synced.
// TODO: Configuration sync will be handled through a more robust profile-based solution in the future.
if (remoteCFILocation && configCFI) {
if (CFI.compare(configCFI, remoteCFILocation) < 0) {
// While previewing a deep-link target, do NOT yank the view to the
+1 -1
View File
@@ -14,7 +14,7 @@ const Alert: React.FC<{
const divRef = useKeyDownActions({ onCancel, onConfirm });
return (
<div className={clsx('z-[100] flex justify-center px-4')}>
<div className={clsx('z-[130] flex justify-center px-4')}>
<div
ref={divRef}
role='alert'
@@ -7,11 +7,18 @@ interface ModalPortalProps {
showOverlay?: boolean;
}
// Coordinated overlay z-index scale (all clear the desktop `.window-border`
// page frame at z-99 — see globals.css). Low -> high:
// 100 RSVP overlay · 101 RSVP controls · 110 Settings dialog ·
// 120 modal / command palette · 130 toast · 200 app-lock.
// ModalPortal is the top modal layer, so it sits above the Settings dialog —
// a modal opened from inside Settings (e.g. Add OPDS Catalog) must win.
// Invariants are enforced by src/__tests__/styles/zIndexScale.test.ts.
const ModalPortal: React.FC<ModalPortalProps> = ({ children, showOverlay = true }) => {
return ReactDOM.createPortal(
<div
className={clsx(
'fixed inset-0 isolate z-[100] flex items-center justify-center',
'fixed inset-0 isolate z-[120] flex items-center justify-center',
showOverlay && 'bg-black bg-opacity-50',
)}
style={{ transform: 'translateZ(0)' }}
@@ -126,7 +126,7 @@ const CommandPalette: React.FC = () => {
return (
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
<div
className='fixed inset-0 z-[100] flex items-start justify-center bg-black/50 pt-[15vh]'
className='fixed inset-0 z-[120] flex items-start justify-center bg-black/50 pt-[15vh]'
onClick={handleBackdropClick}
>
{/* eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions */}
@@ -347,11 +347,12 @@ const SettingsDialog: React.FC<{ bookKey: string }> = ({ bookKey }) => {
<Dialog
isOpen={true}
onClose={handleClose}
// Settings is the top-level modal: raise it above the full-screen RSVP
// speed-reader overlay (z-[10000]) so dictionary management opened from
// inside RSVP shows on top instead of behind it (#3235). !important beats
// the Dialog's hardcoded z-50.
className='modal-open !z-[10050]'
// Settings sits in the overlay z-index scale (see ModalPortal.tsx) above
// the RSVP immersive overlay (z-100) so dictionary management opened from
// inside RSVP shows on top instead of behind it (#3235), and below the
// modal layer (z-120) so a modal opened from inside Settings (e.g. Add
// OPDS Catalog) renders on top. !important beats the Dialog's hardcoded z-50.
className='modal-open !z-[110]'
bgClassName={bookKey ? 'sm:!bg-black/20' : 'sm:!bg-black/50'}
boxClassName={clsx(
'sm:min-w-[520px] overflow-hidden not-eink:bg-base-200',
+17 -1
View File
@@ -44,6 +44,19 @@ export function pickWinningPages(
* decided the other way by updated_at (which page-turn progress dominates)
* issue #4634.
*/
/**
* `undefined` (the client omitted reading_status entirely e.g. a locally
* imported book that never had a status set) and `null` (the DB default) both
* mean "no reading status". Collapse them so a statusless book never registers
* as a status change. Without this, the `statusChanged` branch below rewrites
* `updated_at = now()` on every push for such books, and since the 1-day
* re-sync window re-pushes recently-touched books each cycle, they get a fresh
* timestamp every sync and pin themselves to the top of the date-sorted
* library.
*/
export const readingStatusChanged = (client?: string | null, server?: string | null): boolean =>
(client ?? null) !== (server ?? null);
export function resolveReadingStatusMerge(
client: Pick<DBBook, 'reading_status' | 'reading_status_updated_at'>,
server: Pick<DBBook, 'reading_status' | 'reading_status_updated_at'>,
@@ -419,7 +432,10 @@ export async function POST(req: NextRequest) {
// Only rewrite when the resolved status VALUE differs from the
// server's — a timestamp-only difference on the same value is a
// no-op, and rewriting it would churn updated_at + re-propagate.
const statusChanged = status.reading_status !== serverBook.reading_status;
const statusChanged = readingStatusChanged(
status.reading_status,
serverBook.reading_status,
);
if (statusChanged) {
// Server wins the row, but the client's status is newer. Write
// server's row with the fresher status and bump updated_at so
@@ -86,6 +86,11 @@ export abstract class BaseAppService implements AppService {
sharePosition?: { x: number; y: number; preferredEdge?: 'top' | 'bottom' | 'left' | 'right' };
},
): Promise<boolean>;
abstract saveImageToGallery(
filename: string,
content: ArrayBuffer,
mimeType: string,
): Promise<boolean>;
abstract ask(message: string): Promise<boolean>;
abstract openDatabase(
schema: SchemaType,
@@ -38,7 +38,7 @@ import {
import { getOSPlatform, isContentURI, isFileURI, isValidURL } from '@/utils/misc';
import { getDirPath, getFilename } from '@/utils/path';
import { NativeFile, RemoteFile } from '@/utils/file';
import { copyURIToPath, getStorefrontRegionCode } from '@/utils/bridge';
import { copyURIToPath, getStorefrontRegionCode, saveImageToGallery } from '@/utils/bridge';
import { copyFiles } from '@/utils/files';
import { BaseAppService } from './appService';
@@ -756,7 +756,15 @@ export class NativeAppService extends BaseAppService {
if (wantShare) {
let shareablePath = options?.filePath;
if (!shareablePath) {
shareablePath = await this.resolveFilePath(filename, 'Temp');
// Write into a Temp SUBDIRECTORY, never the Temp root. On Android the
// sharekit plugin copies the shared file to `<cacheDir>/<name>` before
// sharing, and Tauri's Temp dir IS `<cacheDir>` — writing to the root
// makes that a copy onto itself, whose output stream truncates the
// source to 0 bytes (the shared image came out 0 KB). A subdirectory
// gives the plugin's copy a distinct source path. (#4680)
const shareDir = await this.resolveFilePath('shared', 'Temp');
await mkdir(shareDir, { recursive: true });
shareablePath = await this.resolveFilePath(`shared/${filename}`, 'Temp');
if (typeof content === 'string') {
await writeTextFile(shareablePath, content);
} else if (content) {
@@ -802,6 +810,36 @@ export class NativeAppService extends BaseAppService {
}
}
async saveImageToGallery(
filename: string,
content: ArrayBuffer,
mimeType: string,
): Promise<boolean> {
// MediaStore is Android-only; other platforms keep the saveFile/share path.
if (!this.isAndroidApp) return false;
// Write the bytes to a Temp subdirectory (not the Temp root, mirroring the
// share path), then hand the path to the native MediaStore insert.
const shareDir = await this.resolveFilePath('shared', 'Temp');
await mkdir(shareDir, { recursive: true });
const srcPath = await this.resolveFilePath(`shared/${filename}`, 'Temp');
try {
await writeFile(srcPath, new Uint8Array(content));
const res = await saveImageToGallery({
srcPath,
fileName: filename,
mimeType,
albumName: 'Readest',
});
return res.success;
} catch (error) {
console.error('Failed to save image to gallery:', error);
return false;
} finally {
// Best-effort cleanup of the staged file.
await remove(srcPath).catch(() => {});
}
}
async ask(message: string): Promise<boolean> {
return await ask(message);
}
@@ -409,6 +409,11 @@ export class NodeAppService extends BaseAppService {
}
}
// No system photo gallery on Node; callers fall back to the saveFile flow.
async saveImageToGallery(): Promise<boolean> {
return false;
}
async ask(): Promise<boolean> {
return false;
}
@@ -396,6 +396,11 @@ export class WebAppService extends BaseAppService {
}
}
// No system photo gallery on the web; callers fall back to the saveFile flow.
async saveImageToGallery(): Promise<boolean> {
return false;
}
async ask(message: string): Promise<boolean> {
return window.confirm(message);
}
+3
View File
@@ -155,6 +155,9 @@ export interface AppService {
sharePosition?: { x: number; y: number; preferredEdge?: 'top' | 'bottom' | 'left' | 'right' };
},
): Promise<boolean>;
// Save an image into the system photo gallery (Android MediaStore). Returns
// false on platforms without a gallery (web/desktop) or on failure.
saveImageToGallery(filename: string, content: ArrayBuffer, mimeType: string): Promise<boolean>;
getDefaultViewSettings(): ViewSettings;
loadSettings(): Promise<SystemSettings>;
+21
View File
@@ -10,6 +10,19 @@ export interface CopyURIResponse {
error?: string;
}
export interface SaveImageToGalleryRequest {
srcPath: string;
fileName: string;
mimeType: string;
albumName?: string;
}
export interface SaveImageToGalleryResponse {
success: boolean;
uri?: string;
error?: string;
}
export interface UseBackgroundAudioRequest {
enabled: boolean;
}
@@ -108,6 +121,14 @@ export async function copyURIToPath(request: CopyURIRequest): Promise<CopyURIRes
return result;
}
export async function saveImageToGallery(
request: SaveImageToGalleryRequest,
): Promise<SaveImageToGalleryResponse> {
return await invoke<SaveImageToGalleryResponse>('plugin:native-bridge|save_image_to_gallery', {
payload: request,
});
}
export async function invokeUseBackgroundAudio(request: UseBackgroundAudioRequest): Promise<void> {
await invoke('plugin:native-bridge|use_background_audio', {
payload: request,
+37
View File
@@ -1,3 +1,40 @@
export interface ParsedDataUrl {
bytes: Uint8Array;
mimeType: string;
}
/**
* Decode a `data:` URL into raw bytes plus its MIME type. Used to turn the
* in-memory image shown in the gallery viewer back into a file for the
* share / export flow.
*/
export function dataUrlToBytes(dataUrl: string): ParsedDataUrl {
const match = /^data:([^;,]*)(;base64)?,(.*)$/s.exec(dataUrl);
if (!match) {
throw new Error('Not a data URL');
}
const mimeType = match[1] || 'application/octet-stream';
const isBase64 = !!match[2];
const data = match[3] ?? '';
if (isBase64) {
const binary = atob(data);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return { bytes, mimeType };
}
// Non-base64 data URLs hold percent-encoded text (e.g. inline SVG).
return { bytes: new TextEncoder().encode(decodeURIComponent(data)), mimeType };
}
/** Derive a file extension from an image MIME type (e.g. image/svg+xml -> svg). */
export function imageExtensionFromMime(mimeType: string): string {
const subtype = (mimeType.split('/')[1] || 'png').toLowerCase();
const base = subtype.split('+')[0]!;
return base === 'jpeg' ? 'jpg' : base;
}
/**
* Process book cover for Discord Rich Presence:
* - Fit to 512x512 with transparent background
+13 -19
View File
@@ -70,6 +70,19 @@
</supports>
<releases>
<release version="0.11.12" date="2026-06-20">
<description>
<ul>
<li>Reading: Fixed full-page cover images and cover backgrounds that didn't display correctly under a page texture</li>
<li>Reading: Fixed a crash when opening large PDF files on mobile devices</li>
<li>Reading: You can now save or share an image straight from the image viewer</li>
<li>Speed Reading: Fixed the control bar overlapping the playback controls on mobile</li>
<li>Sync: Books without a reading status no longer jump back to the top after every sync</li>
<li>Sync: Your per-device reading view settings now stay local instead of syncing between devices</li>
<li>OPDS: Fixed the Add Catalog dialog appearing behind Settings on mobile</li>
</ul>
</description>
</release>
<release version="0.11.10" date="2026-06-19">
<description>
<ul>
@@ -210,25 +223,6 @@
</ul>
</description>
</release>
<release version="0.9.100" date="2026-02-13">
<description>
<ul>
<li>Text-to-Speech: Automatically moves to the next chapter when the current chapter finishes</li>
<li>Reading: Fixed the reading ruler on Android</li>
<li>Reading: Improved RSVP layout and stability</li>
<li>Reading: Fixed an issue where some EPUB books could not adjust font size</li>
<li>Annotations: Added a magnifier (loupe) when adjusting text selection</li>
<li>Annotations: Text selection now snaps to word boundaries for better accuracy</li>
<li>Annotations: Custom highlight colors now apply correctly in the sidebar</li>
<li>Dictionary: Added a back button for easier navigation</li>
<li>Dictionary: Fixed an issue where Dictionary and Wikipedia could not open on Android</li>
<li>Metadata: Added collapsible sections in Book Details</li>
<li>Metadata: Added support for parsing series information from Calibre-exported EPUB files</li>
<li>Eink: Improved component layout and colors in E-ink mode</li>
<li>Layout: Sidebar toggle button position is now persistent</li>
</ul>
</description>
</release>
</releases>
<metadata_license>FSFAP</metadata_license>
+1 -1
View File
@@ -1 +1 @@
db24d2fbcc69636b1ebb98a5f2d7e78a27b41da55faa4a99f037380e5c3141f0 ../../data/metainfo/appdata.xml
1e0de8d004148c86f38822baed75abe6a1104df872c71f1b5541eaa10b28329c ../../data/metainfo/appdata.xml