chore(agent): update agent memories (#4958)
This commit is contained in:
@@ -1,149 +1,132 @@
|
|||||||
# Readest Project Memory
|
# Readest Project Memory
|
||||||
|
|
||||||
## Key Reference Documents (aggregators)
|
## Key Reference Documents (aggregators)
|
||||||
- [Bug Patterns](bug-patterns.md) · [CSS & Style](css-style-fixes.md) — EPUB CSS + style.ts · [TTS](tts-fixes.md)
|
- [Bug Patterns](bug-patterns.md) · [CSS & Style](css-style-fixes.md) EPUB CSS + style.ts · [TTS](tts-fixes.md)
|
||||||
- [Layout & UI](layout-ui-fixes.md) — insets/z-index · [Platform Compat](platform-compat-fixes.md) — Android/iOS/Linux/macOS · [Annotator & Reader](annotator-reader-fixes.md)
|
- [Layout & UI](layout-ui-fixes.md) insets/z-index · [Platform Compat](platform-compat-fixes.md) · [Annotator & Reader](annotator-reader-fixes.md)
|
||||||
## Safety & Security
|
## Safety & Security
|
||||||
- [In-place delete wiped originals](in-place-delete-wiped-originals.md) — never `fs.removeFile` `external` source;
|
- [In-place delete wiped originals](in-place-delete-wiped-originals.md) never `fs.removeFile` `external` · [Backup zip Windows paths #4703](backup-windows-zip-paths-4703.md) normalize `\` · [download_file scope Android #4639](download-file-scope-android-regression.md)
|
||||||
- [Backup zip Windows paths (#4703)](backup-windows-zip-paths-4703.md) — `\` entry names broke restore; normalize
|
- [Security advisories 2026-06](security-advisories-web-2026-06.md) 4 GHSA #4638
|
||||||
- [download_file scope Android (#4639)](download-file-scope-android-regression.md) — strict `is_allowed` broke
|
|
||||||
- [Security advisories 2026-06](security-advisories-web-2026-06.md) — 4 GHSA #4638 (OPDS SSRF, storage key, Stripe
|
|
||||||
## Paginator & Scroll
|
## Paginator & Scroll
|
||||||
- [Reading ruler line-aware](reading-ruler-line-aware.md) — snaps to real lines; iframe frame-offset map
|
- Reading ruler: [line-aware](reading-ruler-line-aware.md) frame-offset map; [vertical-rl backwards #4865](reading-ruler-vertical-rtl-4865.md)
|
||||||
- [TOC expand + auto-scroll](toc-expand-and-autoscroll.md) — #4059 collapse-default breaks scroll-to-current
|
- [Vertical-rl horizontal pagination (#624)](vertical-rl-horizontal-pagination-624.md) — horizontal inputs + two-phase slide; rtl gated `!vertical`
|
||||||
- [BooknoteView auto-scroll (#4352)](booknote-view-autoscroll-4352.md) — reload via `initialized` ref;
|
- [Slide/curl turn styles via VT (#555)](page-turn-styles-viewtransitions-555.md) — VT turns gated on nested-VT-groups support (iOS 18 WebKit crashes despite having the API); Tauri fallback = `CapturedPageTurn` capture pipeline (WebGL mesh curl + flat canvas slide, full-gridcell capture, instant nav = drop `animated`); MERGED #4940 (2026-07-05), verified live mac/iOS/Android (mobile = JPEG capped 2x, PNG was 1.5s/turn); Win/Linux capture still open
|
||||||
- [TOC current-position row](toc-current-position-row.md) — synthetic row; insert AFTER to keep flatItems index
|
- TOC: [expand + auto-scroll](toc-expand-and-autoscroll.md); [current-position row](toc-current-position-row.md); [table heading clip #4439](toc-table-heading-clip-4439.md); [BooknoteView auto-scroll #4352](booknote-view-autoscroll-4352.md)
|
||||||
- [Swipe page-turn bg flash](paginator-swipe-bg-flash.md) — static `#background`; `computeBackgroundSegments` per-rAF
|
- Paginated bg: [swipe flash](paginator-swipe-bg-flash.md); [texture occlusion #4399](paginated-texture-occlusion-4399.md); [gutter bleed #4394](paginator-gutter-bleed-asymmetry-4394.md); [bg-replace reflow #4785](pageturn-bg-replace-reflow-4785.md)
|
||||||
- [Paginated texture occlusion (#4399)](paginated-texture-occlusion-4399.md) — opaque bg occludes texture; `hasTexture
|
- [Inline-block column overflow](inline-block-column-overflow.md) `#demoteUnfragmentableBoxes`
|
||||||
- [Background overflows column (#4394)](paginator-gutter-bleed-asymmetry-4394.md) — bg bled into gutter; clamp to
|
- FXL/PDF: [fit-width scroll reset #4683](fixed-layout-paginated-scroll-reset-4683.md); [PDF spread seam #4587](pdf-spread-canvas-seam-4587.md); [spine seam #4857](fxl-spread-spine-seam-4857.md)
|
||||||
- [Inline-block column overflow](inline-block-column-overflow.md) — body can't fragment; `#demoteUnfragmentableBoxes`
|
- Scrolled: [PDF wheel double #4727](pdf-scroll-mode-wheel-double-4727.md); [header title center #4436](scrolled-header-title-center-4436.md); [Duokan fullscreen cover](duokan-fullscreen-cover-scroll.md)
|
||||||
- [FXL fit-width scroll reset (#4683)](fixed-layout-paginated-scroll-reset-4683.md) — scrollTop not reset;
|
|
||||||
- [PDF spread 1px seam (#4587)](pdf-spread-canvas-seam-4587.md) — fractional dpr truncates; pin `canvas.style=viewport`
|
|
||||||
- [PDF scrolled wheel double (#4727)](pdf-scroll-mode-wheel-double-4727.md) — drop manual `scrollBy` over native
|
|
||||||
- [Scrolled header title center (#4436)](scrolled-header-title-center-4436.md) — view covering `renderedStart + size/2`
|
|
||||||
- [Duokan fullscreen cover scroll](duokan-fullscreen-cover-scroll.md) — #4379 cover collapses; gate on `this.#column`
|
|
||||||
- [TOC table heading clip (#4439)](toc-table-heading-clip-4439.md) — #4400 scroll-wrapper `overflow:auto` clips
|
|
||||||
- [Page-turn bg-replace reflow (#4785)](pageturn-bg-replace-reflow-4785.md) — `#replaceBackground` rebuilt context per
|
|
||||||
## Critical Files (Most Bug-Prone)
|
## Critical Files (Most Bug-Prone)
|
||||||
- `src/utils/style.ts` EPUB CSS hub · `packages/foliate-js/paginator.js` layout/image/bg · `src/services/tts/TTSController.ts` TTS state machine
|
- `src/utils/style.ts` EPUB CSS hub · `packages/foliate-js/paginator.js` · `src/services/tts/TTSController.ts`
|
||||||
- `src/hooks/useSafeAreaInsets.ts` insets · `src/app/reader/components/FoliateViewer.tsx` view orchestration · `.../annotator/Annotator.tsx` annotation lifecycle
|
- `src/hooks/useSafeAreaInsets.ts` · `src/app/reader/components/FoliateViewer.tsx` · `.../annotator/Annotator.tsx`
|
||||||
## Sync Notes
|
## Sync Notes
|
||||||
- [Grimmory native sync](grimmory-native-sync.md) — Booklore-fork REVERTED; id by ISBN/ASIN + koreader-hash
|
- [Grimmory native sync](grimmory-native-sync.md) Booklore-fork REVERTED
|
||||||
- KOSync: [CFI spine resolution](kosync-cfi-spine-resolution.md) convert via CFI's own spine; [connect() false-positive #4692](kosync-connect-false-positive-4692.md)
|
- KOSync: [CFI spine resolution](kosync-cfi-spine-resolution.md); [connect() false-positive #4692](kosync-connect-false-positive-4692.md)
|
||||||
- [Empty-start CFI sync](empty-start-cfi-sync.md) — `isMalformedLocationCfi` → discard synced value
|
- [Empty-start CFI sync](empty-start-cfi-sync.md) · [Custom fonts vanish #4410](custom-fonts-reincarnation-4410.md) CRDT remove-wins
|
||||||
- [Custom fonts vanish on sync (#4410)](custom-fonts-reincarnation-4410.md) — CRDT remove-wins; re-import
|
- koplugin: [note deletion](koplugin-note-deletion-sync.md) tombstone; [stats #4666](koplugin-stats-sync.md); [bulk download #4751](koplugin-bulk-download-4751.md); [dup book rows #4861](koplugin-stats-duplicate-book-rows-4861.md)
|
||||||
- koplugin: [note deletion](koplugin-note-deletion-sync.md) `recordDeletion` tombstone; [stats #4666](koplugin-stats-sync.md) statistics.sqlite3 delta LuaSettings; [bulk download #4751](koplugin-bulk-download-4751.md)
|
- [Statusless re-pin #4677](sync-statusless-book-rebump-4677.md) · [pull cursor synced_at #4678](sync-synced-at-cursor-4678.md)
|
||||||
- [Statusless books re-pin top (#4677)](sync-statusless-book-rebump-4677.md) — `(a??null)!==(b??null)`
|
- [koplugin library stale #4934](koplugin-library-stale-synced-cursor-4934.md) pull cursor updated_at→synced_at + split push watermark + v2→v3 heal migration
|
||||||
- [Pull cursor via synced_at (#4678)](sync-synced-at-cursor-4678.md) — books `synced_at` + BEFORE trigger
|
- WebDAV: [metadata #4756](webdav-metadata-sync-4756.md) LWW; [group membership #4942](webdav-group-membership-sync-4942.md) mergeBookMetadata dropped groupId/groupName; [credentials #4810](webdav-credential-sync-4810.md); [connect nullified #4780](webdav-connect-nullified-4780.md) stale closure
|
||||||
- [WebDAV metadata sync (#4756)](webdav-metadata-sync-4756.md) — PR #4776; LWW on `book.updatedAt` +
|
- [WebDAV deletion + upload-after-enable (#4860/#4856)](webdav-deletion-and-upload-after-enable-4860-4856.md) edit-wins LWW + tombstone union
|
||||||
- [File-sync refactor (#4784)](webdav-filesync-refactor-plan.md) — `FileSyncEngine`/`FileSyncProvider`/`merge.ts`;
|
- File sync: [refactor #4784](webdav-filesync-refactor-plan.md) `FileSyncEngine`; [third-party auto-sync #4835](third-party-library-autosync-4835.md)
|
||||||
- [Third-party library auto-sync (#4835)](third-party-library-autosync-4835.md) — `useLibraryFileSync` parity w/
|
- [Transfer Queue clear not persisted](transfer-queue-clear-persistence.md) hook mutated store directly, skipped `persistQueue()`; route clears through `transferManager`
|
||||||
- [WebDAV connect nullified (#4780)](webdav-connect-nullified-4780.md) — catch+finally saved STALE closure;
|
- [Multi-window settings clobber (#4580)](multiwindow-settings-clobber-4580.md)
|
||||||
- [WebDAV credential sync (#4810)](webdav-credential-sync-4810.md) — `webdav.*` missing from
|
- Google Drive: [research](gdrive-sync-provider-research.md); [multi-PR status](gdrive-provider-multipr-status.md)
|
||||||
- [Multi-window settings clobber (#4580)](multiwindow-settings-clobber-4580.md) — stale window overwrites shared
|
- [Hardcover edition_id (#4792)](hardcover-progress-edition-id-4792.md)
|
||||||
- Google Drive: [research](gdrive-sync-provider-research.md) Drive as `FileSyncProvider` token-persist+resumable; [multi-PR status](gdrive-provider-multipr-status.md)
|
|
||||||
- [Hardcover progress edition_id (#4792)](hardcover-progress-edition-id-4792.md) — `edition_id` fell back to
|
|
||||||
## Build, Testing & CI
|
## Build, Testing & CI
|
||||||
- [format:check separate gate](verify-format-check-gate.md) — `pnpm format:check` own gate before push
|
- [Nightly quick-sharun hang #4906](nightly-quick-sharun-hang-4906.md) pin via cache pre-seed + step timeouts
|
||||||
- [Worktree rebase submodule drift](worktree-rebase-submodule-drift.md) — rebase leaves foliate-js submodule old; `git
|
- [format:check separate gate](verify-format-check-gate.md) · [Worktree rebase submodule drift](worktree-rebase-submodule-drift.md)
|
||||||
- [Android CDP e2e lane](android-cdp-e2e-lane.md) — `pnpm test:android` adb+CDP; CI KVM emulator
|
- Android CDP: [e2e lane](android-cdp-e2e-lane.md) `pnpm test:android`; [WebView profiling](cdp-android-webview-profiling.md); [double-tap gesture](android-e2e-doubletap-cdp-gesture.md)
|
||||||
- [CDP Android WebView profiling](cdp-android-webview-profiling.md) — adb+CDP JS probes; locked-device freezes fetch
|
- [Tauri Rust↔JS parser parity](tauri-parser-parity-tests.md)
|
||||||
- [Tauri Rust↔JS parser parity](tauri-parser-parity-tests.md) — #4369 cross-check; `dcterms:modified`→`published`
|
- TTS tests: [browser e2e harness](tts-browser-e2e-harness.md); [paragraph+RSVP sync #3235](tts-sync-paragraph-rsvp-3235.md) TTS-is-clock
|
||||||
- [TTS browser e2e harness](tts-browser-e2e-harness.md) — seed `settings.globalViewSettings` or getMergedRules crashes
|
- [fastlane App Store](fastlane-apple-appstore-submission.md) `APPLE_API_KEY_PATH` out of build env
|
||||||
- [TTS paragraph+RSVP sync (#3235)](tts-sync-paragraph-rsvp-3235.md) — TTS-is-clock; overlay CLONE via CSS Custom
|
- [Turbopack cache OOM (#4619)](turbopack-build-cache-oom-docker-standalone.md)
|
||||||
- [fastlane App Store](fastlane-apple-appstore-submission.md) — keep `APPLE_API_KEY_PATH` out of macOS build env
|
- [Deps override workflow](deps-security-overrides-workflow.md) `pnpm-workspace.yaml`
|
||||||
- [Turbopack cache OOM (#4619)](turbopack-build-cache-oom-docker-standalone.md) — partial cache freeze; gate on
|
- [Xcode 26.2 broke iOS builds (swift-rs)](xcode26-swiftrs-ios-build-broken.md) — phantom `Bundle.main`/`privacy:` errors; vendored `packages/swift-rs` `--triple`/`--sdk`; Package.swift platforms floor now enforced
|
||||||
- [Deps override workflow](deps-security-overrides-workflow.md) — overrides in `pnpm-workspace.yaml`; tauri-plugins
|
- [pdfjs vendor wasm](pdfjs-vendor-wasm-decoders.md) copy `wasm/*`
|
||||||
- [pdfjs vendor wasm](pdfjs-vendor-wasm-decoders.md) — pdfjs 5.7 moved JBIG2 to `jbig2.wasm`; copy `wasm/*`
|
- [CI/PR delivery + push keepalive](ci-pr-delivery-and-push.md)
|
||||||
- [CI/PR delivery + push keepalive](ci-pr-delivery-and-push.md) — temp-index plumbing; SSH `ServerAliveInterval`
|
|
||||||
## Platform Compat
|
## Platform Compat
|
||||||
- [Android hyphen selection (#1553)](android-hyphen-selection-bounds-1553.md) — Blink start handle on last hyphen;
|
- Android: [hyphen selection #1553](android-hyphen-selection-bounds-1553.md); [NativeFile vs RemoteFile I/O](android-nativefile-remotefile-io.md)
|
||||||
- [NativeFile vs RemoteFile I/O](android-nativefile-remotefile-io.md) — NativeFile slow; asset Range broken;
|
- [Window-state sanitizer #4398](window-state-sanitize-4398.md) · [Android themed icon #4733](android-themed-icon-4733.md)
|
||||||
- [Window-state sanitizer (#4398)](window-state-sanitize-4398.md) — invalid json crashes WebView2; sanitize before
|
- [Open-with intent #4521](android-open-with-intent-flow.md) · [dict lookup hijack #4559](dict-lookup-browser-hijack-4559.md)
|
||||||
- [Android Open-with intent (#4521)](android-open-with-intent-flow.md) — VIEW gated by `autoImportBooksOnOpen`
|
- [Large-PDF OOM range flood (#3470)](pdf-oom-range-flood-3470.md) MAX_CONCURRENT_RANGES=6
|
||||||
- [Dict lookup browser hijack (#4559)](dict-lookup-browser-hijack-4559.md) — missing `<queries>` sdk36; filter in
|
- [Black screen external cache (#4853)](android-black-screen-external-cache-4853.md) FIXED PR#4889; drop `$CACHE` grants
|
||||||
- [Large-PDF OOM range flood (#3470)](pdf-oom-range-flood-3470.md) — un-awaited ranges OOM; MAX_CONCURRENT_RANGES=6
|
- [macOS 26 Tahoe close→black window (#4875)](macos26-tahoe-close-black-screen-4875.md) `minimize()` not `hide()`
|
||||||
- [Android themed icon (#4733)](android-themed-icon-4733.md) — no monochrome → force-commit; tint=SRC_IN
|
- [Linux app invisible after backup (#3682)](linux-transparent-window-invisible-3682.md) FIXED PR#4904 opaque window
|
||||||
|
- [Apple OAuth expired + deeplink swallowed (#4881)](oauth-deeplink-error-swallowed-4881.md)
|
||||||
|
- [Annotation link ignored when reader open #4887](deeplink-drop-running-macos-4887.md) `open-book-in-reader` event
|
||||||
|
- [iOS auto-brightness locks (#4885)](ios-brightness-lock-background-4885.md) brightness GLOBAL; release on bg
|
||||||
|
- [iOS share .txt stuck #4917](ios-share-txt-stuck-supportstext.md) drop SupportsText in `project.yml` (xcodegen src)
|
||||||
|
- [Updater disable non-AppImage (#4874)](updater-disable-nonappimage-linux-4874.md)
|
||||||
|
- [Fullscreen no-op Phosh (#4034)](fullscreen-maximized-phosh-4034.md) drop `isMaximized` branch
|
||||||
## Reader Features & UI
|
## Reader Features & UI
|
||||||
- [Mobile reading widgets (#1602/PR#4842)](mobile-reading-widgets.md) — home-screen widget iOS+Android; gotchas: iOS
|
- [Android Auto TTS #3919/PR#4907](android-auto-tts-3919.md) MERGED; CarPlay blocked on entitlement
|
||||||
- [PDF scrolled-mode lag (#4795/#4031)](pdf-scroll-lag-preload-4795.md) — 415ms/page render vs 50% margin; widen to
|
- Widgets: [mobile reading #1602/PR#4842](mobile-reading-widgets.md); [iOS App Group stripped PR#4891](ios-widget-appgroup-stripped-appstore.md); [cover bright right-edge line](ios-widget-cover-bright-edge-line.md) fractional resize → round target to whole px
|
||||||
- [Scrolled-PDF pinch-zoom (#4817)](scrolled-pdf-pinch-zoom-4817.md) — live pinch + no-shift (gap×`--scroll-zoom` +
|
- PDF: [scrolled lag #4795](pdf-scroll-lag-preload-4795.md); [scrolled pinch-zoom #4817](scrolled-pdf-pinch-zoom-4817.md); [pinch vs two-finger scroll #4858](pinch-vs-twofinger-scroll-4858.md); [text selection misplaced w/ OS font scale #4480](pdf-text-selection-fontscale-4480.md) divide `--total-scale-factor` by detected font scale
|
||||||
- [Search modes #4560 + spoiler-bound bug](search-modes-4560-and-spoiler-bound-bug.md) — regex + nearby-words;
|
- [Search modes #4560](search-modes-4560-and-spoiler-bound-bug.md)
|
||||||
- [OPDS groups carousel (#4750)](opds-groups-carousel-4750.md) — >=2 groups → virtuoso carousel; `scrollToIndex`
|
- [OPDS groups carousel #4750](opds-groups-carousel-4750.md) · [WebDAV browser sort+search #4724](webdav-browse-sort-search-4724.md)
|
||||||
- [WebDAV browser sort + search (#4724)](webdav-browse-sort-search-4724.md) — PR #4786;
|
- [Image zoom trackpad flicker (#4742)](image-zoom-trackpad-flicker-4742.md) macOS pinch=`ctrl+wheel`
|
||||||
- [Image zoom trackpad flicker (#4742)](image-zoom-trackpad-flicker-4742.md) — macOS pinch=`ctrl+wheel`;
|
- Instant highlight: [ate tap/swipe](instant-highlight-tap-paginate.md); [delete orphan #4773](instant-highlight-delete-orphan-4773.md); [empty leak #4791](empty-highlight-leak-on-annotate-cancel-4791.md)
|
||||||
- [Instant Highlight ate tap/swipe](instant-highlight-tap-paginate.md) — preventDefault killed tap-paginate;
|
- Selection: [keyboard adjust #4728](keyboard-selection-adjust-4728.md); [cross-page auto-turn #4741](cross-page-selection-autoturn-4741.md)
|
||||||
- [Keyboard selection adjust (#4728)](keyboard-selection-adjust-4728.md) — Shift+←/→ char, Ctrl/Alt+Shift word;
|
- Click/tap: [double-click word select](iframe-double-click-word-select.md); [dblclick-drag #4524](dblclick-drag-pageturn-4524.md); [tap open image/table #4600](tap-to-open-image-table-4600.md)
|
||||||
- [Double-click word select](iframe-double-click-word-select.md) — orphaned `iframe-double-click` →
|
- [Annotator onLoad listener leak (#4735)](annotator-onload-listener-leak-paragraph-mode.md)
|
||||||
- [Cross-page selection auto-turn (#4741)](cross-page-selection-autoturn-4741.md) — `useAutoPageTurn` dwell;
|
- Paragraph mode: [toggle/resume #4717](paragraph-mode-toggle-resume-4717.md); [accidental exit #4474](paragraph-mode-accidental-exit-4474.md)
|
||||||
- [Annotator onLoad listener leak (#4735)](annotator-onload-listener-leak-paragraph-mode.md) — `useRendererInputListener
|
- [#4584 tap-death](issue-4584-tap-death-investigation.md) UNFIXED; likely WebView-148
|
||||||
- [Paragraph mode toggle/resume (#4717)](paragraph-mode-toggle-resume-4717.md) — snapshot live Set; resume from fresh
|
- [PDF/CBZ Contrast view-menu](pdf-cbz-contrast-view-menu.md) ONE `filter:`
|
||||||
- [Paragraph-mode accidental exit (#4474)](paragraph-mode-accidental-exit-4474.md) — `paragraph-show-controls`; bar
|
- [Header/footer over light PDF in dark (#4901)](pdf-header-footer-contrast-blend-4901.md) `mix-blend-difference`, FIXED light anchor
|
||||||
- [#4584 tap-death](issue-4584-tap-death-investigation.md) — UNFIXED; `isPopuped` RED HERRING; likely WebView-148
|
- [iOS instant-dict double popup](ios-instant-dict-double-popup.md) once-per-gesture latch
|
||||||
- [Dblclick-drag turns page (#4524)](dblclick-drag-pageturn-4524.md) — `isMouseDown` gates `postSingleClick`
|
- Dict: [popup font size #4443](dict-popup-font-size-4443.md); [lemmatization #4574](dict-lemmatization-4574.md); [popup speak button #4876](dict-popup-tts-speak-4876.md) standalone wordPronouncer, Edge-direct + dedicated WebAudio ctx
|
||||||
- [Tap to open image/table (#4600)](tap-to-open-image-table-4600.md) — `iframe-open-media` + `detectMediaTarget`
|
- Word Lens: [inline gloss](wordlens-feature.md) CFI-safe ruby; [en-en](wordlens-en-en.md)
|
||||||
- [PDF/CBZ Contrast view-menu](pdf-cbz-contrast-view-menu.md) — per-book `contrast`; ONE `filter:` (invert+contrast);
|
- [Stripe highest-active plan (#4694)](stripe-plan-highest-active-4694.md)
|
||||||
- [iOS instant-dict double popup](ios-instant-dict-double-popup.md) — once-per-gesture latch; `isLongPressHold` 300ms
|
- [Save image to gallery (#4680)](save-image-to-gallery-android.md) MediaStore
|
||||||
- Dict: [popup font size #4443](dict-popup-font-size-4443.md) `--dict-font-scale` MDict `::part(dict-content)`; [lemmatization #4574](dict-lemmatization-4574.md)
|
- [Webtoon Mode (#3647)](webtoon-mode-3647.md)
|
||||||
- Word Lens: [inline gloss](wordlens-feature.md) CFI-safe `<ruby cfi-skip>…<rt cfi-inert>` TTS/search isolation; [en-en](wordlens-en-en.md)
|
- [Middle-click autoscroll #4951](middle-click-autoscroll-4951.md) Autoscroller RAF core; `containerPosition +=`; armed-books preventDefault
|
||||||
- [Stripe highest-active plan (#4694)](stripe-plan-highest-active-4694.md) — `plans.plan` = MAX over active subs
|
- [Biometric app-lock #4645](biometric-app-lock-4645.md) · [Reference Pages #4542](reference-pages-672-4542.md) · [e-ink refresh page-turner #4687](eink-screen-refresh-pageturner-4687.md)
|
||||||
- [Save image to gallery (#4680)](save-image-to-gallery-android.md) — MediaStore; sharekit 0-byte self-copy
|
- [Share intent + toolbar (#4014)](annotation-share-toolbar-4014.md)
|
||||||
- [Webtoon Mode (#3647)](webtoon-mode-3647.md) — no-gap scrolled images; FXL fit-width; `--scroll-page-gap`
|
- Customize Toolbar: [global serializeConfig #4760](customize-toolbar-global-serializeconfig.md); [e-ink black bar #4839](customize-toolbar-eink-black-bar-4839.md)
|
||||||
- [Biometric app-lock (#4645)](biometric-app-lock-4645.md) — flag from `appLockStore`; plugin `cfg(mobile)`
|
- [Edge TTS Web Audio engine (#3851)](edge-tts-webaudio-engine.md) gapless WebAudioPlayer + WSOLA
|
||||||
- [Reference Pages (#4542)](reference-pages-672-4542.md) — 'reference' progressStyle; `referencePageCount`
|
- [Background TTS sessions PR#4941](tts-background-session-decoupling.md) — hash-keyed session manager, detach/attach, NowPlayingBar; header X routes `onCloseBook` NOT `onGoToLibrary`
|
||||||
- [E-ink screen refresh page-turner (#4687)](eink-screen-refresh-pageturner-4687.md) — bindable 'refresh' action;
|
- Native TTS: [iOS #4676](native-ios-tts-4676.md) pause==stop; [offline halt #4613](native-tts-offline-autoadvance-4613.md)
|
||||||
- [Share intent + toolbar (#4014)](annotation-share-toolbar-4014.md) — Share gated mobile+macOS;
|
- Edge TTS: [word highlight #4017](edge-tts-word-highlighting-4017.md); [drift](tts-word-highlight-singletextnode-drift.md)
|
||||||
- [Instant highlight delete orphan (#4773)](instant-highlight-delete-orphan-4773.md) — stale memoized index + in-place
|
- TTS UX: [highlight granularity](tts-highlight-granularity-setting.md); [start-from-selection](tts-start-from-selection.md); [reuse session](tts-reuse-session-mode-entry.md)
|
||||||
- [Empty highlight leak on annotate cancel (#4791)](empty-highlight-leak-on-annotate-cancel-4791.md) — Annotate
|
- RSVP: [control-bar REVERT](rsvp-control-bar-overlap-revert.md); [font #4519](rsvp-font-settings-4519.md); [RTL word #4630](rsvp-rtl-word-display-4630.md)
|
||||||
- [Customize Toolbar global (#4760)](customize-toolbar-global-serializeconfig.md) — `serializeConfig` ref-compare →
|
- [Overlay z-index scale](zindex-overlay-scale.md) RSVP 100 → app-lock
|
||||||
- [Customize Toolbar e-ink black bar (#4839)](customize-toolbar-eink-black-bar-4839.md) — preview Zone copied
|
- [Global annotation page-turn lag (#4575)](global-annotation-pageturn-perf-4575.md)
|
||||||
- Native TTS: [iOS #4676](native-ios-tts-4676.md) AVSpeechSynthesizer pause==stop rate `pow^(1/2.5)`; [offline halt #4613](native-tts-offline-autoadvance-4613.md)
|
- [Overlayer splitRange text nodes](overlayer-splitrange-textnodes.md)
|
||||||
- Edge TTS: [word highlight #4017](edge-tts-word-highlighting-4017.md) `audio.metadata` WordBoundary by rAF gate on UA; [drift](tts-word-highlight-singletextnode-drift.md)
|
- [Android image callout freeze](android-image-callout-freeze.md) `.no-context-menu` ANCESTOR
|
||||||
- [TTS highlight granularity setting](tts-highlight-granularity-setting.md) — Word/Sentence; gate `prepareSpeakWords`
|
- [Inline-img vertical-align (#4866)](inline-img-vertical-align-4866.md) gated on computed valign
|
||||||
- [TTS start-from-selection](tts-start-from-selection.md) — use last mark at/before sel; cloneRange+deselect
|
- [Table dark-mode tint #4419](table-dark-mode-tint-4419.md) · [footnote aside border #4438](footnote-aside-namespace-order-4438.md)
|
||||||
- [Reuse TTS session on mode entry](tts-reuse-session-mode-entry.md) — `redispatchPosition()` + `tts-sync-request`
|
- Proofread: [enhancements #4700](proofread-enhancements-4700.md); [per-book CRDT #4781](proofread-per-book-crdt-sync.md); [edit Find + toggle #4859](proofread-edit-toggle-4859.md)
|
||||||
- RSVP: [control-bar overlap REVERT](rsvp-control-bar-overlap-revert.md) #4585 fixed/#4589 stale-reverted; [font face/family #4519](rsvp-font-settings-4519.md) `getBaseFontFamily` overlay top doc; [RTL word #4630](rsvp-rtl-word-display-4630.md)
|
- [Russian NBSP (#4769)](russian-hanging-prepositions-nbsp-4769.md)
|
||||||
- [Overlay z-index scale](zindex-overlay-scale.md) — RSVP 100 / Settings 110 / ModalPortal 120 / toast 130 / app-lock
|
- OPDS: [Firefox strict-XML #4479](opds-firefox-strict-xml-4479.md); [JSON search #4502](opds2-json-search-4502.md); [HTML desc #4503](opds-html-description-4503.md); [self-link #4749](opds-self-link-metadata-4749.md); [popular dedup #4782](opds-popular-catalog-dedup-4782.md); [auto-download subdir crawl #4272](opds-autodownload-subdir-crawl-4272.md) bounded BFS, never crawl newest-feed catalogs
|
||||||
- [Global annotation page-turn lag (#4575)](global-annotation-pageturn-perf-4575.md) — `global` re-fanned every turn;
|
- [D-pad Navigation](dpad-navigation.md)
|
||||||
- [Overlayer splitRange text nodes](overlayer-splitrange-textnodes.md) — `'p,h1-h4'` dropped `li`; walk text nodes +
|
- [koplugin cover upload (#4374)](koplugin-cover-upload.md)
|
||||||
- [Android image callout freeze](android-image-callout-freeze.md) — `.no-context-menu` on ANCESTOR
|
- [Calibre plugin push #4863](calibre-plugin-push-4863.md) OAuth localhost relay
|
||||||
- [Table dark-mode tint (#4419)](table-dark-mode-tint-4419.md) — `blockquote, table *` tint gated on `overrideColor`
|
- [Calibre custom columns #4811](calibre-custom-columns-4811.md) `metadata.calibreColumns`
|
||||||
- [Footnote aside border line (#4438)](footnote-aside-namespace-order-4438.md) — @font-face before @namespace; hoist
|
|
||||||
- Proofread: [enhancements #4700](proofread-enhancements-4700.md) regex UI/Ctrl+P reuse/`wholeWord` no-op; [per-book CRDT #4781](proofread-per-book-crdt-sync.md)
|
|
||||||
- [Russian hanging-preposition NBSP (#4769)](russian-hanging-prepositions-nbsp-4769.md) — generic `nbsp` +
|
|
||||||
- OPDS: [Firefox strict-XML #4479](opds-firefox-strict-xml-4479.md) junk after `</feed>` slice to last close; [JSON search #4502](opds2-json-search-4502.md) expand `{?query}` BEFORE resolveURL; [HTML desc #4503](opds-html-description-4503.md) decode-once+sanitize; [self-link meta #4749](opds-self-link-metadata-4749.md) `rel:self` deref; [popular dedup #4782](opds-popular-catalog-dedup-4782.md)
|
|
||||||
- [D-pad Navigation](dpad-navigation.md) — Android TV remote / arrow-key nav
|
|
||||||
- [koplugin cover upload (#4374)](koplugin-cover-upload.md) — `extractLocalCover` via `getCoverImage`
|
|
||||||
- [Calibre plugin push #4863](calibre-plugin-push-4863.md) apps/readest-calibre-plugin; OAuth localhost relay + /sync carry-over
|
|
||||||
## Library Fixes
|
## Library Fixes
|
||||||
- [Book action platform surfaces](book-actions-platform-surfaces.md) — context menu Tauri-desktop-only; cross-platform
|
- [Book action platform surfaces](book-actions-platform-surfaces.md) · [menu append race #4389](tauri-menu-append-race-4389.md)
|
||||||
- [Tauri menu append race (#4389)](tauri-menu-append-race-4389.md) — single `await Menu.new({ items })`
|
- TXT: [author recognition #4390](txt-author-recognition-4390.md); [chapter measure-word FP #4658](txt-chapter-measure-word-4658.md)
|
||||||
- TXT: [author recognition #4390](txt-author-recognition-4390.md) `parseLabeledAuthor`+`isPlausibleAuthorName`; [chapter measure-word FP #4658](txt-chapter-measure-word-4658.md)
|
- [Cover stale (in-place mutation)](cover-stale-inplace-mutation-memo.md)
|
||||||
- [Cover stale (in-place mutation)](cover-stale-inplace-mutation-memo.md) — pure `getBookWithUpdatedMetadata`
|
- [Series/author back no-op (#4437)](series-folder-back-noop-4437.md)
|
||||||
- [Series/author back no-op (#4437)](series-folder-back-noop-4437.md) — Next 16.2 empty-search no-op; `handleBack`
|
- [Library/reader separate texture #4743](library-reader-separate-texture-4743.md) · [list view series overflow #4796](list-view-series-overflow-4796.md)
|
||||||
- [Library/reader separate texture (#4743)](library-reader-separate-texture-4743.md) — `libraryBackground*`
|
- [Recently-read shelf (#3797)](recent-read-shelf-3797.md)
|
||||||
- [List view series overflow (#4796)](list-view-series-overflow-4796.md) — fixed `h-28` clipped series+description
|
- [Auto-import watched folders (#3889)](auto-import-watched-folders-3889.md) per-folder opt-in
|
||||||
- [Recently-read shelf (#3797)](recent-read-shelf-3797.md) — PR #4829; reuse `BookItem` + shared `useOpenBook`; flex
|
|
||||||
## Architecture & Patterns
|
## Architecture & Patterns
|
||||||
- foliate-js submodule at `packages/foliate-js/`; multiview paginator preloads adjacent sections
|
- foliate-js submodule at `packages/foliate-js/`; multiview paginator preloads adjacent sections
|
||||||
- [Markdown .md support (#774)](markdown-md-support-774.md) — in-memory foliate book (no EPUB) in `src/utils/md.ts`;
|
- [Turso "concurrent use forbidden"](turso-concurrent-use-forbidden.md) `op_lock` async mutex
|
||||||
- Style: `getLayoutStyles()` always, `getColorStyles()` when overriding color; `transformStylesheet()` rewrites EPUB CSS at load
|
- Markdown: [.md support #774](markdown-md-support-774.md); [resume position #4862](markdown-resume-position-4862.md)
|
||||||
- TTS independent section tracking (`#ttsSectionIndex`); safe insets: native plugin → useSafeAreaInsets → styles; Dropdowns `DropdownContext`
|
- Style: `getLayoutStyles()` always, `getColorStyles()` when overriding; `transformStylesheet()` rewrites EPUB CSS
|
||||||
- Stale settings closure: store-hook `settings` stale across `await`; persist `useSettingsStore.getState().settings` ([#4780](webdav-connect-nullified-4780.md)
|
- TTS `#ttsSectionIndex`; insets: native plugin → useSafeAreaInsets → styles; Dropdowns `DropdownContext`
|
||||||
- [Foliate touch-listener capture phase](foliate-touch-listener-capture-phase.md) — suppress gestures via
|
- Stale settings closure: persist `useSettingsStore.getState().settings` ([#4780](webdav-connect-nullified-4780.md))
|
||||||
- [iframe cross-realm instanceof](iframe-cross-realm-instanceof.md) — `instanceof Element` false; duck-type `'closest'
|
- [Page margins not live #4898](page-margin-live-update-4898.md) in-place mutation froze memo
|
||||||
- [Virtuoso + OverlayScrollbars](virtuoso_overlayscrollbars.md) — useOverlayScrollbars for mobile webviews
|
- [Foliate touch-listener capture phase](foliate-touch-listener-capture-phase.md)
|
||||||
- [Design system → DESIGN.md](feedback_design_system_doc.md) — never `pl/pr/ml/mr/text-left/right` (RTL)
|
- [iframe cross-realm instanceof](iframe-cross-realm-instanceof.md) duck-type `'closest'`
|
||||||
|
- [Virtuoso + OverlayScrollbars](virtuoso_overlayscrollbars.md)
|
||||||
|
- [Design system → DESIGN.md](feedback_design_system_doc.md) never `pl/pr/ml/mr` (RTL)
|
||||||
## Workflow & Feedback
|
## Workflow & Feedback
|
||||||
- [Commit messages English-only](feedback-commit-message-english-only.md) — English only (no CJK, no em/en dashes)
|
- [Commit messages English-only](feedback-commit-message-english-only.md) no CJK, no em/en dashes
|
||||||
- [Test file filter](feedback_test_file_filter.md) — `pnpm test <path>` without `--` runs one file
|
- PR flow: [rebase onto origin/main](feedback_pr_rebase.md); [fresh branch per PR](feedback_pr_new_branch.md); [always `pnpm worktree:new`](feedback_use_worktree.md); [commit locally, don't push until confirmed](feedback_dont_push_every_change.md)
|
||||||
- [Rebase before PR](feedback_pr_rebase.md) — rebase onto origin/main before PRs
|
- [Test file filter](feedback_test_file_filter.md) `pnpm test <path>` no `--`
|
||||||
- [New branch per PR](feedback_pr_new_branch.md) — fresh branch from main per PR/issue
|
- [No test seams in prod](feedback_no_test_seams_in_prod.md) · [no lookbehind regex](feedback_no_lookbehind_regex.md)
|
||||||
- [Use worktree](feedback_use_worktree.md) — never `git worktree add`; always `pnpm worktree:new`
|
- i18n: [en plurals manual](feedback_en_plurals_manual.md); [i18n:extract prunes keys](i18n-extract-prunes-keys.md)
|
||||||
- [Never push every change](feedback_dont_push_every_change.md) — commit locally until user confirms
|
- [Dependabot transitive fixes](dependabot-pnpm-overrides.md) `pnpm-workspace.yaml` `overrides:`
|
||||||
- [No test seams in prod](feedback_no_test_seams_in_prod.md) — prod never imports `__reset*ForTests`
|
- [Upgrade gstack locally](feedback_gstack_upgrade.md)
|
||||||
- [No lookbehind regex](feedback_no_lookbehind_regex.md) — never `(?<=)`/`(?<!)`; build check rejects
|
|
||||||
- [en plurals manual](feedback_en_plurals_manual.md) — only plural variants + proper nouns; `_one`/`_other`
|
|
||||||
- [i18n:extract prunes keys](i18n-extract-prunes-keys.md) — `removeUnusedKeys` deletes valid keys; revert churn, add
|
|
||||||
- [Dependabot transitive fixes](dependabot-pnpm-overrides.md) — pin in `pnpm-workspace.yaml` `overrides:`; alert#≠issue#
|
|
||||||
- [Upgrade gstack locally](feedback_gstack_upgrade.md) — upgrade from project `.claude/skills/gstack`
|
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
---
|
||||||
|
name: android-e2e-doubletap-cdp-gesture
|
||||||
|
description: Nightly Android E2E double-tap test failed since
|
||||||
|
metadata:
|
||||||
|
node_type: memory
|
||||||
|
type: project
|
||||||
|
originSessionId: eafe11ed-faac-4406-957d-1674353f081c
|
||||||
|
---
|
||||||
|
|
||||||
|
Nightly `Android E2E (CDP)` failed every night from 2026-06-28 (first night after
|
||||||
|
PR #4846 merged) with `timed out waiting for selection of "..." (last: null)` in
|
||||||
|
`double-click.android.test.ts`. The test never passed on CI. Fixed 2026-07-04 —
|
||||||
|
harness-only, no app code changed.
|
||||||
|
|
||||||
|
**Two stacked root causes:**
|
||||||
|
|
||||||
|
1. **PRIMARY — feature is opt-in on mobile.** `DEFAULT_MOBILE_VIEW_SETTINGS`
|
||||||
|
ships `disableDoubleClick: true` (double-click detection delays single-tap
|
||||||
|
page turns by the 250ms disambiguation window, so mobile opts out).
|
||||||
|
`handleClick` (iframeEventHandlers.ts) then posts `iframe-single-click`
|
||||||
|
IMMEDIATELY and never arms the double-click window — the #4846 double-tap
|
||||||
|
word selection is deliberately "gated by the user's double-click setting"
|
||||||
|
(Annotator comment). The e2e assumed default config → could never pass on a
|
||||||
|
fresh device. Diagnostic signature: `iframe-single-click` ~20ms after click
|
||||||
|
(window disabled) vs ~250ms after (window armed, no second click).
|
||||||
|
2. **SECONDARY — adb double-tap can't hit the window.** The old helper ran
|
||||||
|
`input tap x y && input tap x y`; each `input` invocation spawns a fresh
|
||||||
|
app_process JVM (measured 0.9–1.05s each, 28s cold). Warm fast host ≈130ms
|
||||||
|
click gap (passes), loaded CI emulator >250ms (always fails).
|
||||||
|
|
||||||
|
**Fix (all in `src/__tests__/android/`):**
|
||||||
|
- `reader.ts patchGlobalViewSettings(patch)` — force-stop app, read/patch
|
||||||
|
`settings.json` `globalViewSettings` via `adb shell run-as` (debug builds
|
||||||
|
only; file lives at the app data dir ROOT, not files/), write back via
|
||||||
|
base64 pipe, return previous values for restore in afterAll. Missing
|
||||||
|
settings.json is fine: `loadSettings` deep-merges partial file over defaults.
|
||||||
|
- `cdp.ts CdpPage.doubleTap(cssX, cssY)` — ONE
|
||||||
|
`Input.synthesizeTapGesture {tapCount: 2, duration: 20, gestureSourceType: 'touch'}`;
|
||||||
|
renderer-internal timing gives ~200ms click gap on a busy emulator. TWO
|
||||||
|
sequential synthesizeTapGesture commands are too slow (~535ms gap — each
|
||||||
|
resolves long after its gesture). Raw `Input.dispatchTouchEvent` delivers
|
||||||
|
touch events but does NOT reliably synthesize clicks on Android WebView.
|
||||||
|
- Word finder requires `range.getClientRects().length === 1`: a
|
||||||
|
hyphenated/wrapped word's bounding rect spans two lines, so its center taps
|
||||||
|
between lines and selects the neighboring word (saw `'party' !== 'sensation'`).
|
||||||
|
- `dismissSelection` picks a mid-column tap spot (0.78H or 0.25H) that the
|
||||||
|
`.selection-popup` doesn't cover — a blind 0.78H tap can press a toolbar
|
||||||
|
button when the selection sits low.
|
||||||
|
|
||||||
|
**Other gotchas:** headless emulator display sleeps → adb `input` no-ops while
|
||||||
|
CDP input still works (`input keyevent KEYCODE_WAKEUP`); a leaked single-click
|
||||||
|
(broken double-tap) toggles header or opens the media viewer, contaminating the
|
||||||
|
session; local repro = `pnpm tauri android build --debug --target aarch64` +
|
||||||
|
`adb install -r` + `pnpm test:android`.
|
||||||
|
|
||||||
|
Related: [[android-cdp-e2e-lane]], [[iframe-double-click-word-select]]
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
---
|
||||||
|
name: calibre-custom-columns-4811
|
||||||
|
description: "Surface Calibre custom columns from OPF user metadata (#4811) - parse formats, calibreColumns field, details UI, library search"
|
||||||
|
metadata:
|
||||||
|
node_type: memory
|
||||||
|
type: project
|
||||||
|
originSessionId: 7f74fc26-9614-4fe3-987b-66c8ce412523
|
||||||
|
---
|
||||||
|
|
||||||
|
Feature #4811 SHIPPED (app PR #4939 merged 2026-07-05 as `ec45a08`; foliate-js#47 merged as `8485e93`): surface Calibre custom columns embedded in EPUB OPFs. Worktree and branches cleaned up.
|
||||||
|
|
||||||
|
**Calibre OPF encodings (verified against calibre source opf2.py/opf3.py):**
|
||||||
|
- OPF2: one `<meta name="calibre:user_metadata:#label" content="{json}"/>` per column; label must start with `#`
|
||||||
|
- OPF3: a single `<meta property="calibre:user_metadata">{"#label": {...}}</meta>` (raw property attr always literally `calibre:user_metadata`; the `calibre:` prefix maps to `https://calibre-ebook.com` but foliate's URL-resolution concatenates without `:` so match the RAW attr, not the resolved one). Calibre prefers OPF3 over OPF2 when both present (`read_user_metadata3 || read_user_metadata2`).
|
||||||
|
- Value in `#value#` (array for multi-value), series index in `#extra#`; datetimes wrapped `{"__class__": "datetime.datetime", "__value__": "<ISO>"}`, unset date = `0101-01-01`; embedded files carry EVERY library column so empty values (null/''/[]/rating 0/undefined-date) must be dropped at parse time.
|
||||||
|
|
||||||
|
**Where things live:**
|
||||||
|
- Parser: `getCalibreUserMetadata` in foliate-js `epub.js`, attached AFTER `tidy()` (tidy would collapse single-element value arrays) as `metadata.calibreColumns` `[{label, name, datatype, value, extra?}]`
|
||||||
|
- Type: `CalibreCustomColumn` in `src/libs/document.ts`; `BookMetadata.calibreColumns`
|
||||||
|
- Formatter: `formatCalibreColumnValue` in `src/utils/book.ts` (rating → ★ half-stars /2, series → `Name [idx]`, bool → ✓/✗, comments → strip tags, datetime → formatDate)
|
||||||
|
- UI: extra grid cells in `BookDetailView.tsx` Metadata section after Identifier (column names are user content, NOT i18n keys)
|
||||||
|
- Search: `getCalibreColumnsText` in `src/app/library/utils/libraryUtils.ts` `createBookFilter` (both regex and substring branches)
|
||||||
|
|
||||||
|
**Why safe:** metaHash dedupe uses only title/authors/identifiers; metadata editor spreads `{...metadata}` so the field survives edits; import assigns `loadedBook.metadata` as-is. Calibre plugin pushes already embed user metadata via calibre `set_metadata`, so plugin-pushed books get columns through the same OPF parse (the plugin's flat `customColumns` wire field is a DIFFERENT shape and stays unused). E2E-verified on the real sample (Elena Sabe, OPF3, 11 columns → 7 shown, search "CT1" filters). Related: [[calibre-plugin-push-4863]].
|
||||||
@@ -7,7 +7,7 @@ metadata:
|
|||||||
originSessionId: 5d4d83a0-0aee-4200-852f-555df5243bed
|
originSessionId: 5d4d83a0-0aee-4200-852f-555df5243bed
|
||||||
---
|
---
|
||||||
|
|
||||||
`apps/readest-calibre-plugin/` (added 2026-07-04, commit 29a42853d on dev) implements #4863: calibre GUI plugin pushing selected books + metadata into the Readest cloud, modeled on BookFusion's plugin.
|
`apps/readest-calibre-plugin/` implements #4863: calibre GUI plugin pushing selected books + metadata into the Readest cloud, modeled on BookFusion's plugin. MERGED to main via PR #4918 (2026-07-04, merge 6b403d019); packaged in releases as `Readest-<version>.calibre-plugin.zip` by release.yml's `build-calibre-plugin` job.
|
||||||
|
|
||||||
Design decisions and hard-won protocol facts:
|
Design decisions and hard-won protocol facts:
|
||||||
- **Identity**: `Book.hash` = partial MD5 (KOReader algorithm; JS `1024 << -2` wraps to 0, so offsets are 0, 1024, 4096, ... 1024<<20). metaHash = `md5(NFC("title|authors,|ids,"))`, preferred id scheme uuid > calibre > isbn; Python impl verified byte-identical to `js-md5` output.
|
- **Identity**: `Book.hash` = partial MD5 (KOReader algorithm; JS `1024 << -2` wraps to 0, so offsets are 0, 1024, 4096, ... 1024<<20). metaHash = `md5(NFC("title|authors,|ids,"))`, preferred id scheme uuid > calibre > isbn; Python impl verified byte-identical to `js-md5` output.
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
---
|
||||||
|
name: dict-popup-tts-speak-4876
|
||||||
|
description: "Dictionary popup speaker button pronounces the headword via Edge TTS (#4876), with a standalone pronouncer that bypasses TTSController"
|
||||||
|
metadata:
|
||||||
|
node_type: memory
|
||||||
|
type: project
|
||||||
|
originSessionId: 98d0ef1c-84c2-4a16-85a0-0abad0010923
|
||||||
|
---
|
||||||
|
|
||||||
|
Issue #4876: add a "speak" button to the dictionary popup so a looked-up word
|
||||||
|
can be pronounced. Implemented on branch `feat/dict-popup-tts` (commit
|
||||||
|
f2acafb4b, 2026-07-06). Button-only (no auto-speak); speaker icon sits inline
|
||||||
|
left of the headword in the shared `DictionaryResultsHeader`, so it covers both
|
||||||
|
the desktop `DictionaryPopup` and mobile `DictionarySheet`.
|
||||||
|
|
||||||
|
Key file: `src/services/tts/wordPronouncer.ts` — a standalone single-word
|
||||||
|
pronouncer, deliberately independent of the reader's `TTSController`:
|
||||||
|
- **Speak ASAP**: never calls `EdgeTTSClient.init()` (which wastes a round trip
|
||||||
|
synthesizing "test"). Calls `EdgeSpeechTTS.createAudioData()` directly; its
|
||||||
|
static LRU MP3 cache makes repeat words instant.
|
||||||
|
- **Dedicated Web Audio context** (`new WebAudioPlayer(() => new AudioContext())`,
|
||||||
|
NOT the module-shared context the reader uses) so pronouncing a word can never
|
||||||
|
resume/suspend or overlap an active read-aloud session. One extra AudioContext,
|
||||||
|
fine under WebKit's ~4 cap.
|
||||||
|
- **Gesture warmup**: `warmWordAudio()` must be called synchronously in the click
|
||||||
|
handler (the hook's `speakWord` does this) because `pronounceWord` resumes the
|
||||||
|
context only after a network await, outside WebKit's autoplay gesture window.
|
||||||
|
- **Engine order**: Edge wss -> Edge https proxy (`fetchWithAuth`, throws "Not
|
||||||
|
authenticated" when logged out) -> platform fallback. Fallback reuses the
|
||||||
|
existing `WebSpeechClient` (desktop/web) / `NativeTTSClient` (mobile app)
|
||||||
|
standalone via `genSSMLRaw(word)` + `setPrimaryLang(lang)`; the SSML default
|
||||||
|
`xml:lang="en"` is overridden by `parseSSMLMarks(ssml, primaryLang)`.
|
||||||
|
- `requestToken` guards staleness so a superseded in-flight synth bails.
|
||||||
|
|
||||||
|
Hook: `useDictionaryResults` gained `isSpeaking` + `speakWord`; cancels on word
|
||||||
|
change / unmount. Voice pick = `TTSUtils.getPreferredVoice('edge-tts', lang)`
|
||||||
|
then first `isSameLang` match then `en-US-AriaNeural`.
|
||||||
|
|
||||||
|
Tests: `src/__tests__/services/tts/wordPronouncer.test.ts` (Edge-first / fallback
|
||||||
|
contract; jsdom has no AudioContext so `getPlayer()` returns null unless
|
||||||
|
`globalThis.AudioContext` is stubbed + `WebAudioPlayer` mocked). Speak-button
|
||||||
|
wiring test added to `DictionarySheet.test.tsx` (mocks the pronouncer module).
|
||||||
|
|
||||||
|
NOT verified live: real audio playback + iOS gesture warmup (not unit-testable).
|
||||||
|
Related: [[edge-tts-webaudio-engine]] (the WebAudio refactor that replaced the old
|
||||||
|
blob-URL `createAudio` with `createAudioData`), [[ios-instant-dict-double-popup]].
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
---
|
||||||
|
name: edge-tts-webaudio-engine
|
||||||
|
description: "Edge TTS Web Audio refactor (#3851/#2033) — gapless engine, WSOLA rate, section timeline + scrubber; branch feat/edge-tts-webaudio; release gates and design invariants"
|
||||||
|
metadata:
|
||||||
|
node_type: memory
|
||||||
|
type: project
|
||||||
|
originSessionId: 97e57af9-5961-4c92-a63e-4582178bf798
|
||||||
|
---
|
||||||
|
|
||||||
|
Branch `feat/edge-tts-webaudio` (worktree `/Users/chrox/dev/readest-feat-edge-tts-webaudio`, built 2026-07-04, NOT pushed) replaces Edge TTS per-sentence `<audio>` playback with a Web Audio pipeline: fetch MP3 at rate 1.0 (unchanged LRU + new in-flight dedup in `edgeTTS.ts`) → decode → `pcm.ts` silence trim → `timeStretch.ts` in-house WSOLA (pitch-preserved client rate, cache never refetches on rate change) → `WebAudioPlayer.ts` gapless scheduling. `SectionTimeline.ts` (measured > per-voice cps EMA in localStorage `readest-tts-voice-cps` > script defaults) powers a TTSPanel scrubber + media-session position/seekto. foliate-js fork branch `feat/tts-get-sentences` adds `getSentences` — fork PR must merge BEFORE the readest PR (submodule pin).
|
||||||
|
|
||||||
|
**Why:** #3851 first-word clipping cause is a HYPOTHESIS (Android reporter reproduced with BT off); treat as falsifiable experiment. #2033 gaps = element restarts + ~300ms Edge trailing silence.
|
||||||
|
|
||||||
|
**Load-bearing invariants (don't regress):**
|
||||||
|
- AudioContext is a module-level singleton, never closed — a fresh TTSController per tts-speak calls `stop()` not `shutdown()`, and WebKit caps ~4 live contexts (leak = permanent silence).
|
||||||
|
- Marks dispatch at AUDIBLE time (player chunk-start via onended, background-safe), never at fetch — else foliate's `#lastMark` runs ahead and prev/next/resume break.
|
||||||
|
- `endSession` fires session-end synchronously when nothing is unfinished — zero-chunk sessions (Edge outage) must not wedge controls in "playing".
|
||||||
|
- `ensureSharedAudioContext()` is called in the tts-speak gesture path BEFORE any await (WebKit autoplay window); `unblockAudio` silent element runs on ALL platforms (desktop Chromium media keys need a playing HTMLMediaElement).
|
||||||
|
- `abortSession` never suspends the context (warm output stream IS the #3851 fix); only user pause suspends.
|
||||||
|
- Word boundaries stay in original untrimmed media time; `getChunkPosition()` returns trim-relative clamped seconds; timeline sums TRIMMED durations.
|
||||||
|
- `POPUP_HEIGHT` in TTSControl.tsx is fixed and non-scrolling — grows to 200 only when a timeline-capable client is active.
|
||||||
|
|
||||||
|
**Follow-up decided (2026-07-04, not yet planned): background TTS decoupling.** App-level TTSSessionManager owns the controller; reader hook becomes attach/detach. Matrix chrox chose: close book = keep playing (headless via `section.createDocument()`); reopen SAME book = seamless reattach (adopt session + `redispatchPosition()` + CFI re-anchoring — the highlighter already re-anchors ranges through CFIs, so cross-doc ranges are safe; swap text supply to rendered doc lazily at next section boundary); open a DIFFERENT book = TTS STOPS (not "keeps playing while browsing"); explicit stop / sleep timer = stops. Fiddly bit: `getCFI` without a rendered view. Recorded in branch TODOS.md.
|
||||||
|
|
||||||
|
**How to apply:** Release gates before closing the issues (in plan Verification): WSOLA A/B listening test 0.2x-3x EN+CJK, Linux WebKitGTK decode (GStreamer), reporter-hardware beta (Soundcore Q20i iOS / Galaxy S22U screen-off), iOS lock-screen + interruption QA, e-ink `[data-eink] .range` fill check (NO eink range rule exists in globals.css), RTL slider direction. Plan + 35-decision audit trail: worktree `.agents/plans/2026-07-03-edge-tts-webaudio.md` (gitignored, local). i18n keys added ('This chapter', 'Chapter progress', 'Failed to seek', '{{elapsed}} of {{total}}') need the /i18n pass. Deferred follow-ups in TODOS.md incl. provider-agnostic local-TTS hedge ([[grimmory-native-sync]] unrelated).
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
---
|
||||||
|
name: ios-share-txt-stuck-supportstext
|
||||||
|
description: iOS sharing a .txt to Readest hung the share sheet; Share Extension NSExtensionActivationSupportsText captured plain-text files
|
||||||
|
metadata:
|
||||||
|
node_type: memory
|
||||||
|
type: project
|
||||||
|
originSessionId: 445ce295-90f6-4ed2-8227-e25b1e0a876d
|
||||||
|
---
|
||||||
|
|
||||||
|
Sharing a `.txt` file to Readest via the iOS share sheet got **stuck**, while EPUB/PDF worked. Root cause: the **Share Extension** (article-URL clipper, added #4256/#4267) wrongly activated for `.txt`. FIXED, **PR #4917 merged** (`fix/ios-share-txt-stuck`).
|
||||||
|
|
||||||
|
- `ShareViewController.swift` only ever extracts an `http(s)` URL. Its activation rule (`project.yml`) had `NSExtensionActivationSupportsWebURLWithMaxCount: 1` **and `NSExtensionActivationSupportsText: true`**.
|
||||||
|
- A `.txt` is UTI `public.plain-text`, which **conforms to `public.text`** → satisfies `SupportsText` → the URL-only clipper activates for a file it can't handle → sheet hangs (for a file-backed provider `loadItem(public.plain-text)` returns a file `URL`, so `loadText`'s `as? String`/`as? Data` both fail → no URL → neither completes nor cleanly cancels).
|
||||||
|
- EPUB (`org.idpf.epub-container`) / PDF (`com.adobe.pdf`) conform to neither text nor web-URL, so they never match the extension and take the **main app** `CFBundleDocumentTypes` "Copy to Readest" open-in-place path (`Readest_iOS/Info.plist`), which imports via `useOpenWithBooks.ts` → `importBook` (format-agnostic; txt→epub via `TxtToEpubConverter`). `.txt` is ALSO declared there, so it imports fine once the extension stops stealing it.
|
||||||
|
|
||||||
|
**Fix (option A):** remove `NSExtensionActivationSupportsText: true`; keep web-URL only. Safari/Chrome "share page" still sends `public.url`, so article clipping is preserved. Only regression: sharing a raw text *selection* containing a link no longer triggers the extension (minor).
|
||||||
|
|
||||||
|
**Source-of-truth gotcha:** `src-tauri/gen/apple/project.yml` is the **xcodegen** source; Tauri's iOS CLI runs `xcodegen` at build time (`tauri-cli/src/mobile/ios/project.rs`) and REGENERATES each target's `Info.plist` from it. The committed `ShareExtension/Info.plist` is a generated artifact marked **`skip-worktree`** (`git ls-files -v` → `S`) — local edits to it are invisible to git and it stays stale at HEAD. So: fix `project.yml` ONLY; a test asserting on the committed plist would pass locally but FAIL on a fresh CI checkout. Regression test lives at `src/__tests__/ios/share-extension-activation-rule.test.ts` (asserts on `project.yml`, strips `#` comments first since the warning comment names the key). Test precedent: `src/__tests__/android/*declarations*.test.ts` read native config via `resolve(process.cwd(), 'src-tauri/...')`.
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
---
|
||||||
|
name: ios-widget-cover-bright-edge-line
|
||||||
|
description: iOS reading widget cover sometimes had a bright hairline at the right edge from fractional resize; round target to whole pixels
|
||||||
|
metadata:
|
||||||
|
node_type: memory
|
||||||
|
type: project
|
||||||
|
originSessionId: fc6acdd3-a3d1-4823-a5c4-7fe75686fc93
|
||||||
|
---
|
||||||
|
|
||||||
|
iOS reading-widget book covers sometimes showed a **bright hairline along the right edge** (Android widget never did). Fixed in PR #4950, `src-tauri/plugins/tauri-plugin-native-bridge/ios/Sources/ReadingWidgetWriter.swift` `writeThumbnail`.
|
||||||
|
|
||||||
|
**Root cause:** the downsample target was fractional:
|
||||||
|
`CGSize(width: image.size.width * scale, height: image.size.height * scale)` with `scale = 240 / longEdge`. For portrait covers the height (longEdge) lands on a whole pixel but the width is fractional. `UIGraphicsImageRenderer` allocates a **whole-pixel** buffer (rounds the size up), while `image.draw(in:)` fills only the exact fractional rect — so when the fractional width rounds *up*, the rightmost pixel column is only partially covered → **semi-transparent** (e.g. alpha 225 instead of 255). `jpegData` has no alpha, so that column flattens to a visible bright line. Intermittent ("sometimes") because it only bites when the fractional part rounds up; portrait-specific because width is the fractional edge.
|
||||||
|
|
||||||
|
**Fix:** round both dimensions to whole pixels so draw-rect == pixel-buffer and every edge pixel is fully covered:
|
||||||
|
`CGSize(width: (image.size.width * scale).rounded(), height: (image.size.height * scale).rounded())`.
|
||||||
|
|
||||||
|
Verified with a faithful CoreGraphics repro (same rasterization as UIKit): `453x680` cover gave edge alpha `225` before, `255` after; all sizes `255` after. Android ([[mobile-reading-widgets]] `ReadingWidgetStore.kt`) is immune because it scales to a fixed integer 240x360 and center-crops.
|
||||||
|
|
||||||
|
No checked-in Swift test: the plugin `Package.swift` has no wired test target and the code is UIKit-only.
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
---
|
||||||
|
name: koplugin-library-stale-synced-cursor-4934
|
||||||
|
description: "#4934 koplugin Library goes stale forever: pull cursor keyed on client updated_at not server synced_at; split pull/push cursors + v2->v3 heal migration"
|
||||||
|
metadata:
|
||||||
|
node_type: memory
|
||||||
|
type: project
|
||||||
|
---
|
||||||
|
|
||||||
|
**Issue #4934, PR #4944 MERGED** (merge commit `0b180da6a`, koplugin Lua only, base `readest/readest:main`). Reporter: iOS + KOReader; the koplugin "Readest library" stopped receiving iOS updates and never recovered. Workaround was delete `koreader/settings/readest_library.sqlite3` + "Pull books now" (works for a while, re-breaks). **iOS/web library unaffected** — the smoking gun.
|
||||||
|
|
||||||
|
**Root cause (the direct follow-up [[sync-synced-at-cursor-4678]] predicted).** Since #4678 the server keys the books GET on the server-stamped `synced_at` (`src/pages/api/sync.ts` `cursorColumn = table==='books' ? 'synced_at' : 'updated_at'`, `.gt('synced_at', since)`). Web/iOS advance their cursor from `synced_at` (`useSync.ts computeMaxTimestamp`, prefers synced_at) → always ≤ server-now → never stale. The **koplugin was left on `updated_at`**: `syncbooks.lua pullBooks` set `last_books_pulled_at = max(updated_at, deleted_at)` of returned rows; `parseSyncRow` never read `synced_at`. `updated_at` is CLIENT event time, and the koplugin stamps it from the **device clock** (`librarystore.lua touchBook` = `os.time()*1000`). An e-reader clock ahead of the server (common; dead RTC / wrong date) — or ANY single row account-wide carrying a future `updated_at` — drove the koplugin's global cursor past server-now, so `synced_at > since` returned nothing **forever**. Delete-sqlite reset the cursor to 0 (workaround); it re-broke once a book-open re-bumped it into the future.
|
||||||
|
|
||||||
|
**Extra hazard #4678 flagged:** `last_books_pulled_at` was SHARED between the pull cursor (vs server synced_at) and push-delta detection (`getChangedBooks` vs LOCAL updated_at) — can't just retarget it to synced_at. So the fix requires a cursor SPLIT.
|
||||||
|
|
||||||
|
**Fix (all in `apps/readest.koplugin/library/`):**
|
||||||
|
1. `librarystore.lua parseSyncRow`: add transient `synced_at = iso_to_ms(dbRow.synced_at)` (NOT a books column; server sends it via `select('*')`). New `getLastPushedAt`/`setLastPushedAt` on key `last_books_pushed_at`.
|
||||||
|
2. `syncbooks.lua`: new pure `row_pull_cursor(parsed)` = `parsed.synced_at` if present else `max(updated_at, deleted_at)` (mirrors computeMaxTimestamp; exported `M._row_pull_cursor` for tests). `pullBooks` seeds `pull_ts`/`push_ts` from their stored values (no regression on empty pages), advances `last_books_pulled_at` from `row_pull_cursor` (synced_at) and `last_books_pushed_at` from `max(updated_at, deleted_at)` of pulled rows. `pushChangedBooks` reads/writes `getLastPushedAt`/`setLastPushedAt` instead of the pull cursor.
|
||||||
|
3. **Cursor split:** pull cursor = server `synced_at` (pull only); push watermark = local `updated_at`, advanced on BOTH pull and push (preserves the old dedup so pulled books aren't re-pushed — the old shared cursor did exactly this).
|
||||||
|
4. **Heal migration `SCHEMA_VERSION 2->3`** (`M.new`, guard `prev>=1 and prev<3`): `INSERT last_books_pushed_at SELECT value FROM ... WHERE key='last_books_pulled_at'` then `UPDATE ... SET value='0' WHERE key='last_books_pulled_at'`. Seeds push watermark from the old shared value (no re-push storm) and zeroes the pull cursor → next sync does ONE full re-pull that re-establishes it on synced_at. **Auto-heals already-stale installs; user need not delete the sqlite.**
|
||||||
|
|
||||||
|
**Scope note (intentional):** the push watermark, seeded from a poisoned future value, still suppresses the koplugin's OWN local pushes until wall-clock passes it — but that's UNCHANGED from before (old shared cursor did the same) and #4934 is a pull/viewing bug ("iOS unaffected"). Not fixing device-clock `updated_at` here. Only functional cursor callers are in syncbooks; `librarywidget.lua:557` only logs it.
|
||||||
|
|
||||||
|
**Tests (TDD, gates `pnpm test:lua` 224✓ / `pnpm lint:lua` exit 0):** `librarystore_spec.lua` — parseSyncRow synced_at, getLast/setLastPushedAt independent round-trip, `v2->v3 migration` (reset pull=0 + seed push, per-user), bumped user_version 2→3 (and the v1->v2 test now lands at 3, migrations cumulative). `syncbooks_spec.lua` — `_row_pull_cursor` (synced_at wins over a future updated_at; fallback; 0), and `pullBooks` integration via injected fake `sync_auth`/client + real in-memory store asserting pull cursor=synced_at (not future updated_at) and push watermark=updated_at distinctly.
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
---
|
||||||
|
name: middle-click-autoscroll-4951
|
||||||
|
description: "Middle-click autoscroll in scrolled mode (#4951): Autoscroller RAF core + armed-books registry in iframeEventHandlers; scrolls via renderer.containerPosition"
|
||||||
|
metadata:
|
||||||
|
node_type: memory
|
||||||
|
type: project
|
||||||
|
originSessionId: d8d21fc2-b63b-4f65-8ca8-f65d9e6b17b2
|
||||||
|
---
|
||||||
|
|
||||||
|
Middle mouse button autoscroll for desktop Tauri apps in scrolled mode
|
||||||
|
(readest#4951), PR #4955 MERGED 2026-07-06 (merge 6f3b401c2). No settings
|
||||||
|
toggle: always on for desktop in scrolled mode (maintainer removed the toggle
|
||||||
|
as unnecessary; middle click has no other use in the reader). The same PR also
|
||||||
|
shipped a locale sync: 69 keys untranslated on main filled across 33 locales
|
||||||
|
(agents per language family; scanner-prunable keys preserved).
|
||||||
|
|
||||||
|
Key structure:
|
||||||
|
- `src/app/reader/utils/autoscroller.ts` — pure `Autoscroller` class (RAF loop,
|
||||||
|
12px dead zone, 10 px/s per px linear velocity capped 4000, whole-pixel
|
||||||
|
emission with fractional carry, held→sticky/drag state machine). Tested in
|
||||||
|
`src/__tests__/reader/utils/autoscroller.test.ts` with injected raf/now.
|
||||||
|
- `useMiddleClickAutoscroll(bookKey, viewRef, containerRef)` hook consumes
|
||||||
|
`iframe-mousedown/mouseup/mousemove/wheel/keydown` messages + window-level
|
||||||
|
capture listeners; returns anchor (container-relative) for
|
||||||
|
`AutoscrollIndicator`. Scrolls with `renderer.containerPosition += delta`
|
||||||
|
(public setter; native scroll path, so section preloading works). Axis from
|
||||||
|
`renderer.scrollProp` ('scrollLeft' = vertical writing → x axis; increasing
|
||||||
|
scrollLeft always moves viewport right even in RTL, no special-casing).
|
||||||
|
- iframeEventHandlers runs in the parent realm: `setAutoscrollArmed(bookKey)`
|
||||||
|
registry lets `handleMousedown` preventDefault middle button (suppresses
|
||||||
|
WebView2's native autoscroll on Windows, avoids double-drive) and
|
||||||
|
`handleAuxclick` swallow link opens; `setAutoscrollTracking(bool)` gates an
|
||||||
|
`iframe-mousemove` postMessage forwarder so it costs nothing when idle.
|
||||||
|
- Pointer deltas use screenX/Y (same trick as useTouchEvent pinch) so iframe
|
||||||
|
coordinate spaces/transforms don't matter. Anchor window position computed in
|
||||||
|
the iframe handler via `event.view.frameElement.getBoundingClientRect()` +
|
||||||
|
client-size scale, posted as windowX/windowY on button-1 mousedown only.
|
||||||
|
- A left click that ends a sticky session must not also turn the page: the
|
||||||
|
hook consumes the later `iframe-single-click` via
|
||||||
|
`eventDispatcher.onSync` within a 500ms window (usePagination checks
|
||||||
|
dispatchSync consumption before paginating).
|
||||||
|
- Setting `middleClickAutoscroll` in `BookLayout` (default TRUE, user chose
|
||||||
|
default-on), toggle in ControlPanel Scroll BoxedList, desktop only
|
||||||
|
(`appService?.isDesktopApp`), disabled unless scrolled mode. Web excluded on
|
||||||
|
purpose (browsers own middle click).
|
||||||
|
|
||||||
|
Related: [[i18n-extract-prunes-keys]] (followed its manual single-key insertion
|
||||||
|
recipe for the 'Middle-Click Autoscroll' label across 33 locales).
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
---
|
||||||
|
name: opds-autodownload-subdir-crawl-4272
|
||||||
|
description: OPDS auto-download
|
||||||
|
metadata:
|
||||||
|
node_type: memory
|
||||||
|
type: project
|
||||||
|
originSessionId: 92b00cca-93fe-4255-bb5f-1db8d3421a35
|
||||||
|
---
|
||||||
|
|
||||||
|
Issue #4272: OPDS auto-download on copyparty missed books in subdirectories (and skipped folders containing only subfolders). Copyparty (`?opds` on any directory) emits subfolders as `rel="subsection"` nav entries (`type="application/atom+xml;profile=opds-catalog"`), files as acquisition entries with `?dl` hrefs, no pagination, no "by newest" feed (template: `copyparty/web/opds.xml`).
|
||||||
|
|
||||||
|
**Fix (PR #4948, MERGED 2026-07-06):** in `src/services/opds/feedChecker.ts`, `checkFeedForNewItems` now branches: catalogs WITH a "by newest" feed keep the old behavior (newest feed + rel=next only, never crawl — whole-library subscription hazard); catalogs WITHOUT one are directory-style and get a breadth-first `crawlFeeds` over `getSubsectionURLs` (skips facet/self/up/start/top/search rels and non-catalog types), bounded by `MAX_CRAWL_DEPTH=5`, `MAX_FEEDS_PER_CRAWL=50` (incl. root fetch), and the `visited` set. rel=next pagination still capped at `MAX_PAGES_PER_FEED` per chain. Collected entryIds are added to the local knownIds copy so a book listed by two crawled feeds is collected once (NOT persisted — failed downloads must stay retryable). Tests: `src/__tests__/services/opds-feed-crawl.test.ts` (mocked `fetchWithAuth` serving URL→XML fixtures).
|
||||||
|
|
||||||
|
**Unresolved iOS half of #4272:** reporter saw "33 downloads failed" on iPhone while macOS downloaded all base-dir books fine. Same TS/Rust download path both platforms (`download_file` in `src-tauri/src/transfer_file.rs`); most plausible cause is iOS suspending the app mid-sync and killing in-flight reqwest connections (35 epubs at DOWNLOAD_CONCURRENCY=3 takes minutes). Retry/backoff (MAX_RETRY_ATTEMPTS=3) picks them up on later launches, but after 3 failures entries are moved to knownEntryIds and permanently skipped with no recovery UI — a repeatedly-interrupted catalog silently loses books. Possible future work: iOS beginBackgroundTask around the sync, or don't hard-cap retries for network-type errors.
|
||||||
|
|
||||||
|
See [[opds-groups-carousel-4750]] · [[download-file-scope-android-regression]].
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
---
|
||||||
|
name: page-turn-styles-viewtransitions-555
|
||||||
|
description: "#555 slide/curl page-turn styles via View Transitions — snapshot layering, shadow-DOM name scoping, margin clip, scrubbed drag tracking"
|
||||||
|
metadata:
|
||||||
|
node_type: memory
|
||||||
|
type: project
|
||||||
|
originSessionId: 91cbce94-0703-478d-9671-b12629fd8d9f
|
||||||
|
---
|
||||||
|
|
||||||
|
Issue #555 (Apple Books/Kindle turn animations): implemented `pageTurnStyle` view setting (Push/Slide/Curl). **MERGED 2026-07-05**: app PR readest#4940 → main `75f1fafe9` (11 commits, includes the Xcode-26.2 swift-rs build fix) + foliate fork readest/foliate-js#48. Mesh curl verified live on macOS/iOS/Android; Windows/Linux capture backends still open (CSS-curl fallback). Worktree removed post-merge (`pnpm worktree:rm feat/page-turn-styles-555` — takes BRANCH name not path; plain `git worktree remove` refuses trees with submodules); phase-2 plan preserved at main checkout `apps/readest-app/.claude/plans/page-curl-mesh-tauri-555.md`.
|
||||||
|
|
||||||
|
**Phase 2 (true mesh curl on Tauri, 2026-07-04):** plan at worktree `apps/readest-app/.claude/plans/page-curl-mesh-tauri-555.md` (plans dir is gitignored). Done + committed: WebGL curl renderer `src/utils/pageCurl.ts` (000814a51) and native-bridge `capture_webview_region` for macOS/iOS (85e592fd1). Renderer gotchas: WebGL canvas needs `preserveDrawingBuffer:true` or readPixels silently returns zeros after any await (browser composited); the clip-space Y flip mirrors triangle winding so `frontFace(CW)` or gl_FrontFacing (front vs whitened back) inverts; corner-grab fold tilt must decay `(1-p)`; travel = `w + PI*r_end`; **WebKit ignores `UNPACK_FLIP_Y_WEBGL` for ImageBitmap uploads** — curl was upside down on iOS (back read as 180°-rotated instead of Apple-Books horizontal mirror); fix = upload unflipped + `vUv = aPos` (f9a49fd4c); test textures MUST be vertically asymmetric to catch flips, and running the vitest browser suite on Playwright WebKit (`instances: [{ browser: 'webkit' }]` temporarily in vitest.browser.config.mts) reproduces iOS WebGL behavior. Capture: binary `ipc::Response` PNG; macOS legacy objc msg_send WKWebView takeSnapshot in plugin `src/platform/macos.rs`, iOS Swift + base64 across JSON plugin boundary. Orchestration committed: `CapturedPageTurn` (`src/app/reader/utils/capturedTurn.ts`, renamed from MeshCurlTurn/meshCurl.ts in 5ba62ef07, host-callback DI, browser-tested) runs capture→overlay→instant-nav→animate with a per-turn style ('curl' WebGL mesh | 'slide' flat canvas); turns the FULL gridcell (header/footer/margins ride the sheet, per Apple Books video + user request 7e300a7bd) — NOTE this diverges from the VT slide/curl which clips furniture static; instant nav = drop `animated` attr (ALL paginator animated paths incl. VT gate on it); backward = mirrored old-page-recedes (rendererRtl = forward?rtl:!rtl); `useCapturedTurn` (renamed from useMeshPageCurl) wraps view.prev/next + touch interceptor (priority 5, ruler=10 swipe-flip=0), drag progress from deltaX/gridWidth, cancel un-curls then navs BACK under the flat overlay; `applyPageTurnAttributes` = single source for turn-style/no-swipe (FoliateViewer open + ControlPanel pageTurnStyle/animated/disableSwipe effects); session `captureBroken` flag → paginator turn-style where VT is fully supported, push elsewhere. Android capture done (PixelCopy in NativeBridgePlugin.kt: CSS px × density + getLocationInWindow; kotlin compile check = `gen/android ./gradlew :tauri-plugin-native-bridge:compileFossDebugKotlin`). **Android perf gotcha (user-verified fixed)**: full-density PNG encode = ~1.5s/turn on Xiaomi 13 (3x, 1080×2400) → curl read as broken; fix = JPEG q85 + dest bitmap capped at 2× CSS px (PixelCopy scales into smaller dest for free) → invoke 1550ms→34ms, overlay mounts 132ms after tap; JS must NOT hardcode blob type 'image/png' (decoder sniffs). iOS same optimization (3e7f58135): `jpegData(0.85)` off-main + `WKSnapshotConfiguration.snapshotWidth = width*2/scale` when scale>2 (snapshotWidth is in POINTS; image px = points×scale); macOS stays PNG. CDP verify lane: `pnpm dev-android` + helpers in `src/__tests__/android/helpers/` (forwardWebViewDevtools + CdpPage.evaluate; `window.__TAURI_INTERNALS__.invoke('plugin:native-bridge|capture_webview_region',{payload})` times raw capture; `document.querySelector('foliate-view').next()` triggers the wrapped turn; `Page.captureScreenshot` for mid-turn frames). Remaining: Windows/Linux capture; LIVE curl visuals still unverified (macOS smoke test reached reader, push path fine; interrupted — user was at the machine). Live-run gotchas: dev binary exits instantly if production Readest runs (single-instance, same bundle id — quit prod first); computer-use MCP can't see/screenshot the bare target/debug binary — drive with bash `screencapture` + JXA CGEvent clicks (AppleScript AX `click at` fires the Book-Details action, not reader open). Shared `target/` symlink gotcha: deleted worktrees leave stale plugin build-script caches ("failed to read plugin permissions" from dead paths) — `cargo clean -p <plugin>...`.
|
||||||
|
|
||||||
|
**Curl final (curved corner fold):** radial-gradient mask on the OLD pseudo — transparent disc grows from the outer-bottom corner (forward) / spine-side corner (backward), fold edge = curved arc like a lifted page corner. The fold animates a GRADIENT STOP via registered `@property --foliate-fold` (re-rasterizes mask per frame). **VT pseudo paint quirks (computed style LIES — always verify with screenshots via vitest browser `page.screenshot`):** width animations compute but don't repaint; `mask-position`/`mask-size` animations paint at wrong scale/not at all; masks apply ONLY to the static old snapshot, NOT the live new layer (backward turns must choreograph old receding, not new unfolding); UA sets `mix-blend-mode: plus-lighter` on old/new (force `normal`); back both layers with `--foliate-vt-bg` (from doc `--theme-bg-color`, textured themes have transparent page bg per #4399); paint uses LINEAR progress ignoring easing (cosmetic). `filter`/`clip-path` order: filter runs BEFORE clip/mask → drop-shadows get cut with the page. **VT skips (`ready` rejects InvalidStateError) when `document.hidden`** — Chrome-MCP automation tab is usually hidden; verify in vitest browser (visible) with frozen animations + screenshots. **Header/footer in both layers:** app marks the boundary with `data-view-transition-root` on the gridcell; paginator prefers `closest('[data-view-transition-root]')` from the outermost shadow host. **True mesh bend on web: impossible** (no DOM pixel access); plan = WebGL curl shader + Tauri native webview capture (WKWebView takeSnapshot / PixelCopy / CapturePreview) as a follow-up; web keeps the arc fold.
|
||||||
|
|
||||||
|
**iOS 18 VT crash + gating (2026-07-06, 5ba62ef07):** iOS 18.7 WKWebView HAS `document.startViewTransition` but the VT slide CRASHES the WebContent process (Sandbox `process-info-codesignature` deny then WebContent gone; Android WebView 147 fine) — API presence is NOT a safe gate. App-side gate `supportsViewTransitionTurns()` (in `useCapturedTurn.ts`) = startViewTransition + `CSS.supports('view-transition-group', 'nearest')` (nested VT groups: Chrome/Edge/WebView 140+ ONLY; Safari ≤27 and Firefox lack it per caniuse — so ALL WebKit and Gecko engines get no VT turns). `applyPageTurnAttributes` only sets `turn-style` when the gate passes, so synced slide/curl settings degrade safely. Fallback on Tauri: `getCapturedTurnStyle()` routes slide → the capture pipeline with `PageSlideRenderer` (`src/utils/pageSlide.ts`, 2D canvas, translateX toward spine = `(rtl?1:-1)*progress*width` with the shared rendererRtl mirror; overlay gets `overflow:hidden` clip + box-shadow edge like the VT slide; backward = old-slides-out-mirrored since only the OUTGOING page can be captured — the overlay div sits above the live iframe so a second capture would include it). Web without full VT: push only; ControlPanel hides Slide/Page Curl (`turnStyleOptions` = push + layered when `supportsViewTransitionTurns() || isTauriAppPlatform()`) and coerces an unsupported synced value to display as Push. Tests: `useCapturedTurn.test.ts` (jsdom, stub CSS.supports + startViewTransition; `vi.stubEnv('NEXT_PUBLIC_APP_PLATFORM','tauri')` toggles isTauriAppPlatform) + slide cases in `captured-turn.browser.test.ts` (read canvas transform via `new DOMMatrixReadOnly(getComputedStyle(c).transform).e`).
|
||||||
|
|
||||||
|
**Mechanism:** slide/curl need old+new page as separate layers — impossible in the rigid multicol strip (see [[vertical-rl-horizontal-pagination-624]]). The View Transitions API rasterizes the outgoing page (annotations included) and animates the snapshot over the live, stationary incoming page. Axis-agnostic: works for vertical-rl too. `turn-style` attribute on the renderer; falls back to push/two-phase when `document.startViewTransition` is missing (old WebKitGTK/iOS<18).
|
||||||
|
|
||||||
|
**Hard-won gotchas:**
|
||||||
|
- **Shadow-DOM tree scoping:** `view-transition-name` on elements inside shadow roots creates NO capture group (Chrome 149) — the name must go on the outermost shadow host in the DOCUMENT tree (walk `getRootNode() instanceof ShadowRoot → .host`). In Readest that's the `foliate-view` element.
|
||||||
|
- **Header/footer stay static** (user requirement): app header/footer (SectionInfo/ProgressBar) are siblings positioned over the margins; the host snapshot covers margins too and slides over them. Fix: clip `::view-transition-group(foliate-turn)` with `--foliate-vt-clip: inset(margins)` (margins read from `--_margin-*` on #top) so margins stay owned by the static root snapshot.
|
||||||
|
- **Landing race:** a neighbor view load mid-transition re-anchors to the stale pre-turn anchor; re-assert `containerPosition = offset` after `transition.finished` (push's cssAnimateScroll does the same at its end).
|
||||||
|
- **Finger tracking = scrubbed VT:** start the transition at drag threshold (direction from net dx), `await ready`, `updateTiming({easing:'linear'})` + `pause()` all `(foliate-turn)` pseudo animations, drive `currentTime = progress × duration` from the finger. Release: `play()` to commit; cancel = `reverse()`, restore `containerPosition` in the anims-finished microtask (before next paint — avoids flashing the target page), then `skipTransition()`.
|
||||||
|
- **getComputedStyle on VT pseudos lies:** it reports rule-matched styles even with no active transition — test with `document.getAnimations()` filtered by `effect.pseudoElement` instead; a layer styled `animation: none` has no entry (proves it's stationary).
|
||||||
|
- Choreography via classes on documentElement (`foliate-vt-{slide,curl} -{forward,backward} -{left,right}`) + one injected document-head stylesheet (pseudo tree lives on :root, not the shadow root). Old-on-top needs `z-index: 1` (new is on top by default). Curl = perspective rotateY fold (flat snapshot can't mesh-bend); side class = spine side = rtl?right:left.
|
||||||
|
|
||||||
|
Tests: `paginator-turn-styles.browser.test.ts` (slide layering old-moves/new-still, curl, vertical-rl, drag tracking commit + revert, push fallback via deleting startViewTransition).
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
---
|
||||||
|
name: pdf-text-selection-fontscale-4480
|
||||||
|
description: PDF text selection/highlight misplaced (into margins, offset down) when OS font-size accessibility scaling is on
|
||||||
|
metadata:
|
||||||
|
type: project
|
||||||
|
---
|
||||||
|
|
||||||
|
**Issue #4480**: on some Android devices PDF text selection/highlight is misplaced — the blue selection rectangles bleed into the blank page margins and sit ~1/3 line too low. Reported on a Galaxy Tab A8; NOT reproducible on a Galaxy S21.
|
||||||
|
|
||||||
|
**Root cause (NOT what it looked like):** it is the **OS accessibility "font size" setting** (Android Settings > Display > Font size, `settings put system font_scale 1.3`), not the WebView version or devicePixelRatio. The OS font scale multiplies every piece of WebView-rendered *text* — including the transparent pdf.js text layer used for selection/highlight — but leaves the *canvas* page bitmap untouched. So the text-layer spans end up `fontScale`x larger than the glyphs baked into the canvas, and the native `::selection` boxes (which follow the span boxes) overshoot the text horizontally and vertically. The Tab A8 (a tablet) had enlarged system fonts; the S21 did not.
|
||||||
|
|
||||||
|
**Ruled out during investigation:** WebView version (Tab A8 was on WebView **148**, newer than the working S21's 147 and a WebView-124 emulator — all fine at default font scale); devicePixelRatio (the paginator's fit-width `zoom` keeps `--total-scale-factor` DPR-invariant); interactive-vs-programmatic selection (both fine). Font-metric/realm mismatch was a red herring: main-app-doc and iframe-doc `measureText` are identical on working devices.
|
||||||
|
|
||||||
|
**Fix** (`packages/foliate-js/pdf.js`, `render()`): detect the OS font scale with a probe (`offsetHeight` of a `100px`/`line-height:1` box = `100 * fontScale`, unaffected by DPR or the `<html>` `scale(1/dpr)` transform) and divide it back out: `--total-scale-factor = scale / getFontScale(doc)`. Positions (`left`/`top`) are px and font-scale-independent, so only font-size needs correcting. At font_scale 1.0 the probe returns 1.0 → no change (no regression). PDF-only; EPUB is unaffected because its text and overlay scale together.
|
||||||
|
|
||||||
|
**Repro/verify harness (reusable):** the release APK's WebView is CDP-debuggable. `adb forward tcp:PORT localabstract:webview_devtools_remote_$(pidof com.bilingify.readest)`, then drive `Runtime.evaluate` over the page WebSocket. The PDF renders in an iframe nested inside foliate-view's shadow DOM — deep-traverse `shadowRoot` + `iframe.contentDocument` to reach `.textLayer`. Create a multi-line selection with `doc.getSelection().addRange()` + `adb exec-out screencap` to see the native highlight. Set `settings put system font_scale 1.3` to reproduce. See [[android-cdp-e2e-lane]].
|
||||||
|
|
||||||
|
Related PDF text-layer notes: [[pdf-spread-canvas-seam-4587]] (the `--total-scale-factor` / canvas-size line this fix touches), [[overlayer-splitrange-textnodes]].
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
---
|
||||||
|
name: pinch-vs-twofinger-scroll-4858
|
||||||
|
description: "Fixed-layout pinch-zoom too sensitive on touchscreen laptops (#4858); distinguish two-finger scroll (same direction) from pinch (opposite) via pending state + deadzone in useIframeEvents.useTouchEvent"
|
||||||
|
metadata:
|
||||||
|
node_type: memory
|
||||||
|
type: project
|
||||||
|
originSessionId: ca71550d-0c81-44d1-b990-3892dc514d77
|
||||||
|
---
|
||||||
|
|
||||||
|
Issue #4858: on 2-in-1 touchscreen laptops (Surface) reading PDFs webtoon-style, a two-finger **scroll** accidentally triggered zoom. User wanted NO zoom-lock option — just make pinch less sensitive and distinguish same-direction (scroll) from opposite-direction (pinch).
|
||||||
|
|
||||||
|
**Where:** `src/app/reader/hooks/useIframeEvents.ts` `useTouchEvent`. Pinch only engages for `getBookData(bookKey)?.isFixedLayout` (PDF / fixed-layout EPUB). Touch events are forwarded from the foliate iframe as `iframe-touch{start,move,end}` postMessages (passive listeners, no preventDefault, so native two-finger scroll happens regardless — the old bug was that we ALSO zoomed).
|
||||||
|
|
||||||
|
**Old bug:** `onTouchStart` set `isPinchingRef=true` immediately on any two-finger touch and `onTouchMove` applied `ratio=currentDist/initialDist` from the very first move. Real human scroll isn't perfectly parallel, so finger separation drifts → ratio ≠ 1 → accidental zoom; `onTouchEnd` committed `round(initialZoom*lastRatio)`.
|
||||||
|
|
||||||
|
**Fix — pending state + magnitude discriminator + deadzone:**
|
||||||
|
- `onTouchStart` (two fingers, fixed layout): enter `pinchPendingRef=true` (NOT `isPinchingRef`), stash both initial touches (`initialTouch0/1Ref`), `initialPinchDist`, `initialZoom`.
|
||||||
|
- `onTouchMove` while pending: compute `separationDelta=|currentDist-initialDist|` and `panDist` = magnitude of the **midpoint travel** `((Δt0+Δt1)/2)`. Pinch keeps midpoint still while separation changes; scroll moves midpoint while separation barely shifts. Decide: pinch if `separationDelta >= PINCH_ACTIVATION_THRESHOLD(24) && separationDelta > panDist`; scroll (bail, native scroll takes over) if `panDist >= TWO_FINGER_PAN_THRESHOLD(12) && panDist >= separationDelta`; else keep waiting. This magnitude compare IS the "opposite vs same direction" test (more robust than a raw dot-product sign).
|
||||||
|
- On pinch confirm: re-baseline `initialPinchDist = currentDist` so zoom starts at 1x from the activation point — the deadzone travel is absorbed, no snap/jump.
|
||||||
|
- `onTouchEnd`: guard is now `isPinchingRef || pinchPendingRef`; only commit the `pinch-zoom` dispatch when `wasPinching` (a pending-only or scroll-resolved gesture leaves zoom untouched).
|
||||||
|
|
||||||
|
Thresholds bias toward scroll (pan needs only 12px, pinch needs 24px separation) = "less sensitive". Uses `screenX/screenY` (not client) because `pinchZoom` CSS-transforms the iframe parent and oscillates client coords. Tests: `src/__tests__/hooks/useTouchEvent.test.tsx` (same-direction scroll → no zoom; opposite → zoom; jitter < deadzone → no zoom). Related: [[scrolled-pdf-pinch-zoom-4817]] (foliate `pinchZoom`/`pinchEnd` live scale + commit), [[image-zoom-trackpad-flicker-4742]] (macOS trackpad pinch = ctrl+wheel, different path via `useMouseEvent`).
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
---
|
||||||
|
name: sentry-crash-reporting-4914
|
||||||
|
description: Sentry crash reporting across JS/Rust/Android/iOS (PR
|
||||||
|
metadata:
|
||||||
|
node_type: memory
|
||||||
|
type: project
|
||||||
|
originSessionId: e1238bc7-0b80-4036-b949-f9a2cf0045bc
|
||||||
|
---
|
||||||
|
|
||||||
|
Sentry crash/error reporting added in PR #4914 (`feat(sentry): add crash reporting for Android, iOS, desktop, and web`). Four layers, one build-time `SENTRY_DSN` (empty => every layer no-ops):
|
||||||
|
|
||||||
|
- **JS + Rust panics**: `tauri-plugin-sentry` 0.5 + `sentry` 0.42 registered in `lib.rs::run()` (guard held to end of `run()`); rustls transport (NOT native-tls, so it cross-compiles for mobile); browser SDK auto-injected. Minidump handler is desktop-only: `#[cfg(not(any(target_os = "ios", target_os = "android")))]`.
|
||||||
|
- **Android native**: `io.sentry:sentry-android:8.47.0` in `gen/android/app/build.gradle.kts` + manifest auto-init `<meta-data>` (`io.sentry.dsn` from `${sentryDsn}` placeholder, `io.sentry.environment`). Crashes+errors only (traces=0, no PII, no replay).
|
||||||
|
- **iOS native**: `sentry-cocoa` via SPM in `project.yml`.
|
||||||
|
|
||||||
|
Config: `sentry_config.rs` holds pure helpers (`sentry_dsn`, `environment_for_version`, `app_version`, `release_name`/`sentry_release`, `corrected_os_name`, `android_version_from_uname`, `is_ignored_browser_error`, `parse_webview_info`/`set_webview_info`/`webview_info`). Scope = crashes+errors only. Symbolication (source-map/ProGuard/dSYM upload) is STILL deferred — several 2026-07 crashes (READEST-2 render loop, READEST-9) could only be triaged to a function name, not a source line, for lack of source maps; upload them.
|
||||||
|
|
||||||
|
**The Rust-client `before_send` (in `lib.rs`) now does three things**, in order, for every event (Rust panics + browser events forwarded by tauri-plugin-sentry): (1) drop known-benign browser noise via `is_ignored_browser_error` (only the View-Transition-skipped-while-hidden message; a transition *timeout* is deliberately kept — real perf signal); (2) rewrite the Android OS name/version (see below); (3) tag `webview.engine`/`webview.version`. The webview tags come from a `set_webview_info` Tauri command the app calls once in `NativeAppService.init()` with `navigator.userAgent`; `parse_webview_info` extracts engine+major-version (Chromium `Chrome/140` checked before WebKit `Version/17`, because Android WebViews also carry a legacy `Version/4.0`) into a global `OnceLock` read in before_send. Added because forwarded browser events carry os/rust/device context but NO browser context, so crashes couldn't be correlated with WebView version. (feat(sentry): tag events with the WebView engine and version, merged 2026-07.)
|
||||||
|
|
||||||
|
**2026-07 production crash-fix batch (all merged).** The recurring root cause was best-effort background work throwing UNHANDLED promise rejections (callers fire-and-forget, so a throw hits the global handler): READEST-1 concurrent-use (turso, see [[turso-concurrent-use-forbidden]]); READEST-5 cloud `deleteFile` threw (log+swallow); READEST-6 statistics DB writes on teardown (`runBestEffort` wrapper in `ReadingStatsTracker`; also covers READEST-4/8 network fails); READEST-A library save to a custom shared-storage folder failed `EACCES` because the save path never called the existing `requestStoragePermission()` (`AppService.saveLibraryBooks` now requests-once-per-session + retries). READEST-2 = zustand `updateTransferProgress` allocating new state on unchanged values → React update loop (equality guard). READEST-9 = `useAppRouter` wraps EVERY nav in a View Transition; opening a book is a heavy render that overruns the ~4s DOM-update budget → `TimeoutError` (fix: book-open navs use the plain `useRouter`, matching 8/10 into-reader paths; version-gating does NOT help — the 4s budget is version-independent).
|
||||||
|
|
||||||
|
**Release + environment key off `package.json`, NOT the crate version.** Originally `release: sentry::release_name!()` = `CARGO_PKG_NAME@CARGO_PKG_VERSION` = `Readest@0.2.2` (stale crate version, never bumped) and `environment` read `CARGO_PKG_VERSION` (so it was ALWAYS "production" — nightly detection was dead). Fix: `build.rs::propagate_app_version()` reads the top-level `"version"` from `../package.json` (line-based parse via `read_json_string_field`, no serde) and bakes `cargo:rustc-env=READEST_APP_VERSION`; `app_version()` reads it via `option_env!` (falls back to `CARGO_PKG_VERSION`), same bake mechanism as `SENTRY_DSN`. `sentry_release()` -> `Readest@<pkg-version>` (e.g. `Readest@0.11.17`), `sentry_environment()` now derives from `app_version()` so nightly (`-YYYYMMDDHH`) correctly reports `environment=nightly`. Android/iOS **native** SDK releases already came from `versionName`/bundle version (tauri derives those from package.json), so only the Rust client (which also handles JS/browser events via tauri-plugin-sentry) needed fixing.
|
||||||
|
|
||||||
|
**OS name "Linux" -> "Android".** On Android, `sentry-contexts::os_context()` builds the OS context from `uname()` (not-macos/not-windows branch): `name = info.sysname` = "Linux", `version = info.release` = kernel string like `6.1.162-android14-11-...`. Fixed with a Rust-client `before_send` in `lib.rs` that, keyed on `std::env::consts::OS == "android"`, rewrites `Context::Os.name` -> "Android" and pulls the Android version ("14") out of the `androidNN` token in `os.version` via `android_version_from_uname`. `before_send` runs AFTER `ContextIntegration::process_event` (which inserts the os context only if `Entry::Vacant`), so the "Linux" context is present to rewrite; applies to browser events too since tauri-plugin-sentry forwards them through the same Rust client. iOS-via-Rust would show "Darwin" but that path is minor (native sentry-cocoa reports iOS correctly) — not remapped.
|
||||||
|
|
||||||
|
**Gotcha 1 — iOS generated files are gitignored + never tracked.** `gen/apple/Sources/Readest/main.mm` and `gen/apple/Readest_iOS/Info.plist` are gitignored (`.gitignore` `src-tauri/gen`) and regenerated by `tauri ios init` — editing them does NOT persist. Only `project.yml` and force-added custom files (like `ShareExtension/*`, `ReadestWidget/*`) survive. So native iOS init lives in a **force-tracked `gen/apple/SentrySupport/SentryBootstrap.m`** (`+[ReadestSentryBootstrap load]`, runs before `main`) wired via `project.yml` (SPM package + `- path: SentrySupport` source), reading the DSN from an iOS-gated Rust C-ABI `readest_sentry_dsn()` FFI (in `sentry_config.rs`) — no generated-file edits. See [[ios-widget-appgroup-stripped-appstore]].
|
||||||
|
|
||||||
|
**Gotcha 2 — sentry-android needs a lifecycle exclude.** `sentry-android-core` (7.x AND 8.x) depends on `androidx.lifecycle:lifecycle-common-java8`, discontinued at 2.9.0+. The app's `lifecycle-process:2.10.0` pin version-aligns it to a nonexistent `2.10.0` -> Gradle `Could not resolve` at `:app:mergeUniversalReleaseNativeLibs`. Fix = `exclude(group="androidx.lifecycle", module="lifecycle-common-java8")` (its Java8 APIs now live in `lifecycle-common`). Same class as [[dependabot-pnpm-overrides]].
|
||||||
|
|
||||||
|
**Gotcha 3 — no dotenv in the tauri build; wire `SENTRY_DSN` yourself.** `tauri-cli` (2.10.1) has NO dotenv dependency, so `.env.local` / `.env.*` are NOT auto-loaded into the cargo/gradle build (Next.js `.env.local` only reaches Next.js). `build.rs` resolves `SENTRY_DSN` with precedence **env -> `.env.local` -> `.env`** and bakes it via `cargo:rustc-env` (covers Rust + iOS FFI; `rerun-if-changed` avoids stale bake); `build.gradle.kts` does the same for the Android manifest placeholder. CI: `release.yml`/`nightly.yml` append `SENTRY_DSN=${{ secrets.SENTRY_DSN }}` to the `.env.local` they already build next to the PostHog/Supabase secrets (`cp .env.local apps/readest-app/.env.local`). iOS App Store release is a local script -> uses the maintainer's local `.env.local`.
|
||||||
|
|
||||||
|
Env tag: `sentry_environment()` now reads `app_version()` (baked from `package.json`), so nightly stamps (`-YYYYMMDDHH`) resolve to `environment=nightly`; store-distributed mobile stays `production`. (Previously it read `CARGO_PKG_VERSION` — crate `0.2.2`, never nightly-stamped — so it was always `production`.)
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
---
|
||||||
|
name: transfer-queue-clear-persistence
|
||||||
|
description: "Transfer Queue \"Clear Completed/Failed/All\" reappeared on reload because the hook mutated the store directly and skipped persistQueue"
|
||||||
|
metadata:
|
||||||
|
node_type: memory
|
||||||
|
type: project
|
||||||
|
originSessionId: daef2308-58b2-425d-924d-8a405b0e096a
|
||||||
|
---
|
||||||
|
|
||||||
|
Transfer Queue "Clear Completed" (also Clear Failed / Clear All) removed items from the panel but they reappeared next time the queue loaded from `localStorage` (`readest_transfer_queue`).
|
||||||
|
|
||||||
|
**Root cause:** `src/hooks/useTransferQueue.ts` called `useTransferStore.getState().clearCompleted()` / `clearFailed()` / `clearAll()` directly — those Zustand actions only mutate in-memory `transfers`, never touching `localStorage`. Only `clearPending` routed through `transferManager.clearPending()`, which calls `this.persistQueue()`. So the persisted copy still held the completed rows and `loadPersistedQueue()` restored them on next init.
|
||||||
|
|
||||||
|
**Fix (PR):** added `clearCompleted()`/`clearFailed()`/`clearAll()` to `src/services/transferManager.ts` (each = store action + `this.persistQueue()`, mirroring `clearPending`), and pointed the hook at the manager methods. Tests in `src/__tests__/services/transfer-manager.test.ts` assert both the store and `localStorage` no longer contain the cleared rows.
|
||||||
|
|
||||||
|
**Why:** the store is in-memory; `transferManager` is the only layer that persists. Any mutation exposed to the UI must go through the manager (which calls `persistQueue()`), not the store directly, or it won't survive reload.
|
||||||
|
|
||||||
|
**How to apply:** when adding a transfer-queue mutation, add a `transferManager` method that pairs the store action with `persistQueue()` and call that from the hook — never call the store's mutating action straight from `useTransferQueue`.
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
---
|
||||||
|
name: tts-background-session-decoupling
|
||||||
|
description: Background TTS across book close (PR
|
||||||
|
metadata:
|
||||||
|
node_type: memory
|
||||||
|
type: project
|
||||||
|
originSessionId: 97e57af9-5961-4c92-a63e-4582178bf798
|
||||||
|
---
|
||||||
|
|
||||||
|
Background TTS decoupling shipped as PR readest/readest#4941, MERGED 2026-07-06 (follow-up to merged #4931 Web Audio engine). Full e2e verified post-merge in Chrome dev-web including sleep timer firing headless (bar countdown chip → stop at 0:00), split view (parallel pane mount does not stop the playing session; pane close with keepTTSAlive keeps it), and bar-tap same-window reopen with live reattach.
|
||||||
|
|
||||||
|
Architecture: `TTSSessionManager` (per-webview singleton, keyed by book HASH — bookKey `${hash}-${uniqueId()}` regenerates per open) owns media bridge, keep-alive, sleep timer, headless persistence (via `setConfig`+throttled `saveConfig`; store setters no-op for closed books), and a deduplicated `tts-playback-state` relay (transit `'stopped'` swallowed; terminal stop only via `tts-session-ended` + `terminated` flag). `TTSController.detachView()/attachView()` re-seeds from `getLastRange()` via CFI anchor. `TTSMediaBridge` replaces `useTTSMediaSession`. Library `NowPlayingBar` reserves shelf clearance via `--now-playing-inset` body var.
|
||||||
|
|
||||||
|
**Close-path gotcha found live (not by tests):** the reader header X routes through `onCloseBook` → `handleCloseBook`, NOT `onGoToLibrary` → `handleCloseBooksToLibrary`. Any close-behavior change must cover BOTH. Eligibility is an explicit `keepTTSAlive` param on `saveConfigAndCloseBook`/`handleCloseBooks` (not a sticky ref): beforeunload/quit-app/window-close pass an event object which coerces to `false` → hard `tts-stop`; SPA closes pass literal `true` → `tts-close-book` (detach).
|
||||||
|
|
||||||
|
Verified in Chrome dev-web: WebAudio generation numbering continues across close→reopen (adoption, no new controller); different-book mount stops the session; pause/stop from the bar; headless position persisted. Related: [[page-turn-styles-viewtransitions-555]], [[edge-tts-word-highlighting-4017]].
|
||||||
|
|
||||||
|
Debug tip: `releaseUnblockAudio()` ("Unblock audio released" log) is called only from `handleStop`/`stopActive` — its appearance in the close flow pinpoints a hard-stop path.
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
---
|
||||||
|
name: ""
|
||||||
|
metadata:
|
||||||
|
node_type: memory
|
||||||
|
originSessionId: e1238bc7-0b80-4036-b949-f9a2cf0045bc
|
||||||
|
---
|
||||||
|
|
||||||
|
Sentry READEST-1 (Android, `tauri.localhost/reader`): unhandled promise rejection `Non-Error promise rejection captured with value: concurrent use forbidden`.
|
||||||
|
|
||||||
|
**Root cause.** `TursoError::Misuse("concurrent use forbidden")` comes from `turso_sdk_kit-0.6.x`'s per-**connection** `ConcurrentGuard` (an `AtomicU32` `compare_exchange(0,1)` in `try_use`, acquired inside every synchronous `step()`/`execute()` poll). turso forbids concurrent use of a single `Connection`. The local plugin `src-tauri/plugins/tauri-plugin-turso` (`wrapper.rs::DbConnection`) holds ONE `turso::Connection` per DB path in `DbInstances` (`Arc<Mutex<HashMap<path, Arc<DbConnection>>>>`). Each `#[command] async fn` (`execute`/`select`/`batch` in `commands.rs`) locks the HashMap only to clone the `Arc<DbConnection>` out, RELEASES it, then `await`s `conn.execute/query` with NO serialization. Tauri dispatches commands on its multi-threaded async runtime, so two overlapping IPC calls for the same path (from `Promise.all`, or independent reader flows — progress save + stats write + annotation query) drive the same connection; whichever hits `step()` on the 2nd thread while the 1st holds the guard gets rejected. The guard is per-*synchronous-step* (released between async IO polls), so the collision is timing-dependent (needs true parallelism), which is why it was rare (2 events/1 user).
|
||||||
|
|
||||||
|
**Fix (PR-pending).** Serialize per-connection ops at the layer that owns the connection: added `op_lock: futures::lock::Mutex<()>` to `DbConnection`; `execute`/`select`/`batch` each `let _op = self.op_lock.lock().await;` first. This is the single choke point for ALL same-path callers (nativeDatabaseService, drizzle proxy, migrate.ts, statisticsDb/ReedyDb) regardless of JS entry point — a JS-only queue in `NativeDatabaseService` wouldn't cover the others or multiple service instances sharing one Rust connection. Bonus: holding the lock across the whole `batch` keeps BEGIN/COMMIT atomic vs interleaved writes, and pins `last_insert_rowid()` to the `execute` that produced it. `batch` calls `self.conn.execute` (not `self.execute`) so no re-entrant deadlock.
|
||||||
|
|
||||||
|
**Test.** `wrapper.rs` `#[cfg(test)] mod tests::concurrent_ops_on_one_connection_do_not_collide` — `#[tokio::test(flavor="multi_thread", worker_threads=8)]`, opens `:memory:`, fans out 64 concurrent INSERTs + 64 SELECTs on one `Arc<DbConnection>`. Deterministically fails pre-fix with `Turso(Misuse("concurrent use forbidden"))` (~0.02s), passes post-fix (5/5 stable). Needed `[dev-dependencies] tokio = { features = ["macros","rt-multi-thread"] }` (runtime tokio features lacked `macros`).
|
||||||
|
|
||||||
|
**Gotchas.**
|
||||||
|
- `DbConnection` is module-private (`mod wrapper` not `pub`), so the test must live INSIDE `wrapper.rs`, not `tests/`.
|
||||||
|
- Plugin tests are NOT in the repo gate: `pnpm test:rust`/`fmt:check`/`clippy:check` are all `-p Readest` only. Run plugin checks explicitly: `cargo test/clippy --manifest-path src-tauri/plugins/tauri-plugin-turso/Cargo.toml` (clippy needs `--no-deps` — the vendored `tauri-runtime-wry` fork emits warnings that `-D warnings` would otherwise promote to errors). Shared cargo target is `/Users/chrox/dev/readest/target`, not `src-tauri/target`.
|
||||||
|
- Pre-existing fmt debt in the plugin: `cargo fmt --check` flags `decode.rs` import ordering (unrelated; left untouched).
|
||||||
|
|
||||||
|
See [[sentry-crash-reporting-4914.md]] (this is the first real issue it caught) and [[bug-patterns]].
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
---
|
||||||
|
name: webdav-group-membership-sync-4942
|
||||||
|
description: File-sync mergeBookMetadata dropped groupId/groupName so group changes on already-synced books never propagated
|
||||||
|
metadata:
|
||||||
|
node_type: memory
|
||||||
|
type: project
|
||||||
|
originSessionId: b97634b0-ccf4-4663-b8c3-895f8e8aed4a
|
||||||
|
---
|
||||||
|
|
||||||
|
MERGED as PR #4946 into `origin/main` (issue #4942, from discussion #4922). WebDAV/file sync only propagated group membership for NEWLY imported books, not for books already present on both devices.
|
||||||
|
|
||||||
|
**Root cause**: `src/services/sync/file/merge.ts` `mergeBookMetadata` overlaid a fixed field subset (title/author/metadata/primaryLanguage/updatedAt) and DROPPED `groupId`/`groupName`. Adding a book to a group bumps `book.updatedAt` (`GroupingModal.tsx handleConfirmGrouping`) and pushes the full book (with group fields) into `library.json`. On a peer, an already-present book is reconciled via `mergeBookMetadata` (the `isRemoteBookMetadataNewer` LWW pass in `engine.ts` ~line 445), which won the `updatedAt` race but discarded the group change. New books instead arrive via `addBookToLibrary` with the full remote object, so their group travels on first import — that asymmetry is exactly what the reporter saw.
|
||||||
|
|
||||||
|
**Fix**: carry `groupId`/`groupName` in `mergeBookMetadata`, assigned RAW (not `?? local`) so a group removal (undefined on the newer side) also propagates. Matches native cloud sync, which already maps `group_id`/`group_name` in `src/utils/transform.ts`. Deprecated `group` field intentionally left out (native sync doesn't carry it either).
|
||||||
|
|
||||||
|
Tests: `merge.test.ts` (add + removal cases), `engine-metadata-sync.test.ts` (full reconcile delivers group into `updateBookMetadata` + re-pushed index). Extends the field subset first documented in [[webdav-metadata-sync-4756]].
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
---
|
||||||
|
name: xcode26-swiftrs-ios-build-broken
|
||||||
|
description: Xcode 26.2/Swift 6.2 broke all tauri iOS builds via swift-rs 1.0.7; fixed by vendored packages/swift-rs using --triple/--sdk
|
||||||
|
metadata:
|
||||||
|
node_type: memory
|
||||||
|
type: project
|
||||||
|
originSessionId: 603140d5-b247-4f0f-9cdf-0c3479cbcb1e
|
||||||
|
---
|
||||||
|
|
||||||
|
Xcode 26.2 (Swift 6.2.4, 2026-07) broke EVERY `tauri ios build` repo-wide: the Swift plugin packages (native-bridge etc.) fail with phantom errors — `type 'Bundle' has no member 'main'`, `extra argument 'privacy' in call`, `cannot infer contextual base in reference to member 'public'`. Root cause: swift-rs 1.0.7 (unmaintained since 2024, latest release) invokes `swift build --arch <host> -Xswiftc -target <ios-triple>` while inheriting Xcode's `SDKROOT`; Swift 6.2's driver no longer honors that mix, so sources compile against the wrong platform's Swift overlays — overlay-provided APIs (`Bundle.main` sugar, os.Logger privacy interpolation) vanish. Related upstream: tauri-apps/tauri#10717, #11103 ("using sysroot for 'iPhoneOS' but targeting 'MacOSX'").
|
||||||
|
|
||||||
|
**Fix (branch feat/page-turn-styles-555):** vendored `packages/swift-rs` + `[patch.crates-io] swift-rs = { path = "packages/swift-rs" }` in root Cargo.toml. Patch in `src-rs/build.rs link()`: use SPM first-class cross-compile flags `--triple <versioned-triple> --sdk <sdk-path>` (drop `--arch` and all `-Xswiftc/-Xcc/-Xcxx` target overrides), `env_remove("SDKROOT")` (leaks from Xcode script phase and breaks SPM's host-targeted MANIFEST compile), and artifact search path becomes `<unversioned-triple>[-simulator]/<config>` (e.g. `arm64-apple-ios/release`) instead of the old hardcoded `<arch>-apple-macosx/<config>`.
|
||||||
|
|
||||||
|
**Gotcha exposed by the fix:** with `--triple`, SPM enforces the deployment floor from the package manifest `platforms:` stanza (the old bypass fed the version straight to swiftc). native-bridge's `ios/Package.swift` declared `.iOS(.v14)` while using unguarded iOS-15 API (StoreKit `Storefront`) → bumped to `.iOS("15.0")` (string form — `.v15` needs swift-tools 5.5, manifest is 5.3), matching the app's `IPHONEOS_DEPLOYMENT_TARGET: 15.0` (gen/apple/project.yml).
|
||||||
|
|
||||||
|
Fast repro/verify loop (~2 min, no xcodebuild): `SDKROOT=$(xcrun --sdk iphoneos --show-sdk-path) IPHONEOS_DEPLOYMENT_TARGET=15.0 cargo build -p tauri-plugin-native-bridge --target aarch64-apple-ios --release` in src-tauri.
|
||||||
|
|
||||||
|
Related: [[page-turn-styles-viewtransitions-555]] (branch where this landed), [[deps-security-overrides-workflow]] (vendoring pattern).
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
<string>1.0</string>
|
<string>1.0</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>0.11.12</string>
|
<string>0.11.17</string>
|
||||||
<key>NSExtension</key>
|
<key>NSExtension</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>NSExtensionPointIdentifier</key>
|
<key>NSExtensionPointIdentifier</key>
|
||||||
|
|||||||
Reference in New Issue
Block a user