forked from akai/readest
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 54d54791b0 | |||
| 7185dca1a2 | |||
| a9526377a2 | |||
| f7e1bddda6 | |||
| 0ab8f6042f | |||
| 5f561504e3 | |||
| b9a3ee725f | |||
| 23d1ef6f13 | |||
| 6e9faaa874 | |||
| d5c640996d |
@@ -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=required∪optional, `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]].
|
||||
@@ -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,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 so‘zlar ustida ona tilingizda qisqa maslahat ko‘rsatadi. Darajangizdan yuqori so‘zlarga 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": "圖片已儲存至相簿"
|
||||
}
|
||||
|
||||
@@ -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": [
|
||||
|
||||
+81
@@ -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",
|
||||
|
||||
+13
@@ -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"]
|
||||
+27
@@ -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",
|
||||
|
||||
+14
-2
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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 @@
|
||||
db24d2fbcc69636b1ebb98a5f2d7e78a27b41da55faa4a99f037380e5c3141f0 ../../data/metainfo/appdata.xml
|
||||
1e0de8d004148c86f38822baed75abe6a1104df872c71f1b5541eaa10b28329c ../../data/metainfo/appdata.xml
|
||||
|
||||
+1
-1
Submodule packages/foliate-js updated: 1cb26e3fe2...828d6132ea
Reference in New Issue
Block a user