Compare commits

..

172 Commits

Author SHA1 Message Date
Huang Xin 4af203755d release: version 0.11.18 (#5003) 2026-07-07 20:17:35 +02:00
Huang Xin 883dae36aa fix(opds): auth negotiation and auto-download fixes for self-hosted catalogs (#5002)
* fix(opds): allow LAN catalogs through the proxy in development

The SSRF host blocklist added in #4638 unconditionally rejected private
addresses, so the dev proxy returned 400 for LAN OPDS catalogs even though
next dev runs on the developer's own machine where reaching the LAN is the
normal use case. Skip the blocklist when NODE_ENV is development, matching
the existing CatalogManager gate that only forbids LAN URLs in production.
Production and test behavior are unchanged.

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

* fix(opds): negotiate Digest auth when the server rejects preemptive Basic with 400

Calibre's content server in digest mode, or auto mode over http, answers a
Basic Authorization header with 400 Unsupported authentication method
instead of a 401 challenge. The preemptive Basic header introduced in #4206
therefore dead-ended the request, since the auth retry only fired on 401 or
403, and digest catalogs failed with Failed to load OPDS feed: 400 Bad
Request on every platform. When a request that carried preemptive Basic
comes back 400, re-issue it once without credentials to surface the
WWW-Authenticate challenge and let the existing negotiation pick the scheme
the server actually wants. Verified end to end against a live Calibre
server on web and Android.

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

* fix(opds): skip SSL verification in auto-download like the manual download path

The native download_file command validates TLS with rustls, which ignores
the OS trust store, so downloads from self-signed or private-CA OPDS
servers fail in the TLS handshake before any request reaches the server.
The manual download path has passed skipSslVerification since #2900 as the
workaround for #2871, but the auto-download path never did, so subscribed
shelves failed to sync while feed browsing and manual downloads of the same
books worked. Pass the same flag in downloadAndImport.

Closes #4988

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 19:47:26 +02:00
Huang Xin 0c24aad606 fix(reader): let page margins shrink into the safe-area inset (#4761) (#5001) 2026-07-08 02:31:01 +09:00
Huang Xin a8d3411203 fix(reader): gate captured slide/curl turn on scrollLocked like push (#5000)
Instant Highlight engages after a 300ms still-hold on text and locks
scrolling (renderer.scrollLocked) so the finger extends the highlight
instead of turning the page. The push paginator honors that lock in its
native swipe, but slide and page curl run through the app-side captured
turn: applyPageTurnAttributes sets no-swipe, so the native swipe bows
out and the captured-turn touch interceptor drives the turn instead.
That interceptor started a page turn on any horizontal swipe without
checking the lock, so a hold-then-swipe paginated with the slide/curl
effect instead of extending the highlight.

Gate the interceptor on renderer.scrollLocked before it begins a drag,
mirroring the native swipe. Bump the foliate-js submodule
(readest/foliate-js#51) to expose scrollLocked via a getter (it was
write-only) and add a regression test.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 18:01:18 +02:00
Huang Xin f8ad47a418 feat(reader): Auto Scroll reading mode for scrolled flow (#4998) (#4999)
Teleprompter-style continuous scrolling toggled from the View menu
(Shift+A), available only in scrolled mode. A PacedScroller drives
whole-pixel forward steps at a constant, user-adjustable velocity;
the speed (25-500 percent, persisted as autoScrollSpeed) is tuned
from a floating control pill that also offers pause/resume and exit,
and fades away while scrolling to keep the mode immersive.

Tapping the page pauses and resumes instead of turning pages or
toggling the bars; manual wheel or drag input simply composes with
the paced scrolling. Escape or leaving scrolled mode ends the
session. When forward progress stalls the session hops to the next
section (single-section scroll mode) or stops with a toast at the
end of the book. Vertical-writing books scroll along the horizontal
axis with the sign convention foliate uses for scrolled offsets.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 17:45:02 +02:00
Huang Xin 17de9357dd feat(reader): redesign the TTS control as a mini player with an expandable player sheet (#4996)
* feat(reader): add formatCountdown helper for TTS timer chips

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(reader): extract shared TTS playback info hook

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(reader): add TTS scrubber with buffer-ahead fill

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(reader): add TTS speed preset chips

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(reader): add persistent TTS mini player

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(reader): add full TTS player sheet with voice and timer sub-views

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(reader): cover player sheet view reset on reopen

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(reader): replace TTS icon and popup with mini player and player sheet

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(reader): reserve mini player clearance and retire showTTSBar setting

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore: retire shipped TTS follow-ups from TODOS

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(i18n): translate the TTS player strings

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(reader): pin mini player transport LTR and unmount the closed player sheet

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(reader): collapse sheet speed, voice, and timer controls into one row

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(tts): stabilize timeline estimates with cumulative voice calibration

Replace the per-sentence EMA with the cumulative ratio of all measured chars to all measured seconds per voice, so the estimated remainder converges instead of re-pricing on every quirky sentence. Legacy stored calibrations migrate as a small prior.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(reader): hide the mini player while the player sheet is open

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(tts): clear stale highlights across sections and stop sentence flash in word mode

Entering a section now scrubs the TTS highlight from every live view, not just the primary, so the outgoing section's last word no longer stays lit in the preloaded neighbor. reapplyCurrentHighlight no longer redraws the whole sentence during word-mode playback while awaiting the first word boundary.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* style(reader): move the mini player progress line to the bottom edge

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(reader): make the TTS progress bar and scrubber legible in eink mode

Grey tints wash out on e-ink: the mini player track gets a 1px hairline with a solid base-content fill (buffer fill hidden), and the sheet scrubber gets a crisp 1px border marking the track extent.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* style(reader): drop the player sheet header label on the main view

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 16:37:59 +02:00
Huang Xin ab628fe258 fix(android): background TTS media controls + lock-screen scrubber/seek + Edge click fix (#4994)
* fix(android): keep background TTS media controls alive when backgrounded

The media-session update commands pushed metadata and playback state to
MediaPlaybackService via Context.startService(), which Android 8+ rejects
with "app is in background" once the app leaves the foreground. Every
per-sentence update then failed, so the lock-screen control went stale and
the foreground service lost the notification refresh that keeps it alive,
dropping background playback.

Deliver updates to the running service in-process (the pattern the existing
requestDeactivation() already uses) so they can never trigger a service
start. Also request POST_NOTIFICATIONS whenever the session activates, not
only when alwaysInForeground is set, so the foreground-service media
notification (the lock-screen control) is not suppressed on Android 13+.

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

* feat(android): show section duration and enable seek on the TTS media session

The Edge/WebAudio TTS engine already reports an estimated section timeline
(position and duration) to the media session on every sentence, but the
Android service never surfaced it: the lock screen had no scrubber and no
seek. Set METADATA_KEY_DURATION so the scrubber shows its length, add
ACTION_SEEK_TO, and handle onSeekTo by handing the target back to the JS
controller (seekToTime) through the existing media-session-seek event.

Playback-state updates that only flip play/pause omit position and duration,
so the service now preserves the last known values instead of resetting the
scrubber to 0. Native TextToSpeech has no timeline (duration stays 0), so the
scrubber simply does not appear there.

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

* fix(android): harden TTS foreground-service promotion + add diagnostics

On MIUI the media service was reclaimed on idle ("Stopping service due to app
idle"), which means startForeground never promoted it to a real foreground
service. Promote with ServiceCompat and an explicit
FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK type (robust on targetSdk 34+) and log
any failure instead of swallowing it.

Decouple the POST_NOTIFICATIONS request from the service start in setActive: a
throwing or hung permission request no longer aborts set_media_session_active,
so the foreground service always starts.

Add trace logging (set_media_session_active, activateSession, startForeground)
to pinpoint the promotion path on-device.

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

* fix(android): drop required keepAppInForeground so the TTS service starts

The set_media_session_active payload required keep_app_in_foreground, but the
media bridge activates with just { active: true }. Tauri rejected the invoke
at the serde layer ("missing field keepAppInForeground") before the command
ran, so the foreground service never started: no media notification, and
Android 15 audio hardening then muted background playback.

The flag was dead everywhere: no platform read it (the foreground service
always starts and the POST_NOTIFICATIONS request is now unconditional). Remove
it from the Rust, Kotlin, iOS, and TS payloads. Effective behavior is
always-foreground.

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

* fix(tts): fade sentence-buffer edges to stop Edge TTS clicks

The silence trim (findSpeechBounds) cuts at an amplitude threshold, not a zero
crossing, so each Edge TTS sentence buffer begins and ends on a non-zero
sample. An AudioBufferSourceNode steps straight from/to silence at its edges,
and that discontinuity clicks/pops between sentences (WSOLA is not involved:
it is a no-op at rate 1.0 and cross-fades its internal splices).

Apply a ~3ms linear fade to each buffer's own copy after WSOLA so the edges
ramp to zero. The trim and the controlled inter-sentence gap are unchanged.

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

* chore(android): remove the dead "Background Read Aloud" setting

alwaysInForeground only drove the removed keepAppInForeground flag; nothing
reads it now (the foreground service always starts, and POST_NOTIFICATIONS is
requested at play time). Remove the setting, its default, the Android
library-menu toggle (which just requested the notification permission), and
prune the now-unused i18n key from all locales.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 13:35:53 +02:00
Huang Xin 3ce5a5c8e3 fix(reader): center the lone PDF page in portrait auto-spread (#4984) (#4992)
In auto-spread mode a portrait viewport shows a single page of the
two-page spread, but the fixed-layout renderer kept the spread-centering
one-sided inline margin on the shown page. With no partner page to meet
at the spine, that margin stranded the lone page in one half of the
viewport whenever it was narrower than the viewport (any zoom below
100%, or a page whose fit-scaled width is less than the viewport width),
which also pushed the page over a page-turn tap zone so every tap turned
the page instead of opening the menu.

Bump the foliate-js submodule (readest/foliate-js#50) to center the lone
portrait page and add a regression test for computeSpreadInlineMargins.

Fixes #4984

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 11:43:15 +02:00
Huang Xin 56abcb4a6c feat(sync): S3-compatible cloud sync provider (#4990)
* feat(sync): S3-compatible cloud sync provider with premium-gated chooser

Add a third file-sync backend for any SigV4 object store (Cloudflare R2,
AWS S3, MinIO, Backblaze B2), end to end: SigV4 transport via aws4fetch
(path-style addressing, ListObjectsV2 with page draining, per-key
deletes, presigned streaming on Tauri, Drive-style error mapping and
backoff), S3 settings slice and defaults, exclusive provider activation
and cross-window flag broadcast, registry memoization, and an
Integrations chooser entry plus connect form that validates the bucket
with one signed listing.

Shared helpers settingsKeyForBackend and cloudProviderDisplayName
replace the scattered per-kind ternaries across the reader and library
sync hooks, fleet detection, and the settings surfaces.

The chooser now marks third-party providers with a Premium badge and
enforces the paywall (CLOUD_SYNC_REQUIRES_PREMIUM on): free plans see
the rows but route to the upgrade page instead of the config sub-pages,
and a downgraded account's still-selected provider is paused rather
than silently falling back to Readest Cloud uploads. Manual provider
sync now reports "N book(s) synced" like the native cloud sync, from
the engine result returned by runActiveFileLibrarySync.

The S3 transport passes the same provider semantic contract as WebDAV
and Google Drive.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* perf(sync): make the library row the ground truth for local file presence

Every sync run re-walked all books whose file is recorded nowhere and
paid two plugin:fs|exists IPC per book per run on Tauri, just to relearn
"no local source", ending in 0 books synced. The library row already
tracks local presence reliably (import, download, and delete all stamp
downloadedAt, and the metadata merge keeps it device-local), so the
file-push gate now trusts the row: a book the row marks as absent costs
zero filesystem and zero remote probes, keeping incremental sync a pure
metadata diff at any library size.

A session-scoped per-provider memo additionally suppresses re-probes of
drifted rows (the row claims a file the filesystem no longer has),
keyed to the book's updatedAt so any local change re-qualifies it.
Row-vs-filesystem split-brain in either direction is healed by Full
Sync, which bypasses the gate, the memo, and the uploaded-file record
and audits the real filesystem.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(sync): tiered request timeouts for the WebDAV client

An unreachable or dead server (a LAN host that went away) left PROPFIND
and HEAD requests pending indefinitely, pinning the Integrations panel
on "Syncing..." and the browse pane on a spinner. Metadata round-trips
(PROPFIND, HEAD, MKCOL, DELETE) answer with headers only, so they now
abort after 5 seconds; GET and PUT carry book-sized bodies over
possibly slow links and keep a 5 minute ceiling instead. Expiry aborts
the request via AbortController and surfaces as a "Request timed out"
NETWORK failure through the existing WebDAVRequestError taxonomy.

Since every library sync run opens with the HEAD etag probe on
library.json, a dead server now fails the whole run within seconds.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(settings): provider panel status and layout fixes

Three small fixes across the provider settings panels:

- A completed manual "Sync now" clears the provider's lastError so the
  Cloud Sync chooser row and the SettingsMenu sync row stop reading
  "Sync failed" after the server comes back; a failed manual run now
  records the error for those surfaces too. Covered by a render harness
  that drives the real form against a mocked engine.
- The sync row shows a relative "Synced a few seconds ago" label (same
  wording as the SettingsMenu row) instead of an absolute timestamp.
- The Google Drive configured-but-inactive state rendered its Tips above
  the action buttons; Tips now close the page in every provider panel
  state.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(i18n): translate the S3 provider and premium gating strings

New keys from the S3-compatible provider (form fields, chooser entry,
tips), the Premium badge, and the parameterized provider tips,
translated across all 33 locales.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(memory): record the S3 provider and sync optimization notes

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 09:34:28 +02:00
Huang Xin 600d69fa50 fix(reader): gate route View Transitions on API support (READEST-9) (#4989)
* fix(reader): gate route View Transitions on the API, turns on groups (READEST-9)

Reverts #4949, which opened books through the plain router to dodge the
"Transition was aborted because of timeout in DOM update" TimeoutError
(Sentry READEST-9). Rather than carve the transition out of one flow, gate it
at the router: useAppRouter routes through the View Transition router only
where the engine has the View Transitions API, and every into-reader path
(including the reverted ones) goes back through useAppRouter.

The base View Transitions API and nested view-transition groups reach very
different browsers, so they become two separate appService capability flags,
each backed by a probe in utils/viewTransition:

* supportsViewTransitionsAPI (document.startViewTransition): the baseline a
  route crossfade needs, landing on Chrome 111+, Safari 18+, recent WebView.
  Gates the router.
* supportsViewTransitionGroup (view-transition-group: nearest, Chrome/WebView
  140+): the far narrower target the paginator's layered turns require. Gates
  the turn-style options and the captured-turn fallback.

Both flags fold in the Linux WebKitGTK carve-out because it crashes on the
snapshot, matching the supportsCanvasContext2DFilter precedent.

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

* fix(tts): enlarge the Now Playing bar and scale its controls responsively

Grow the collapsed bar to h-14 with a 10x10 cover and symmetric px-2 padding,
drive the play/pause and close icons through useResponsiveSize instead of fixed
pixel sizes, and cut the bottom safe-area contribution to a third so the bar
sits closer to the screen edge.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 09:20:06 +02:00
Huang Xin ccb937015d feat(sync): incremental file sync and per-book transfers for the active provider (#4982)
* fix(sync): record remote-present files for no-source books in the upload cursor

With "Upload Book Files" on, a device that holds no local copy of a book
(e.g. the web app with a cloud-only library) HEAD-probed the remote for
every book on every sync: pushBookFile returned 'no-source' and the hash
was never recorded in library.json's uploadedHashes, so needsFilePush
stayed true for the whole library and each run (tab focus, Sync Now,
library change) issued one Drive/WebDAV request per book, 646 requests
per sweep in the reported case.

The HEAD probe already answers whether the file is on the remote. Carry
that in PushBookFileResult.remoteExists and record the hash when a
no-source book's file is already mirrored, so the next incremental sync
skips it and stays O(changed). Books absent both locally and remotely
stay unrecorded so a device that has the bytes can upload them later.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(sync): reach the no-source verdict without probing the remote

A book file sync with "Upload Book Files" on probed the remote for every
book before checking whether this device even holds the bytes. On a
device with a cloud-only library (the web app), none of the books have a
local source, so every sync run (tab focus, Sync Now, library change)
issued one name-lookup request per book against Google Drive, a full
per-book request storm that never converged: with no local file there is
nothing to upload and nothing to record, so the next run repeated it.

Resolve the local source first and return 'no-source' from local state
alone; the remote head probe now runs only when there is a local file to
compare or upload. The probe keeps its non-NETWORK rethrow semantics so
the auth-failure latch (#4981) still stops a run on an expired session.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(sync): incremental file sync and per-book transfers for the active provider

Cut the redundant remote work a file-sync run does and route explicit
per-book uploads/downloads to the active third-party provider (WebDAV /
Google Drive) instead of the gated Readest Cloud transfer queue.

Engine (services/sync/file/engine.ts, wire.ts):
- etag change-probe: one HEAD on library.json, cached per provider for the
  session. When the etag matches the last successful pull, reuse the cached
  index and skip both the index download and the discovery scan. An AUTH
  failure on the probe aborts the run like the full pull does.
- emptyDirs memo carried in the index: dirs found to hold no book file are
  recorded so clients stop re-listing them every run. Re-checked when the
  file arrives (uploadedHashes), on Full Sync, or when a legacy client drops
  the record; pruned only against a listing that actually ran.
- skip the index re-push when the rebuilt index is semantically identical to
  the pulled one, so a restamped byte-copy no longer churns the remote and
  invalidates peers' etag change detection.
- downloadBookFile() for the explicit per-book Download action.

Provider reuse (services/sync/file/providerRegistry.ts):
- memoise one provider per connection key, shared by every surface (reader
  per-book sync, library auto-sync, Sync now / pull to refresh). Reuses the
  Drive path->id cache instead of re-resolving /Readest, books/ and
  library.json by name query on every engine build. Drive connect/disconnect
  resets the cache since its token source changes identity with no key input
  changing.

Google Drive (services/sync/providers/gdrive/GoogleDriveProvider.ts):
- write fast-path: PATCH a path whose id is cached in place with no lookup,
  falling back to a full resolve on a 404 (stale id). Removes a files.list
  per PUT in the steady state.
- dev-only request diagnostics: one line per provider op and per HTTP attempt
  so a run's request budget can be attributed from the console.

Per-book transfers (services/sync/file/runLibrarySync.ts):
- runActiveFileBookUpload / runActiveFileBookDownload build the active
  provider's engine and push/pull a single book, stamping downloadedAt like
  the native path. Wired into the reader/library book actions with toasts.

UI, status, and i18n:
- Readest Cloud sub-page: drop the quota stats and wrap the "Account and
  Storage" row in the BoxedList primitive so it aligns to the design system.
- Shorten provider status/toast copy ("Active", "Google Drive session
  expired", "Library sync via {{provider}}", "KOReader") and translate the
  new keys across all locales.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 22:49:35 +02:00
Huang Xin bcd27b7047 chore(i18n): translate the cloud sync provider-selection strings (#4980)
Adds the 22 strings introduced by the provider-selection series (#4971,
#4973, #4975, #4976) to all 33 locales with real translations, plus the
per-locale CLDR plural forms for the quota batch-failure message and the
hand-curated en _one/_other variants.

Keys were appended without running the scanner's removeUnusedKeys pass,
which would have pruned unrelated live translations; the diff is
additions only. Terminology matches each locale's existing sync strings
(library, reading progress, highlights, storage quota).

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 22:29:26 +02:00
Huang Xin 3503b0234f fix(sync): abort the file-sync run on auth failure instead of marching the library (#4981)
With an expired Google Drive web session, syncLibrary swallowed the
AUTH_FAILED from the index pull and proceeded with remoteIndex = null,
which is indistinguishable from a first sync: every book looked
unpushed, uploadedHashes was empty, and the engine attempted to upload
the entire library (Uploading 16 / 682), logging one failure per book.
Worse, a null index also skips the peers-tombstone union in the final
index re-push, so a transient index-pull failure could have rewritten
library.json and resurrected deleted books (#4860 class).

- An unreadable index (throw) now aborts the run; an absent one
  (404 -> null) keeps first-sync semantics.
- A terminal-failure latch stops the work pools on the first mid-run
  AUTH_FAILED (a token can expire mid-run), skips the index re-push,
  and rethrows so callers surface one re-auth error instead of a
  per-book failure list. Mirrors the existing deleteRemoteBookDir
  contract: auth failures rethrow, everything else aggregates.
- On web, the auto library sync now skips while the Drive session is
  expired (hasValidWebDriveToken), matching the settings form's
  disabled Sync now with its Reconnect CTA.

Tests: unreadable-index abort with zero writes, mid-run abort bounded
to in-flight work with no index re-push, non-auth failures still
non-aborting, absent-index first sync unchanged.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 19:14:54 +02:00
Huang Xin db1d63cdcc test(reader): harden fixed-layout wheel double-scroll test against CI flake (#4978)
The readest#4727 regression test set scrollTop=0, dispatched a synthetic
wheel, waited 60ms, then asserted scrollTop stayed 0. On slow CI runners it
flaked with "expected 4 to be +0".

As sibling scroll pages finish loading, the renderer runs
restoreScrollModeAnchor asynchronously, which at scrollTop=0/page-index-0
snaps scrollTop to page 0's offsetTop, the 4px scroll-page-gap margin. The
60ms post-dispatch delay raced that re-anchoring, so the assertion observed 4
instead of 0. That 4 is unrelated to the wheel bug, which is a 120px jump.

The buggy handler was scrollBy with instant behavior, a synchronous scroll
that lands before dispatchEvent returns. Measure scrollTop synchronously
before and after the dispatch with no await in between and assert they match.
This isolates the wheel handler's own effect and is immune to the async
re-anchoring. Reintroducing the bug still fails the test (before=4, after=124,
a clean 120px delta).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 18:28:11 +02:00
Huang Xin 57868a138e feat(settings): unified Cloud Sync chooser with Readest Cloud as a first-class provider (#4976)
The Integrations section is now one Cloud Sync chooser: Readest Cloud,
WebDAV, and Google Drive as radio rows, Readest Cloud first, with a
scope subtitle stating what the choice governs (library data, on this
device) and what always stays with the Readest account (settings,
statistics, dictionaries).

- Activation helpers move to services (cloudSyncActivation.ts) and
  accept 'readest' (= no third-party provider active); the component
  module re-exports for existing imports.
- Row status lines come from a pure, fully-tested state matrix
  (cloudSyncStatus.ts) so every string is enumerated in one place:
  signed-out / loading / active / available for Readest Cloud;
  not connected / configured / paused / syncing / sync failed /
  book-file-uploads-off warning / active for third-party rows. The
  paused state renders on the affected third-party row (the plan sketch
  placed it on the Readest row; the provider that is paused is the
  third-party one).
- The Readest Cloud row opens an inline sub-page (SubPageHeader + the
  storage/translation Quota + a NavigationRow out to Account) instead of
  ejecting from the Settings dialog; signed-out taps go to login and the
  radio is unchecked and disabled, so an idle signed-out state never
  shows a checked radio while nothing syncs.
- Premium-gated builds keep the Readest Cloud row and show the upgrade
  prompt only in place of the third-party rows.
- Two-direction capability Tips in the WebDAV/Drive sub-pages spell out
  both what syncs only to the user's server and what still flows through
  the Readest account; the Upload Book Files description notes that
  Readest Cloud uploads pause while the provider is selected.
- Manage Sync book/progress/note rows swap their description to
  'Managed by {{provider}}...' via the existing locked-row pattern while
  a third-party provider is selected; the toggles stay interactive and
  persist since they govern the native channels after switch-back.
- The chooser rows form a radiogroup (native same-name radios provide
  arrow-key group movement) with an accessible group label.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 18:16:14 +02:00
Huang Xin e08622b416 feat(sync): route library sync exclusively to the selected cloud provider (#4380) (#4975)
While WebDAV or Google Drive is the selected cloud sync backend, the
native Readest Cloud book/progress/note channels are gated off and the
file-sync engine owns library data end to end:

- isSyncCategoryEnabled returns false for book/progress/note (and their
  legacy aliases) when a third-party provider is selected. A runtime
  override, deliberately not written into syncCategories: the user's own
  toggles persist and take effect again on switch-back. Account channels
  (settings, stats, dictionaries, fonts, textures, OPDS catalogs) always
  stay native.
- persistActiveCloudProvider is the single write path for provider
  switching, used by the chooser, both connect/disconnect flows, and the
  Drive OAuth callback (which previously bypassed the cross-window
  broadcast). The broadcast carries ONLY the enabled flags plus
  providerSelectedAt, never credentials or sync cursors, and only on
  switch events, so a stale window's routine save cannot revert a switch.
- buildWebDAVConnectSettings no longer pre-sets enabled: activation
  belongs to withActiveCloudProvider, so the fresh-connect path now gets
  the syncBooks auto-flip and the providerSelectedAt stamp.
- Sync health: fileSyncStore records lastError per backend; the durable
  lastSyncedAt stays in provider settings. The SettingsMenu sync row
  reads Synced via provider / Sync failed and its tap (with pull to
  refresh and BackupWindow, all routed through pullLibrary) runs the
  file engine via the shared runActiveFileLibrarySync helper instead of
  a gated native pull that would toast undefined book(s) synced.
- Mixed-fleet detection: while gated, the auto-sync interval runs a
  read-only probe of /api/sync since providerSelectedAt; any newer book
  row means another device still syncs natively, and a once-per-session
  notice explains the fork instead of leaving it silent.
- Readest-Cloud-only affordances hide while a third-party provider is
  selected: the quota row becomes a caption naming the active provider,
  Auto Upload disappears from the menu and command palette, the
  BookItem upload badge and the Transfer Queue Upload All button hide.
- providerSelectedAt added to both provider settings types and the
  backup blacklist.

Stacked on the quota-decoupling change for #4959; requires the
metadata-parity change so gated channels lose nothing users can see.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 18:05:54 +02:00
Huang Xin f805477091 perf(koplugin): defer and cache Library group covers (#4954) (#4974)
Opening the Library with a large grouped library was slow because each
folder's 2x2 cover mosaic was recomposed from scratch on every paint (up
to 4 MuPDF cover decodes plus scales per cell). On a 685-book library this
dominated the synchronous open path (254ms of 300ms) and ran again on the
post-sync refresh. Navigation felt fast only because drilling into a group
shows single covers, not mosaics.

Mirror cloud_covers' async pattern in group_covers:
- Cache the composed master bb per group, keyed by a signature that flips
  when the child set or any child's cover availability changes; serve
  cheap copies on a hit. Cache a nil result too, so a group whose covers
  are not ready yet keeps its placeholder without recomposing on every
  refresh; a later cover download flips the signature and recomposes once.
- Compose off the first-paint path: a miss enqueues a background job (one
  mosaic per UI tick) and returns nil so the cell paints its FakeCover
  placeholder immediately; finished mosaics coalesce into one refresh.
- Free cached masters when the Library closes.

Measured synchronous open path drops from 300ms to 151ms on a 685-book
library; mosaic compositing moves off the blocking paint and fills in
progressively. Adds open-path timing logs to librarywidget and
localscanner for on-device diagnosis of future large-library reports.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 17:55:51 +02:00
Huang Xin a72f535346 feat(sync): propagate tags and reading status through third-party file sync (#4973)
The library.json index already carries full Book objects, but the
metadata merge overlay dropped tags and readingStatus on apply, so
tagging or marking a book Finished never reached peers syncing via
WebDAV or Google Drive (the same overlay gap that hit group membership
in #4942):

- mergeBookMetadata carries tags with the metadata LWW subset (raw
  assignment, so tag removal clears on peers) and merges readingStatus
  on its own readingStatusUpdatedAt clock, the client-side mirror of the
  native field-level server merge. This survives the asymmetric race
  where one device edits metadata after a peer changes the status;
  whole-book LWW alone would drop the status change.
- New shouldApplyRemoteBookMetadata reconciliation predicate triggers on
  either clock; the engine's index reconcile uses it so a status-only
  change propagates without a metadata edit.
- Merge-law tests (direction, removal, idempotence, asymmetric races)
  plus engine-level propagation tests for both fields.

Prerequisite for gating native sync when a third-party provider is
selected (#4380): third-party sync should reach metadata parity before
it becomes the only channel.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 17:51:10 +02:00
Huang Xin 942c062d35 fix(sync): decouple Readest Cloud storage quota from third-party cloud sync (#4959) (#4971)
When a third-party provider (WebDAV or Google Drive) is the selected
cloud sync backend, Readest Cloud storage is no longer written to:

- New src/services/sync/cloudSyncProvider.ts policy module: the selected
  provider is derived from the existing per-device enabled flags
  (webdav wins deterministically if both are ever set); the premium
  guard resolves to a PAUSED state instead of silently falling back to
  Readest Cloud, with the user plan cached for non-React modules.
- transferManager gates book uploads on the selected provider: queueUpload
  returns null when gated; pending book uploads from before a provider
  switch are visibly cancelled (cancelReason policy) and pruned on the
  next restore; downloads and replica transfers are never gated.
- Book uploads are deferred until settings hydrate (settings.version
  barrier) so a persisted queue cannot be mis-processed at startup;
  replica transfers are not stalled.
- Quota-exceeded uploads fail fast with zero retries, and a batch import
  produces one summary toast instead of one toast per book.
- Policy cancellations are a distinct bucket via a shared predicate:
  excluded from failed stats, Retry All, and the per-item Retry button.
- Auto-upload call sites (ingest, OPDS, subscriptions) check the provider
  gate; the explicit Upload Book action explains the gate with a toast
  instead of silently doing nothing.
- Activating a provider auto-enables its syncBooks so books keep backing
  up somewhere; a one-time migration (20260706) applies the same flip for
  users who already had a provider enabled.
- webdav.deviceId and webdav.lastSyncedAt are excluded from backups,
  matching the existing googleDrive entries.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 17:31:15 +02:00
Huang Xin a02b236e97 fix: more production crashes (View Transition noise, book-dir race, stats transaction) (#4962)
* fix(sentry): drop more benign View Transition rejections

Broaden is_ignored_browser_error to also drop "Transition was skipped"
(navigation superseded, READEST-F) and "aborted because of invalid state"
(READEST-G), matched case-insensitively alongside the existing hidden-tab case.
These are expected browser behavior — the navigation completes, only the
animation is skipped/aborted. A transition timeout stays visible (real perf
signal, handled separately).

Fixes READEST-F
Fixes READEST-G

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

* fix(library): create the book directory idempotently

Importing a book did a check-then-create with a non-recursive createDir. Two
concurrent imports of the same book both pass the exists check, then the second
create fails — on Windows with "Cannot create a file when that file already
exists" (Sentry READEST-H). Use a recursive create (create_dir_all), a no-op
when the directory already exists.

Fixes READEST-H

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

* fix(stats): serialize applyRemoteEvents to avoid nested transactions

The statistics connection is shared across ReadingStatsTracker instances (split
view). applyRemoteEvents runs a manual BEGIN/COMMIT that the per-op native
connection lock does not make atomic, so two concurrent pulls opened a BEGIN
inside a BEGIN ("cannot start a transaction within a transaction", Sentry
READEST-N). Serialize applyRemoteEvents against itself with a small promise
mutex. Adds a regression test that fails without the guard.

Fixes READEST-N

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 17:05:35 +02:00
Huang Xin 2a837cb50d fix(reader): fix PDF text selection misplaced by OS font scaling (#49) (#4960)
Bumps foliate-js to include readest/foliate-js#49, which corrects PDF text
selection and highlighting drifting down and spilling into the margins when
the device's system font-size accessibility setting is larger than default.
Android scales the transparent text layer's glyphs but not the page canvas,
so the text layer now divides that scale back out of the glyph size.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 08:50:35 +02:00
Huang Xin f7f85330ae chore(agent): update agent memories (#4958) 2026-07-06 07:29:34 +02:00
Huang Xin 4527aa277a feat(reader): add TTS speak button to dictionary popup (#4876) (#4957)
Add a speaker button to the dictionary popup/sheet header that pronounces
the current headword. Tapping it speaks via Edge TTS, falling back to the
platform speech engine (Web Speech on desktop/web, native on the mobile
app) when Edge is unavailable.

To speak as soon as possible, a dedicated wordPronouncer bypasses the
reader's TTSController entirely: it never runs EdgeTTSClient.init() (which
wastes a round trip synthesizing "test"), calls EdgeSpeechTTS directly
(whose static MP3 cache makes repeat words instant), and schedules one
chunk on a dedicated Web Audio context isolated from any active read-aloud
session. The context is warmed synchronously inside the click gesture so
playback is not blocked by autoplay policy after the network await.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 06:56:51 +02:00
Huang Xin 6f3b401c24 feat(reader): middle mouse button autoscroll in scrolled mode (#4955)
* feat(reader): add middle mouse button autoscroll in scrolled mode

Middle-clicking a book in scrolled mode on desktop apps plants an anchor
indicator and scrolls with a velocity proportional to the pointer's
distance from it, like browser autoscroll (#4951). A quick click sticks
until the next click, wheel, or Escape; press-move-release scrolls only
while held. Vertical-writing books autoscroll along the horizontal axis.

The middle button's default is suppressed while the feature is armed so
WebView2's native autoscroll cannot double-drive on Windows. A new
Middle-Click Autoscroll toggle in the Scroll settings section (desktop
only, default on) turns it off.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(reader): drop the middle-click autoscroll toggle

Middle-click autoscroll is a common desktop convention and middle click
has no other use in the reader, so it is always enabled on desktop apps
in scrolled mode instead of being a setting.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(i18n): translate missing strings across all locales

Fill in translations for 69 keys that landed on main without an i18n
pass (search modes, cloud sync and Google Drive settings, page turn
animation styles, TTS states, Word Lens hints, file browser sorting,
watched-folder auto-import) in all 33 locales, plus the English plural
variants for the search result count.

Keys the scanner would prune (strings it cannot see statically on this
branch) are left untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 05:01:30 +02:00
Huang Xin da00a94f66 feat(sentry): tag events with the WebView engine and version (#4952)
Forwarded browser events carry os/rust/device context but no browser context,
so crashes couldn't be correlated with the WebView version. The app now reports
its User-Agent at startup via a set_webview_info command; the parsed engine
(Chromium/WebKit) and major version are stored and attached as webview.engine
and webview.version tags in before_send, covering both Rust panics and
forwarded browser events.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 21:12:21 +02:00
Huang Xin 52be6fa066 fix(reader): open books without a View Transition to avoid timeout (#4949)
useAppRouter wraps every navigation in a View Transition. Opening a book is a
heavy render (the reader mounts and loads the book) that can overrun the
transition's ~4s DOM-update budget and abort with a TimeoutError (Sentry
READEST-9). Navigate to the reader with the plain router instead, matching
every other into-reader path in the app; the transition router stays for
lighter navigation. Applies to the tap-to-open flow (useOpenBook) and the
post-import queued open (library/page).

Fixes READEST-9

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 21:12:05 +02:00
Huang Xin 75f1fafe9f feat(reader): slide and page curl turn animations (#555) (#4940)
* feat(reader): slide and page curl turn animations (#555)

Add an Animation Style setting (Push, Slide, Page Curl) next to the
Paging Animation switch. Slide moves the turning page over the still
previous or next page like the Apple Books slide; Page Curl folds it
open in 3D so the page underneath is partially visible as it turns.
Both styles track the finger: the page follows a horizontal drag and
commits past halfway or on a flick, or settles back. The page header
and footer stay in place while the page turns.

The styles layer a View Transitions snapshot of the outgoing page over
the live, stationary incoming page, since the pages of one section live
in a single iframe and can never be on screen twice. They work for all
writing modes including vertical-rl, and on engines without the View
Transitions API (older WebViews) the paginator falls back to the
existing push animation, so all platforms keep working page turns.

The paginator changes live in the foliate-js submodule; this bumps the
pointer, wires viewSettings.pageTurnStyle to the renderer turn-style
attribute, and adds browser tests covering slide layering, curl,
vertical-rl, finger tracking with commit and revert, and the push
fallback.

Fixes #555

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(reader): add WebGL page curl renderer for mesh turn animations (#555)

Grid mesh deformed around a cylinder: content past the fold wraps over
and lands mirrored on top with a whitened page back, transparent where
the page has curled away. Corner grabs start as a steep diagonal pinch
that straightens as the turn completes so the whole page clears by the
end. Groundwork for the Tauri mesh curl; capture and orchestration land
separately.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(native-bridge): capture webview region as PNG on macOS and iOS (#555)

New capture_webview_region plugin command returns a binary PNG snapshot
of the calling webview (tauri::ipc::Response, no JSON overhead) for the
mesh page-curl texture. macOS goes through WKWebView
takeSnapshotWithConfiguration via with_webview on the main thread with
a 500ms timeout; iOS snapshots in Swift and hands the PNG across the
JSON-only plugin boundary base64-encoded, decoded back to bytes in
mobile.rs. Windows, Linux, and Android reject for now so the JS side
falls back to the CSS curl.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(reader): drive the mesh page curl on Tauri platforms (#555)

Wire the WebGL curl renderer and the native webview capture into page
turns. A MeshCurlTurn controller runs the pipeline per turn: snapshot
the content box, overlay the captured page drawn flat, turn the live
view instantly underneath (the paginator's animated paths all gate on
the animated attribute), then curl the capture away. Backward turns
mirror the fold to the spine edge, matching the layered VT curl's
old-page-recedes choreography.

useMeshPageCurl wraps the view's prev/next so taps, keys, and wheel
turns all curl, and registers a touch interceptor (between the reading
ruler and the fixed-layout swipe) that scrubs the curl from the finger,
committing past halfway or on a flick and otherwise un-curling and
turning back under the overlay. The paginator stays out of the way via
no-swipe while the mesh is active; if the native capture ever fails the
session falls back to the paginator's CSS arc-fold curl and the shared
applyPageTurnAttributes helper restores turn-style.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(build): restore iOS builds on Xcode 26.2 with a vendored swift-rs

Swift 6.2's driver no longer honors swift-rs 1.0.7's cross-compilation
style (swift build --arch <host> with per-swiftc -target overrides and
an inherited SDKROOT): plugin sources compile against the wrong
platform's Swift overlays and fail with baffling errors like type
'Bundle' has no member 'main' and extra argument 'privacy' in call.
Upstream swift-rs is unmaintained, so vendor it under packages/swift-rs
via a crates-io patch and build with SPM's first-class --triple/--sdk
flags instead, dropping the leaked SDKROOT so the host-targeted
manifest compile stays clean. Artifacts land in the unversioned-triple
directory now, so the link search path follows.

With --triple, SPM enforces the deployment floor declared in
Package.swift (the old override bypassed it): bump native-bridge to
iOS 15.0, matching the app's deployment target, since StoreKit's
Storefront is used unguarded.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(native-bridge): capture webview region on Android via PixelCopy (#555)

Implements the Android side of capture_webview_region so the mesh page
curl works there too. The Kotlin command scales the CSS-pixel rect by
the display density, offsets it by the webview's window position, and
reads the pixels back from the window surface with PixelCopy (API 26+,
the app's minSdk), which includes the hardware-accelerated WebView that
View.draw would miss. PNG encoding runs off the main thread and the
result crosses the JSON plugin boundary base64-encoded, decoded back to
bytes in mobile.rs like iOS.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(reader): right the upside-down page curl on iOS (#555)

The renderer oriented its texture with UNPACK_FLIP_Y_WEBGL, which WebKit
ignores for ImageBitmap uploads: on iOS the captured page rendered
upside down, and the mirrored page back read as rotated 180 degrees
instead of the ink-through-paper horizontal mirror Apple Books shows.
Upload unflipped and sample page coordinates directly so no pixel-store
flag is involved.

The page texture in the browser test was only horizontally asymmetric,
which is how the flip slipped through; it now uses four quadrants fed
through the production PNG-blob-to-ImageBitmap path and pins the
vertical orientation. Verified red/green by running the suite on
Playwright WebKit, which reproduces the iOS behavior.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(reader): curl the whole page including header, footer, and margins (#555)

The mesh curl captured only the margin-inset content box, leaving the
running header, footer, and page margins static while just the text
column turned. A physical page turn takes the whole sheet with it, as
Apple Books does, so the capture and overlay now span the full reader
cell. The overlay mounts above the in-cell header (z-10) and footer, so
the static copies never show through the turning page.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(reader): gate layered View Transition turns and slide from a capture instead (#555)

iOS 18 WebKit ships document.startViewTransition but crashes the WebContent
process when a page-turn transition snapshots the reader, so the mere
presence of the API is not enough for the layered slide/curl turns. Require
nested view-transition groups (Chrome/WebView 140+) as the marker of a
mature engine before setting turn-style on the renderer.

Engines that fail the check no longer lose the slide on Tauri: the mesh
curl's capture pipeline generalizes to CapturedPageTurn and now also drives
a flat slide overlay (capture the outgoing page, turn instantly underneath,
translate the captured page out toward the spine, mirrored for backward
turns), clipped to the content box with an edge shadow like the VT slide.
On the web, engines without full support fall back to push and the
Slide/Page Curl options are hidden from the Animation Style select; a
synced slide/curl setting from another device reads as Push there.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(reader): make the Android page curl start instantly (#555)

The Android capture encoded a full-density PNG: 1080x2400 on a 3x
Xiaomi 13 took ~1.5s per turn, so the page sat frozen long enough to
read as the curl not working at all. Encode JPEG instead (the page is
opaque) and cap the destination bitmap at 2x CSS pixels - PixelCopy
scales into a smaller bitmap for free and the moving page stays sharp.
Measured on device over CDP: the capture invoke drops from 1550ms to
34ms and the curl overlay mounts 132ms after the tap.

The JS side stops hardcoding an image/png blob type and lets the
decoder sniff the platform's actual format.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* perf(reader): encode iOS page-curl captures as capped JPEG (#555)

Apply the Android speedup to iOS: encode the snapshot as JPEG (the
page is opaque) off the main thread, and cap it at 2x CSS pixels via
WKSnapshotConfiguration.snapshotWidth on 3x screens, cutting both the
encode time and the base64 payload crossing the JSON plugin boundary.
The JS side already sniffs the image format from the bytes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 20:49:57 +02:00
Huang Xin 9321c2cd39 fix(widget): round iOS cover thumbnail size to whole pixels (#4950)
The iOS reading widget downsampled covers to a fractional target size.
UIGraphicsImageRenderer allocates a whole-pixel buffer while draw(in:)
fills only the exact fractional rect, so for portrait covers whose scaled
width rounds up the rightmost pixel column was left partially covered and
semi-transparent. Encoded to JPEG that column flattened into a visible
bright hairline along the right edge (intermittent, portrait covers only).

Round both target dimensions to whole pixels so the draw rect matches the
pixel buffer and every edge pixel is fully covered. Android is unaffected
because it scales to a fixed 240x360 and center-crops.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 20:24:40 +02:00
Huang Xin e7f0b53bdf fix(opds): crawl subdirectories when auto-downloading directory-style catalogs (#4948)
Copyparty and other file servers expose each folder as an OPDS feed where
subfolders are rel="subsection" navigation entries. Auto-download only
followed the catalog's "by newest" feed or the subscribed feed itself, so
books in subfolders were never discovered, and a folder containing only
subfolders was skipped entirely.

When a catalog has no "by newest" feed, treat it as a directory-style
listing and crawl its subsection navigation entries breadth-first, bounded
by MAX_CRAWL_DEPTH levels, MAX_FEEDS_PER_CRAWL fetches, and the visited
set. Library catalogs with a "by newest" feed keep the previous behavior
and are never crawled. Facet and structural rels (self, up, start, top,
search) are excluded so the crawl cannot escape the subscribed folder.

Fixes #4272

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 19:31:57 +02:00
Huang Xin 3f4d4b8643 fix(transfer): persist queue when clearing completed/failed/all (#4947)
Clear Completed, Clear Failed and Clear All mutated the Zustand store
directly, so the cleared items were never written back to localStorage.
On the next load the persisted queue restored them and they reappeared
in the Transfer Queue panel.

Route these clears through transferManager (like clearPending already
does) so each calls persistQueue() after mutating the store.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 19:01:43 +02:00
Huang Xin 9202864846 fix: real fix for library-save storage-permission crash + narrowed view-transition filter (#4943)
* fix(library): request storage permission when saving to a custom folder

On Android a custom library folder on shared storage needs All Files Access.
The import/settings/migrate paths request it, but the library-save path
(updateBook/updateBooks -> saveLibraryBooks) did not, so on a device without
the permission every save failed with EACCES; because callers (sync, imports)
don't await/catch it, it surfaced as an unhandled-rejection crash (Sentry
READEST-A). saveLibraryBooks now catches a storage-permission error, requests
the permission through the existing flow, and retries once. It prompts at most
once per session so background saves don't repeatedly open system settings; a
still-denied save is logged rather than crashing (the user was already shown
the All Files Access screen).

Fixes READEST-A

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

* fix(sentry): drop only the benign hidden-tab View Transition error

The View Transition API skips a transition when the tab is hidden; that
unhandled rejection is expected browser behavior and pure noise, so it is
dropped in before_send. A transition timeout abort (READEST-9) is NOT dropped:
a slow DOM update can signal a real performance problem, so it stays visible.

Fixes READEST-7

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 18:30:32 +02:00
Huang Xin 2963e75bdd fix(sync): propagate group membership for already-synced books (#4946)
Group membership synced only for newly-imported books. Re-grouping a
book already present on both devices bumped book.updatedAt and won the
library-index LWW race, but mergeBookMetadata dropped groupId/groupName
from the overlay, so the change never reached peers. New books instead
arrive via addBookToLibrary with the full remote object, which is why
their group did travel.

Carry groupId/groupName in mergeBookMetadata, matching native cloud
sync (transform.ts maps group_id/group_name). Values are assigned raw
so a group removal also propagates.

Fixes #4942

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 18:27:17 +02:00
Huang Xin ec45a080fc feat(metadata): surface calibre custom columns from EPUB metadata (#4939)
Parse calibre's embedded user metadata (custom columns) from the OPF
in foliate-js, store it on BookMetadata.calibreColumns, render the
columns in the book details view, and match column names and values
in the library search so a value like a recommends tag can be found
by typing it.

Closes #4811
2026-07-05 18:08:36 +02:00
Huang Xin 0b180da6a6 fix(koplugin): key library pull cursor on synced_at to stop stale library (#4934) (#4944)
The KOReader plugin's incremental books pull went permanently stale: after
a while it stopped receiving any updates made from other devices, and only
deleting readest_library.sqlite3 + "Pull books now" recovered it (until it
re-broke). The iOS/web library was unaffected.

Root cause: since #4678 the server keys the books pull on the server-stamped
synced_at column, and the web client (computeMaxTimestamp) advances its
cursor from synced_at. The koplugin was left on updated_at: pullBooks
advanced last_books_pulled_at from max(updated_at, deleted_at). updated_at
is client-supplied, and the koplugin bumps it from the device clock
(touchBook = os.time()*1000). An e-reader clock ahead of the server (or any
row anywhere carrying a future updated_at) drove the cursor past server-now,
so the server's synced_at > since filter returned nothing forever.

The cursor was also shared between the pull side (compared vs server
synced_at) and push-delta detection (getChangedBooks vs local updated_at),
so it could not simply be retargeted.

Fix:
- parseSyncRow reads synced_at; new row_pull_cursor() prefers it and falls
  back to max(updated_at, deleted_at) for a pre-synced_at server, mirroring
  computeMaxTimestamp.
- Split the cursor: last_books_pulled_at now tracks server synced_at (pull
  only); new last_books_pushed_at tracks local updated_at for getChangedBooks
  and is advanced on both pull and push, preserving push dedup.
- v2 -> v3 migration seeds the push watermark from the old shared value and
  resets the pull cursor to 0, auto-healing already-stale installs with one
  full re-pull (no manual sqlite deletion) and no re-push storm.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 17:52:01 +02:00
Huang Xin 843ab3448b feat(tts): keep TTS playing when the book is closed (#4941)
* refactor(tts): controller owns its foliate TTS instance and emits lifecycle events

view.close() nulls view.tts, so the controller keeps its own handle
(mirrored to view.tts while attached; reads prefer the public mirror).
state becomes an accessor that dispatches tts-state-change on a
microtask, and terminal conditions (end of content, error exhaustion)
fire an explicit tts-session-ended: 'stopped' is a transit value that
occurs on every paragraph advance and must never be read as death.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(tts): make TTSController detachable from the reader view

detachView enters headless mode: layout-dependent work is guarded, the
dead hook's preprocess/section-change closures are severed, and text
supply continues through created documents while position events keep
flowing. attachView adopts a new view without touching in-flight audio,
re-seeding the fresh text instance from the old cursor AT the
synchronous swap point (auto-advance during async prep would otherwise
replay a paragraph) and aborting via an attach epoch when a detach
supersedes it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(tts): move media session ownership to a session-scoped bridge

ttsMediaBridge binds directly to the controller (metadata per mark,
clamped position state, transport handlers, the silent keep-alive
element) so the lock screen keeps working when the reader hook is
unmounted. The hook's last-writer-wins handler effect and its
per-render re-registration are gone; the panel now derives isPlaying
from the controller's state channel, so lock-screen transport keeps
the in-reader UI truthful. useTTSMediaSession had no consumers left
and is removed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(tts): add hash-keyed TTS session manager with sleep timer and headless persistence

Sessions key by book hash (bookKey is regenerated per open), the
playback-state relay dedupes transit stopped values so paragraph
advances never flicker followers, and terminal handling rides the
explicit tts-session-ended event. The sleep timer survives reader
unmount, and headless positions persist through the book config on
disk (view/progress stores are cleared on close and reopen loads
from disk).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(tts): keep TTS playing across book close and reattach on reopen

Back-to-library and Android back dispatch tts-close-book (detach when
the session is not terminated: transit stopped states during chapter
transitions must not kill it); quit and window-destroying closes keep
the hard tts-stop so the foreground service tears down with the
webview. The unmount cleanup transfers ownership to the manager
instead of shutting down, covering deep-link book switches and
split-view pane closes. Mounting a book adopts a matching background
session once the view is ready (primary pane only) and stops a
different book's session unless it is still mounted elsewhere. The
sleep timer moves to the manager and a one-time toast announces the
first background continuation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(tts): now-playing bar in the library for background sessions

Floating pill above the shelf while a TTS session outlives its
reader: cover, title, sleep-timer countdown, play/pause following the
manager-relayed playback channel, and a hard stop. Tapping the body
reopens the book in the SAME window regardless of the new-window
preference, since the session is a per-webview singleton. Deleting
the playing book stops the session before its data is cleared. The
bookshelf reserves scroll clearance via a --now-playing-inset var the
bar sets while visible.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(tts): make the header close button background-eligible

The header X routes through handleCloseBook (onCloseBook), not
handleCloseBooksToLibrary, so the sticky eligibility ref never got
set and closing a book from the header hard-stopped a live TTS
session. Replace the ref with an explicit keepTTSAlive parameter on
saveConfigAndCloseBook/handleCloseBooks: back-to-library, Android
back, and pane closes pass true; beforeunload, quit-app, and window
close invoke handleCloseBooks with an event object, which coerces to
a hard stop. This also removes the stickiness where one background
close would have made a later quit detach instead of stop.

Verified live in Chrome dev-web: close from the header keeps audio
playing with the now-playing bar shown; reopening reattaches the
same session (generation numbering continues); opening a different
book stops it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 17:38:57 +02:00
Huang Xin 42f9b8fe3c feat(tts): gapless Web Audio playback engine for Edge TTS with chapter timeline and seek (#4931)
* feat(tts): add PCM speech-bounds detection for sentence audio trimming

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(tts): add WSOLA time-stretch for pitch-preserved playback rate

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(tts): add sentence duration store with per-voice speaking-rate calibration

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(tts): serve edge audio as ArrayBuffer with in-flight fetch dedup

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(tts): add WebAudioPlayer with gapless chunk scheduling and backpressure

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(tts): play edge TTS through gapless Web Audio pipeline

Replaces the per-sentence audio element with trimmed, time-stretched
buffers scheduled on the shared AudioContext. Marks dispatch at audible
time so schedule-ahead cannot run foliate's cursor past the voice; a
decode failure or missing audio skips the chunk instead of wedging the
session; pause and resume ride context suspend and resume with no iOS
rewind hack; the object-URL cache is gone.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(tts): add section timeline with measured and estimated sentence durations

Includes the foliate-js submodule bump for the getSentences export
(fork branch feat/tts-get-sentences; fork PR must merge before this
lands so the pinned SHA resolves).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(tts): expose section playback position and sentence-snapped seeking

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(tts): surface playback position and seek in the media session

Position state is clamped, never skipped, so the lock-screen scrubber
stays live when estimates overshoot; seekto units map per backend
(native ms, web seconds). The AudioContext warms up in the tts-speak
gesture path before any await, and the silent keep-alive element now
runs on all platforms so desktop hardware media keys survive the
removal of the per-sentence audio element.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(tts): add seekable chapter progress bar to the TTS panel

The scrubber joins the transport cluster with a thin range-xs track and
flanking tabular time labels so it cannot be misgrabbed for the chunky
rate slider (which persists a global setting). States: reserved
disabled slot until the lazy timeline lands, persists across chapter
transitions, optimistic thumb with failure toast, monotonic position,
tilde-prefixed estimated totals, sentence-event updates under e-ink.
The popup grows only when a timeline-capable client is active.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: record deferred TTS listening-engine follow-ups

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: record background TTS decoupling design decisions

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(tts): slim the panel scrubber to a native track with remaining time

Match the footer Jump to Location slider (plain native range: thin
track, small thumb) instead of the chunky daisyUI pill, show remaining
time with a minus prefix on the right, and drop the This chapter
caption. Popup height shrinks accordingly. Verified live in Chrome.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore: pin foliate-js to merged main with getSentences export

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(tts): catch autoplay rejection from the keep-alive element

Running the silent keep-alive on all platforms exposed an un-awaited
play() that headless Chromium rejects without a user gesture, failing
CI on unhandled rejections while every test passed. The keep-alive is
best-effort; the production path is gesture-qualified.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 10:50:22 +02:00
dastarruer 727f6150a6 fix: change formatter to nixpkgs-fmt (#4932) 2026-07-05 08:52:48 +09:00
Huang Xin 4dbe9cc9f1 fix: Sentry production hardening (release/OS tags, unhandled-rejection & render-loop guards) (#4929) 2026-07-05 08:50:36 +09:00
Huang Xin 5301020a02 feat(koplugin): pull sync on device wake with book open (#4928)
Waking the device with a book already open now pulls progress,
annotations and stats like reopening the book does, instead of
requiring a manual sync or book reopen. The pull is delayed 1s so
Wi-Fi can come back up after wake (same delay upstream kosync uses
on resume), debounced against rapid Suspend/Resume pairs (Android
fires them on focus changes), and the pending task is dropped on
widget close so it cannot run against a torn-down ReaderUI.

Closes #4924

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 17:25:59 +02:00
Huang Xin 6013341cb8 fix(turso): bump plugin submodule to serialize connection operations (#4927)
Advances the tauri-plugin-turso submodule to include
readest/tauri-plugin-turso#2, which serializes execute/select/batch on a
single turso connection behind an async mutex. Fixes the "concurrent use
forbidden" crash seen in production (unhandled promise rejection in the
reader on Android): turso rejects overlapping operations on one connection,
and the plugin previously drove a shared connection from concurrent Tauri
commands with no serialization.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 17:11:29 +02:00
Huang Xin 6b403d019e feat(calibre): add Readest calibre plugin to push books and metadata (#4918)
* feat(calibre): add Readest calibre plugin to push books and metadata (#4863)

Add apps/readest-calibre-plugin, a calibre GUI plugin that uploads
selected books with their metadata into the user's Readest cloud
library, modeled on BookFusion's open-source plugin.

- Selective manual push from the calibre toolbar with per-book status
  (uploaded / updated / up to date / failed) and quota handling
- Books are content-addressed with the same partial MD5 as the apps,
  so re-pushing updates the existing entry instead of duplicating;
  metadata edits re-push without re-uploading the file
- Metadata mapping includes series, tags, identifiers and optional
  calibre custom columns; carries over server-side fields (progress,
  reading status, grouping, cover) that POST /sync would null out
- Auth mirrors readest.koplugin and the desktop app: email/password
  plus browser OAuth (Google/Apple/GitHub/Discord) through a localhost
  callback server with the fragment-to-query relay
- Pure-logic modules (api.py, wire.py, oauth.py) are calibre-free and
  covered by 56 unit tests (make test); make zip builds the plugin

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ci(release): package readest-calibre-plugin in releases

Mirror the KOReader plugin packaging: a build-calibre-plugin job stamps
PLUGIN_VERSION in __init__.py with the release version from
apps/readest-app/package.json, builds the zip via make, and uploads
Readest-<version>.calibre-plugin.zip to the GitHub release. The version
committed in git stays a development placeholder.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(agent): add calibre plugin project memory

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(calibre): embed metadata in OPF and dedupe by calibre uuid

Rework book identity so metadata can be embedded into the uploaded file
without creating duplicates, as requested in review:

- Embed calibre metadata (including custom columns) into a temporary
  copy of the book file at upload time via calibre's set_metadata; the
  library file is never modified
- Dedupe by the calibre book uuid carried in the entry's metadata
  identifier, which survives file-byte changes, with a live-row
  preference when both hash and uuid match rows
- Detect file content changes via calibreSourceHash, the raw library
  file fingerprint stored in the pushed metadata, so detection works
  from any machine; v1 rows fall back to book_hash which equals the
  raw hash for them
- A changed file now replaces the old entry in one sync push (new row
  with carried-over reading status, grouping, progress and created
  date, plus a tombstone for the old row) and deletes the old cloud
  files to reclaim quota
- Metadata-only edits still update the library entry without
  re-uploading the file

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(calibre): set copyright holder to Bilingify LLC

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 16:40:16 +02:00
dastarruer 395a1e67a1 fix(nix): get nix devshell working (#4883)
* fix: update flake lock and properly pass zlib to pkgconfig

* chore: install nixfmt formatter

* chore: add startup script

* fix: ignore files in the nix store

* docs: change required node version to v24

* chore: upgrade to node v24

* fix: specify XDG_DATA_DIRS to fix webkitgtk issues

* fix: add required config to get android emulator working

- Add a `postInit` script for the android shell to auto-configure an Android emulator
- Required compile-time dependencies
- Required compilation targets for app to successfully build and run.

* fix: silence warning by using stdenv.hostPlatform.system instead of system

* chore: remove android-studio
2026-07-04 12:56:21 +02:00
Huang Xin c86decc2c1 fix(test): make Android double-tap e2e pass on default-config CI devices (#4921) 2026-07-04 19:46:02 +09:00
Huang Xin 1d3dfd395f fix(ios): stop share extension hijacking shared .txt files (#4917)
The iOS share extension is a web-article URL clipper, but its activation
rule enabled NSExtensionActivationSupportsText. A .txt file is
public.plain-text (conforms to public.text), so that key made the
URL-only extension activate for plain-text files it cannot handle: the
share sheet hung instead of the file taking the main app's
CFBundleDocumentTypes "Copy to Readest" import path, which handles txt
fine (like EPUB and PDF, which never matched the extension).

Drop NSExtensionActivationSupportsText so the extension activates only
for web URLs. Shared .txt files now route to the working document-open
import path; sharing a web page URL from Safari or Chrome still works.

Add a regression guard asserting project.yml (the xcodegen source of
truth for the generated, skip-worktree Info.plist) never re-enables text
activation.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 04:05:15 +02:00
Huang Xin 967a7833ca feat(sentry): add crash reporting for Android, iOS, desktop, and web (#4914)
Report crashes and unhandled errors from every layer to one Sentry project via a
single build-time SENTRY_DSN (empty => disabled, so local and fork builds do not
report):

- JS/WebView + Rust panics via tauri-plugin-sentry (rustls transport; minidump
  handler desktop-only).
- Android native (JVM/NDK/ANR) via sentry-android 8.47.0 with manifest auto-init
  (excludes the discontinued lifecycle-common-java8 transitive).
- iOS native via sentry-cocoa started from a tracked SentrySupport/+load bootstrap
  that reads the DSN from a Rust readest_sentry_dsn() FFI (no generated-file edits).
- SENTRY_DSN resolved at build time from the environment, then .env.local, then
  .env (build.rs bakes it via cargo:rustc-env; build.gradle.kts for Android). CI
  passes the SENTRY_DSN secret through the existing .env.local step.

Crashes + errors only: traces sample rate 0, no session replay, no PII.
Symbolication (source maps / ProGuard / dSYM upload) is a follow-up.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 17:26:26 +02:00
Huang Xin 6391bfe788 feat(settings): redesign theme mode toggle as a segmented control (#4831) (#4913)
The three theme-mode toggles were small btn-circle btn-sm icons spaced by
gap-4, so on mobile they were hard to hit and easy to mis-tap. Replace them
with a segmented control: an ARIA radiogroup of three adjacent radio segments
sharing one track. Each segment is a full-height tap target (min 44px wide,
36px tall) with no dead space between them, and the active segment gets the
app's canonical base-300 fill.

In e-ink mode the active segment uses a solid eink-inverted fill instead of a
nested border, so it stays legible without a second border clashing with the
track outline.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 17:23:24 +02:00
Huang Xin 745f28f346 fix(reader): distinguish two-finger scroll from pinch-zoom on touchscreens (#4858) (#4912)
On touchscreen laptops (e.g. Surface), scrolling a fixed-layout book
webtoon-style with two fingers moving the same direction accidentally
triggered pinch-zoom. The old code committed to a pinch on the first
two-finger touch and applied the raw distance ratio from the first move,
so a slightly non-parallel scroll drifted the finger spacing and zoomed.

Defer the decision with a pending state: on two fingers, compare the
change in finger separation against the midpoint travel. A pinch changes
separation while the midpoint stays put; a scroll moves the midpoint
while separation barely shifts. Zoom only engages once separation change
crosses a 24px deadzone and outweighs the pan distance; a 12px pan locks
the gesture as a scroll and lets the page scroll natively. On pinch
confirm, re-baseline the distance so zoom starts at 1x with no snap.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 16:39:06 +02:00
Huang Xin 2b524439bf fix(reader): keep running header/footer readable over light PDFs in dark mode (#4901) (#4911)
The running section title and page-number footer used text-neutral-content,
which is a light color in dark mode. A light-mode PDF stays white under a dark
theme (invertImgColorInDark defaults to false), so the light text sat on the
white page and became unreadable.

Blend the header/footer text against whatever is behind it using
mix-blend-mode: difference with a fixed white/75 anchor, so it inverts to dark
on a light page and stays light on a dark margin. white/75 matches the former
neutral-content brightness over the dark theme, so reflowable books look
unchanged. E-ink keeps its plain base-content text; StatusInfo and the sticky
progress bar manage their own colors and are left untouched.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 14:14:02 +02:00
Huang Xin 8c91ad411c fix(reader): open annotation deep link when a different book is open (#4887) (#4910)
An annotation deep link (readest://book/{hash}/annotation/{id}?cfi=...) for a
book that is not the one currently shown in the reader was ignored: the reader
stayed on the open book. It only worked from the library page.

Two causes, both in the reader-mounted path:

- useOpenAnnotationLink fell through to navigateToReader when the target book
  had no live view. router.push to the same /reader route does not re-run the
  reader's one-shot init effect, so it was a no-op and the book never changed.
  Route it through the in-place switch event (open-book-in-reader) carrying the
  cfi, mirroring useOpenBookLink.

- The "already open, jump in place" check scanned all viewStates, which keep
  stale entries for books switched away from (their views are detached from the
  DOM, never cleared on switch). Switching A -> B -> A matched the stale A view
  and called goTo on a dead view. Scope the check to the currently displayed
  bookKeys instead.

useBooksManager.openBookInReader now accepts an optional cfi and jumps to it
once the switched-in view is ready (marking it a preview so the saved position
is not overwritten).

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 13:26:41 +02:00
Huang Xin c8e2c95335 feat(library): auto-import new books from watched folders (#3889) (#4902)
* feat(library): add autoImportFromFolders setting (default off)

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

* feat(library): add selectNewImportableFiles folder-scan filter

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

* feat(library): add useAutoImportFolders trigger hook

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

* feat(library): auto-import new books from watched folders on open and focus

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

* feat(library): add Auto Import New Books from Folders toggle

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

* fix(library): don't resurrect deleted books or re-toast bad files on folder auto-import

- Add collectKnownSourcePaths() pure helper that includes soft-deleted books
- importBooks() accepts { silent } option and returns failedPaths
- autoImportFromWatchedFolders uses collectKnownSourcePaths and session-scoped
  autoImportFailedPathsRef to skip already-failed files on subsequent scans

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

* feat(library): make folder auto-import a per-folder option in the import dialog

Replace the global autoImportFromFolders toggle (and its standalone Settings
menu entry) with a per-folder opt-in shown as a sub-option of Read in place in
the Import-from-Folder dialog. Store the watched set as settings.autoImportFolders
(a subset of externalLibraryFolders; device-local, backup-blacklisted). The
library rescan now iterates autoImportFolders instead of a global-gated
externalLibraryFolders.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 12:14:08 +02:00
Huang Xin 2680614c15 ci(nightly): fix nightly update detection broken by AppImage bundling hang (#4909)
Since 2026-06-29 the nightly Linux legs hang forever while bundling the
AppImage and hit the job-level timeout, which reports the legs as
'cancelled'. The assemble-manifest guard treats 'cancelled' as run
cancellation and skips promoting nightly/latest.json, so nightly update
detection has been broken for ALL platforms since then (#4906).

Root cause: the truly-portable AppImage bundler in the tauri fork
downloads quick-sharun.sh from the unpinned main branch of
pkgforge-dev/Anylinux-AppImages. Upstream strace-mode changes on
2026-06-29 (acb1d719, 867c0b15) made the script execute the staged app
launcher scripts and WebKitGTK binaries under Xvfb to trace dlopened
libraries; the spawned WebKit processes survive the process-group kill
and quick-sharun waits forever.

Fixes:
- Pre-seed the tauri tools cache with quick-sharun.sh pinned to the
  last known-good revision (b3a9e985, used by the green 06-27/06-28
  nightlies) in both nightly.yml and release.yml. The bundler only
  downloads the moving main-branch script when the file is absent.
- Add step-level timeout-minutes to the nightly build steps and raise
  the job timeout to a 75-minute backstop, so a future hang fails only
  that leg ('failure') instead of tripping the job timeout
  ('cancelled'), and assemble-manifest still promotes the manifest
  fragments from the healthy legs.

Closes #4906

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 11:38:25 +02:00
Huang Xin 71cb3ace91 feat(android): Android Auto media support for TTS playback (#3919) (#4907)
Readest now shows up in the Android Auto launcher as a media app and
projects the TTS media session so playback can be controlled from the
car display (play/pause, previous/next sentence).

- Declare the com.google.android.gms.car.application meta-data and the
  automotive_app_desc media capability that Android Auto requires to
  list the app
- Make MediaPlaybackService safe to bind for browsing: audio focus, the
  silent keep-alive player, and the foreground notification no longer
  start in onCreate but on an explicit ACTIVATE_SESSION command, so a
  car client connecting to browse does not steal audio focus or post a
  phantom playing notification
- Deactivate the session with an in-process call instead of
  stopService, which would neither run onDestroy nor clear the
  foreground state while a media browser keeps the service bound
- Serve the current book as a playable browse item (cover downscaled to
  stay under the binder transaction limit) and handle
  onPlayFromMediaId/onPlayFromSearch
- Honor the foreground service contract when MediaButtonReceiver
  cold-starts the service with no active session

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 03:58:53 +02:00
Huang Xin 4b2c5f93ab fix(window): keep Linux window opaque so it can't turn invisible (#3682) (#4904)
On Linux the window was created fully transparent to draw rounded corners
(#1982), but on WebKitGTK a transparent window composites as transparent
whenever its web process is too busy to repaint damaged regions (for example
during a library backup). Interacting with the app then makes it appear to
turn invisible, showing the desktop through the window.

Make the window opaque everywhere: the main window in lib.rs and the
reader/extra windows in nav.ts. Drop the rounded-window treatment
(hasRoundedWindow=false) so no rounded 1px border floats on the now square
opaque window, and give the Linux loading placeholders a solid background.
An opaque window retains its last painted frame instead of going invisible;
the tradeoff is square corners on Linux.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 21:41:26 +02:00
Huang Xin 84c5a9dae6 fix(window): enter fullscreen from maximized windows (#4034) (#4903)
The fullscreen toggle had an isMaximized branch (from #872) that called
unmaximize() and never setFullscreen() when the window was maximized. Phosh
windows are always maximized, so the button appeared to do nothing; on Windows
it only worked when the window was not maximized.

Toggle fullscreen unconditionally. The maximize handler already exits
fullscreen first, so the two controls stay consistent.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 20:51:57 +02:00
Huang Xin c5304cd46c fix(reader): turn pages horizontally for vertical-rl books (#624) (#4899)
Vertical-rl books paged along the vertical scroll axis: page turns slid
up/down and only vertical swipes turned pages. Vertical books read with
right-to-left page progression, so page turns now work horizontally,
matching printed vertical books:

- Swipes track the finger: the page follows a horizontal drag and the
  release commits the turn (past half a page width or a flick in the
  drag direction) or settles the page back.
- Arrow keys, tap zones, and the wheel follow the same rtl mapping that
  horizontal-rtl books use.
- Animated turns run a two-phase horizontal slide that continues from
  the dragged offset: the outgoing page exits along the page
  progression, the scroll jumps while off-screen, and the incoming page
  follows in from the opposite edge. A single-phase push is impossible
  because CSS multicol stacks vertical-rl pages along the vertical
  scroll axis inside one iframe, so the outgoing and incoming page can
  never be on screen side by side.
- With animation disabled (or e-ink), turns swap instantly as before.

The paginator changes live in the foliate-js submodule; this bumps the
pointer and adds browser tests with a vertical-rl EPUB fixture covering
direction detection, drag tracking, drag revert, horizontal swipe
mapping in both directions, the legacy vertical swipe, the horizontal
slide animation, the instant non-animated swap, and the unchanged
horizontal-ltr swipe behavior.

Fixes #624

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 20:51:36 +02:00
Huang Xin fd8fbb178c fix(reader): apply page margin changes live on all platforms (#4898) (#4900)
Adjusting the top, bottom, left, or right page margin had no visible
effect until an unrelated setting (e.g. Show Header) was toggled.

The BooksGrid perf refactor (#4562) memoized the derived view/content
insets on the ViewSettings object identity. saveViewSettings mutates
ViewSettings in place (same reference), so the memo never recomputed on
a margin edit and the new margin never reached the paginator. Left and
right margins were always stale; top and bottom only refreshed when the
header/footer visibility (an effect dependency) changed, which is why
toggling the header appeared to apply a pending change.

Extract the inset derivation into useContentInsets and memoize by the
resolved numeric values instead of the object reference: identical
numbers across a page turn keep a stable reference (no re-render storm),
while a changed margin yields a new one that propagates to the renderer.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 20:24:34 +02:00
Huang Xin 77ea87c344 fix(updater): disable in-app updater on non-AppImage Linux (#4874) (#4897)
Tauri's Linux updater can only self-update AppImage bundles, so deb/rpm/
pacman and Flatpak installs showed a "Software Update" prompt that could
never apply. READEST_DISABLE_UPDATER also had no effect: the variable
reached the process, but its value only flowed to the frontend through a
WebView init-script global (window.__READEST_UPDATER_DISABLED) that is
not reliably visible to page scripts on Linux/WebKitGTK.

Make the decision authoritative in Rust and read it over IPC:

- Add compute_updater_disabled (pure, unit-tested) plus the
  is_updater_disabled desktop command: an env opt-out, Flatpak, or a
  Linux non-AppImage install disables the updater. setup() reuses the
  same helper for the init-script global.
- NativeAppService.init() sets hasUpdater from the command for desktop
  apps instead of relying on the init-script global.

Non-AppImage Linux installs now defer to the system package manager and
fall back to the "What's New" release notes.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 18:55:02 +02:00
Huang Xin bd415a8501 fix(koplugin): fold duplicate stats book rows so synced time shows in KOReader (#4895)
The stats pull keyed the statistics book table by md5 alone, while
KOReader's native statistics plugin keys rows by exact (title, authors,
md5). When the two parsers extract slightly different metadata for the
same file, the first native open creates a second, zeroed book row that
the KOReader UI reads, and the reading time synced from Readest stays
stranded on the sync-created row.

applyRemote now inserts a book row only for an md5 the DB has never
seen, attaches pulled events to the row the native plugin reads (native
rows always set pages and last_open; sync-created rows leave pages
NULL), and folds never-adopted duplicate rows into the surviving row on
every pull, so existing databases heal and the stranded time reappears.
Adopted rows and the live session's cached book id are never deleted,
and the totals recompute no longer regresses last_open below a real
native open timestamp.

Fixes #4861

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 18:31:06 +02:00
Huang Xin 849f151166 fix(ios): release screen brightness on background so auto-brightness resumes (#4885) (#4896)
On iOS `UIScreen.main.brightness` is a global device setting, not a
per-window one like Android. Once Readest overrode it (brightness slider
or left-edge swipe gesture) the override survived backgrounding, so
swiping to the home screen left the system stuck at an extreme level and
ambient auto-brightness appeared locked. The only cleanup lived in the
reader's unmount effect, which never runs when the app is merely sent to
the background, and the native `brightness < 0` "release" branch was a
no-op stub.

Native (NativeBridgePlugin.swift): capture the system brightness before
the first override, restore it on `appDidEnterBackground` so iOS resumes
auto-brightness, and re-apply the app's value on `appWillEnterForeground`.
Implement the negative-value release path (restore + forget state),
mirroring Android's BRIGHTNESS_OVERRIDE_NONE.

JS (useScreenBrightness hook, replacing the racy inline Reader effect):
apply the manual brightness while reading, release via
setScreenBrightness(-1) on unmount and when "System Screen Brightness" is
toggled back on. Excludes screenBrightness from deps so live slider/gesture
drags don't flash release-then-reapply.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 18:23:26 +02:00
Huang Xin 9f65e3d415 fix(auth): surface OAuth callback errors on desktop deeplink (#4881) (#4894)
* fix(auth): handle OAuth callback errors on desktop deeplink (#4881)

The Tauri deeplink OAuth handler only parsed the URL hash for an
access_token, so error callbacks were silently swallowed and the login
screen froze with no feedback. This is how an expired Apple provider
secret (GoTrue "Unable to exchange external code") surfaced to users as
a dead login screen on macOS and Linux.

Extract a pure, tested parseOAuthCallbackUrl() that reads both the hash
(implicit-flow tokens) and the query string (provider/GoTrue errors),
and route errors to /auth/error before the token branch, matching the
web callback page.

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

* chore(scripts): add Apple client secret generator (#4881)

Apple caps the "Sign in with Apple" client secret JWT at 6 months, so the
web OAuth flow (macOS non-store and Linux) breaks with "Unable to exchange
external code" when it expires. This script regenerates the ES256 JWT using
Node built-in crypto (no new dependency) for pasting into the Supabase Apple
provider config.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 17:03:56 +02:00
Huang Xin 4d645befde feat(library): add "Progress Read" sort option (#4427) (#4893)
Sort the library by reading progress (current/total pages). Books that
have never been opened read 0% and sort to the unread end; groups sort
by their most-progressed book. Direction reuses the existing
ascending/descending toggle, so "most read first" is Descending.

Adds the "Progress Read" entry to the Sort by menu and translates it
across all locales.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 16:52:17 +02:00
Huang Xin df34de1c38 fix(sync): WebDAV upload-after-enable and deletion propagation (#4856, #4860) (#4892)
* fix(sync): upload book files when Upload Book Files is enabled after first sync (#4856)

Incremental sync decided what to push purely from `isLocalNewer`
(`book.updatedAt` vs the shared index). A book's config/cover change over
time, but its FILE is immutable per hash and only needs uploading once.
After a first sync with "Upload Book Files" off, toggling it on never
bumped `book.updatedAt`, so the book was skipped and its file never
reached the remote.

Record which book FILES are already on the remote in library.json
(`uploadedHashes`) and split the push decision: config/cover stay gated on
the incremental "changed locally" cursor, while a file is (re)uploaded only
when syncBooks is on and its hash isn't recorded yet. This keeps an
incremental "Sync now" O(changed) — once a file is recorded, later syncs
skip it with no per-book HEAD probe, so large libraries don't pay an
O(library) cost on every sync. Full Sync bypasses the record as an escape
hatch for out-of-band drift. The record is additive and optional, so an
old client that rewrites the index just drops it and the next new-client
sync re-verifies each file once and re-records it.

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

* fix(sync): propagate WebDAV book deletions to peers and the server (#4860)

A book deleted on one device only tombstoned itself in library.json — the
deletion never reached other devices or the server:
  - peers kept the book: the reconcile pass skipped deletedAt entries, so a
    tombstone never removed the local copy;
  - the server kept the files: the per-hash directory was never GC'd;
  - the tombstone could vanish entirely: a device that had never seen the
    book rebuilt the index purely from its own library, dropping the
    tombstone and silently reviving the book for everyone.

Fixes all three in engine.syncLibrary:
  - apply a peer's tombstone locally (LocalStore.deleteBookLocally removes
    the app-managed copy and persists the tombstone), with
    edit-wins-over-delete LWW so a book still being read isn't yanked;
  - GC the remote per-hash directory of tombstoned books, scoped to the
    dirs the discovery scan saw so removed dirs are never re-DELETEd;
  - union remote-only entries (chiefly tombstones) into the re-pushed index
    so a deletion can't be dropped by a device that never had the book.

Books tombstoned mid-run are excluded from the push pass via the merged
state so a just-deleted book isn't re-uploaded right before it is GC'd.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 16:28:42 +02:00
Huang Xin 81802a7c72 fix(ios): keep App Group entitlement on widget/share extensions in App Store builds (#4891)
The App Store export re-sign (xcodebuild -exportArchive, automatic signing)
stripped com.apple.security.application-groups from the ReadestWidget and
ShareExtension binaries because both targets set
CODE_SIGN_ALLOW_ENTITLEMENTS_MODIFICATION: YES. The provisioning profiles and
source entitlements both grant the group, but the signed extension binaries did
not, so the widget read an empty snapshot from the shared App Group container
and showed only the placeholder book icon. Dev builds were unaffected.

Remove the flag from both extension targets (the main app already ships the
group correctly without it) so signing uses the exact CODE_SIGN_ENTITLEMENTS
content. Add scripts/verify-ios-appstore-entitlements.sh and run it from
release-ios-appstore.sh before upload so a stripped App Group fails the release
instead of shipping a dead widget.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 16:22:26 +02:00
Huang Xin a3609731c3 fix(macos): minimize instead of hide on macOS 26 to avoid black window (#4890)
On macOS 26 (Tahoe), Apple regressed NSWindow ordering so that
orderOut: (what Tauri's hide() maps to) no longer removes the window
from the screen. The close-to-hide handler left a focused black
phantom window instead of hiding it, and the only recovery was to
quit and relaunch the app.

This is an OS-level regression, not a Readest bug: the same failure
hits native, non-webview apps such as kitty (kovidgoyal/kitty#8952),
and tao 0.34.8 calls a bare orderOut with no Tahoe workaround.

Fix: on macOS 26 or later, minimize() the main window instead of
hide(). Minimize is a different AppKit path that dodges the buggy
orderOut, keeps the app in the dock, and preserves the open book. The
existing Reopen handler already unminimizes on dock reopen, so no
extra restore logic is needed. Older macOS keeps the previous hide()
behavior. Version detection reads NSProcessInfo.operatingSystemVersion.

Closes #4875

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 15:52:37 +02:00
Huang Xin 7a8354d63b fix(android): avoid black screen when external cache dir is unavailable (#4889)
The fs capability granted the built-in `fs:allow-cache-read` and
`fs:allow-cache-write` sets. Those sets bundle `scope-cache`, which
carries the external `$CACHE` base directory. At startup Tauri resolves
every granted scope entry, so `$CACHE` resolves through Android's
`getExternalCacheDir`. On devices whose external storage volume cannot
be prepared (e.g. custom ROMs where `/storage/emulated/0/Android/data/
<pkg>/cache` fails to mkdir) that returns null, the resolve errors, and
the graceful "skip unresolvable entry" arm is gated to non-Android, so
the error propagates: fs scope build fails, app init aborts, and the
window stays black.

Readest only performs I/O under the internal app cache (`$APPCACHE` ->
`getCacheDir`, always available), so it never needs the external
`$CACHE` scope. Grant the scope-free `fs:read-all` and `fs:write-all`
command sets to preserve command coverage, and move the `$APPCACHE`
scope (plus the iOS container path) into `fs:scope`. Startup then never
resolves external storage.

Add a regression guard asserting the default capability grants no
external-`$CACHE` fs permission while keeping the internal cache scope
and full read/write command coverage.

Closes #4853

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 15:51:44 +02:00
Huang Xin 5bc8eda50b feat(proofread): editable Find pattern and per-rule enable/disable toggle (#4859) (#4888)
* fix(proofread): keep disabled book rules visible in the manager list

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

* feat(proofread): add per-rule enable/disable toggle in the manager

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

* feat(proofread): allow editing Find pattern, regex, and case on existing rules

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

* i18n: add proofread edit and toggle strings

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 15:38:53 +02:00
dependabot[bot] 8cd3cacbec chore(deps): bump the github-actions group with 5 updates (#4884) 2026-07-02 18:43:16 +09:00
Huang Xin 49391124c5 fix(reader): correct reading ruler direction for vertical-rl books (#4865) (#4879)
Vertical-rl (Japanese/Chinese vertical) books read top-to-bottom with
columns progressing right-to-left, but getDirection only derived rtl from
the horizontal dir/direction, which stays ltr for these books. As a result
viewSettings.rtl was false and the reading ruler laid columns out
left-to-right, advancing the band the wrong way (reverse reading order).

Treat writing-mode: vertical-rl as RTL in getDirection so vertical-rl runs
through the same rtl paths that horizontal-rtl already uses: the reading
ruler coordinate mapping, page-turn tap mapping, footer navigation, and the
progress bar. Page-turn taps for these books now follow the vertical-rl
convention (tap left to go forward), matching horizontal-rtl behavior.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 17:40:52 +02:00
Huang Xin 17e60f1e49 fix(reader): fix fixed-layout spread spine seam and zoomed-out blank page (#4857) (#4873)
Bump foliate-js with two fixed-layout (EPUB and PDF) two-page spread fixes:

- Spine seam: overlap the two pages by one device pixel to hide the 1px white
  seam that appeared at the spine at a fractional devicePixelRatio (e.g.
  Windows 150% display scale).
- Zoomed-out blank page: keep non-PDF pages in block flow below 100% zoom; the
  PDF-only zoom-out centering was pushing the un-scaled iframe out of view and
  blanking the page.

Adds a unit test for the computeSpreadSpineOverlap helper.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 17:38:00 +02:00
Huang Xin 4d0be496b2 fix(layout): respect author vertical-align on inline images (#4866) (#4878)
img.has-text-siblings forced vertical-align: baseline on every inline
image with text siblings, out-specifying a book's own value (for example
a CJK glyph-substitution image nudged with vertical-align: -0.15em).
Because baseline is the CSS initial value, the declaration only ever
mattered when it clobbered an authored value.

Keep baseline as a default only: move it to a new
has-text-siblings-baseline class that applyImageStyle adds only when the
image has no author-set vertical-align (detected via getComputedStyle).
Refactor applyImageStyle to a two-phase read-then-write pass to avoid a
getComputedStyle-after-write style recalc.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 17:19:20 +02:00
Huang Xin 3ac1a1a45b fix(reader): remember last read position for markdown files (#4871)
Markdown sections were created with `cfi: ''`, but foliate-js builds a
location CFI as `section.cfi ?? CFI.fake.fromIndex(index)`. Nullish
coalescing does not fall back for an empty string, so every saved position
collapsed to a section-less CFI that resolves to no section. Reopening a
`.md` book then fell back to the start even though the library still showed
the correct read percentage.

Set each section's `cfi` to `CFI.fake.fromIndex(index)`, the same fake spine
CFI foliate synthesizes for single-file formats that omit it (e.g. fb2), so
positions round-trip across reopens.

Fixes #4862

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 04:16:30 +02:00
Huang Xin 01bc015985 release: version 0.11.17 (hotfix for an Android crash) (#4852) 2026-06-29 04:21:28 +02:00
Huang Xin 781a297993 ci(release): attest release and nightly build artifacts (#4851)
Add actions/attest-build-provenance to both build workflows so every
binary is attested in the same job that builds it, the only point
where provenance meaningfully proves an artifact was built from source
rather than uploaded by hand.

release.yml (build-tauri): grant id-token and attestations write
permissions, then attest the desktop bundles via the tauri-action
artifactPaths output, the Android apks, and the Windows portable exe.

nightly.yml (build): same permissions plus one step attesting the
staged nightly-out binaries. Nightlies ship via download.readest.com,
but gh attestation verify is digest based so it verifies them too.

Verify a download with:
  gh attestation verify <file> --repo readest/readest

Closes #4848

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 04:14:21 +02:00
Huang Xin a23427ccc6 fix(widget): avoid recycling aliased source bitmap for 2:3 covers (#4850)
Bitmap.createBitmap returns the same immutable instance when the
center-crop covers the whole source, which happens for covers that
decode to exactly 2:3. writeThumbnail then recycled that instance
before createScaledBitmap used it, crashing with "cannot use a
recycled source in createBitmap". Guard the recycle the same way the
scaled vs cropped case is already guarded, and add an instrumented
regression test.

Also bundles a pending widget debugging note and a regenerated
fastlane README that were staged in the working tree.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 04:13:17 +02:00
Huang Xin 5358d85c0b release: version 0.11.16 (#4847) 2026-06-28 20:50:30 +02:00
Huang Xin ea99106677 fix(sync): silence third-party cloud-sync error toasts (#4845)
* fix(sync): never toast third-party cloud-sync errors; log to console only

The reader's per-book auto-sync surfaced an "Cloud sync authentication failed.
Reconnect in Settings." toast on any AUTH_FAILED (e.g. an expired web Google
Drive token), interrupting reading. Background sync failures shouldn't pop a
toast — drop it and console.warn every sync error instead (the AUTH_FAILED
branch only chose toast-vs-console, so it collapses to a plain log). Removes the
now-unused authFailedToast + useTranslation/FileSyncError imports.

Manual "Sync now" (FileSyncForm) still reports its result — it's a deliberate,
foreground action. Native cloud sync (useBooksSync) is unaffected.

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

* feat(sync): surface an expired cloud-sync session in the reader + Settings

With sync-error toasts silenced, an expired third-party session (e.g. the
short-lived web Google Drive token) had no UI indicator. Surface it without the
old per-failure error toast:

- Reader: a single top-right `hint` ("Google Drive session expired. Reconnect in
  Settings.") — the same affordance as the native "Reading Progress Synced"
  hint. De-duplicated via a per-instance ref so it shows once, not on every
  page-turn sync; reset on a successful sync / provider switch (web reconnect
  reloads anyway).
- Settings → Google Drive: Disconnect swaps to Reconnect when the session is
  expired, and "Sync now" is disabled (FileSyncForm gains a `syncNowDisabled`
  prop) so a sync that would just fail isn't offered. No hint text in Settings.
- webTokenStore.hasValidWebDriveToken() backs the web detection (the token lives
  in sessionStorage; native auto-refreshes so it doesn't apply there).

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 20:29:14 +02:00
Huang Xin 70bad93ebf feat(reader): select word on double-click and run instant action or toolbar (#4846)
Double-click (mouse) or touch double-tap on a word now selects that word,
like a long-press selection, then runs the configured instant quick action
or raises the annotation toolbar when none is set.

The iframe posted iframe-double-click but nothing consumed it, so a touch
double-tap did nothing (Android has no native double-tap word-select; on
desktop the browser already selects the word natively via the pointerup
path).

- sel.ts: getWordRangeAt expands a caret to its word-like segment via
  Intl.Segmenter (CJK and Latin); getWordRangeFromPoint resolves the caret
  at a point and delegates.
- useTextSelector: handleDoubleClick selects the word and routes through the
  existing makeSelection flow (guarded so the programmatic selectionchange
  echo is ignored). It no-ops when a native selection already exists, so the
  desktop double-click path is not double-fired.
- Annotator: consume iframe-double-click, resolve the visible section
  doc/index, and set pointerDownTimeRef to 0 so the deliberate double-tap
  bypasses the touch long-press hold gate before the instant action fires.

Tests: unit coverage for the word-range helpers and the selection routing
(plus the desktop guard), and an Android CDP e2e for the double-tap gesture
on a real device.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 19:47:09 +02:00
Huang Xin eaf307e71e fix(translate): align RTL translated text to the start (#4844)
Inline translation wrappers set lang but never dir, so RTL target
languages (Arabic, Hebrew, Persian, etc.) inherited the source
document's LTR base direction. Justified text then pushed its last
line to the LTR start (left) instead of the RTL start (right).

Derive the wrapper's dir from the target language via
getDirFromLanguage so justified RTL translations align to the start.
Extract the node construction into createTranslationTargetNode to make
the behavior unit-testable.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 18:58:56 +02:00
Huang Xin b87cbfa21a feat(sync): Google Drive on web via full-page redirect OAuth (#4843)
Brings the Google Drive provider to the web build. Native uses PKCE + a
reverse-DNS redirect + keychain refresh token; none of that works in a browser,
and the GIS popup token model is broken by the app's COOP `same-origin` header
(needed for Turso's SharedArrayBuffer) which severs the popup's opener handle and
fires `popup_closed` instantly. So web uses a full-page redirect, which doesn't
rely on `window.opener` and works under COOP.

- auth/webRedirectFlow.ts: builds the implicit (response_type=token) auth URL,
  begins the redirect (CSRF state + return path in sessionStorage), and parses
  the token from the callback fragment. Implicit flow because a secretless Web
  client can't do a code exchange.
- auth/webTokenStore.ts: sessionStorage-backed access-token store (no refresh
  token in this model; the token is short-lived).
- WebDriveAuth: browser DriveAuth — reads the stored token, fails AUTH_FAILED
  once expired (prompts a reconnect; no background refresh), accountLabel via
  about.get.
- app/gdrive-callback: OAuth return route — validates state, stores the token,
  marks Drive the active cloud provider (+ account label), routes back.
- buildGoogleDriveProvider: web branch builds the provider on WebDriveAuth +
  globalThis.fetch (Drive REST is CORS-enabled; streaming stays Tauri-only so web
  buffers). Official Web client id baked (NEXT_PUBLIC_GOOGLE_WEB_CLIENT_ID
  overrides). googleDriveConnect web Connect = redirect; Disconnect clears the
  token. Drive row shown on web.

No background token refresh: a secretless browser client gets no refresh token
and Google blocks hidden-iframe silent renewal, so the user reconnects per
session (a server-side token broker would be needed for auto-refresh; out of
scope). Tests cover the redirect helpers, token store, and WebDriveAuth.

Ops: add `https://web.readest.com/gdrive-callback` + `http://localhost:3000/gdrive-callback`
to the Web client's Authorized redirect URIs.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 18:39:52 +02:00
Huang Xin 7da41a65ad feat(widget): add mobile home-screen reading widgets (#1602) (#4842)
Add a resizable home-screen widget on iOS and Android showing recent
in-progress books with cover, reading progress, and tap-to-open.

- One responsive widget: Android resizable 1x1 to 4x3 (one book per
  column, up to 3); iOS Small/Medium/Large families. Covers are cropped,
  rounded, with a percent badge and a progress bar (baked into the bitmap
  on Android, SwiftUI overlays on iOS).
- TTS controls (previous, play-pause, next) appear in 2+ row sizes when
  TTS is active, wired to the existing media session. Reading progress
  stays live during background TTS via a fraction computed from the baked
  offline locations.
- Publishes a snapshot plus downsized cover thumbnails to the iOS App
  Group and Android SharedPreferences through a new update_reading_widget
  native-bridge command; refresh is debounced and driven by library and
  progress changes, TTS, and app backgrounding.
- Tapping a cover opens readest://book/{hash}, switching the reader in
  place when one is already open.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 18:03:16 +02:00
Huang Xin 7972de1909 fix(eink): render Customize Toolbar preview as bordered surface, not black bar (#4839) (#4841)
The Customize Toolbar sub-page shows a content-width preview of the live
selection popup, copying its bg-gray-600 text-white styling. Unlike the real
reader popup (which gets its e-ink chrome from .popup-container in globals.css),
the preview Zone is a plain div with no e-ink override, so under
[data-eink='true'] the dark fill survived and the row painted as an unreadable
solid black bar.

Scope the dark fill to non-e-ink (not-eink:bg-gray-600 not-eink:text-white) and
let eink-bordered render the preview in e-ink as the popup's e-ink chrome: a
base-100 surface with a 1px base-content border. The chip icons already invert
to base-content via the global [data-eink] button rule. Also fall the empty-state
hint back to base-content in e-ink so it stays legible once the surface turns
base-100.

Verified via computed styles under [data-eink]: background oklch(1 0 0) (white),
1px oklch(0.2 0 0) border, dark icons — matching the reader's annotation toolbar.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 16:13:42 +02:00
Huang Xin 5f44c95592 feat(sync): library-scoped auto-sync for third-party cloud (WebDAV / Drive) (#4835)
Parity with native useBooksSync: keep library.json current on import, delete,
and book-close, not just on a manual "Sync now".

- useLibraryFileSync: new library-scoped hook (counterpart of useBooksSync),
  mounted once on the library page. Builds the active provider's engine async
  and runs engine.syncLibrary on every library change (import adds a row,
  delete sets deletedAt, closing a book bumps updatedAt), debounced 5s and
  gated on the global file-sync mutex + Sync Strategy + Upload Book Files. The
  reader's per-book useFileSync is unchanged (it's the per-book progress sync).
- Pass the FULL library (incl. soft-deleted books) to engine.syncLibrary, in
  both the new hook and the manual FileSyncForm "Sync now": the engine
  tombstones deleted books in library.json so deletions propagate, and keeping
  them in the input set stops the discovery pass from re-downloading a book the
  user just deleted (its remote hash dir lingers until the GC sweep).
- Tests: engine tombstones a soft-deleted book in the pushed index and does not
  re-download one whose remote dir still exists.

Gated only by the active provider's enabled flag + strategy (cloud sync is
currently ungated from premium). Never runs before the library loads from disk,
so it can't push an empty index over the remote.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 11:20:32 +02:00
Huang Xin ae03be96d0 chore(agent): update agent memories (#4833) 2026-06-28 05:11:46 +02:00
Huang Xin 69599e2bcc fix(reader): render code operators literally instead of as ligatures (#4832)
Fira Code is the bundled monospace fallback used when the chosen mono
font is missing (e.g. Consolas on Android). Its default-on contextual
alternates ligate code operators such as "<=" and "=>" into single
glyphs, which misrepresents code in books like VHDL or math texts. Set
font-variant-ligatures: none on pre, code, kbd so operators render
literally. The underlying text is unchanged, so selection and copy
already produced the correct characters.

Fixes #4830

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 05:04:40 +02:00
Huang Xin 4d08b01b41 feat(library): add recently read shelf to the library (#3797) (#4829)
Add an opt-in "Recently read" carousel at the top of the library that
shows the most recently read books for quick resume. The strip reuses
the BookItem component and mirrors the bookshelf grid column widths, so
covers render and align identically at any column count. It scrolls
horizontally with arrow buttons, opens a book through a shared
availability-aware path (downloads cloud-only synced books first), and
is toggled from the View menu (off by default).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 19:31:15 +02:00
Huang Xin d932444b78 fix(sync): cloud-sync settings polish + temporary premium ungate (#4828)
* fix(settings): clamp option-row description to a single line

SettingsRow descriptions wrapped to multiple lines on narrow (mobile)
widths, giving boxed-list rows uneven heights (e.g. "Uploads book files
to your other devices." in the Cloud Sync panel). Clamp the description
to one line with ellipsis in the shared primitive so every option row
stays uniform; the description is a hint, not a paragraph (longer copy
belongs in a Tips block). Codified in DESIGN.md.

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

* i18n(settings): shorten sync strategy labels to "Send only" / "Receive only"

Rename the Sync Strategy options (shared by the Cloud Sync and KOReader Sync
forms). Keys renamed in every locale, preserving existing translations.

* feat(sync): temporarily ungate third-party cloud sync from premium

Cloud sync (WebDAV / Google Drive) ships available to every plan, incl. free,
while the feature stabilises. Gated behind a single CLOUD_SYNC_REQUIRES_PREMIUM
flag (off) via isCloudSyncAllowed; the paywall code (CLOUD_SYNC_PLANS /
isCloudSyncInPlan) is intact, so re-gating in an upcoming release is a one-line
flip. Applies to the Settings provider rows and the reader auto-sync gate.

* fix(settings): polish cloud-sync connect buttons

Use btn-contrast for the WebDAV and Google Drive Connect CTAs (theme-neutral,
e-ink correct); rename "Connect Google Drive" to "Connect"; move the Google
Drive sign-in tips below the Connect button.

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 19:11:17 +02:00
Huang Xin c6f2a83d92 fix(sync): retry thrown transport errors in Google Drive sync (#4827)
Google Drive library sync failed on Android: after the first few requests
every files.list threw `error sending request for url (...)` and the sync
stuck at "Syncing 0 / N". The provider's backoff only retried 429/5xx
responses; a thrown fetch propagated immediately. On mobile a long
multi-request sync hits transient transport failures (a pooled keep-alive
connection to googleapis.com going bad), so without a retry every request
after the first batch failed.

- withBackoff now retries a thrown fetch with the same bounded exponential
  backoff as 429/5xx, letting reqwest re-establish a fresh connection.
- mapDriveError classifies a thrown transport error (TypeError, or the
  Tauri HTTP plugin's plain "error sending request" Error) as NETWORK
  instead of UNKNOWN, so the engine's head-probe short-circuit treats it
  as transient.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 18:34:31 +02:00
Huang Xin ae9fb05f2c feat(sync): Google Drive sign-in on Android + iOS (mobile OAuth) (#4823)
* feat(sync): Google Drive sign-in on Android (Custom Tab OAuth)

Add the Android OAuth runner so Drive can be connected on Android, reusing the
same provider / token store / connect flow as desktop.

- oauthAndroid.ts: runAndroidOAuth wires the DI OAuth flow to a Chrome Custom
  Tab via the existing authWithCustomTab native bridge (keeps the Tauri Activity
  foregrounded so the in-flight redirect survives). Headless-unit-tested.
- googleDriveConnect: dispatch the platform runner by OS (Android -> Custom Tab,
  desktop -> system browser deep link).
- IntegrationsPanel: show the Google Drive provider row on Android too.
- Native (device-verification pending — no Android toolchain in CI):
  NativeBridgePlugin.kt handleIntent now also resolves the reverse-DNS
  com.googleusercontent.apps.<id>:/oauthredirect redirect through the same
  pending invoke as the Supabase callback; a matching BROWSABLE intent-filter
  added to AndroidManifest.xml (mirrors the tauri.conf.json deep-link scheme).

Full suite 6475 green; lint + format clean. The native sign-in needs on-device
Android verification before this ships.

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

* feat(sync): Google Drive sign-in on iOS (ASWebAuthenticationSession OAuth)

Add the iOS OAuth runner so the Drive provider connects on iPhone/iPad,
mirroring the Android Custom Tab flow.

- oauthIos.ts: runIosOAuth drives the shared PKCE flow through
  authWithSafari, keyed to the client-id-derived reverse-DNS callback
  scheme so the web-auth session intercepts the redirect.
- nativeAuth.ts: AuthRequest gains an optional callbackScheme; the
  Supabase login keeps the native "readest" default.
- googleDriveConnect.ts: resolveOAuthRunner dispatches ios to runIosOAuth.
- IntegrationsPanel.tsx: show the Google Drive cloud-sync row on iOS.

Native (device-verify pending, no iOS toolchain in CI):
- auth_with_safari honors args.callbackScheme (default "readest").
- Info-ios.plist registers the reverse-DNS scheme in CFBundleURLTypes,
  mirroring the AndroidManifest gdrive-oauth filter.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 16:59:55 +02:00
Huang Xin 531f0b58ae feat(sync): stream Google Drive book uploads/downloads from disk (#4824)
Add uploadStream + downloadStream to the Google Drive provider so book
files sync straight from/to disk instead of buffering the whole file in
the JS heap. Marshaling a large book across the WebView<->Rust bridge as
a single Uint8Array crashes the renderer on mobile, so book sync over
Drive was effectively desktop-only; this unlocks it on Android/iOS and
keeps the heap flat for gigabyte-scale PDFs on desktop too.

- driveRest.ts: resumableCreateUrl / resumableUpdateUrl builders.
- GoogleDriveProvider: uploadStream opens a Drive resumable session
  (POST new / PATCH existing; metadata in the initiation, so no reparent
  follow-up), then PUTs the bytes to the one-time session URI via the
  native upload plugin (tauriUpload). downloadStream GETs alt=media to
  disk via tauriDownload with a bearer token. Attached on Tauri only;
  web keeps the buffered fallback. Both swallow to false per the provider
  contract (engine retries once).

Reuses @tauri-apps/plugin-upload already shipped for WebDAV streaming;
no new native code.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 16:59:35 +02:00
Huang Xin 324bb8a366 feat(reader): add e-ink screen refresh page-turner action (#4687) (#4822)
Add a bindable "Refresh Page" action to Settings > Behavior > Page Turner
that triggers a deep e-ink full refresh (GC16) to clear screen ghosting,
gated to e-ink mode on Android.

It reuses the existing hardware page-turner key-binding machinery: a new
'refresh' slot in HardwarePageTurnerSettings, shown only when isAndroidApp
and the e-ink view setting is on. Pressing the bound key calls a new native
bridge command instead of paginating.

The native side is device-agnostic: EinkRefreshController probes each vendor
mechanism via reflection and stops at the first that works, covering Onyx
BOOX (Qualcomm View.refreshScreen), Tolino/Nook (NTX postInvalidateDelayed)
and Boyue-style Rockchip (requestEpdMode) without bundling any vendor SDK.
A success:false result is a soft no-op on non-e-ink hardware. iOS gets a stub.

Verified on an Onyx BOOX Leaf5: the Onyx path fires and performs a visible
full GC16 refresh.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 11:18:11 +02:00
Huang Xin f8916e128e fix(reader): smooth pinch-zoom and pan for scrolled-mode PDF (#4817)
Bumps foliate-js to readest/foliate-js#43. In scrolled-mode PDF the page now zooms live under a pinch and commits without a layout shift (the inter-page gap scales with the zoom so the committed layout matches the transform-scaled preview, and the centre page is restored to its pre-commit on-screen rect), a page zoomed wider than the viewport is pannable horizontally, and the page iframes stay interactive when idle so native text selection keeps working. readest already drives the renderer's pinchZoom on a two-finger gesture, so the only reader-side change is the submodule bump plus a unit test for the new scroll pinch transform.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 11:15:33 +02:00
Huang Xin 7e78f80e14 feat(sync): Google Drive cloud sync + premium Third-party Cloud Sync section (desktop) (#4821)
* feat(sync): add Google Drive file-sync provider core

Second FileSyncProvider for the merged provider-agnostic file-sync engine,
behind the provider seam. This is the CI-testable core only: no settings UI
and no platform OAuth runners yet (those land in later phases).

- GoogleDriveProvider over the Drive v3 REST API: id-addressed path
  resolution with a per-instance id cache, create-then-name uploads, real
  idempotent ensureDir, files.list pagination, Retry-After-aware 429/5xx
  backoff, per-path folder-creation locks with deterministic duplicate
  collapse, stale-id eviction, and FileSyncError mapping (403 split into
  rate-limit vs permission).
- DI OAuth layer: pkce, parseRedirect (redirect-target + CSRF state),
  reverseDnsRedirect, tokenStore (iOS client, no secret), oauthFlow.
- PersistedDriveAuth with single-flight token refresh; keychain-backed
  token store with no ephemeral fallback for the refresh token; account
  label via about.get.
- providerRegistry (backend kind to provider) and buildGoogleDriveProvider
  assembly.
- Shared transport-agnostic provider semantic contract, run against both
  WebDAV and Drive.
- Keyed secure-KV bridge contract (set/get/clear_secure_item); the native
  keychain implementation lands with the desktop OAuth slice that first
  exercises it.

Adapted from ratatabananana-bit/Readest-google-drive-mod-patcher (AGPL-3.0)
with the author's explicit permission.

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

* feat(sync): multi-provider file-sync settings + sync-state foundation

PR2 foundation for a second file-sync backend (Google Drive). The
behaviour-sensitive reader-hook and Sync-now form generalization land in
PR3 alongside OAuth, where Drive actually connects and the multi-provider
paths can be exercised and live-verified (and the extracted form gets its
second consumer, avoiding a single-use abstraction).

- GoogleDriveSettings type (mirrors WebDAVSettings minus URL/credentials/
  rootPath, plus accountLabel) wired into SystemSettings, with
  DEFAULT_GOOGLE_DRIVE_SETTINGS in the defaults.
- googleDrive.deviceId + googleDrive.lastSyncedAt added to the backup
  blacklist so device-local sync identity / cursors never restore onto
  another device. Covered by the existing backup-settings test.
- Generalize webdavSyncStore into fileSyncStore: per-backend progress keyed
  by provider kind, plus a global library-sync mutex (beginSync returns
  false when another backend already holds the lock) since every backend's
  syncLibrary mutates the same local library. Migrate WebDAVForm and
  IntegrationsPanel to the keyed API; WebDAV behaviour is unchanged.

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

* feat(native-bridge): add keyed secure key-value store commands

A generic, keyed secret store over the same OS keychain backends as the
sync passphrase (set/get/clear_secure_item), so secrets that aren't the
single sync passphrase get the same XSS-free cross-launch persistence
without each needing its own native command. The Google Drive OAuth token
store (PR1's KeychainTokenPersistence) is the first consumer; a future
cloud provider's refresh token reuses it.

- Desktop (macOS/Windows/Linux): keyring-core, keyed by the item key as
  the entry account under the existing "Readest Safe Storage" service.
- Android: EncryptedSharedPreferences (a dedicated readest_secure_items_v1
  file, the item key as the pref key).
- iOS: Security framework Keychain (kSecClassGenericPassword, dedicated
  service, the item key as kSecAttrAccount).

Registered in the plugin invoke handler + build COMMANDS + default
permission set (autogenerated permission files regenerated; the passphrase
entries are preserved). The TS bridge wrappers shipped in PR1.

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

* feat(sync): desktop Google Drive OAuth runner + connect flow

The desktop half of Drive sign-in: open consent in the system browser, capture
the reverse-DNS redirect the OS routes back, and exchange the code for tokens.

- oauthDesktop.ts: runDesktopDeepLinkOAuth wires the DI OAuth flow to the
  desktop mechanics (open default browser, capture via single-instance /
  onOpenUrl, cold-browser fallback after a grace period, hard deadline). Fully
  headless-unit-tested via injected deps.
- spawn_fresh_browser.rs (+ registration, Windows-only winreg dep): the cold
  browser the runner falls back to when the user's already-running browser
  snapshotted protocol associations before the scheme was registered (a
  Windows-specific failure). Resolves the default browser from the registry and
  spawns it cold with an isolated --user-data-dir; a no-op on macOS/Linux where
  the default-browser open already routes the redirect. Pure helpers unit-tested.
- connectGoogleDrive.ts: run the platform OAuth runner, persist the token
  (fail-loud — Drive is not reported connected if the refresh token does not
  save), and resolve the account label via about.get (best-effort).

OAuth runner adapted from ratatabananana-bit/Readest-google-drive-mod-patcher
(AGPL-3.0) with the author's permission. Scheme registration + the ingress
redirect filter + the Drive connect UI land in the following commits; live
desktop verification follows once the official Google client id is provisioned.

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

* feat(sync): filter Google OAuth redirects out of the deep-link ingress

The reverse-DNS OAuth redirect (com.googleusercontent.apps.<id>:/oauthredirect)
is delivered through the same single-instance / onOpenUrl channels as book-file
deep links. Without a filter the book-import consumer would treat the redirect
URL as a file path to open. Drop it at the ingress source (useAppUrlIngress)
before the app-incoming-url broadcast, so no consumer ever sees it; the Drive
sign-in runner still captures it via its own listeners.

isGoogleOAuthRedirectUrl matches the scheme prefix (not a specific client id),
so it stays correct regardless of which client is baked into the build.

Note: registering the scheme in tauri.conf.json (so the OS routes it back to the
app) needs the official Google client id, which is a provisioning prerequisite.

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

* feat(sync): bake the official Google Drive OAuth client id + redirect scheme

Provisioned the Readest Google Cloud OAuth client (iOS application type, no
secret, drive.file scope). Bake the client id as the default in
getGoogleClientId (overridable via NEXT_PUBLIC_GOOGLE_CLIENT_ID for forkers,
who must also regenerate the manifest schemes) and register the derived
reverse-DNS redirect scheme com.googleusercontent.apps.<id> in tauri.conf.json
(desktop + mobile deep-link) so the OS routes the OAuth redirect back to the
app. The client id is a public client identifier, not a secret.

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

* feat(sync): Google Drive connect UI + shared FileSyncForm

Make Drive usable from Settings, and extract the now-two-consumer sync controls.

- FileSyncForm: the provider-agnostic sync controls (sub-toggles, conflict
  strategy, manual "Sync now" with progress + result toast), parameterised by
  backend kind and building the provider through the registry. Extracted from
  WebDAVForm now that a second consumer exists. WebDAVForm keeps its
  URL/credentials connect panel + browse pane and renders FileSyncForm for the
  sync section; behaviour is unchanged (WebDAV "Sync now" goes through the same
  provider via the registry).
- GoogleDriveForm: an OAuth connect panel (Connect -> runGoogleDriveConnect ->
  store token in keychain -> "Connected as <email>"; Disconnect) + FileSyncForm.
- googleDriveConnect.ts: assemble the env client id + keychain + desktop runner
  into connectGoogleDrive/disconnectGoogleDrive for the UI.
- IntegrationsPanel: a "Google Drive" row + sub-page, shown only on desktop
  (mobile OAuth runners land in later phases).

Reader-side auto-sync (generalizing useWebDAVSync) is a follow-up; manual
"Sync now" already exercises the full Drive stack. Full suite 6412 green.

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

* feat(settings): unified Third-party Cloud Sync section (exclusive provider)

Group WebDAV + Google Drive into a new "Third-party Cloud Sync" section and make
them mutually exclusive — only one cloud provider syncs the library at a time.

- New unified "Cloud Sync" sub-page (CloudSyncForm): a provider picker (radio,
  the AIPanel mutually-exclusive pattern) on top, the shared FileSyncForm sync
  options below for whichever provider is active. Google Drive is offered only on
  desktop; on mobile the page is WebDAV only and the picker is hidden.
- withActiveCloudProvider helper: enabling one provider disables the other in one
  save. Both panels' connect/activate paths use it. Unit-tested.
- WebDAVForm / GoogleDriveForm refactored into embeddable panels (the unified
  page owns the header). Drive gains a "configured but inactive" state so
  switching back re-activates it without a fresh sign-in; explicit Disconnect
  clears the keychain token.
- IntegrationsPanel: remove the two separate WebDAV / Google Drive rows from
  "Reading Sync" (now KOReader Sync / Readwise / Hardcover only); add the
  Third-party Cloud Sync section with one Cloud Sync row (status = active
  provider). Old webdav/gdrive deep-links route to the unified page.

Also removes the temporary Drive concurrency probe (the upload already runs at
the intended concurrency 4; the probe confirmed it).

Full suite 6416 green; lint + format clean.

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

* feat(reader): auto-sync the active cloud provider while reading

Generalize the reader sync hook from useWebDAVSync to useFileSync so the active
third-party cloud provider (WebDAV OR Google Drive) syncs per-book while reading
— pull-on-open, debounced push on progress/booknote changes, cover/file upload —
not just via the manual "Sync now" in settings.

Since the providers are mutually exclusive, the hook drives exactly the one
enabled backend, built through the provider registry. The build is async (the
Google Drive provider probes the OS keychain), so the engine lives in state and
the pull-on-open waits for it; switching providers mid-session resets the
per-book locks. The engine is keyed on connection-relevant settings so a
lastSyncedAt write doesn't re-probe the keychain. deviceId / lastSyncedAt now
write the active provider's settings slice; the auth-failed toast is
provider-neutral; the per-book events are renamed *-file-sync.

WebDAV reader-sync behaviour is unchanged. Full suite 6416 green.

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

* feat(settings): surface cloud providers in the section with inline switch

Show WebDAV + Google Drive as separate rows in the Third-party Cloud Sync
section (instead of one "Cloud Sync" row), so both providers are visible and the
active one can be switched right there.

- CloudProviderRow: a trailing radio makes a provider the single active sync
  target inline (enabled only when it's already configured — WebDAV creds / a
  Drive token); the row body / chevron opens its config sub-page (connect, sync
  options, disconnect). Status reads Active / Configured / Not connected, with a
  Syncing… indicator.
- Each provider drills into its own sub-page again (WebDAV / Google Drive),
  rendering the embeddable panel under a SubPageHeader; the brief unified
  CloudSyncForm picker page is removed (its old deep-link maps to Google Drive).
- Switching stays exclusive via withActiveCloudProvider; an inline switch trusts
  the stored credentials/token (no re-validate / re-OAuth).

Full suite 6416 green; lint + format clean.

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

* feat(sync): gate third-party cloud sync behind a premium plan

WebDAV + Google Drive sync is now a premium feature: available on any paid plan
(Plus, Pro, or Lifetime), not on free.

- isCloudSyncInPlan(plan) helper (mirrors isEmailInPlan; plus/pro/purchase).
- IntegrationsPanel: free users see the Third-party Cloud Sync section with an
  upgrade row ("Available on Plus, Pro, or Lifetime") that opens the plans page
  instead of the provider rows; the cloud-sync deep-links are gated too (waiting
  for the plan to load before deciding).
- useFileSync: the reader's auto-sync only runs on a paid plan, so a downgraded
  user's sync stops even if a provider's enabled flag lingers.

Full suite 6418 green; lint + format clean.

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

* fix(sync): escape backslashes in Drive query literals (CodeQL)

escapeDriveLiteral escaped single quotes but not the backslash escape
character, so a file name containing a backslash (or ending in one) could
break out of the single-quoted Drive `files.list` query literal and malform
the query. Escape backslashes first, then single quotes, so the backslashes
added for the quotes are not doubled.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 10:28:08 +02:00
Luis Cortes 9496de301b fix(node-app-service): ensure correct cross-platform path resolution in NodeAppService (#4819)
* refactor(node): use path.join() in path resolver

* fix(node): use native path separators in resolveFilePath.
2026-06-27 08:19:21 +02:00
Huang Xin 348c85f648 fix(reader): cap auto page-turn corner zone size (#4812) (#4820)
The corner-dwell auto page-turn zone is a quarter-ellipse whose radius is
a fraction (0.15) of the reading area on each axis. On wide screens such
as desktop or multi-column pages, that fraction grows the zone until it
reaches deep into the text, so selecting in a column and resting the
pointer there turns the page unexpectedly.

Cap each axis of the corner radius at 50px so the engagement zone stays a
real corner regardless of page width, while preserving the existing feel
on phones.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 07:51:24 +02:00
Huang Xin 9e93445336 fix(sync): sync WebDAV credentials across devices (#4810) (#4818)
WebDAV connection settings were never part of the bundled settings
replica, so the "Credentials" sync toggle had no effect on them and
users had to re-enter their WebDAV server, username, and password on
every device.

Add webdav.serverUrl / username / password / rootPath to
SETTINGS_WHITELIST and gate username / password behind
SETTINGS_ENCRYPTED_FIELDS, matching how KOSync / Readwise / Hardcover
credentials are handled. Per-device bookkeeping (enabled, deviceId,
lastSyncedAt, sync sub-toggles) stays local, mirroring KOSync which
syncs credentials but not its enabled flag: a fresh device pre-fills
the connect form and the user clicks Connect.

Also add a webdav deep-merge case to mergeSettings. Without it the
top-level shallow merge on pull would replace the whole webdav object
with the four-field patch and wipe the local per-device fields.

Update the credentials category description to mention WebDAV and
migrate the i18n key across all locales.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 06:34:37 +02:00
Huang Xin 24370ca511 feat(reader): render Markdown (.md) files at runtime (#774) (#4816)
Open standalone .md files in the reader without converting to EPUB. A new
makeMarkdownBook (src/utils/md.ts) parses Markdown to sanitized HTML with
marked + DOMPurify, splits the document into sections at H1 boundaries, and
builds an in-memory foliate book (modeled on fb2.js) with a nested heading
TOC. DocumentLoader routes .md/.markdown before the TXT path so a Markdown
file served as text/plain is not converted to EPUB. Layout, font and theme
settings apply the same as for any other format.

Relative-image resolution and Markdown bundle/folder packages are left as
follow-ups (a standalone file has no sibling-asset access on the web).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 05:36:32 +02:00
Huang Xin 580c5e5deb fix(reader): eliminate PDF scrolled-mode rendering lag on mobile (#4795) (#4813)
PDF pages rendered blank while scrolling in scrolled mode (#4795,
resurfacing #4031). On-device profiling showed each page takes hundreds
of ms to render while the preload margin gave only about half a page of
lead, and loads were unbounded and unprioritized.

Bump the foliate-js submodule to widen the scrolled-mode preload margin
and drive page loading through a bounded, viewport-prioritized scheduler
(readest/foliate-js#40). Adds unit coverage for the new planScrollModePages
scheduler.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 05:02:16 +02:00
Huang Xin a0227f98e2 perf(reader): stop per-frame background reflow on swipe page turns (#4785) (#4814)
Fixes readest/readest#4785

Swipe page turns dropped frames, worst when crossing .xhtml section
boundaries. The paginator's #replaceBackground rebuilt its whole paint
context every animation frame (a getComputedStyle plus one
getBoundingClientRect per rendered view), and that per-frame cost scales
with the number of loaded views, which peaks at a boundary where adjacent
sections are preloaded.

Bumps the foliate-js submodule to 15fc999 (readest/foliate-js#41) to
snapshot the paint context once per gesture and reuse it on every frame,
and to defer the heavy mid-drag section preload off the active drag. The
bump also advances foliate-js to current main, picking up the
gpu-composite page-turn opt-in (readest/foliate-js#39).

Adds a real-browser test that drives an animated turn and a synthetic drag
and asserts the section <html> computed style is read a small constant
number of times instead of once per frame (snap 39 -> <=3, drag 7 -> <=1).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 04:26:37 +02:00
Huang Xin 97868f0486 fix(reader): keep negative table margins from clipping wrapped layout tables (#4439) (#4808)
A decorative table-of-contents page lays out as nested tables where the inner
table pulls itself up with a negative top margin and the CONTENTS heading uses
line-height:1em. Since #4400 wraps every table in a `.scroll-wrapper`
(overflow:auto), that negative margin bled the heading above the wrapper's clip
box and overflow cut off the top half of its glyphs.

Hoist any negative margins from the wrapped element onto the wrapper and zero
them on the element: the box stays in place, the element sits flush inside it so
overflow cannot clip it, and scrollWidth is no longer inflated by the margin so a
table that actually fits still gets marked fit. Positive and auto margins are
left alone, so an over-wide table still scrolls and a centered table stays
centered.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 15:25:21 +02:00
Huang Xin 4874eb9ae7 feat(reader): add TTS highlight granularity setting (word or sentence) (#4807) 2026-06-26 18:48:57 +08:00
Huang Xin dced42912f feat(reader): filter exported annotations by color and style (#4801) (#4806)
Add a Filter section to the annotation export dialog so users who color-code
highlights (e.g. red for important, yellow for difficult words) can export only
selected colors and styles.

The selection is stored as exclusions in NoteExportConfig, so an empty filter
exports everything and any color or style added later is included by default.
A new pure helper filterExportGroups applies the filter to both the default
formatter and the custom-template paths, and only filters a dimension when at
least two distinct values are present so a hidden row never silently drops notes.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 11:44:34 +02:00
Huang Xin 01a54238ae fix(annotator): clean up empty highlight on annotation cancel (#4791) (#4804)
Clicking "Annotate" on a selection eagerly creates a highlight (with an
empty note) as the anchor for the note being typed, so the selection stays
visible while the NoteEditor is open. Cancelling the note instead of saving
left that empty highlight behind: it leaked into the config DB, showed as a
stale card in the Booknotes list, and left a phantom yellow highlight.

handleHighlight now returns the created BookNote only when it pushes a new
record (null when it restyles an existing highlight, which predates the flow
and must survive a cancel). handleAnnotate tracks that id via the new
notebookNewHighlightId store field; cleanup is keyed on the id, not the cfi,
so a fresh selection that collides with an existing highlight's cfi can't
wrongly delete it.

removeEmptyAnnotationPlaceholder tombstones the tracked placeholder only when
it still has no note text, and the Notebook tears its overlay down. Cleanup is
presentation-driven: an effect removes the placeholder whenever the creation
editor stops being shown (Cancel, Escape, overlay, close, swipe, navigate),
plus a second effect for book-switch and reader-close. Save survives the guard
and clears the tracked id.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 09:51:36 +02:00
Huang Xin 1558078391 fix(settings): keep global settings in sync across windows (#4580) (#4803)
On desktop the app runs multiple windows (one library plus one per open
book), and each keeps its own in-memory settings loaded once at window
open. Global settings persist to a single shared settings.json, and every
window writes the whole object on save. A window opened before the user
customized a global view setting therefore clobbers that change with its
own stale (often default) value on its next save, most visibly a reader
window reverting Click to Paginate back to the default on close.

Broadcast the global view and read settings after every save and have all
other windows adopt them, preserving each window's device-local fields
(paths, lastOpenBooks, sync cursors, brightness). The receive path only
updates the in-memory store, so there is no save or broadcast loop. No-op
off Tauri.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 09:43:31 +02:00
Huang Xin 7544835fb8 chore(agent): update agent memories (#4802) 2026-06-26 07:55:36 +02:00
Huang Xin 4ba78490a7 fix(library): prevent series and description overlap in list view (#4796) (#4799)
The list-mode book item used a fixed h-28 height. When a book belongs to
a series, the title, authors, series, description, and progress row
together exceed 112px and overflow, so the series and summary lines
collide and get clipped. Larger system font scaling (such as the Android
accessibility font size setting) inflates line heights and makes the
overlap worse, which is what the reporter saw on a Pixel 10 Pro.

Use min-h-28 instead so the row grows to fit its content. Non-series rows
keep the same 112px height, and the list is virtualized with measured
heights so variable row heights are fine.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 05:19:04 +02:00
Huang Xin 0b4993407c feat(reader): add contrast option to PDF/CBZ view menu (#4800)
Add a Contrast stepper to the reader View menu for fixed-layout
(PDF/CBZ) documents. It increases and decreases page contrast via a
CSS filter on the rendered page images, applies to the whole book,
and is stored per-book (local to the current document).

The filter is built in applyFixedlayoutStyles by combining any
dark-mode invert with the contrast amount into a single filter
declaration. Persisted with skipGlobal so it never touches global
view settings, and added to FoliateViewer's effect dependencies so
the change re-applies across all rendered pages.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 05:16:36 +02:00
Huang Xin 370a516620 feat(reader): glue non-breaking spaces after short Russian words (#4769) (#4798)
Russian typography requires short function words (prepositions,
conjunctions, particles) to never hang at the end of a line. Add an
`nbsp` content transformer that inserts U+00A0 after such words so they
stick to the following word. The source file is never modified.

The transformer is language-driven via an NBSP_LANGUAGES registry keyed
by language code (only `ru` is configured today), so adding another
language is a single entry. It runs only for matching books and rewrites
text between tags with a regex, leaving tags, attributes, and the XML
declaration intact. Runs after whitespace normalization so the inserted
spaces are not stripped under the override-layout setting.

The space-to-NBSP swap is length-preserving (both are single UTF-16 code
units), so DOM character offsets and CFIs stay valid for every word
before and after the transform; tests enforce this invariant.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 04:52:32 +02:00
Huang Xin 4c39d769e6 fix(hardcover): never send a book id as edition_id (#4792) (#4794)
When a book was matched via Hardcover title search with no featured
edition and the user had not selected a specific edition, the sync
client fell back to using the Hardcover book id as the edition_id.
Hardcover's Action rejects that with a parse-failed error
("ActionWebhookErrorResponse ... key 'message' not found"), so progress
and note sync failed for those books.

Leave editionId null when no real edition is known, make the read and
journal mutations accept a nullable edition_id, and omit edition_id when
adding a book so Hardcover uses the book's default edition.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 18:41:00 +02:00
Huang Xin 58f84d18c1 fix(sync): keep WebDAV connection after restart when a pull overlaps it (#4793)
useSync.pullChanges already re-read the live store settings for its
in-try setSettings, but its catch and finally still wrote the stale
per-render hook closure. When a settings change lands during an
in-flight pull (most visibly a WebDAV connect), the pull's finally
overwrote settings.json with the pre-change snapshot, so the connection
read back as "Not connected" after the app was reopened.

WebDAV was the unique casualty because it is the only integration
credential not in the replica SETTINGS_WHITELIST, so unlike
kosync/readwise/hardcover it is never re-hydrated from the server on the
next launch. Android's slower network widens the pull window, which made
the overlap reliable there.

Read useSettingsStore.getState().settings in both the catch and the
finally, matching the in-try path. This is a general fix that preserves
any concurrent settings change, not just WebDAV. Adds a regression test
that drives the real hook with a connect landing mid-pull.

Fixes #4780

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 16:55:52 +02:00
Huang Xin 13e0fb814f feat(webdav): sort and filter the WebDAV browser (#4724) (#4786)
Add per-folder sort and search to the WebDAV browse pane in Settings,
Integrations, WebDAV.

- Sort by name, date modified, date created, or size, ascending or
  descending; the choice persists in WebDAV settings so a chosen
  "recent first" order survives across sessions.
- Filter the current folder by file name or matched book title.
- Request and parse the WebDAV creationdate property; servers that omit
  it fall back gracefully to a stable name order with no broken dates.
- Sort and search resolve a per-hash book directory to its library
  title so they operate on what the user actually sees.

Sort and filter are pure, unit-tested helpers in webdavBrowseUtils;
creationdate parsing is covered by a listDirectory test. Verified on a
Xiaomi device against a live WebDAV server (675 books): name, modified
asc/desc, title filter, and persistence across an app restart.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 10:43:57 +02:00
Huang Xin fb943987eb fix(opds): hide popular catalog after adding it to My Catalogs (#4782) (#4787)
Adding a built-in popular catalog (e.g. Project Gutenberg) to My Catalogs
left it still rendering in the Popular Catalogs section, so it looked like a
duplicate. Only the card's Add button was hidden; the card itself stayed.

Filter added (and disabled) entries out of the Popular list entirely via a
new pure helper getUnaddedPopularCatalogs, which matches by normalized URL
(trim + lowercase) to mirror the store's findByUrl dedup. The section already
auto-hides when the list is empty, so it disappears once all are added.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 10:12:41 +02:00
Huang Xin 99b9adfe85 refactor(sync): provider-agnostic file-sync engine with incremental WebDAV sync (#4784)
* refactor(sync): extract provider-agnostic layout paths

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

* refactor(sync): extract wire envelope module

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

* refactor(sync): extract pure merge module with law tests

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

* refactor(sync): add FileSyncProvider and LocalStore interfaces

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

* feat(sync): FileSyncEngine orchestration over a provider

Port WebDAVSync's per-book + library-wide sync onto FileSyncProvider +
LocalStore. Behavior preserved; the #4756 metadata-reconciliation test is
retargeted to drive the engine through a fake provider + store.

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

* refactor(sync): move WebDAV client + connect settings under providers/webdav

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

* feat(sync): WebDAVProvider implementing FileSyncProvider

Wraps the WebDAV transport client, maps WebDAVRequestError to the neutral
FileSyncError, and owns Tauri streaming upload/download. Adds a
provider-conformance suite future backends can run against.

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

* feat(sync): shared appService-backed LocalStore bridge

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

* refactor(reader): drive WebDAV sync through FileSyncEngine

Construct a WebDAVProvider + shared LocalStore + engine once per hook; the
inline buffered/streaming book-file loader collapses into the provider +
store, so the hook no longer imports tauriUpload or the file path helpers.

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

* refactor(settings): drive WebDAV library sync + browse through the provider

WebDAVForm now builds a WebDAVProvider + shared LocalStore + engine and calls
engine.syncLibrary; the ~170-line inline callback block (buffered/streaming
loaders, URL+auth construction) is gone. WebDAVBrowsePane builds a provider for
the engine-level deleteRemoteBookDir cleanup helper.

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

* refactor(sync): remove WebDAV-specific sync module, WebDAV is now a provider

Delete src/services/webdav (WebDAVSync/WebDAVPaths + the transitional client
and connect-settings shims). The superseded webdav-metadata-sync test is
replaced by engine-metadata-sync; webdav-delete now drives deleteRemoteBookDir
through a WebDAVProvider and asserts FileSyncError.

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

* fix(sync): hydrate library before WebDAV Sync now to prevent clobber

Sync now while the library store was unloaded (app launched into reader/
settings without mounting the Library view) merged the engine's
addBookToLibrary / updateBookMetadata against an empty in-memory library,
persisting a downloaded book or a metadata update as the entire library and
wiping what was on disk. Pre-existing bug surfaced during the file-sync
review. Hydrate the store in handleSyncNow and harden the store bridge with a
load-if-unloaded guard (mirrors useLibraryStore.updateBooks).

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

* refactor(sync): make listDirectory honor the FileSyncError contract

listDirectory threw a plain Error (and let raw fetch failures escape), so
WebDAVProvider flattened every list() failure to FileSyncError(UNKNOWN). Throw
the same WebDAVRequestError taxonomy as the file-level helpers (AUTH_FAILED /
NOT_FOUND / NETWORK) so the provider maps them correctly. Add list() cases to
the provider-conformance suite.

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

* test(sync): cover streaming upload, discovery/download, and receive paths

The metadata-sync gate only exercised the buffered metadata + config-merge
paths. Add engine tests for streaming uploadStream (+ HEAD short-circuit +
one-shot retry), remote-only discovery -> streaming download -> addBook, and
the receive strategy (pull-only, no config or index writes).

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

* feat(sync): incremental WebDAV Sync now + bounded concurrency

Sync now was a full walk of every book each run (675 round-trips even when
nothing changed). Default to incremental: diff the local library against the
shared library.json index per hash and only process books whose local copy is
newer (or absent). book.updatedAt bumps on every progress/notes/metadata save
(bookDataStore.saveConfig), so the index is a reliable per-book change marker.
Remote-newer books pull their config in the reconcile pass so peer progress
still propagates. A new 'Full Sync' toggle (default off) re-checks everything.

Also run the reconcile / download / push phases over a bounded worker pool
(default concurrency 4) instead of one book at a time.

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

* refactor(sync): simplify Sync now toast to a single book count

The completion toast built a multi-line success bullet list (downloaded /
pulled / pushed / uploaded). Replace it with the same single-line info toast
the native cloud sync uses: '{{count}} book(s) synced'. Add a booksSynced
counter to the engine result (a Set of distinct hashes touched in any
direction, since the per-action counters overlap under Full Sync). Failures
still surface as a warning.

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

* fix(ui): raise toasts above modals so they aren't hidden by open dialogs

Toasts rendered at z-50, below the Settings dialog (z-110) and ModalPortal
(z-120), so a toast dispatched from an open dialog (e.g. WebDAV 'Sync now')
was buried. The documented overlay scale already places toast at 130; the
component just hadn't followed it. Move the toast to z-[130] and extend the
zIndexScale invariant test to guard TOAST > MODAL/SETTINGS and APP_LOCK > TOAST.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 09:57:52 +02:00
Huang Xin 79ae8a48ba feat(reader): sync per-book proofread rules across devices (#4781)
Per-book and selection-scope proofread (find/replace) rules were pushed in
the synced book config but dropped on pull (applyRemoteProgress only applied
location), so they never propagated across devices. Merge them by id on the
config pull, mirroring the booknote CRDT path. Library-scope rules keep
syncing via the settings replica.

- Add updatedAt/deletedAt to ProofreadRule. Delete is now a tombstone for
  book/selection scope so a removal is not resurrected by a peer's live copy;
  library-scope deletion keeps the hard splice (settings-replica whole-field
  LWW already handles it).
- Add mergeProofreadRules (by id, updatedAt/deletedAt last-write-wins) and
  merge into applyRemoteProgress; refresh the live view only when the merged
  rules actually changed.
- Backfill a content-derived id for id-less rules (legacy/foreign/hand-edited)
  via ensureRuleId, and seed book/library ids from content so the same rule
  created on two devices dedupes instead of duplicating. Selection rules keep
  a per-instance unique id. Without this, id-less rules collide on one Map key
  and clobber each other.
- Filter tombstoned rules from the transformer and the manager dialog list.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 09:49:30 +02:00
Huang Xin 0589cb4f4a fix(reader): stop a quick-deleted highlight from being re-drawn (#4773) (#4779)
The per-relocate re-apply effect reads a memoized annotation index. A
highlight deleted in place after the index was built still sits in its
bucket, and selectLocationAnnotations trusted the build-time deletedAt
filter, so the effect re-drew the just-deleted overlay and left it
orphaned on the page until the book was reopened.

Re-check deletedAt at the read site: in selectLocationAnnotations and in
the sibling globals re-apply loop in Annotator.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 09:33:55 +02:00
dependabot[bot] cecb1c5312 chore(deps): bump the github-actions group with 2 updates (#4775)
Bumps the github-actions group with 2 updates: [actions/checkout](https://github.com/actions/checkout) and [actions/cache](https://github.com/actions/cache).


Updates `actions/checkout` from 6.0.3 to 7.0.0
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/df4cb1c069e1874edd31b4311f1884172cec0e10...9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0)

Updates `actions/cache` from 5.0.5 to 6.0.0
- [Release notes](https://github.com/actions/cache/releases)
- [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md)
- [Commits](https://github.com/actions/cache/compare/27d5ce7f107fe9357f9df03efb73ab90386fccae...2c8a9bd7457de244a408f35966fab2fb45fda9c8)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: 7.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
- dependency-name: actions/cache
  dependency-version: 6.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-25 06:50:48 +02:00
Huang Xin cd3a53f507 fix(sync): WebDAV Sync now pulls latest book metadata and merges config (#4756) (#4776)
* fix(sync): pull newer WebDAV book metadata to devices that already hold the book (#4756)

syncLibrary only pulled title/author/cover for books missing from the local
library. For a book a device already held it only pushed, so a peer's metadata
edit never propagated back, and the final library.json re-push clobbered the
peer's newer metadata with this device's stale copy.

Add a last-writer-wins reconciliation pass keyed on book.updatedAt: when the
shared index has a strictly newer copy of a locally-held book, merge its
metadata, re-pull the cover, persist it via a new updateBookMetadata callback,
and keep the merged copy authoritative for the index re-push so neither
direction loses the edit. Surface a "metadata updated" counter in the sync
toast and history, and translate the new strings across all locales.

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

* fix(sync): merge remote config before pushing in WebDAV Sync now (#4756)

The manual library "Sync now" pushed each book's config.json blind, so it
could overwrite a peer's booknotes (element-set CRDT) or regress newer remote
progress (per-config LWW) that this device had not pulled yet. The reader hook
already pull-merges before pushing; the library path did not, so notes and
progress could diverge or regress on the remote until a device happened to open
the book.

Give syncLibrary's config push the same read-merge-write cycle: pull-merge then
push the merged superset, persisting it locally so the device converges too.
Gated on canPull so 'silent' converges while 'send' keeps the local copy
authoritative and 'receive' still never pushes.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 06:50:34 +02:00
Huang Xin e80ab1762b refactor(settings): polish sync and integration panels (#4774)
- Background Image: move the Library/Reader scope into the section title
  ("Background Image (Library)" and "Background Image (Reader)") instead
  of a separate "Applies to ..." sublabel line.
- Send to Readest: render approved-sender emails monospace to match the
  inbound address, and wrap long addresses to at most two lines instead
  of truncating on one line.
- WebDAV: split the "Uploading X / Y" progress into a status line plus a
  one-line book title.
- WebDAV: reword the "Upload Book Files" description to "Uploads book
  files to your other devices."
- WebDAV: rename the "Always use latest" strategy to "Send and receive".
  KOSync keeps "Always use latest" since it must contrast with its
  "Ask on conflict" option.
- WebDAV: remove the Sync History section and its persisted log model;
  the sync engine still reports per-book failures in its result.

Updated i18n across all 33 locales.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 06:10:20 +02:00
Huang Xin 0c7ffa9799 fix(reader): stop iOS page-turn animation stutter (#4768) (#4772)
* fix(reader): stop iOS page-turn animation stutter (#4768)

iOS users saw occasional page-turn animation stutter that was not present
on earlier 0.11.x builds. It traces to foliate-js commit c1c7315 (first
shipped in 0.11.4): the large-section rafAnimateScroll fallback and the
removal of persistent compositor-layer hints, both added to fix a ~1s
Blink freeze on Android Chromium at high DPR.

Apple WebKit composites those layers fine, so on iOS (notably 120Hz
ProMotion devices) the changes only cost smoothness: large-section turns
animate scroll on the main thread, and every turn promotes a layer
on-demand instead of using a persistent one.

Opt the iOS renderer into foliate-js's new gpu-composite path, which
restores persistent compositor layers and skips the main-thread
rafAnimateScroll fallback. Other platforms keep the Android freeze fix.
Bumps the foliate-js submodule.

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

* chore(deps): repin foliate-js to merged gpu-composite commit (#4768)

readest/foliate-js#39 squash-merged to a new commit on main. Move the
submodule pin off the now-orphaned PR branch commit to the merged main
commit. No content change.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 04:46:16 +02:00
Huang Xin 44a6900da0 feat(reader): extend selections and highlights across pages (#4741) (#4767)
* docs(plan): design for cross-page corner auto-turn (#4741)

Extract useAutoPageTurn so the corner-dwell page turn works for instant
highlight drags and for range-editor handle drags, not just native text
selection. Decouple the dwell liveness from the DOM selection and anchor
each range's non-dragged end to a DOM position so it survives the scroll.

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

* docs(plan): add keyboard turn-on-cross to cross-page design (#4741)

Shift+Arrow selection adjust extends into the off-screen next column
without turning the page. Fold it into the feature with an immediate
turn-on-cross (no dwell) in the keyboard path, reusing the page-edge
geometry from useAutoPageTurn.

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

* feat(reader): extend selections and highlights across pages (#4741)

Extract the corner-dwell auto page-turn (#1354) into useAutoPageTurn,
decoupled from the DOM selection, so every selection gesture can drive
it in paginated mode, not just native text selection:

- Instant Highlight drag: feed the finger corner into the dwell machine
  and DOM-anchor the highlight start so it survives the page scroll.
- SelectionRangeEditor and AnnotationRangeEditor handle drags: feed the
  dragged-handle corner; anchor the non-dragged end to a DOM position so
  the edited range spans pages (the annotation editor previously resolved
  both ends from window coordinates and lost the previous page).
- Shift+Arrow keyboard selection adjust: turn the page immediately when
  the extended focus leaves the visible page, so the growing selection
  stays in view.

An after-turn re-emit rebuilds each gesture's range from the held
position so the selection extends onto the new page without waiting for
the next move.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 19:23:11 +02:00
Huang Xin d963b911c8 fix(reader): zoom linked images on single tap (#4757) (#4766)
A single tap on an image wrapped in an <a> element followed the link instead of opening the image viewer, because postSingleClick returned early for any element inside an anchor before reaching media detection.

Compute the media target up front and let it bypass the anchor guard, so a tapped image/table/svg-image opens the viewer just like long-press already does. Footnotes are excluded so footnote anchors keep their popup and navigation behavior.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 17:57:19 +02:00
Huang Xin e0b537bc16 feat(koplugin): bulk download all cloud books from Library view (#4751) (#4765)
Downloading a Readest cloud library into KOReader previously required
tapping each book one at a time. Add a "Download all books" action to
the Library view menu that pulls every cloud-only book to the device
in one pass.

- LibraryStore:listCloudOnlyBooks() returns the downloadable
  cloud-present, not-local books for the current user (whole library,
  independent of the active search/group view).
- librarywidget.downloadAll() streams them sequentially via the
  existing syncbooks.downloadBook path inside a Trapper coroutine:
  cancellable progress (Trapper:info Abort/Continue), per-book
  failures skipped and counted, summary toast at the end. Bridges
  downloadBook's sync-or-async callback with a coroutine suspended
  check so it serializes correctly either way.
- Wire the action into the view-menu Actions section.
- Add the new UI strings and translate them across all 33 locales.

Closes #4751

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 17:42:31 +02:00
Huang Xin 163487b5e3 feat(reader): add regex and nearby-words search modes (#4560) (#4764)
Add Calibre-parity search modes to the reader's full-text search. The
"Match Whole Words" toggle becomes a single-select mode group: Contains,
Whole Words, Regular Expression, Nearby Words.

- Regex and nearby-words matching live in the foliate-js submodule
  (bumped here); the sidebar threads `mode` and `nearbyWords` through.
- Nearby distance is chosen with a "within N words" control (5/10/20/50,
  default 10), not parsed from the query, so trailing numbers stay
  literal search words.
- Per-mode modifiers: Match Diacritics is greyed out for regex (no-op).
- Calm inline error for invalid regex / too-few nearby words, a
  no-results state, and a results-count footer.
- Nearby matches render a segmented excerpt emphasizing each matched
  word and highlight every word in the book.
- BookConfig schema v2 -> v3 migrates the deprecated `matchWholeWords`
  boolean to `mode` (still written for sync back-compat).

Also fix two search interactions:
- option changes (e.g. within-N-words) now take effect immediately by
  reading the latest config at search time instead of a stale closure.
- closing search from the results nav bar now exits the sidebar search
  mode, not just the results (search-bar visibility lifted to the store).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 17:23:09 +02:00
Huang Xin f7124cbeea fix(css): multiply mix blend for images in dark override color mode (#4763) 2026-06-24 16:12:10 +02:00
Huang Xin 005aa2d615 fix(security): iframe srcdoc atrribute can lead to arbitrary code execution (#4762) 2026-06-24 16:01:18 +02:00
Huang Xin 7da5f83213 fix(reader): make annotation toolbar customization apply to all books (#4760)
The customized annotation toolbar only took effect in the book where it
was changed, instead of applying to every book.

Root cause: serializeConfig decided which per-book view settings to
persist as overrides using a reference check (globalViewSettings[key]
!== value). It deep-clones the config first, so array-valued settings
like annotationToolbarItems are always a fresh reference and were stored
as a per-book override on every save (each progress autosave). On reopen
the per-book override shadowed the global value, so a global toolbar
change never reached already-opened books.

Compare view-setting values by content, not reference, so array/object
settings equal to the global value are no longer persisted per-book. This
also fixes the same latent issue for paragraphMode, proofreadRules,
ttsHighlightOptions and noteExportConfig.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 15:48:03 +02:00
Huang Xin ac6249cbc3 feat(opds): show groups as horizontal carousels when 2+ groups (#4750) (#4755)
Feeds that list several groups previously rendered each group as a full
grid, which made scrolling past many groups tedious. When a feed has two
or more groups, render each group's items in a compact horizontal
carousel, matching what Thorium does.

Each carousel is a horizontally virtualized react-virtuoso list, so only
the covers in view are mounted and fetched; off-screen covers load lazily
as the row is scrolled. Scroll arrows page through by index and stay
centered on the cover artwork.

Book items also get rounded covers (matching the library bookshelf) and
drop the inline acquisition badge, which remains on the publication
detail page.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 07:28:38 +02:00
Huang Xin 7d1a60b9ea feat(library): separate background texture for library and reader (#4754)
* feat(library): separate background texture for library and reader (#4743)

The library and reader shared a single background texture, so a reader
backdrop with borders or other reading-oriented decoration looked wrong
on the bookshelf. Let users set them independently.

- Add device-local libraryBackground{TextureId,Opacity,Size} to
  SystemSettings. Each field falls back to the reader/global value when
  unset (getLibraryViewSettings), so an existing bookshelf looks
  unchanged until the user picks a library texture, then decouples
  per-field. No migration needed; the selection stays per-device like
  the reader's, while imported images keep syncing via the texture kind.
- Make the Color panel's Background Image picker context-aware: opened
  from the library it edits the library texture, opened while reading it
  edits the reader texture. A sublabel states which page it applies to.
- Apply the library texture at boot and on every library mount, so
  returning from a textured book restores the bookshelf background.
- useBackgroundTexture now unmounts on 'none' instead of early-returning,
  since library and reader share one style element: switching a page to
  None must clear a texture the other page mounted.

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

* i18n: translate library/reader background texture labels (#4743)

Add translations for the two new context sublabels ("Applies to the
Library" / "Applies to the Reader") across all 33 locales, anchored to
each locale's existing Library and reading terminology.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 07:24:30 +02:00
Huang Xin e2f65278ec fix(opds): dereference publication self link for full metadata (#4749) (#4753)
OPDS 2.0 feeds may list a publication in summary form (title, cover, and
only a rel="self" link of type application/opds-publication+json), serving
the full record (acquisition links, description, publisher, subjects) only
when the client follows that link on click, as Thorium does. Readest
ignored it, so such books showed no download option and no description.

Add opdsPublication.ts with getPublicationDetailHref and
parsePublicationDocument (OPDS 2.0 JSON and Atom entry, absolutizing
link/image hrefs), and dereference the link in the OPDS page when a
feed-selected summary advertises one, upgrading the detail view in place
once it loads.

Also render an OPDS 2.0 JSON HTML description as sanitized markup instead
of literal tags by falling back from the typed content to the plain
description string in getOPDSDescriptionHtml.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 07:16:59 +02:00
Huang Xin 8810aa6db0 fix(reader): stop trackpad pinch-zoom flicker on image viewer (#4742) (#4748)
On macOS a trackpad pinch-to-zoom is delivered as a rapid stream of
ctrl+wheel events. The zoomed image kept its 0.05s transform transition
during that stream, so each event restarted the in-flight transition
from its interpolated mid-point and the image lagged and flickered. This
is the same root cause as the #4451 pan flicker, which was fixed by
dropping the transition during the gesture for the pan and touch-pinch
paths; the wheel-zoom path was the only continuous gesture left with the
transition on.

Suppress the transition while a wheel-zoom gesture is streaming, cleared
on a short debounce since wheel has no explicit gesture-end event.
Discrete zoom (buttons, double-click, keyboard) keeps its smoothing.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 18:28:42 +02:00
Huang Xin 6301c620a8 fix(library): import books opened via "Open with" by default on mobile (#4746) (#4747)
A recent change (#4407) made Android's "Open with Readest" (VIEW intent,
used by Telegram and similar apps) always open the file as a transient
book: into the reader but never written to the library, with its filePath
pointing at the original content:// URI. Once that temporary URI grant
dies the book can no longer be reopened, so the user has to re-share it
from the source app every time.

Make the transient behavior an opt-out gated by the existing
autoImportBooksOnOpen setting, and surface it on mobile:

- The VIEW handler now consults the setting via a new shouldOpenTransient
  predicate. When auto-import is on it falls through to the same library
  ingest path as a share-sheet SEND (full ingest plus cloud upload); when
  off it keeps the transient open.
- Read the setting from disk in the handler rather than the settings
  store, since on a cold-start "Open with" the store is not hydrated yet
  and would wrongly fall back to a transient open.
- Show the "Auto Import on File Open" toggle on mobile (was desktop only).
- Default autoImportBooksOnOpen to true on mobile so shared files persist
  and sync by default; the desktop default is unchanged.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 18:28:29 +02:00
Huang Xin acd4a67dcf fix(reader): require a still-hold before instant-highlight on touch (#4745)
* fix(reader): require a still-hold before instant-highlight on touch

Instant Highlight (the highlighter quick action) engaged on every
pointer-down over text, calling preventDefault, which swallowed the
single tap / swipe that turns the page on Android. Tapping the side
margins still worked only because they are not selectable text; the
synthetic-click fallback was also dead on Android (native touchend
calls handlePointerUp with no event).

Gate engagement behind a 300ms still hold for touch/pen: a tap
releases first and a swipe moves first, so both fall through to
pagination, and only a deliberate still hold starts drag-to-highlight.
Mouse input keeps engaging immediately (click vs. press-drag is
already unambiguous).

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

* chore(agent): update agent memories

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 11:32:41 +02:00
Huang Xin b1346bf16d feat(wordlens): en-en glosses, styling, derivation lemmas, display-time cap (#4744)
* feat(wordlens): support en-en monolingual glosses

Gloss difficult English words with a short English definition for learners
reading English with English hints.

- build: buildEnEn + shortDefGloss read ECDICT's English `definition` column
  (first/primary WordNet sense, POS-stripped, drop ;-example, <=24 word-boundary
  with trailing-connector trim). New `en-en` CLI branch; buildEnZh/buildEnEn now
  share a buildEnPack core.
- gating: drop the hardcoded `hint === source` rejections (wordlensSection,
  WordLensPanel) so same-language packs are allowed; availability is decided by
  the manifest (resolvePack returns null when no pack exists).
- data: data/wordlens/en-en.json (26,578 entries) + regenerated manifest.json.

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

* feat(wordlens): gloss styling, derivation lemmas, display-time cap

Builds on the en-en monolingual gloss support with refinements and
regenerated packs.

- settings: per-book gloss <rt> font size (em) and color in
  Settings > Language > Word Lens (getRubyStyles reads viewSettings).
- en-en hints: WordNet hybrid (a simpler synonym, else a category
  hypernym, else the ECDICT definition) instead of raw verbose
  definitions.
- lemmatization: gate difficulty by the lemma rank for every English
  source pair. enBaseFormCandidates now also covers -able/-ible suffixes
  and negative prefixes (un/in/im/ir/il), so insufferable resolves to
  suffer. A candidate is accepted when the English definition names the
  base OR the Chinese translations share a content character, which keeps
  true derivations (insufferable -> suffer) and rejects coincidental
  stems (capable -> cap). en-X packs inherit the en-en lemma table.
- display cap: the max gloss length is applied at render time in
  cleanGloss (MAX_GLOSS_LEN), so the packs store the full hint and the
  cap can change without regenerating data.
- tooling: pnpm wordlens:preview to sample pack entries; cache build
  corpora under data/wordlens/.sources (gitignored).
- data: regenerate en-en, en-zh and en-de/es/fr/pt/ru plus the manifest.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 11:15:34 +02:00
Huang Xin 428168ac91 fix(reader): show the centred section's chapter title in scrolled mode (#4739)
In scrolled mode the header chapter title was wrong while transitioning
between sections, while paginated mode was correct (#4436). foliate-js
#getVisibleRange returned the first overlapping view (topmost in scroll
order), so when the tail of one section was a thin sliver at the top of
the viewport and the next section occupied the centre and most of the
screen, the relocate event reported the sliver's section — and its title
lagged behind what the reader was reading.

Bump foliate-js to prefer the view covering the viewport centre
(readest/foliate-js#37) and add a browser-lane regression test that
scrolls a sliver of section K to the top with K+1 across the centre and
asserts the relocate index is K+1.

Fixes #4436

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 01:56:10 +02:00
Huang Xin e982af1725 feat(reader): adjust text selection with Shift/Ctrl/Opt+Arrow keys (#4728) (#4738)
Support standard desktop shortcuts for refining an active text selection:
Shift+Left/Right by character, Ctrl/Option+Shift+Left/Right by word. Only
active while text is selected; otherwise the keys fall through to page
navigation as before.

Root cause: after a selection the reader container (not the book iframe)
holds focus, so Shift+Left/Right keystrokes reach the parent shortcut
handler and matched the page-turn shortcuts, turning the page instead of
refining the selection.

The new onAdjustTextSelection action runs before the navigation actions:
when a selection is active it extends the iframe selection via
Selection.modify() and suppresses the page turn; an iframe-forwarded key
(already extended natively) just suppresses navigation. handleSelectionchange
now refreshes the popup/range for keyboard-driven changes (no pointer drag)
so the selection toolbar follows the refined selection.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 01:32:13 +02:00
Huang Xin 787641b5b1 chore(agent): update agent memories (#4737) 2026-06-22 18:52:39 +02:00
Huang Xin 664b6125a2 feat(android): add monochrome themed launcher icon (#4733) (#4736)
Android 13+ recolors the adaptive icon's `<monochrome>` layer with a
wallpaper-derived tint when the user enables themed icons. Support was added
in #2122/#2153 (the `ic_launcher_monochrome.png` assets) but #2353 ("fixed
launcher icon size") rewrote the committed adaptive icon to inset the
foreground 22% and silently dropped the `<monochrome>` layer, so themed icons
stopped working in shipped builds.

Restore it by re-adding a `<monochrome>` layer (same 22% inset as the
foreground) and shipping the monochrome mipmaps. The CI/release flow
regenerates `gen/android` (`tauri android init` + `tauri icon`) then
`git checkout .` to restore tracked customizations; `tauri icon` does not emit
a monochrome layer, so the mipmaps are force-committed under `gen/` like the
other customized resources.

The monochrome artwork is redesigned: Android tints the layer via SRC_IN
(alpha only), which flattened the old desaturated-logo asset into a solid
blob. A narrow vertical center gap now splits the open book into two pages
with a visible spine while keeping the bookmark, so the mark keeps its
character when themed.

Verified on a Pixel 9 Pro emulator (Android 36) with Themed Icons enabled, and
guarded by src/__tests__/android/themed-icon.test.ts.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 18:44:57 +02:00
Huang Xin bc9b8b23e6 fix(reader): stop per-chapter listener leak that degrades paragraph mode (#4735)
The annotator's foliate `load` handler (onLoad) attached a renderer
`scroll` listener and, on Android, a global `native-touch` dispatcher
listener on every section load. Both the renderer and the eventDispatcher
outlive individual sections — and foliate fires `load` for preloaded
neighbour sections too — so these listeners accumulated without bound, one
set per chapter. Each renderer `scroll` (fired on every paragraph-mode
`goTo`) then ran all of them, and on Android the scroll/native-touch
handlers do real work. Reading a long book (e.g. a 3000-chapter web novel)
in paragraph mode slowed down steadily after a few chapters and only an
app restart cleared it.

Register these listeners once per view via a new `useRendererInputListeners`
hook with cleanup, instead of once per section load. The native-touch
handler now resolves the CURRENT primary section's doc/index at fire time
rather than capturing a (possibly off-screen, preloaded) section's. The
redundant `scroll` → `repositionPopups` listener is dropped — a dedicated
effect already repositions popups on scroll. Doc-scoped listeners stay in
onLoad, since they die with the section's iframe.

Add useRendererInputListeners unit tests covering register-once-per-view,
no-accumulation-across-re-renders, latest-handler routing, Android gating,
and unmount cleanup.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 18:12:39 +02:00
Huang Xin 140b71ee30 feat(dictionary): add adjustable dictionary popup font size (#4443) (#4734)
Expose `::part(dict-content)` on the MDict shadow content and add a
dictionary popup font-size setting (Settings → Language → Dictionaries),
independent of the main reading view.

- mdictProvider: tag the in-shadow body with `part="dict-content"` and a
  stable `dict-shadow-host` class so the popup's `::part()` rule can reach
  across the shadow boundary — MDict is the only provider that renders into
  a shadow root, so ordinary popup CSS can't touch it.
- DictionarySettings.fontScale (default 1) with setFontScale + load-merge;
  synced cross-device via the `dictionarySettings.fontScale` whitelist entry.
- DictionaryResultsView drives `--dict-font-scale` + `data-dict-content` on
  each per-tab container. globals.css re-bases the light-DOM Tailwind text
  utilities to `em` within that scope and sizes the MDict shadow body via
  `::part(dict-content)`, so every provider scales from one lever.
- SettingsSelect control (85–175%) in the Dictionaries panel.

Tests: jsdom unit tests (part attribute, store fontScale, sync whitelist)
plus a real-Chromium browser test for the em-rebasing + `::part` + custom-
property-inheritance CSS contract jsdom cannot model.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 17:51:23 +02:00
Huang Xin 082edc204b fix(sync): sync updated book covers across devices (#4544) (#4731)
* docs: design for syncing updated book data (cover + file) (#4544)

Cover-change sync via a content hash (coverHash = partial MD5 of cover.png)
plus a cover_updated_at field-level merge timestamp; file updates ride the
existing re-import / metaHash dedupe.

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

* fix(sync): sync updated book covers across devices (#4544)

Editing a book's cover wrote cover.png locally but changed no hash (the
cover is keyed by the file hash), so peers had no signal to re-download it
and the change never propagated.

Give the cover its own content-addressed version:
- coverHash = partial MD5 of cover.png; a peer re-downloads the cover iff
  the synced hash differs from the local one (idempotent, no churn on
  identical/re-extracted covers — compatible with the metaHash dedupe).
- coverUpdatedAt = field-level LWW timestamp so a page-turn that wins
  whole-row LWW on updated_at can't clobber a cover edit (mirrors the
  reading_status_updated_at fix for #4634).

Editing a cover recomputes the hash, bumps coverUpdatedAt, and re-uploads
only the cover; the server merges cover fields independently; peers
re-download on a hash diff. File updates continue to ride the existing
re-import / metaHash dedupe (changed file -> changed hash -> re-key).

Migration 016 adds cover_hash / cover_updated_at to books.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 17:31:03 +02:00
Huang Xin 1b44b95d3a fix(reader): smooth single-notch wheel scroll over PDF pages in scrolled mode (#4727) (#4732)
Bumps foliate-js to drop the redundant manual `scrollBy` the scrolled-mode
page iframes ran on every wheel event. Because those iframes are
`scrolling="no"`, the browser already chains the wheel to the host scroller
natively; the extra scrollBy stacked on top, so wheeling over a page moved
it ~2x as far in an instant lurch while the margins scrolled smoothly by one
notch. Native scroll-chaining now provides the single smooth scroll over both
the page and the margins.

Adds a browser-lane regression test that mounts the real <foliate-fxl>
renderer in scrolled mode and asserts a wheel over a page does not
programmatically move the host scroller.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 17:17:24 +02:00
Huang Xin acf2b165f3 fix(library): keep in-place book paths absolute so uploads stay in fs scope (#4720) (#4730)
* fix(library): keep in-place book paths absolute so uploads stay in fs scope (#4720)

resolveFilePath joined `${prefix}/${path}` unconditionally. For base 'None'
(in-place / external books, whose filePath lives outside Books/<hash>/) the
prefix is empty, so an already-absolute source path became `/C:\Users\...`
on Windows (and `//Users/...` on POSIX). The native upload guard added in
#4639 (transfer_file.rs `ensure_path_allowed`) then rejected that malformed
path as "permission denied: path not in filesystem scope", so uploading a
folder-imported book failed on Windows.

Return the path verbatim when the prefix is empty.

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

* style(library): use btn-contrast for the Import-from-folder confirm button (#4720)

Aligns the dialog's confirm CTA with the sibling import dialogs
(ImportFromUrlDialog, FailedImportsDialog), which already use the
theme-neutral, e-ink-correct btn-contrast instead of the colored
btn-primary.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 15:53:59 +02:00
Huang Xin 942095bcd6 fix(reader): make Shift+P toggle, exit, and resume paragraph mode reliably (#4717) (#4725)
Three paragraph-mode problems, all fixed:

- Shift+P inside paragraph mode flashed and re-entered (and pressing it
  repeatedly did nothing). A single keypress toggled twice: eventDispatcher
  .dispatch() iterated the live listener Set while awaiting each listener, and
  the exit's awaited dispatch('paragraph-mode-disabled') let React re-run
  useParagraphMode's subscription effect mid-loop, adding a handler the same
  dispatch then called. Snapshot the listeners before iterating (dispatchSync
  already did), so a listener added during a dispatch can't fire for the
  current event.

- Shift+P / Escape only worked when focus sat on the overlay. Handle the
  overlay's keys the way a dialog/alert does: focus the dialog element on open
  and handle Escape / the toggle shortcut / paragraph navigation in its own
  onKeyDown (stopping propagation so the global handler can't double-fire),
  instead of a global window listener. Suppress the focus ring on the
  programmatically-focused, non-tab-stop container.

- Resume jumped to the chapter start, and repeated enter/exit walked further
  back. Two causes: (a) entering/exiting scrolled the underlying view to the
  focused paragraph's start, which rewinds a page when that paragraph began on
  the previous page — don't scroll on resume/exit (the paragraph is already on
  screen); navigation still scrolls. (b) resume preferred the rAF-debounced
  store progress and a stored last-paragraph CFI that can come out malformed
  and resolve to an empty range, shadowing the correct candidate and sending
  findByRange to the first block. Resume from the view's live, foliate-
  generated lastLocation CFI first (set synchronously on every relocate,
  resolved against the current document so it survives iframe recreation).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 08:22:00 +02:00
Huang Xin f4bb111267 feat(translator): add Urdu as a Translate Text target language (#4721) (#4726)
Add Urdu (ur) to TRANSLATOR_LANGS so it appears in the Translate Text
language list (inline TranslatorPopup and Settings → Translation). The
list is not provider-gated, and Google/Azure/Yandex all translate to
Urdu; Urdu is already in MIGHT_BE_RTL_LANGS so the translated output
renders right-to-left.

Closes #4721

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 06:17:10 +02:00
Huang Xin a6d28ffcdf fix(reader): add Alt+P proofread shortcut and let Shift+P exit paragraph mode (#4717) (#4723)
On Windows/Linux, Ctrl+P opens the proofread/replace rules but also
triggers the browser print dialog, since the selection shortcut handlers
return undefined and never preventDefault. Add a print-free `alt+p`
binding for Proofread Selection alongside ctrl+p/cmd+p.

Also fix Shift+P being unable to exit paragraph mode: the paragraph
overlay attaches a capture-phase keydown listener that calls
stopImmediatePropagation() on every key while visible, so the global
toggle shortcut never reached useShortcuts. Honor the configured
"Toggle Paragraph Mode" shortcut directly in the overlay so the same
shortcut that enters paragraph mode also exits it.

Extract the shared shortcut event-matching into matchesShortcut() in
utils/shortcutKeys.ts and reuse it from useShortcuts instead of its
private duplicate.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 04:57:04 +02:00
Huang Xin b87c735c1e fix(tts): keep native System TTS reading past unspeakable chunks offline (#4613, #4408) (#4716)
Android System TTS (and iOS) read-aloud could stop offline and refuse to
continue — #4613 "stops at the end of the chapter, won't advance" and #4408
"stops at random intervals" — after which the play/headphone controls felt
wedged.

Root cause: `TTSController.#speak` only auto-advances when the last event code
is `end`. The native client surfaces an offline engine failure as a terminal
`error` code (Android `UtteranceProgressListener.onError`). This typically
happens on a specific utterance the offline engine can't synthesize — e.g. an
unsupported character — characteristically the first utterance after a chapter
boundary, even with a local/offline voice (online, engines often fall back to
network synthesis, which is why it only breaks offline). On `error` the
controller never called `forward()` and left `state` stuck at `playing`, so
playback dead-ended and the controls couldn't recover. Edge/Web clients throw
instead (handled by `error()`), so only the native client hit this.

Fix (native-scoped, no change to the Edge/Web path): when the active client is
the native client and an utterance ends with a terminal `error` (still playing,
not aborted, not one-time), skip that chunk and advance just as a normal `end`
would — re-speaking the same unsynthesizable text would only fail again. A
consecutive-error cap stops playback gracefully if the engine can't speak
anything, so a wholly-unusable engine doesn't silently race to the end of the
book and the state machine always leaves `playing`.

Tests: tts-controller covers skip-on-error advancing past a bad chunk, and the
consecutive-error cap stopping gracefully (bounded, not wedged in playing).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 04:50:37 +02:00
Huang Xin 9155ae627c feat(sync): decouple the incremental-pull cursor from updated_at via server synced_at (#4678) (#4712)
* feat(sync): decouple the incremental-pull cursor from updated_at (#4678)

`books.updated_at` was overloaded as both the incremental-pull cursor
(`GET /api/sync?since=…` filters `updated_at > since`, devices keep one
global `max(updated_at)` watermark) and the library "date read" sort key.
A server-resolved reading-status merge had to be written with a timestamp
greater than every peer's global cursor to propagate, which forced
`updated_at = now()` and reordered the date-read library by sync-processing
time (the #4677 symptom).

Introduce a server-assigned `synced_at` column on `books`, stamped by a
`BEFORE INSERT OR UPDATE` trigger on every write, used only as the pull
cursor. `updated_at` stays pure client event time used only for sorting.

- Migration 016 + baseline schema: add `synced_at` (NOT NULL DEFAULT now()),
  index `(user_id, synced_at)`, trigger `set_books_synced_at`. Backfill
  `synced_at = updated_at` before creating the trigger so existing devices'
  cursors hand over without a re-sync storm.
- GET: books filters/orders on `synced_at > since` (a delete bumps synced_at,
  so the deleted_at clause is dropped); configs/notes stay on updated_at.
- POST: extract `buildStatusPropagationRow` and drop the `updated_at = now()`
  bump — the trigger advances synced_at so peers re-pull the status change
  while updated_at (the sort key) stays put.
- Client `computeMaxTimestamp` keys on synced_at, falling back to
  updated_at/deleted_at for pre-migration servers and configs/notes.

Backward-compatible: `synced_at >= updated_at` always, so `synced_at > since`
is a strict superset of `updated_at > since` — old web clients and the
koplugin keep working with no data loss (at worst a redundant idempotent
re-pull of rare server-merged rows). The koplugin's shared pull/push cursor
is left untouched; a proper split is a follow-up.

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

* fix(sync): make the books synced_at backfill safe for large live tables (#4678)

The single `UPDATE … WHERE synced_at IS NULL` deadlocked on a 3.8M-row
production `books` table: it rewrites every row in one transaction while the
live /api/sync push path upserts books rows, and the two lock rows in opposite
orders. `ALTER COLUMN … SET NOT NULL` (full-table ACCESS EXCLUSIVE scan) and a
plain CREATE INDEX (write-blocking SHARE lock) compounded it.

Rework migration 016 as an online migration (run via psql, not in a wrapping
transaction):
- backfill in small autocommitted batches via a procedure, using
  FOR UPDATE SKIP LOCKED so it never waits on an app-locked row;
- CREATE INDEX CONCURRENTLY instead of a blocking build;
- install the trigger last (so it can't clobber the updated_at backfill);
- drop the hard SET NOT NULL (the default + trigger + backfill keep the column
  populated and the client falls back to updated_at); a NOT VALID CHECK +
  VALIDATE alternative is included, commented, for operators who want it.

The baseline schema.sql (fresh, empty installs) keeps the simple inline
NOT NULL DEFAULT now() + trigger.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 21:24:01 +02:00
Huang Xin a9c0f3d46d fix(reader): remove 1px white seam in PDF spread at fractional DPI (#4587) (#4713)
In a PDF two-page spread at a fractional devicePixelRatio (Windows display
scale 150% -> dpr 1.5), a one-pixel white bar appeared at the spine on certain
zoom levels. foliate-js' pdf.js sized the page canvas only via its bitmap, so
the fractional viewport width was truncated and the canvas rendered up to ~1
device pixel narrower than the page box, exposing the background at the spine.

Bump the foliate-js submodule to the fix (readest/foliate-js#35) which pins an
explicit canvas CSS size to the un-truncated viewport dimensions, and add a
regression test that drives render() at dpr 1.5 and asserts the canvas fills
its box exactly.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 21:20:28 +02:00
Huang Xin 30727d353a fix(reader): release volume-key page-flip while TTS is playing (#4691) (#4710)
When "page turn with volume buttons" is enabled, the volume keys were
intercepted for the whole reading session, so switching from reading to
TTS left them flipping pages instead of adjusting playback volume.

Gate the volume-key interception on this book's TTS playback state
(via the existing `tts-playback-state` bus): release interception while
TTS is playing so the OS handles volume, and re-acquire it when TTS is
paused or stopped. The acquire/release pair is keyed on the playback
state so the deviceStore reference count stays balanced.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 19:51:23 +02:00
Huang Xin 316ca3c941 fix(kosync): reject non-KOReader-Sync server URLs on connect (#4692) (#4711)
A wrong Server URL can land on the host's static web UI, which answers
200 OK with an HTML page. connect() treated any 2xx from /users/auth (or
/users/create) as a successful login, so the user was silently
"connected" to an endpoint that can never sync: pulls report 0% and
pushes fail with no error surfaced. This is the root of the symptom in
#4692 (KOReader sync to a Grimmory/Booklore server failing on Android).

Validate that the auth/registration response is an actual KOReader Sync
JSON object (a real server replies e.g. {"authorized":"OK"}; an HTML
page fails JSON parsing) and otherwise return a clear
"Not a KOReader Sync server. Check the Server URL." message.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 19:51:15 +02:00
Huang Xin 15f1838781 chore(agent): update agent memories (#4709) 2026-06-21 19:09:21 +02:00
Huang Xin c781aeddaa feat(reader): add sticky progress bar with chapter ticks (#4707)
Add an always-visible, opt-in progress bar with chapter tick marks in the
persistent footer, so reading progress no longer disappears like the hover
footer slider does.

- New StickyProgressBar: a 1px rounded-border capsule with a fill and
  chapter tick marks; display-only, e-ink aware, and RTL safe. Ticks render
  inside the clipped track so the rounded ends crop them and they never
  exceed the border.
- Chapter ticks come from the TOC, mapped to spine-section start fractions
  (getChapterTickFractions); the first and last ticks are trimmed so they
  do not crowd the rounded ends.
- Thread the overall size-domain reading fraction through setProgress so the
  bar fill aligns with the tick domain.
- Footer layout: when enabled the bar grows on the left and the info widgets
  group to the right with even spacing; otherwise the existing layout is
  unchanged.
- Horizontal writing mode only; vertical keeps the current footer.
- Add the showStickyProgressBar view setting, a LayoutPanel toggle, and i18n.

Closes #1616.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 19:03:24 +02:00
Huang Xin 9735f497db feat(reader): proofread rule sync, regex, reorder, and dialog refresh (#4700) (#4708)
- Sync library-scope replacement rules across devices (settings whitelist).
  Book and selection rules already ride along the book config.
- Add regex support: a Regex toggle on the selection popup plus a full
  add-rule form (find / replace / scope / regex / case-sensitive) in the
  Proofread Rules manager.
- Reuse Ctrl/Cmd+P to open the rules manager when nothing is selected
  (handleProofread); opens the create-from-selection popup otherwise.
- Translate the whole-word warning, which was hardcoded English.
- Drag-to-reorder rules within each category (dnd-kit), persisted via the
  rule order field across both the book config and global settings.
- Modernize the manager dialog with the settings primitives and a
  btn-contrast CTA; fix mobile height clipping and the inset scrollbar.
- Translate all pending i18n strings across 33 locales (includes the
  delete-confirm strings surfaced by the extractor).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 19:01:04 +02:00
Huang Xin febb0d9a69 fix(backup): normalize Windows backslash paths in backup zip entries (#4706)
A backup .zip exported on Windows failed to restore on every platform
(Web, Android, Windows): books restored with metadata but no files or
covers.

`appService.readDirectory` returns paths using the host separator, so on
Windows `file.path` is `hash\cover.png` (backslash). `addBackupEntriesToZip`
used that verbatim as the zip entry name, so entries were named with `\`.
Restore matches a book's files with `filename.startsWith(`${hash}/`)`
(forward slash), which never matched the backslash names, so every book
file was silently skipped.

Normalize the zip entry name to forward slashes when adding files. Entry
names are now cross-platform and restorable everywhere. Already-exported
broken backups need re-exporting from the fixed app.

Fixes #4703

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 18:48:07 +02:00
Huang Xin 799fc0e0ab feat(library): add opt-in "purge reading data" toggle to delete confirm (#4698) (#4705)
Replace the standalone "Purge Data" menu item with an opt-in toggle on the
delete confirmation alert (default off). When enabled, the delete escalates
to a full purge that also wipes the book's reading-data sidecars (config and
nav), instead of leaving the metadata folder behind. The single, bulk, and
multi-select deletes all share the same alert, so this also covers batch
deletes that previously kept every metadata folder.

- Alert: add optional children, confirmLabel, confirmButtonClassName slots
- DeleteConfirmAlert: new wrapper owning the toggle and red escalation
- BookDetailView: drop the Purge Data menu item and onPurge prop
- BookDetailModal: route the standard delete to purge when the toggle is on
- Bookshelf/page: route the bulk delete batch to purge when the toggle is on

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 18:24:29 +02:00
Huang Xin 4fa7f76bc1 feat(payment): observability for store subscription webhooks (#4704)
* feat(payment): observability for store subscription webhooks

Add monitoring for the App Store / Google Play webhooks so store-side
subscription changes are observable on Cloudflare, where stored Workers
Logs are head-sampled at 1% and would miss almost all low-volume webhook
events.

- Add iap/telemetry.ts: every webhook invocation emits a structured log
  line (streamed in full by `wrangler tail`) and a Cloudflare Analytics
  Engine data point (100% capture, independent of log sampling). Writes
  no-op off the Worker runtime, mirroring the getCloudflareContext guard
  in deepl/translate.ts.
- Instrument both webhook routes to record outcome (handled, skipped,
  rejected, error), notification type, status, reason, and latency on
  every return path.
- Add GET /api/cron/iap-reconcile: a CRON_SECRET-protected sweep that
  counts drift (rows still active while their store expiry has passed = a
  missed webhook) in both IAP tables and records a reconcile metric.
  Detection-only; never mutates state.
- Add the IAP_WEBHOOK_AE Analytics Engine binding to wrangler.toml.

New configuration: CRON_SECRET (reconcile auth) and an iap_webhooks
Analytics Engine dataset bound as IAP_WEBHOOK_AE. The reconcile route is
triggered on a schedule (a Cloudflare Cron Trigger worker that fetches
the URL, or any external scheduler) with an Authorization bearer header.

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

* refactor(payment): move reconciliation to a dedicated cron Worker

Replace the public CRON_SECRET-protected /api/cron/iap-reconcile route
with a dedicated Cloudflare Cron Worker. A Cron Trigger invokes the
worker's scheduled() handler directly, so there is no public HTTP surface
and no shared request secret to manage - the strongest option on
Cloudflare (OpenNext's generated worker only exports `fetch`, so the main
worker cannot host a scheduled() handler).

- Add workers/iap-reconcile: a self-contained worker (own package.json,
  tsconfig, wrangler.toml) matching the existing workers/send-email
  convention, registered in pnpm-workspace.yaml. Hourly Cron Trigger;
  reads the IAP tables via the Supabase service role and records a drift
  metric to the shared iap_webhooks Analytics Engine dataset.
- Reconcile logic lives in workers/iap-reconcile/src/reconcile.ts and is
  unit-tested from the app suite.
- Remove the public route and its test; drop the now-unused
  recordIapReconcile from iap/telemetry.ts (webhook telemetry unchanged).

Configuration: set SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY as secrets
on the worker and deploy it with `wrangler deploy` from its directory.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 18:02:02 +02:00
Huang Xin 359fdddcf4 feat(payment): handle App Store and Google Play subscription webhooks (#4701)
Add server-push endpoints so store-side subscription changes (cancel,
refund, expire, renew, grace period) are reflected in the database, not
only the in-app verification flow. Previously only Stripe had a webhook.

- POST /api/apple/notifications: verify and decode App Store Server
  Notifications V2, resolve the user by original_transaction_id, map the
  notification type to a status, and update the subscription and plan. A
  single endpoint serves Sandbox and Production. Refunded one-time
  purchases are marked refunded and storage is recomputed.
- POST /api/google/notifications: verify the Pub/Sub shared-secret token,
  decode the RTDN, resolve the user by purchase_token, re-verify against
  the Play Developer API (overriding the status for terminal events such
  as REVOKED/EXPIRED and grace period), and handle voided purchases.
- Add an isEntitledStatus helper and reuse the existing
  createOrUpdateSubscription and plan-update logic shared with Stripe.

New configuration: GOOGLE_RTDN_VERIFICATION_TOKEN (shared secret in the
Pub/Sub push URL) and the optional GOOGLE_IAP_PACKAGE_NAME; Apple reuses
APPLE_IAP_BUNDLE_ID and the existing service-account credentials.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 16:48:17 +02:00
Huang Xin 96d65d9960 feat(tts): add native local iOS TTS (AVSpeechSynthesizer) (#4697)
Implement on-device iOS text-to-speech using AVSpeechSynthesizer,
mirroring the Android native TextToSpeech plugin so the shared
NativeTTSClient drives both platforms through the same command and
tts_events contract.

- Swift NativeTTSPlugin: speak/stop/pause/resume/rate/pitch/voice and
  voice enumeration, with region-disambiguated duplicate voice names and
  a small preUtteranceDelay to avoid first-word clipping.
- Enable the native TTS client on iOS in TTSController.
- Make TTS teardown resilient: reset UI state up front and tear down the
  controller, media session, and background audio in parallel so a slow
  native shutdown can never leave the TTS icon or lock-screen session
  stuck on.
- Keep iOS on navigator.mediaSession for the lock screen (Android uses
  the native foreground service), which restores the Edge TTS cover and
  current-sentence metadata.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 09:53:23 +02:00
Huang Xin ab935f8510 fix(library): preserve original files when deleting "read in place" books (#4696)
Deleting a book imported via "Import From Directory" with "Read books in
place" ran fs.removeFile on the user's own source file, permanently
destroying the original (it was not even moved to the Recycle Bin). Cloud
sync only uploads after a successful sync, so unsynced originals were
unrecoverable.

deleteBook now only removes files Readest created: the managed copy under
Books/<hash>/ and the app-generated sidecars (cover.png, plus the whole
Books/<hash>/ dir on purge). External sources (book.filePath, base 'None',
covering in-place and transient imports) are never touched.

This reverses behavior that was previously deliberate and tested; the
in-place tests now assert the source file is preserved across
local/both/purge while sidecar removal is unchanged.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 08:42:35 +02:00
Huang Xin 2153f7cc0c fix(reader): reset scroll to top on paginated fit-width page turn (#4683) (#4695)
In paginated fixed-layout mode (PDF / fixed-layout EPUB) with fit-width zoom, a
page taller than the viewport makes the renderer host scroll vertically. Turning
the page kept the previous page's vertical offset, so the next page opened
scrolled to the end instead of the top. The bug only shows on WebKit
(Linux/iOS/macOS), which preserves the scroll offset across the page content
swap; Blink (Android/Chrome) resets it to zero.

Bump foliate-js to the fix (readest/foliate-js#34): reset scrollTop on a page
turn only. Add a unit test for the new computePaginatedScroll helper.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 07:46:33 +02:00
Huang Xin 9e163fe746 fix(payment): reflect highest active plan across overlapping Stripe subscriptions (#4694)
* fix(payment): reflect highest active plan across overlapping Stripe subscriptions

When a user upgrades Plus to Pro, both subscriptions stay active until the old
one is cancelled. Each subscription webhook overwrote plans.plan with only that
event's plan, so whichever webhook arrived last won and could downgrade the
account back to plus.

Derive plans.plan from the highest active (or trialing) subscription via a new
getHighestActivePlan helper, used by createOrUpdateSubscription and by the
cancellation handler so a still-active higher plan is preserved instead of
dropping to free.

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

* test(payment): add opt-in live Stripe test for getHighestActivePlan

Skipped by default (CI included); runs only when STRIPE_SECRET_KEY and
STRIPE_TEST_CUSTOMER_ID are set, so it can be exercised locally against a real
customer with overlapping subscriptions. The module under test is imported
dynamically so the file stays import-safe while skipped.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 07:29:39 +02:00
Huang Xin 89f98979e1 chore(release): fastlane for iOS and macOS release (#4685) 2026-06-20 08:40:15 +02:00
Huang Xin 353d381427 fix(deps): bump undici and dompurify overrides for security advisories (#4684)
Raise the pnpm-workspace transitive overrides to the patched versions:

- undici >=7.24.0 -> >=7.28.0 <8 (resolves to 7.28.0). Fixes 7 advisories:
  WebSocket DoS, SOCKS5 cross-origin routing, SOCKS5 TLS bypass,
  Set-Cookie header injection, shared-cache info disclosure, response
  queue poisoning, and SameSite downgrade (GHSA undici < 7.28.0).
  Bounded below 8 to stay on the patched 7.x line and avoid an
  unvetted major bump (an open-ended range pulled undici 8.5.0).
- dompurify >=3.4.9 -> >=3.4.11 (resolves to 3.4.11). Fixes permanent
  ALLOWED_ATTR pollution via setConfig() (<= 3.4.10).

Verified: pnpm test, pnpm lint, pnpm build-web all pass; lockfile sweep
confirms no vulnerable undici/dompurify versions remain.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 08:30:20 +02:00
Huang Xin 54d54791b0 release: version 0.11.12 (#4682) 2026-06-20 06:41:25 +02:00
Huang Xin 7185dca1a2 feat(reader): add save/share button to image gallery toolbar (#4680)
* feat(reader): add save/share button to image gallery toolbar

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

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

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

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

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

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

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

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

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

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

Also includes local agent memory notes that were staged alongside.

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

---------

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

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

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

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

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

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

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

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

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

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

Also stage the project-memory note for this regression.

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 01:41:16 +02:00
874 changed files with 67945 additions and 8300 deletions
+3 -3
View File
@@ -25,7 +25,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 90
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: initialize git submodules
run: git submodule update --init --recursive
@@ -46,7 +46,7 @@ jobs:
cache: pnpm
- name: setup Java
uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5
uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5
with:
distribution: 'zulu'
java-version: '17'
@@ -97,7 +97,7 @@ jobs:
test -n "$APK"
- name: cache AVD snapshot
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
id: avd-cache
with:
path: |
+1 -1
View File
@@ -61,7 +61,7 @@ jobs:
# your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages
steps:
- name: Checkout repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
# Add any setup steps before running the `github/codeql-action/init` action.
# This includes steps like installing compilers or runtimes (`actions/setup-node`
+2 -2
View File
@@ -39,7 +39,7 @@ jobs:
platform=${{ matrix.platform }}
echo "PLATFORM_PAIR=${platform//\//-}" >> "$GITHUB_ENV"
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
submodules: recursive
@@ -61,7 +61,7 @@ jobs:
- name: Build and push by digest
id: build
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: .
file: ./Dockerfile
+48 -4
View File
@@ -28,7 +28,7 @@ jobs:
outputs:
nightly_version: ${{ steps.v.outputs.nightly_version }}
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: main
persist-credentials: false
@@ -40,6 +40,12 @@ jobs:
build:
needs: compute-version
permissions:
contents: read
# Required by actions/attest-build-provenance: id-token mints the Sigstore
# OIDC identity, attestations writes the provenance to the repo's store.
id-token: write
attestations: write
strategy:
fail-fast: false
matrix:
@@ -72,9 +78,11 @@ jobs:
args: '--target aarch64-pc-windows-msvc --bundles nsis'
runs-on: ${{ matrix.config.os }}
timeout-minutes: 60
# Backstop only — must stay ABOVE setup time + the per-step timeouts below,
# because a job-level timeout reports `cancelled` and skips assemble-manifest.
timeout-minutes: 75
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: main
persist-credentials: false
@@ -93,7 +101,7 @@ jobs:
- name: setup Java (for Android build only)
if: matrix.config.release == 'android'
uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5
uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5
with:
distribution: 'zulu'
java-version: '17'
@@ -134,6 +142,7 @@ jobs:
echo "NEXT_PUBLIC_SUPABASE_URL=${{ secrets.NEXT_PUBLIC_SUPABASE_URL }}" >> .env.local
echo "NEXT_PUBLIC_SUPABASE_ANON_KEY=${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }}" >> .env.local
echo "NEXT_PUBLIC_APP_PLATFORM=tauri" >> .env.local
echo "SENTRY_DSN=${{ secrets.SENTRY_DSN }}" >> .env.local
cp .env.local apps/readest-app/.env.local
- name: install rclone
@@ -167,6 +176,7 @@ jobs:
- name: build and sign Android apks
if: matrix.config.release == 'android'
shell: bash
timeout-minutes: 55
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NDK_HOME: ${{ env.ANDROID_HOME }}/ndk/28.2.13676358
@@ -207,9 +217,30 @@ jobs:
if: matrix.config.release == 'linux'
run: cargo install tauri-cli --git https://github.com/tauri-apps/tauri --branch feat/truly-portable-appimage --force
# The truly-portable AppImage bundler downloads quick-sharun.sh from
# Anylinux-AppImages@main ONLY when it is not already in the tauri tools
# cache. An upstream strace-mode change (2026-06-29) made bundling launch
# the app under Xvfb and hang forever, timing out the Linux legs (#4906).
# Seed the cache with the last known-good revision so the bundler never
# fetches the moving main-branch script.
- name: pin quick-sharun.sh for AppImage bundling (Linux)
if: matrix.config.release == 'linux'
run: |
set -euo pipefail
cache_dir="${XDG_CACHE_HOME:-$HOME/.cache}/tauri"
mkdir -p "$cache_dir"
curl -fsSL --retry 3 -o "$cache_dir/quick-sharun.sh" \
"https://raw.githubusercontent.com/pkgforge-dev/Anylinux-AppImages/b3a9e985cdedf7efa81d172f182cd13983743147/useful-tools/quick-sharun.sh"
chmod +x "$cache_dir/quick-sharun.sh"
- name: build desktop bundles
if: matrix.config.release != 'android'
shell: bash
# A hung build must fail the STEP (step timeout -> job failure), not hit
# the job-level timeout: job timeouts report `cancelled`, which the
# assemble-manifest guard treats as run cancellation and skips promoting
# latest.json for ALL platforms (#4906).
timeout-minutes: 45
env:
TAURI_BUNDLER_NEW_APPIMAGE_FORMAT: 'true'
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
@@ -238,6 +269,7 @@ jobs:
- name: build and sign portable binaries (Windows only)
if: matrix.config.os == 'windows-latest'
shell: bash
timeout-minutes: 30
env:
TAURI_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
@@ -381,6 +413,18 @@ jobs:
;;
esac
# Attest the distributable binaries staged in nightly-out (apks, AppImage,
# app.tar.gz, setup/portable exe). gh attestation verify is digest-based,
# so it verifies these against readest/readest even though they ship via
# download.readest.com rather than a GitHub release. The .sig updater
# signatures are excluded — they are not binaries users run.
- name: attest nightly binaries
uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1
with:
subject-path: |
nightly-out/Readest*
!nightly-out/*.sig
- name: upload artifacts + fragment to R2
shell: bash
run: |
+13 -13
View File
@@ -14,19 +14,19 @@ jobs:
SCCACHE_GHA_ENABLED: 'true'
RUSTC_WRAPPER: sccache
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
submodules: 'true'
- name: setup sccache
uses: mozilla-actions/sccache-action@9e7fa8a12102821edf02ca5dbea1acd0f89a2696 # v0.0.10
- name: Install minimal stable with clippy and rustfmt
uses: actions-rust-lang/setup-rust-toolchain@46268bd060767258de96ed93c1251119784f2ab6 # v1
uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1
with:
toolchain: stable
override: true
components: rustfmt, clippy
- name: Cache apt packages
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: /var/cache/apt/archives
key: apt-rust-lint-${{ runner.os }}
@@ -47,7 +47,7 @@ jobs:
build_web_app:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
submodules: 'true'
@@ -84,7 +84,7 @@ jobs:
- name: cache playwright browsers
id: playwright-cache
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
@@ -122,7 +122,7 @@ jobs:
matrix:
shard: [1, 2]
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
submodules: 'true'
@@ -144,7 +144,7 @@ jobs:
- name: cache playwright browsers
if: matrix.shard == 1
id: playwright-cache
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
@@ -179,7 +179,7 @@ jobs:
contents: read
pull-requests: read
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
submodules: 'true'
@@ -215,7 +215,7 @@ jobs:
- name: cache apt packages
if: steps.changes.outputs.koplugin == 'true'
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: /var/cache/apt/archives
key: apt-test-koplugin-${{ runner.os }}
@@ -250,7 +250,7 @@ jobs:
SCCACHE_GHA_ENABLED: 'true'
RUSTC_WRAPPER: sccache
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
submodules: 'true'
@@ -266,7 +266,7 @@ jobs:
# The tauri tests run `next dev`, whose Turbopack cache lives in
# `.next/dev/cache` (a different path from the `next build` cache).
- name: cache Turbopack dev cache
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: apps/readest-app/.next/dev/cache
key: turbo-dev-tauri-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
@@ -282,7 +282,7 @@ jobs:
uses: mozilla-actions/sccache-action@9e7fa8a12102821edf02ca5dbea1acd0f89a2696 # v0.0.10
- name: install Rust toolchain
uses: actions-rust-lang/setup-rust-toolchain@46268bd060767258de96ed93c1251119784f2ab6 # v1
uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1
with:
toolchain: stable
# Disable this action's built-in rust-cache so the explicit
@@ -307,7 +307,7 @@ jobs:
cache-workspace-crates: 'true'
- name: Cache apt packages
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: /var/cache/apt/archives
key: apt-tauri-${{ runner.os }}
+81 -5
View File
@@ -19,7 +19,7 @@ jobs:
release_version: ${{ steps.get-release-notes.outputs.release_version }}
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: setup node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
- name: get version
@@ -89,7 +89,7 @@ jobs:
contents: write
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: create KOReader plugin zip
env:
@@ -118,10 +118,41 @@ jobs:
echo "Uploading ${plugin_zip} to GitHub release"
gh release upload ${{ needs.get-release.outputs.release_tag }} ${plugin_zip} --clobber
build-calibre-plugin:
needs: get-release
permissions:
contents: write
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: create calibre plugin zip
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
version=${{ needs.get-release.outputs.release_version }}
# Stamp PLUGIN_VERSION in __init__.py with the release version
# (from apps/readest-app/package.json, mirroring the koplugin's
# _meta.lua stamp above); the committed value is a dev placeholder.
version_tuple=$(echo "${version}" | awk -F. '{printf "(%d, %d, %d)", $1, $2, $3}')
perl -i -pe "s/^PLUGIN_VERSION = \(\d+, \d+, \d+\)/PLUGIN_VERSION = ${version_tuple}/" \
apps/readest-calibre-plugin/__init__.py
make -C apps/readest-calibre-plugin zip
plugin_zip="apps/readest-calibre-plugin/dist/Readest-${version}.calibre-plugin.zip"
echo "Uploading ${plugin_zip} to GitHub release"
gh release upload ${{ needs.get-release.outputs.release_tag }} ${plugin_zip} --clobber
build-tauri:
needs: get-release
permissions:
contents: write
# Required by actions/attest-build-provenance: id-token mints the Sigstore
# OIDC identity, attestations writes the provenance to the repo's store.
id-token: write
attestations: write
strategy:
fail-fast: false
matrix:
@@ -156,7 +187,7 @@ jobs:
runs-on: ${{ matrix.config.os }}
timeout-minutes: 60
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: initialize git submodules
run: git submodule update --init --recursive
@@ -172,7 +203,7 @@ jobs:
- name: setup Java (for Android build only)
if: matrix.config.release == 'android'
uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5
uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5
with:
distribution: 'zulu'
java-version: '17'
@@ -225,6 +256,7 @@ jobs:
echo "NEXT_PUBLIC_SUPABASE_URL=${{ secrets.NEXT_PUBLIC_SUPABASE_URL }}" >> .env.local
echo "NEXT_PUBLIC_SUPABASE_ANON_KEY=${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }}" >> .env.local
echo "NEXT_PUBLIC_APP_PLATFORM=tauri" >> .env.local
echo "SENTRY_DSN=${{ secrets.SENTRY_DSN }}" >> .env.local
cp .env.local apps/readest-app/.env.local
- name: build and upload Android apks
@@ -267,6 +299,14 @@ jobs:
gh release upload ${{ needs.get-release.outputs.release_tag }} $universial_apk.sig --clobber
gh release upload ${{ needs.get-release.outputs.release_tag }} $arm64_apk.sig --clobber
- name: attest Android apks
if: matrix.config.release == 'android'
uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1
with:
subject-path: |
apps/readest-app/Readest_${{ needs.get-release.outputs.release_version }}_universal.apk
apps/readest-app/Readest_${{ needs.get-release.outputs.release_version }}_arm64.apk
- name: download and update latest.json for Android release
if: matrix.config.release == 'android'
env:
@@ -308,7 +348,24 @@ jobs:
if: matrix.config.release == 'linux'
run: cargo install tauri-cli --git https://github.com/tauri-apps/tauri --branch feat/truly-portable-appimage --force
- uses: tauri-apps/tauri-action@84b9d35b5fc46c1e45415bdb6144030364f7ebc5 # v0
# The truly-portable AppImage bundler downloads quick-sharun.sh from
# Anylinux-AppImages@main ONLY when it is not already in the tauri tools
# cache. An upstream strace-mode change (2026-06-29) made bundling launch
# the app under Xvfb and hang forever (#4906). Seed the cache with the
# last known-good revision so the bundler never fetches the moving
# main-branch script. Keep in sync with nightly.yml.
- name: pin quick-sharun.sh for AppImage bundling (Linux)
if: matrix.config.release == 'linux'
run: |
set -euo pipefail
cache_dir="${XDG_CACHE_HOME:-$HOME/.cache}/tauri"
mkdir -p "$cache_dir"
curl -fsSL --retry 3 -o "$cache_dir/quick-sharun.sh" \
"https://raw.githubusercontent.com/pkgforge-dev/Anylinux-AppImages/b3a9e985cdedf7efa81d172f182cd13983743147/useful-tools/quick-sharun.sh"
chmod +x "$cache_dir/quick-sharun.sh"
- uses: tauri-apps/tauri-action@1deb371b0cd8bd54025b384f1cd735e725c4060f # v1.0.0
id: tauri
if: matrix.config.release != 'android'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -332,6 +389,16 @@ jobs:
releaseBody: ${{ needs.get-release.outputs.release_note }}
args: ${{ matrix.config.args || '' }}
# Attest the freshly built desktop bundles (installers, AppImage, dmg,
# updater archives + their .sig). tauri-action reports their on-disk paths
# as a JSON array; fromJSON('"\n"') yields a real newline to join them into
# the newline-delimited list subject-path expects.
- name: attest desktop bundles
if: matrix.config.release != 'android' && steps.tauri.outputs.artifactPaths != ''
uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1
with:
subject-path: ${{ join(fromJSON(steps.tauri.outputs.artifactPaths), fromJSON('"\n"')) }}
- name: upload release notes to GitHub release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -384,6 +451,15 @@ jobs:
echo "Uploading signature to GitHub release"
gh release upload ${{ needs.get-release.outputs.release_tag }} $bin_file.sig --clobber
# The portable rebuild above is not produced by tauri-action, so it is not
# covered by the "attest desktop bundles" step; attest it here. Exactly one
# portable exe is staged at the workspace root per Windows leg.
- name: attest Windows portable binary
if: matrix.config.os == 'windows-latest'
uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1
with:
subject-path: Readest_*-portable.exe
- name: download and update latest.json for Windows portable release
if: matrix.config.os == 'windows-latest'
env:
+1 -1
View File
@@ -34,7 +34,7 @@ jobs:
steps:
- name: "Checkout code"
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
+1 -1
View File
@@ -11,7 +11,7 @@ jobs:
permissions:
contents: read
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
submodules: 'true'
- uses: amondnet/vercel-action@de09aeac2ace6599ec9b11ef87558759a496bac4 # v42
+2 -2
View File
@@ -20,8 +20,8 @@ Basically you need to install or update the following development tools:
- **Rust** and **Cargo** for Tauri development
```bash
nvm install v22
nvm use v22
nvm install v24
nvm use v24
npm install -g pnpm
rustup update
```
Generated
+474 -8
View File
@@ -36,6 +36,7 @@ dependencies = [
"read-progress-stream",
"reqwest 0.12.28",
"semver",
"sentry",
"serde",
"serde_json",
"tauri",
@@ -57,6 +58,7 @@ dependencies = [
"tauri-plugin-os",
"tauri-plugin-persisted-scope",
"tauri-plugin-process",
"tauri-plugin-sentry",
"tauri-plugin-sharekit",
"tauri-plugin-shell",
"tauri-plugin-sign-in-with-apple",
@@ -71,9 +73,19 @@ dependencies = [
"tokio",
"tokio-util",
"walkdir",
"winreg 0.52.0",
"zip 2.4.2",
]
[[package]]
name = "addr2line"
version = "0.25.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b"
dependencies = [
"gimli",
]
[[package]]
name = "adler2"
version = "2.0.1"
@@ -544,6 +556,21 @@ dependencies = [
"tracing",
]
[[package]]
name = "backtrace"
version = "0.3.76"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6"
dependencies = [
"addr2line",
"cfg-if",
"libc",
"miniz_oxide",
"object",
"rustc-demangle",
"windows-link 0.2.1",
]
[[package]]
name = "base64"
version = "0.21.7"
@@ -748,7 +775,7 @@ checksum = "cfd1e3f8955a5d7de9fab72fc8373fade9fb8a703968cb200ae3dc6cf08e185a"
dependencies = [
"borsh-derive",
"bytes",
"cfg_aliases",
"cfg_aliases 0.2.1",
]
[[package]]
@@ -1030,6 +1057,12 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "cfg_aliases"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e"
[[package]]
name = "cfg_aliases"
version = "0.2.1"
@@ -1340,6 +1373,30 @@ dependencies = [
"libc",
]
[[package]]
name = "crash-context"
version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "031ed29858d90cfdf27fe49fae28028a1f20466db97962fa2f4ea34809aeebf3"
dependencies = [
"cfg-if",
"libc",
"mach2",
]
[[package]]
name = "crash-handler"
version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2066907075af649bcb8bcb1b9b986329b243677e6918b2d920aa64b0aac5ace3"
dependencies = [
"cfg-if",
"crash-context",
"libc",
"mach2",
"parking_lot",
]
[[package]]
name = "crc32c"
version = "0.6.8"
@@ -1621,6 +1678,16 @@ dependencies = [
"keyring-core",
]
[[package]]
name = "debugid"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bef552e6f588e446098f6ba40d89ac146c8c7b64aade83c051ee00bb5d2bc18d"
dependencies = [
"serde",
"uuid 1.23.2",
]
[[package]]
name = "default-net"
version = "0.22.0"
@@ -1964,7 +2031,7 @@ dependencies = [
"rustc_version",
"toml 1.1.2+spec-1.1.0",
"vswhom",
"winreg",
"winreg 0.55.0",
]
[[package]]
@@ -2730,6 +2797,12 @@ dependencies = [
"weezl",
]
[[package]]
name = "gimli"
version = "0.32.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7"
[[package]]
name = "gio"
version = "0.18.4"
@@ -2894,6 +2967,17 @@ dependencies = [
"system-deps 7.0.8",
]
[[package]]
name = "goblin"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b363a30c165f666402fe6a3024d3bec7ebc898f96a4a23bd1c99f8dbf3f4f47"
dependencies = [
"log",
"plain",
"scroll",
]
[[package]]
name = "gtk"
version = "0.18.2"
@@ -3068,6 +3152,17 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "hostname"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "617aaa3557aef3810a6369d0a99fac8a080891b68bd9f9812a1eeda0c0730cbd"
dependencies = [
"cfg-if",
"libc",
"windows-link 0.2.1",
]
[[package]]
name = "html5ever"
version = "0.29.1"
@@ -3983,6 +4078,15 @@ dependencies = [
"libc",
]
[[package]]
name = "mach2"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44"
dependencies = [
"libc",
]
[[package]]
name = "malloc_buf"
version = "0.0.6"
@@ -4126,6 +4230,75 @@ version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "minidump-common"
version = "0.21.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c4d14bcca0fd3ed165a03000480aaa364c6860c34e900cb2dafdf3b95340e77"
dependencies = [
"bitflags 2.11.1",
"debugid",
"num-derive",
"num-traits",
"range-map",
"scroll",
"smart-default",
]
[[package]]
name = "minidump-writer"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2abcd9c8a1e6e1e9d56ce3627851f39a17ea83e17c96bc510f29d7e43d78a7d"
dependencies = [
"bitflags 2.11.1",
"byteorder",
"cfg-if",
"crash-context",
"goblin",
"libc",
"log",
"mach2",
"memmap2",
"memoffset",
"minidump-common",
"nix 0.28.0",
"procfs-core",
"scroll",
"tempfile",
"thiserror 1.0.69",
]
[[package]]
name = "minidumper"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4ebc9d1f8847ec1d078f78b35ed598e0ebefa1f242d5f83cd8d7f03960a7d1"
dependencies = [
"cfg-if",
"crash-context",
"libc",
"log",
"minidump-writer",
"parking_lot",
"polling",
"scroll",
"thiserror 1.0.69",
"uds",
]
[[package]]
name = "minidumper-child"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7c4f23f835dbe67e44ddf884d3802ff549ca5948bf60e9fd70e9a13c96324d1"
dependencies = [
"crash-handler",
"minidumper",
"thiserror 1.0.69",
"uuid 1.23.2",
]
[[package]]
name = "minimal-lexical"
version = "0.2.1"
@@ -4351,6 +4524,18 @@ dependencies = [
"libc",
]
[[package]]
name = "nix"
version = "0.28.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4"
dependencies = [
"bitflags 2.11.1",
"cfg-if",
"cfg_aliases 0.1.1",
"libc",
]
[[package]]
name = "nix"
version = "0.31.3"
@@ -4359,7 +4544,7 @@ checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d"
dependencies = [
"bitflags 2.11.1",
"cfg-if",
"cfg_aliases",
"cfg_aliases 0.2.1",
"libc",
]
@@ -4486,6 +4671,17 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
[[package]]
name = "num-derive"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "num-integer"
version = "0.1.46"
@@ -4856,6 +5052,15 @@ dependencies = [
"objc",
]
[[package]]
name = "object"
version = "0.37.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe"
dependencies = [
"memchr",
]
[[package]]
name = "once_cell"
version = "1.21.4"
@@ -5386,6 +5591,12 @@ version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
[[package]]
name = "plain"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
[[package]]
name = "plist"
version = "1.9.0"
@@ -5581,6 +5792,16 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "procfs-core"
version = "0.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d3554923a69f4ce04c4a754260c338f505ce22642d3830e049a399fc2059a29"
dependencies = [
"bitflags 2.11.1",
"hex",
]
[[package]]
name = "proptest"
version = "1.11.0"
@@ -5724,7 +5945,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20"
dependencies = [
"bytes",
"cfg_aliases",
"cfg_aliases 0.2.1",
"pin-project-lite",
"quinn-proto",
"quinn-udp",
@@ -5764,7 +5985,7 @@ version = "0.5.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd"
dependencies = [
"cfg_aliases",
"cfg_aliases 0.2.1",
"libc",
"once_cell",
"socket2",
@@ -5943,6 +6164,15 @@ dependencies = [
"rand_core 0.9.5",
]
[[package]]
name = "range-map"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "12a5a2d6c7039059af621472a4389be1215a816df61aa4d531cfe85264aee95f"
dependencies = [
"num-traits",
]
[[package]]
name = "rapidhash"
version = "4.4.1"
@@ -6078,6 +6308,7 @@ dependencies = [
"cookie",
"cookie_store",
"encoding_rs",
"futures-channel",
"futures-core",
"futures-util",
"h2",
@@ -6269,6 +6500,12 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "rustc-demangle"
version = "0.1.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d"
[[package]]
name = "rustc-hash"
version = "1.1.0"
@@ -6332,6 +6569,7 @@ version = "0.23.40"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b"
dependencies = [
"log",
"once_cell",
"ring",
"rustls-pki-types",
@@ -6514,6 +6752,26 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "scroll"
version = "0.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ab8598aa408498679922eff7fa985c25d58a90771bd6be794434c5277eab1a6"
dependencies = [
"scroll_derive",
]
[[package]]
name = "scroll_derive"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1783eabc414609e28a5ba76aee5ddd52199f7107a0b24c2e9746a1ecc34a683d"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "sdd"
version = "3.0.10"
@@ -6596,6 +6854,113 @@ dependencies = [
"serde_core",
]
[[package]]
name = "sentry"
version = "0.42.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "989425268ab5c011e06400187eed6c298272f8ef913e49fcadc3fda788b45030"
dependencies = [
"httpdate",
"reqwest 0.12.28",
"rustls",
"sentry-backtrace",
"sentry-contexts",
"sentry-core",
"sentry-panic",
"sentry-tracing",
"tokio",
"ureq",
]
[[package]]
name = "sentry-backtrace"
version = "0.42.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68e299dd3f7bcf676875eee852c9941e1d08278a743c32ca528e2debf846a653"
dependencies = [
"backtrace",
"regex",
"sentry-core",
]
[[package]]
name = "sentry-contexts"
version = "0.42.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fac0c5d6892cd4c414492fc957477b620026fb3411fca9fa12774831da561c88"
dependencies = [
"hostname",
"libc",
"os_info",
"rustc_version",
"sentry-core",
"uname",
]
[[package]]
name = "sentry-core"
version = "0.42.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "deaa38b94e70820ff3f1f9db3c8b0aef053b667be130f618e615e0ff2492cbcc"
dependencies = [
"rand 0.9.4",
"sentry-types",
"serde",
"serde_json",
"url",
]
[[package]]
name = "sentry-panic"
version = "0.42.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b7a23b13c004873de3ce7db86eb0f59fe4adfc655a31f7bbc17fd10bacc9bfe"
dependencies = [
"sentry-backtrace",
"sentry-core",
]
[[package]]
name = "sentry-rust-minidump"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63964525bf74b16233dbcfb307e11485ebd8ff8f87f6ae212b07ca7937cd2db1"
dependencies = [
"minidumper-child",
"sentry",
"thiserror 2.0.18",
]
[[package]]
name = "sentry-tracing"
version = "0.42.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fac841c7050aa73fc2bec8f7d8e9cb1159af0b3095757b99820823f3e54e5080"
dependencies = [
"bitflags 2.11.1",
"sentry-backtrace",
"sentry-core",
"tracing-core",
"tracing-subscriber",
]
[[package]]
name = "sentry-types"
version = "0.42.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e477f4d4db08ddb4ab553717a8d3a511bc9e81dde0c808c680feacbb8105c412"
dependencies = [
"debugid",
"hex",
"rand 0.9.4",
"serde",
"serde_json",
"thiserror 2.0.18",
"time",
"url",
"uuid 1.23.2",
]
[[package]]
name = "serde"
version = "1.0.228"
@@ -6988,6 +7353,17 @@ version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
[[package]]
name = "smart-default"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eb01866308440fc64d6c44d9e86c5cc17adfe33c4d6eed55da9145044d0ffc1"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "socket2"
version = "0.6.4"
@@ -7167,8 +7543,6 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "swift-rs"
version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4057c98e2e852d51fdcfca832aac7b571f6b351ad159f9eda5db1655f8d0c4d7"
dependencies = [
"base64 0.21.7",
"serde",
@@ -7889,11 +8263,16 @@ name = "tauri-plugin-native-bridge"
version = "0.1.0"
dependencies = [
"apple-native-keyring-store",
"base64 0.22.1",
"block",
"cocoa",
"dbus-secret-service-keyring-store",
"font-enumeration",
"keyring-core",
"objc",
"schemars 0.8.22",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"thiserror 2.0.18",
@@ -7992,6 +8371,22 @@ dependencies = [
"tauri-plugin",
]
[[package]]
name = "tauri-plugin-sentry"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7432b519b6d2d027082a940783c61ba9b684b38d8df7558cd5f924d87009b295"
dependencies = [
"base64 0.22.1",
"schemars 0.8.22",
"sentry",
"sentry-rust-minidump",
"serde",
"tauri",
"tauri-plugin",
"thiserror 2.0.18",
]
[[package]]
name = "tauri-plugin-sharekit"
version = "0.3.1"
@@ -8894,7 +9289,7 @@ dependencies = [
"branches",
"bumpalo",
"bytemuck",
"cfg_aliases",
"cfg_aliases 0.2.1",
"cfg_block",
"chrono",
"crc32c",
@@ -9100,6 +9495,15 @@ version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971"
[[package]]
name = "uds"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "885c31f06fce836457fe3ef09a59f83fe8db95d270b11cd78f40a4666c4d1661"
dependencies = [
"libc",
]
[[package]]
name = "uds_windows"
version = "1.2.1"
@@ -9111,6 +9515,15 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "uname"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b72f89f0ca32e4db1c04e2a72f5345d59796d4866a1ee0609084569f73683dc8"
dependencies = [
"libc",
]
[[package]]
name = "unarray"
version = "0.1.4"
@@ -9217,6 +9630,34 @@ dependencies = [
"typenum",
]
[[package]]
name = "ureq"
version = "3.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0"
dependencies = [
"base64 0.22.1",
"log",
"percent-encoding",
"rustls",
"rustls-pki-types",
"ureq-proto",
"utf8-zero",
"webpki-roots 1.0.7",
]
[[package]]
name = "ureq-proto"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c"
dependencies = [
"base64 0.22.1",
"http",
"httparse",
"log",
]
[[package]]
name = "url"
version = "2.5.8"
@@ -9260,6 +9701,12 @@ version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1292c0d970b54115d14f2492fe0170adf21d68a1de108eebc51c1df4f346a091"
[[package]]
name = "utf8-zero"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e"
[[package]]
name = "utf8_iter"
version = "1.0.4"
@@ -10099,6 +10546,15 @@ dependencies = [
"windows-targets 0.42.2",
]
[[package]]
name = "windows-sys"
version = "0.48.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
dependencies = [
"windows-targets 0.48.5",
]
[[package]]
name = "windows-sys"
version = "0.52.0"
@@ -10420,6 +10876,16 @@ dependencies = [
"memchr",
]
[[package]]
name = "winreg"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a277a57398d4bfa075df44f501a17cfdf8542d224f0d36095a2adc7aee4ef0a5"
dependencies = [
"cfg-if",
"windows-sys 0.48.0",
]
[[package]]
name = "winreg"
version = "0.55.0"
+4
View File
@@ -40,3 +40,7 @@ rust-version = "1.77.2"
[patch.crates-io]
tauri = { path = "packages/tauri/crates/tauri" }
tauri-plugin-fs = { path = "packages/tauri-plugins/plugins/fs" }
# Xcode 26.2 (Swift 6.2) broke upstream swift-rs 1.0.7's per-swiftc target
# override; the vendored copy cross-compiles via `--triple`/`--sdk` instead.
# Upstream is unmaintained (last release 2024). See packages/swift-rs.
swift-rs = { path = "packages/swift-rs" }
+133 -138
View File
@@ -1,144 +1,139 @@
# Readest Project Memory
## Key Reference Documents
- [Bug Fixing Patterns](bug-patterns.md) - Common bug categories, root causes, and fix strategies
- [CSS & Style Fixes](css-style-fixes.md) - EPUB CSS override patterns and the style.ts pipeline
- [TTS Fixes](tts-fixes.md) - Text-to-Speech architecture and bug patterns
- [Layout & UI Fixes](layout-ui-fixes.md) - Safe insets, z-index, platform-specific UI issues
- [Platform Compat Fixes](platform-compat-fixes.md) - Android, iOS, Linux, macOS platform-specific bugs
- [Annotator & Reader Fixes](annotator-reader-fixes.md) - Highlight, selection, accessibility bugs
## Security
- [download_file scope Android regression](download-file-scope-android-regression.md) — #4639 strict `is_allowed` broke ALL Android downloads to app data dir (covers/dicts/books); `app.fs_scope()` lacks command-scoped capability globs; fix = `app.path()` base-dir membership. On-device CDP verify recipe + raw-invoke Channel trick
- [Security advisories 2026-06](security-advisories-web-2026-06.md) — all 4 GHSA fixed in PR #4638 (web: A OPDS-proxy SSRF + canonical `isBlockedHost` in network.ts + `isLanAddress` merge, B storage `isSafeObjectKeyName`, D Stripe `metadata.userId` ownership) + PR #4639 (native C: `transfer_file.rs` fs_scope guard). OPDS proxy can't require auth (`<img>` usage); strict `is_allowed` for C; shared-target worktree build-cache pollution gotcha
## Paginator Scroll Knowledge
- [Issue #4112 scroll-anchoring](issue-4112-scroll-anchoring.md) — RESOLVED (PR #4349). Scroll-anchoring suppressed at scrollTop 0 when prepending a section in scrolled mode; fix patterns (prepend compensation, eager backward preload, no-blank nav) + test & dev-server gotchas
- [Reading ruler line/column-aware](reading-ruler-line-aware.md) — ruler snaps to real lines; multi-column band spans one column; Range.getClientRects() returns tall block boxes that must be dropped; iframe frame-offset mapping; synthetic-key throttling
- [TOC expand + auto-scroll](toc-expand-and-autoscroll.md) — #4059 collapse-by-default policy in `tocTree.ts`; pinned-sidebar mounts before progress → dynamic expansion breaks scroll-to-current via (1) spurious onScroll clearing pending and (2) Virtuoso scrollToIndex landing short after row growth (re-assert on rAF)
- [BooknoteView auto-scroll (#4352)](booknote-view-autoscroll-4352.md) — virtualizing the annotation/bookmark list dropped auto-scroll-to-nearest; two paths (reload: OverlayScrollbars resets scrollTop → re-apply in `initialized` via ref; tab-switch: synchronous scrollToIndex on fresh-mounted list wedges Virtuoso → use `initialTopMostItemIndex` + skip-gate). Mirrors TOCView. Includes dev-server/Fast-Refresh/screenshot-vs-DOM verification gotchas
- [TOC current-position row](toc-current-position-row.md) — synthetic "Current position" row (open-book icon + live `progress.page`) injected one level deeper under the active TOC item via `buildTOCDisplayItems` in `TOCItem.tsx`. INVARIANT: insert AFTER the active item so its `flatItems` index stays valid for the auto-scroll effects
- [Swipe page-turn bg flash](paginator-swipe-bg-flash.md) — white↔black flash on swipe+animation only; `#background` was static screen-space and didn't track content during drag/snap; fix = sliding per-view full-bleed segments (`computeBackgroundSegments`) rebuilt on scroll + per-rAF synced to the view transform during snap
- [Duokan fullscreen cover hidden in scroll mode](duokan-fullscreen-cover-scroll.md) — #4379 `data-duokan-page-fullscreen` cover pinned `position:absolute height:100%` collapses against auto-height scroll container; gate fullscreen on `this.#column` + reset stale absolute props on toggle (`setImageSize` in paginator.js)
- [Paginated texture occlusion](paginated-texture-occlusion-4399.md) — #4399 host `.foliate-viewer::before` texture absent in paginated (shown in scrolled); opaque `#background` container (`= fallbackBg`) from the swipe-flash fix occludes it; shared `textureAwareBackground` helper + `hasTexture ? '' : fallbackBg` container
- [Dark-mode texture occluded by body bg (#4446)](dark-mode-texture-body-bg-4446.md) — RESOLVED (PR #4564): `body.theme-dark{bg !important}` from #4392 (v0.11.4, NOT foliate-js) painted iframe bodies opaque dark → occluded host texture + poisoned `docBackground` capture → opaque segments/view bgs; fix = `transparent !important` UNCONDITIONALLY (texture-gating would go stale: capture is once-per-section-load); CDP gotchas = patch ALL multiview iframes, stale preload views survive navigation ±2, load-listener sees exact capture-time state
- [Background overflows column (#4394, PR #4429)](paginator-gutter-bleed-asymmetry-4394.md) — paginated page bg stretched into the outer `--_outer-min` gutter → mixed cover/title 2-up spread shifted off-centre (~250px at 1920px). KEEP the grid (`--_outer-min` keeps margins symmetric); fix = clamp `computeBackgroundSegments` to `[containerStart,containerEnd]` (Math.max/Math.min) so bg stays in its column. 2 wrong tries first (bleed-gating, "page shouldn't be yellow"); foliate submodule needs dev-server RESTART to pick up edits
- [Inline-block column overflow](inline-block-column-overflow.md) — chapter skips to "Reference materials", clipping a large middle; EPUB wraps body in `display:inline-block` div → atomic-inline box can't fragment across columns → vertical overflow clipped (scrollHeight≫clientHeight). Fix = paginator `#demoteUnfragmentableBoxes` in `columnize` (col-mode, over-tall atomic-inline→fragmentable block). Renders via goTo/next but pages unreachable; scrolled mode unaffected
## Key Reference Documents (aggregators)
- [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) · [Annotator & Reader](annotator-reader-fixes.md)
## Safety & Security
- [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)
- [Security advisories 2026-06](security-advisories-web-2026-06.md) 4 GHSA #4638; SSRF guard broke dev-LAN OPDS, dev-only exemption 2026-07 PR#5002
## Paginator & Scroll
- Reading ruler: [line-aware](reading-ruler-line-aware.md) frame-offset map; [vertical-rl backwards #4865](reading-ruler-vertical-rtl-4865.md)
- [Vertical-rl horizontal pagination (#624)](vertical-rl-horizontal-pagination-624.md) — horizontal inputs + two-phase slide; rtl gated `!vertical`
- [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
- [Captured turn ignored instant-highlight hold](captured-turn-instant-highlight-scrolllock.md) — captured slide/curl swipe path (app interceptor, `no-swipe` set) didn't honor `renderer.scrollLocked`; fix = foliate `get scrollLocked()` + gate `useCapturedTurn` move on it (push/VT-slide already gated); PR#5000 + foliate#51
- 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)
- 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)
- [Inline-block column overflow](inline-block-column-overflow.md) `#demoteUnfragmentableBoxes`
- 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); [portrait auto-spread off-center #4984](fxl-portrait-autospread-offcenter-4984.md) MERGED PR#4992+foliate#50 lone page kept one-sided auto margin -> stranded + taps turned page; `computeSpreadInlineMargins`
- 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)
## Critical Files (Most Bug-Prone)
- `src/utils/style.ts` - Central EPUB CSS transformation hub (14+ bug fixes)
- `packages/foliate-js/paginator.js` - Page layout, image sizing, backgrounds
- `src/services/tts/TTSController.ts` - TTS state machine, section tracking
- `src/hooks/useSafeAreaInsets.ts` - Safe area inset management
- `src/app/reader/components/FoliateViewer.tsx` - Reader view orchestration
- `src/app/reader/components/annotator/Annotator.tsx` - Annotation lifecycle
- `src/utils/style.ts` EPUB CSS hub · `packages/foliate-js/paginator.js` · `src/services/tts/TTSController.ts`
- `src/hooks/useSafeAreaInsets.ts` · `src/app/reader/components/FoliateViewer.tsx` · `.../annotator/Annotator.tsx`
## Sync Notes
- [KOSync CFI spine resolution](kosync-cfi-spine-resolution.md) — convert via the CFI's own spine (`getXPointerFromCFI`/`getCFIFromXPointer`), never `new XCFI(primaryDoc, primaryIndex)`; primaryIndex lags during scroll → spine-mismatch throw
- [Empty-start CFI sync bug](empty-start-cfi-sync.md) — `epubcfi(/6/24!/4,,/20/1:58)` (empty-start range) from the cfi-inert skip-link transitional window; jumps to wrong section end; `isMalformedLocationCfi` → discard the synced value in `useProgressSync` (NOT the local open path); foliate fix doesn't repair already-synced values
- [Custom fonts disappear on cloud sync (#4410)](custom-fonts-reincarnation-4410.md) — CRDT remove-wins: re-import-after-delete needs a `reincarnation` token or the pull re-applies the tombstone; `addFont`/`addTexture` minted none; fix mirrors dictionary (both cases) + OPDS token style; coverage matrix per kind
- [koplugin note deletion sync](koplugin-note-deletion-sync.md) — koplugin push only walked LIVE annotations so deletions never reached the server; fix = `recordDeletion` persists a `deletedAt` tombstone to `doc_settings.readest_sync.deleted_notes`, `push` folds+clears them; deletion signal in `onAnnotationsModified` is `items.index_modified < 0`
- [koplugin stats sync (#4666)](koplugin-stats-sync.md) — reading-stats sync (pull on open / push on close, whole statistics.sqlite3 delta, cursor-based); 3-bug chain: plain-table-not-LuaSettings `settings:readSetting` crash; missing required books/notes/configs; statBooks/statPages need `optional_params` (Spore expected=requiredoptional, `payload`≠accepted); large-backlog UI-stall + silent-retry risk unfixed
## Testing
- [Nightly updater Android E2E](nightly-updater-android-e2e.md) — real Xiaomi/HyperOS test of #4577 self-updater; `pnpm dev-android` (--features devtools) for CDP, raw-socket CDP discovery, nightly>stable comparator, MIUI 单次安装授权 install gates
- [Android CDP e2e lane](android-cdp-e2e-lane.md) — `pnpm test:android`: adb+CDP drives the installed app on device/emulator; discover-don't-assume targeting, injected hyphenation, MediaStore VIEW transient open (canonical `_data` path gotcha), per-section frame restore; CI workflow with KVM emulator + debug x86_64 APK (no signing secrets)
- [CDP Android WebView profiling](cdp-android-webview-profiling.md) — drive the on-device Readest WebView via adb+CDP to run JS probes/benchmarks inside the live app (no rebuild); gotchas: locked device freezes fetch (not invoke), visible:false throttles setTimeout, `__TAURI_INTERNALS__.convertFileSrc/invoke` always present, books in internal `/data/user/0/...`, fs `read{rid,len}` last-8-bytes=nread, `fs|close` not ACL-allowed, curl mishandles WebView HTTP framing
- [Tauri Rust↔JS parser parity tests](tauri-parser-parity-tests.md) #4369 native Rust EPUB/MOBI parser; how to cross-check vs foliate-js in the `.tauri.test.ts` WebView suite (CWD disk path for Rust, Vite URL for JS, normalizer-based compare, cover presence-only, desc whitespace-collapse); the `dcterms:modified``published` divergence fix
- [TTS browser e2e harness](tts-browser-e2e-harness.md) — faithful auto-advance test (real `<foliate-view>` + real `useTTSControl` + mock ONLY the 3 client modules; mock `speak()` yields `end` to drive the real `forward()` walk); seed readerStore/bookDataStore + `settings.globalViewSettings` (else `getMergedRules` crash stops TTS); reproduce FoliateViewer relocate→setProgress; sample-alice Ch4=section 6/Ch5=section 7; assert badge `false` BEFORE tts-stop
- [TTS sync chrome verification](tts-sync-chrome-verification.md) — Edge TTS WORKS in claude-in-chrome (WebSpeech errors there with `InvalidStateError`); use an Edge voice to verify TTS-driven features live (RSVP followed at ~171 wpm). Synthetic-CFI debug recipe (expose controller, `syncToCfi(view.getCFI(docIndex, word.range))`). Exposed the #3235 cross-realm `instanceof Range` bug (frozen RSVP/paragraph follow) → `isRangeLike()` duck-type fix
- [TTS sync paragraph+RSVP (#3235, PR #4576)](tts-sync-paragraph-rsvp-3235.md) — TTS-is-clock follow: canonical `tts-position{cfi,kind:word|sentence,sectionIndex,sequence}`; in-mode 🔊 audio toggle (`build{Paragraph,Rsvp}TtsSpeakDetail`, live-range gate); **current word/sentence highlight painted on the overlay CLONE via CSS Custom Highlight API** (no DOM mutation, spans inline; offsets relative to para-start map 1:1 to clone, `getTextSubRange` reuse, index-tagged vs stale); kind-gating `decideParagraphTtsHighlight` (Edge word wins, skip coarse sentence); `::highlight()` from `ttsHighlightOptions`
## Build & Vendoring
- [Turbopack build-cache OOM + gated Docker standalone (#4619)](turbopack-build-cache-oom-docker-standalone.md) — interrupted-build partial `turbopackFileSystemCacheForBuild` cache → 42 workers/18GB-swap freeze (clean build=~6.5GB); disabled the flag; `output:'standalone'` gated on `BUILD_STANDALONE` (Docker-only); tauri CI uses `next dev` (config-independent)
- [Deps/security override workflow](deps-security-overrides-workflow.md) — fix transitive npm Dependabot alerts: main monorepo overrides live in `pnpm-workspace.yaml` (NOT root package.json); `packages/tauri-plugins` is a SEPARATE submodule project w/ own lockfile + `minimumReleaseAge` (main workspace has no age gate); bound 0.x overrides like `vite`; verify via test+lint+build-web. PR #4618 (esbuild 0.28.1, vitest 4.1.9)
- [R2 rclone CreateBucket 403 (#4588)](r2-rclone-createbucket-403.md) — single-file `rclone copyto`/`moveto` probes CreateBucket → 403 on object-scoped R2 token; use a directory `rclone copy` (or `no_check_bucket=true`); broke nightly assemble, not the release flow
- [Deploy workers.dev SNI-block + proxy](deploy-workers-dev-sni-proxy.md) — pnpm deploy crash (CN): workers.dev SNI-blocked (DoH useless), R2 populate WS hangs even via proxy; shipped fix = `dangerous.disableIncrementalCache:true` in open-next.config (stock deploy skips populate; readest has no ISR so runtime no-op)
- [pdfjs vendor wasm decoders](pdfjs-vendor-wasm-decoders.md) — scanned PDFs blank in CI build only (0.11.2 regression); pdfjs 5.7.x moved JBIG2 to `jbig2.wasm`, `copy-pdfjs-wasm` allow-list dropped it; `cpx` no-errors on empty glob; local stale `public/vendor` (gitignored, not refreshed by `tauri build`) masked it; fix = copy `wasm/*`
- [Cloud Sync provider selection #4959/#4380](cloud-sync-provider-selection-plan.md) MERGED #4971+#4973+#4975+#4976: derived provider, exclusive routing, syncBooks auto-enable, fleet probe, chooser; i18n pass + live verify pending
- [Grimmory native sync](grimmory-native-sync.md) Booklore-fork REVERTED
- 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) · [Custom fonts vanish #4410](custom-fonts-reincarnation-4410.md) CRDT remove-wins
- 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)
- [Statusless re-pin #4677](sync-statusless-book-rebump-4677.md) · [pull cursor synced_at #4678](sync-synced-at-cursor-4678.md)
- [koplugin library stale #4934](koplugin-library-stale-synced-cursor-4934.md) pull cursor updated_at→synced_at + split push watermark + v2→v3 heal migration
- 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 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`; [third-party auto-sync #4835](third-party-library-autosync-4835.md)
- [Transfer Queue clear not persisted](transfer-queue-clear-persistence.md) hook mutated store directly, skipped `persistQueue()`; route clears through `transferManager`
- [Multi-window settings clobber (#4580)](multiwindow-settings-clobber-4580.md)
- Google Drive: [research](gdrive-sync-provider-research.md); [multi-PR status](gdrive-provider-multipr-status.md); [full walk every sync](gdrive-fullwalk-every-sync-no-source-cursor.md) no-source books never recorded in uploadedHashes + focus refires pullLibrary
- [S3/R2 provider](s3-r2-sync-provider.md) third backend, aws4fetch SigV4 path-style, full slice on dev uncommitted; live R2 verify pending
- [Hardcover edition_id (#4792)](hardcover-progress-edition-id-4792.md)
## Build, Testing & CI
- [Nightly quick-sharun hang #4906](nightly-quick-sharun-hang-4906.md) pin via cache pre-seed + step timeouts
- [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`; [WebView profiling](cdp-android-webview-profiling.md); [double-tap gesture](android-e2e-doubletap-cdp-gesture.md)
- [Tauri Rust↔JS parser parity](tauri-parser-parity-tests.md)
- TTS tests: [browser e2e harness](tts-browser-e2e-harness.md); [paragraph+RSVP sync #3235](tts-sync-paragraph-rsvp-3235.md) TTS-is-clock
- [fastlane App Store](fastlane-apple-appstore-submission.md) `APPLE_API_KEY_PATH` out of build env
- [Turbopack cache OOM (#4619)](turbopack-build-cache-oom-docker-standalone.md)
- [Deps override workflow](deps-security-overrides-workflow.md) `pnpm-workspace.yaml`
- [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
- [pdfjs vendor wasm](pdfjs-vendor-wasm-decoders.md) copy `wasm/*`
- [CI/PR delivery + push keepalive](ci-pr-delivery-and-push.md)
## Platform Compat
- [Android hyphen selection bounds (#1553)](android-hyphen-selection-bounds-1553.md) — Blink paints the start handle on the paragraph's LAST hyphen when a touch selection starts at the first word of a hyphenated paragraph (`ComputePaintingSelectionStateForCursor` lacks the generated-text offset remap, hyphen offsets {0,1}); drag-extend re-anchors base there. Fix = repair jumped anchor + suppress handles (empty-commit needs one painted frame) + `SelectionRangeEditor` custom handles; multicol NOT required; desktop/iOS unaffected
- [Android NativeFile vs RemoteFile I/O](android-nativefile-remotefile-io.md) — why NativeFile is slow (4-IPC/chunk + bridge serialization, tauri#9190); RemoteFile CANNOT replace it on Android (asset-protocol Range broken: start>0 → "Failed to fetch", start-0 capped at 1,024,000; plain no-Range fetch returns full file at 281 MB/s); measured 44/100/281 MB/s; speedups = handle-reuse (2.3×), whole-file asset loader (6.3×), or fix wry upstream. Verified live via CDP.
- [Window-state sanitizer (#4398)](window-state-sanitize-4398.md) — Windows launch crash (WebView2 0x80070057) from invalid `.window-state.json` (`-32000` minimized sentinel / `0×0`); our plugin already has upstream #253 fix so bad files are stale; defense-in-depth `window-state-sanitizer` plugin registered BEFORE window-state (plugin init = registration order); coord threshold `-16000` (~halfway to the -32000 sentinel; real desktops sit a few thousand px off origin) keeps multi-monitor negatives
- [Android Open-with intent flow (#4521)](android-open-with-intent-flow.md) — "Open with"/"Send to" pipeline: `NativeBridgePlugin.kt::handleIntent``shared-intent``useAppUrlIngress``useOpenWithBooks` (VIEW=transient→reader, SEND=library+upload). Telegram fails where file-manager works on TWO axes: cold-start delivery (fixed by #4527, on dev NOT released v0.11.4) + foreign-private-file read (Telegram FileProvider non-persistable grant vs shared-storage FUSE real-path). adb MediaStore VIEW repro tests pipeline but CANNOT reproduce the read axis (MANAGE_EXTERNAL_STORAGE bypasses grant)
- [Dict lookup → OEM browser hijack (#4559)](dict-lookup-browser-hijack-4559.md) — VIVO system-dict lookup opened the browser not Eudic. PRIMARY: no `<queries>` for `ACTION_PROCESS_TEXT` under targetSdk36 → dictionary apps invisible, only auto-visible browser returned (fix = add `<queries>` to plugin manifest). SECONDARY: browser registers PROCESS_TEXT + is default → filter browsers in pure `decideLookupDispatch` (explicit/chooser/unavailable). Remember-the-pick via `IntentSender`+`EXTRA_CHOSEN_COMPONENT``LookupChoiceReceiver`→SharedPreferences (`ACTION_CHOOSER` has no native Always); reset row in `CustomDictionaries.tsx`
- [Android sideload same versionCode](android-sideload-same-versioncode.md) — sideloaded APK reinstall allows EQUAL versionCode (only strictly-lower blocked); Play Store's increment rule does NOT apply to sideload. Nightly APKs share base versionCode and still install. Corrects a plausible-but-wrong review claim
## Feature Notes
- [Webtoon Mode (#3647)](webtoon-mode-3647.md) — seamless no-gap scrolled reading for image books (PRs #4662 + foliate-js#30); fixed-layout scroll mode is fit-width by construction (ignores `zoom`, only `scale-factor`); `scroll-gap` attr→`--scroll-page-gap` var; clear-on-leave in BOTH ViewMenu effect AND Shift+J; worktree submodule has local-path origin (push SHA direct to fork)
- [Biometric app-lock (#4645)](biometric-app-lock-4645.md) — fingerprint/Face ID startup unlock layered over PIN (mobile); gate must read flag from `appLockStore` not un-seeded `settingsStore` (race); `tauri-plugin-biometric` is `#![cfg(mobile)]` (desktop clippy skips it; pin in root Cargo.lock); scope i18n manually (en unscanned, full extract churns drift)
- [Tap to open image/table (#4600)](tap-to-open-image-table-4600.md) — single-tap opens gallery/table-zoom in **reflowable** EPUBs (long-press unchanged); `iframe-long-press` message renamed to `iframe-open-media`, hook `useLongPressEvent``useOpenMediaEvent`; shared `detectMediaTarget`; `handleClick` got `isFixedLayout`
- [#4584 tap-death investigation](issue-4584-tap-death-investigation.md) — UNFIXED; `isPopuped` self-heals (RED HERRING, don't "fix" it); likely WebView-148-specific (emulator=133 can't repro); Android emulator/CDP gesture-verification gotchas (swiftshader ANR=artifact, CDP can't native-select, screenX=0)
- [Dictionary lemmatization (#4574)](dict-lemmatization-4574.md) — inflected selections (`ran`/`mice`/`analyses`) resolve to base headwords (`run`/`mouse`/`analysis`) in dicts that store only lemmas (ODE). Pluggable `lemmatize/` registry (default English, explicit non-English no-op), English rules+irregulars, appended to tail of `buildLookupCandidates` so exact match wins; over-generate + dict-validates; `-ses→-sis` ordered before `-es`
- [Word Lens inline gloss (feat/word-wise)](wordlens-feature.md) — Kindle-style native-language hint above hard words; CFI-safe via `<ruby cfi-skip>…<rt cfi-inert>` (epubcfi hoist+merge, NOT just tree-walk); TTS/search isolation (tags:['rt'] + rangeTextExcludingInert + search attributes:['cfi-inert']); gloss data = curated starters, full asset built by `build-wordlens-data.mjs` (ECDICT/CC-CEDICT+HSK)
- [iOS instant-dict double popup](ios-instant-dict-double-popup.md) — iOS emits multiple `selectionchange`/long-press → instant sys-dict fired 2-3×; deferredAction `fired` once-per-gesture latch + `beginGesture`; tap-to-deselect re-fire fixed by `isLongPressHold` 300ms gate (!isAndroid); Word Lens `wantWordLensDict` now routes via `handleDictionary` to honor system dict
- [Edge TTS word highlighting (#4017, PR #4566)](edge-tts-word-highlighting-4017.md) — keep sentence marks, add word highlight via `audio.metadata` WordBoundary (verbatim input span, 100-ns ticks) synced to `audio.currentTime` by rAF; readaloud endpoint gates on UA (Edg, non-headless) NOT Origin; fixed browser `new WebSocket(url,{headers})` SyntaxError (wss never worked on web); overlay = `<path>` in FOLIATE-PAGINATOR shadow root; dev-web verify recipe (browse --proxy + UA spoof, never Origin header)
- [Reference Pages (#672+#4542, PR #4549)](reference-pages-672-4542.md) — 'reference' progressStyle from foliate `pageItem`/`book.pageList` (numeric-max total rule, roman-tail safe); per-book `referencePageCount` via skipGlobal save; verification EPUBs + dev-web synthetic drag-drop import trick; locale-tail rebase-conflict recipe (checkout --ours → re-extract → re-translate)
- [OPDS Firefox strict-XML parse (#4479)](opds-firefox-strict-xml-4479.md) — MEK feed has junk after `</feed>`; Firefox DOMParser → `<parsererror>` (silent back-nav), Chrome lenient; `parseOPDSXML` slices root start→last close tag; jsdom mirrors Firefox; wired into page.tsx + validateOPDSURL + feedChecker (latter also #4181 `looksLikeXMLContent` swap)
- [OPDS 2.0 JSON search greyed out (#4502)](opds2-json-search-4502.md) `isSearchLink` ignored templated `application/opds+json` links → `hasSearch` false → disabled navbar input; add `MIME.OPDS2`+`templated`, `expandOPDSSearchTemplate` (foliate `uri-template.js`), handleSearch OPDS2 branch. Gotcha: `resolveURL` mangles `{?query}` braces — expand template BEFORE resolving
- [OPDS HTML description (#4503)](opds-html-description-4503.md) — detail-view descriptions showed raw `<p>`/`&quot;` tags; aggregator double-escapes `type="text"` summary + `PublicationView` dumped it into unsanitized `dangerouslySetInnerHTML`; fix = `getOPDSDescriptionHtml` (decode-one-level-iff-fully-escaped, then `sanitizeHtml`)
- [Manage Cache + iOS container layout](manage-cache-ios-layout.md) — `'Cache'` base = `Library/Caches/<bundle>` only (not all of Caches); iOS `Documents/Inbox` cleared too; WebKit cache + tmp out of reach; never touch App Support
- [D-pad Navigation](dpad-navigation.md) — Android TV remote / keyboard arrow navigation design, key files, and pitfalls
- [Cloudflare Workers WebSocket](cloudflare-workers-websocket.md) — use fetch() Upgrade pattern (not `ws` npm); CF delivers binary frames as Blob (must serialize async decodes)
- [Share-a-Book Feature (in progress)](share-feature.md) — locked decisions for the /s/{token} share-link feature; plan at ~/.claude/plans/ok-we-will-learn-cosmic-acorn.md
- [readest.koplugin i18n](koplugin-i18n.md) — gettext loader at `apps/readest.koplugin/i18n.lua`, `.po` catalog at `locales/<i18next-code>/translation.po`, extract/apply scripts in `scripts/`
- [koplugin cover upload](koplugin-cover-upload.md) — #4374 uploadBook only shipped cached cloud covers; local-origin books uploaded blank. Fix = `extractLocalCover` via `FileManagerBookInfo:getCoverImage(nil, file)``writeToFile(path,"png")`. KOReader checkout at `/Users/chrox/dev/koreader`
## Feedback
- [Commit messages English-only](feedback-commit-message-english-only.md) — commit messages + PR titles must be English only (no CJK glyphs, no em/en dashes); keep CJK examples/screenshots in the PR body, code, and tests. From PR #4660
## Patterns
- [Virtuoso + OverlayScrollbars](virtuoso_overlayscrollbars.md) — useOverlayScrollbars hook integration for overlay scrollbars on mobile webviews
- [Design system → DESIGN.md](feedback_design_system_doc.md) — codify recurring UI/UX rules in `apps/readest-app/DESIGN.md`; never `pl/pr/ml/mr/text-left/text-right` (RTL); §5 boxed list anatomy has uniform `min-h-14` rows and chromeless controls
## Reader UI Fixes
- [Search excerpt no context for styled words (#4594)](search-excerpt-context-4594.md) — RESOLVED (foliate-js#25 + readest#4631). italic/`<i>` word = own `strs[]` text node; `makeExcerpt` read context only WITHIN the node → empty pre/post; fix = `collectBefore/After` walk neighbour nodes (+2 latent multi-node match bugs: string-index `slice`, `start===end`)
- [Global annotation page-turn lag (#4575)](global-annotation-pageturn-perf-4575.md) — highlighting recurring names = `global` highlights re-fanned-out (TreeWalker + getCFI/occurrence + SVG churn) EVERY page turn (~25-45ms desktop, ×mobile); fix = `WeakMap<Document,...>` memo in `globalAnnotations.ts` skips already-expanded sections; live-profiled via dev-web foliate-view; GBK-TXT synthetic-drop import recipe
- [Overlayer splitRange by text nodes](overlayer-splitrange-textnodes.md) — highlight SVG missed bullet-list text when range also touched a `<p>`: `#splitRangeByParagraph`'s `'p,h1-h4'` selector dropped `li` (3rd whack-a-mole after f087826/920676b); fix = walk text nodes + `img,svg` in overlayer.js, never block-tag selectors; jsdom test stubs `Range.prototype.getClientRects`
- [Android image callout freeze](android-image-callout-freeze.md) — long-press `<img>` fires WebView native callout that collides with app touch handlers → whole-app freeze; `-webkit-touch-callout: none` doesn't inherit so put `.no-context-menu` on an ancestor of the image (`.no-context-menu img` rule in globals.css); seen on book covers (#4345) + image preview/zoom (#4420, `ImageViewer.tsx`)
- [ProgressBar focus-ring line (#4397)](progressbar-focus-ring-4397.md) — decorative `.progressinfo` footer was `tabIndex={-1}` → Android long-press focused it → stray content-width focus-ring line at the bottom every page; fix = drop tabIndex (role='presentation' must not be focusable); ffmpeg-the-video debugging + live-browser `:focus-visible` confirmation
- [Table dark-mode tint regression (#4419)](table-dark-mode-tint-4419.md) — `blockquote, table *` color-mix tint in `getColorStyles` must stay gated on `overrideColor` (gate added #2377, removed #4055, re-broke → #4419); safe now that #4392 light-bg rewriters handle #4028 zebra legibility; SAME rule paints vertical-TOC `.space`/▉ spacer cells (▉ U+2589 = blank glyph, contours=0) → "spacing changes" symptom; both fixed by the gate
- [Double-click-drag turns page (#4524)](dblclick-drag-pageturn-4524.md) — web double-click+drag selection also turned the page; 1st click's deferred single-click (250ms) fires mid-drag while 2nd-click button held; fix = `isMouseDown` flag in `iframeEventHandlers.ts` gates the deferred `postSingleClick`; synthetic-repro gotchas (shadow-DOM iframe walk, chained-repro timing pollution, reload to re-bind listeners)
- [RSVP font face/family (#4519)](rsvp-font-settings-4519.md) — RSVP word was hardcoded `font-mono`; now mirrors the reader font via `getBaseFontFamily(viewSettings)` (new export in `style.ts`, shares `buildFontFamilyLists` with `getFontStyles`). Overlay renders in the TOP document (portal to body) where custom + basic Google fonts are mounted; known gap = built-in CJK web fonts only in top doc when `isCJKEnv()`
- [RSVP RTL word display (#4630)](rsvp-rtl-word-display-4630.md) — Arabic/RTL word window showed separated, reversed letters: ORP focus-letter split slices words by char index (breaks shaping/order); fix = `isRTLText` → render RTL whole via the CJK `.rsvp-word-whole` branch with `dir=rtl`. Literal-RTL-char Edit pitfall → write regex with `\u` escapes
- [Edge TTS word-highlight drift on middle sentences](tts-word-highlight-singletextnode-drift.md) — `rangeTextExcludingInert` TEXT_NODE fast path ignored range offsets → returned whole paragraph → word offsets drift (spoken "Those"→hl "if th"); only middle sentences of single-`<span>` paras (cac=TEXT_NODE); Edge-only; fix=slice [startOffset,endOffset]; added dev-only `[TTS] word-sync` log; select-word→popup-headphone repro
- [TTS start-from-selection bugs](tts-start-from-selection.md) — foliate `from()` picked first mark at/after selection → started NEXT sentence for non-first words (fix=last mark at/before); + Annotator now `cloneRange()`+`view.deselect()` on TTS start so the word doesn't stay selected; jsdom needs `CSS.escape` polyfill (vitest.setup) since `from()` uses it
- [Reuse TTS session on Paragraph/RSVP entry](tts-reuse-session-mode-entry.md) — modes only engaged following on a fresh `playing` event → entering with TTS already playing didn't sync. Fix = `TTSController.redispatchPosition()` + `useTTSControl` `tts-sync-request` replay (position-before-state) + per-mode engage-on-entry effect (following=true, reset lastSequenceSeen, dispatch request); RSVP paused branch also `setExternallyDriven(true)`. Paragraph live-verified ("Following audio" on entry)
- [Footnote aside border line (#4438)](footnote-aside-namespace-order-4438.md) — v0.11.4 regression: stray horizontal line below footnote marker. #4383 inlined custom `@font-face` BEFORE the `@namespace epub` (which lived in `getPageLayoutStyles`), invalidating it per CSS spec → namespaced `aside[epub|type~="footnote"]{display:none}` dropped → book's `aside{border:3px double}` showed. Only with custom fonts loaded. Fix = hoist `@namespace` to front of `getStyles`. Repro needs XHTML (`epub:type` namespaced only in XML); Playwright `setContent` parses HTML and won't reproduce
- [Scrolled-mode notch mask vs texture (#4486)](notch-mask-texture-4486.md) — top inset mask occluded the bg texture; full-cell + clip-path paint-box-matching for tile alignment; CDP-inject + MAE seam verification on device; adb taps in status-bar region eaten by SystemUI
- [Paragraph-mode accidental exit + off-center bar (#4474)](paragraph-mode-accidental-exit-4474.md) — backdrop/center taps exited focus mode (stray "too high/low" taps); `ParagraphBar` only reshows on mousemove (no touch reshow) so can't just delete tap-exits → new `paragraph-show-controls` event reveals the bar instead. Also bar `absolute``fixed`: it centered on the gridcell which a pinned sidebar shifts right, while the paragraph centers on the `fixed inset-0` overlay/viewport
- [Share intent + customizable toolbar (#4014)](annotation-share-toolbar-4014.md) — Share tool in the selection toolbar (sharekit gated mobile+macOS only re: #4343 Windows freeze; `canShareText`/`shareSelectedText` in dual-purpose `share.ts`) + drag-and-drop customizer sub-page; `annotationToolbarItems` view setting (Share hidden by default); pure helpers in `annotationToolbar.ts`
- Android: [hyphen selection #1553](android-hyphen-selection-bounds-1553.md); [NativeFile vs RemoteFile I/O](android-nativefile-remotefile-io.md)
- [Window-state sanitizer #4398](window-state-sanitize-4398.md) · [Android themed icon #4733](android-themed-icon-4733.md)
- [Open-with intent #4521](android-open-with-intent-flow.md) · [dict lookup hijack #4559](dict-lookup-browser-hijack-4559.md)
- [Large-PDF OOM range flood (#3470)](pdf-oom-range-flood-3470.md) MAX_CONCURRENT_RANGES=6
- [Black screen external cache (#4853)](android-black-screen-external-cache-4853.md) FIXED PR#4889; drop `$CACHE` grants
- [macOS 26 Tahoe close→black window (#4875)](macos26-tahoe-close-black-screen-4875.md) `minimize()` not `hide()`
- [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
- [Android Auto TTS #3919/PR#4907](android-auto-tts-3919.md) MERGED; CarPlay blocked on entitlement
- 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
- 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) OS font-scale inflates text-layer glyph size not positions; divide `--text-scale-factor` (font-size lever) by detected scale, NOT `--total-scale-factor`
- [Search modes #4560](search-modes-4560-and-spoiler-bound-bug.md)
- [OPDS groups carousel #4750](opds-groups-carousel-4750.md) · [WebDAV browser sort+search #4724](webdav-browse-sort-search-4724.md)
- [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)
- Selection: [keyboard adjust #4728](keyboard-selection-adjust-4728.md); [cross-page auto-turn #4741](cross-page-selection-autoturn-4741.md)
- 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)
- [Annotator onLoad listener leak (#4735)](annotator-onload-listener-leak-paragraph-mode.md)
- Paragraph mode: [toggle/resume #4717](paragraph-mode-toggle-resume-4717.md); [accidental exit #4474](paragraph-mode-accidental-exit-4474.md)
- [#4584 tap-death](issue-4584-tap-death-investigation.md) UNFIXED; likely WebView-148
- [PDF/CBZ Contrast view-menu](pdf-cbz-contrast-view-menu.md) ONE `filter:`
- [Header/footer over light PDF in dark (#4901)](pdf-header-footer-contrast-blend-4901.md) `mix-blend-difference`, FIXED light anchor
- [iOS instant-dict double popup](ios-instant-dict-double-popup.md) once-per-gesture latch
- 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
- Word Lens: [inline gloss](wordlens-feature.md) CFI-safe ruby; [en-en](wordlens-en-en.md)
- [Stripe highest-active plan (#4694)](stripe-plan-highest-active-4694.md)
- [Save image to gallery (#4680)](save-image-to-gallery-android.md) MediaStore
- [Webtoon Mode (#3647)](webtoon-mode-3647.md)
- [Middle-click autoscroll #4951](middle-click-autoscroll-4951.md) Autoscroller RAF core; `containerPosition +=`; armed-books preventDefault
- [Auto Scroll teleprompter #4998](auto-scroll-teleprompter-4998.md) MERGED PR#4999 PacedScroller + useAutoScroll + gridcell-centered pill; scrolled-only; tap=pause via iframe-single-click consume
- [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)
- [Share intent + toolbar (#4014)](annotation-share-toolbar-4014.md)
- Customize Toolbar: [global serializeConfig #4760](customize-toolbar-global-serializeconfig.md); [e-ink black bar #4839](customize-toolbar-eink-black-bar-4839.md)
- [Edge TTS Web Audio engine (#3851)](edge-tts-webaudio-engine.md) gapless WebAudioPlayer + WSOLA
- [Background TTS sessions PR#4941](tts-background-session-decoupling.md) — hash-keyed session manager, detach/attach, NowPlayingBar; header X routes `onCloseBook` NOT `onGoToLibrary`
- [TTS player redesign](tts-player-redesign.md) mini-player + Dialog sheet replaces icon/popup/TTSBar; MERGED #4996; open: isPlaying glyph desync at section transitions
- [Android bg TTS media session fix](android-bg-tts-media-session-fix.md) — #4941/#4931 regression: `startService()` dies backgrounded → in-process instance calls; always request POST_NOTIFICATIONS; + lock-screen duration scrubber + `onSeekTo` (Edge-only)
- Native TTS: [iOS #4676](native-ios-tts-4676.md) pause==stop; [offline halt #4613](native-tts-offline-autoadvance-4613.md)
- Edge TTS: [word highlight #4017](edge-tts-word-highlighting-4017.md); [drift](tts-word-highlight-singletextnode-drift.md)
- 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)
- 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)
- [Overlay z-index scale](zindex-overlay-scale.md) RSVP 100 → app-lock
- [Global annotation page-turn lag (#4575)](global-annotation-pageturn-perf-4575.md)
- [Overlayer splitRange text nodes](overlayer-splitrange-textnodes.md)
- [Android image callout freeze](android-image-callout-freeze.md) `.no-context-menu` ANCESTOR
- [Inline-img vertical-align (#4866)](inline-img-vertical-align-4866.md) gated on computed valign
- [Table dark-mode tint #4419](table-dark-mode-tint-4419.md) · [footnote aside border #4438](footnote-aside-namespace-order-4438.md)
- 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)
- [Russian NBSP (#4769)](russian-hanging-prepositions-nbsp-4769.md)
- 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; [preemptive Basic 400s digest Calibre](opds-preemptive-basic-digest-400.md) bare-retry on 400 PR#5002; [auto-download TLS #4988](opds-autodownload-tls-skipssl-4988.md) skipSslVerification parity PR#5002
- [D-pad Navigation](dpad-navigation.md)
- [koplugin cover upload (#4374)](koplugin-cover-upload.md)
- [koplugin Library slow open #4954](koplugin-library-open-mosaic-cache-4954.md) group mosaics recomposed every paint; PR#4974 availability-keyed cache + async compose + cache nil result
- [Calibre plugin push #4863](calibre-plugin-push-4863.md) OAuth localhost relay
- [Calibre custom columns #4811](calibre-custom-columns-4811.md) `metadata.calibreColumns`
## Library Fixes
- [Tauri menu append race (#4389)](tauri-menu-append-race-4389.md) — un-awaited `Menu.append()` (async IPC) in `BookshelfItem.tsx` → context-menu items shuffle order every open (native only, invisible in jsdom); fix = single `await Menu.new({ items })` of ordered `MenuItemOptions`; order/inclusion extracted to pure `getBookContextMenuItemIds` for unit testing
- [TXT author recognition (#4390)](txt-author-recognition-4390.md) — 【】-titled Chinese web-novels show author missing/garbage; they're TXT→EPUB (title==full filename is the tell, check `txt.ts` not foliate-js); `extractTxtFilenameMetadata` only handled 《》 + greedy header capture grabbed metadata blobs; fix = `parseLabeledAuthor` for any filename + `isPlausibleAuthorName` guard
- [TXT chapter measure-word false positives (#4658)](txt-chapter-measure-word-4658.md) — `第一封信`/`第四本书…` (量词 prose) detected as chapters; `createChapterRegexps('zh')` unit class split into strong `[章节回讲篇话]` (attached title OK) vs weak/量词 `[卷本册部封]` (title needs a separator or line end, never a bare noun)
- [Cover stale until refresh (in-place mutation vs React.memo)](cover-stale-inplace-mutation-memo.md) — editing a book cover in details + Save left the library cover stale until reload; `handleUpdateMetadata` mutated `book` IN PLACE so memoized `<BookCover>`'s prev snapshot pointed at the same object → comparator saw no change → skip; fix = pure `getBookWithUpdatedMetadata` returns a NEW book object. Cloning in `updateBook` wouldn't help (original already mutated). Verified live on emulator via CDP fiber-store extraction (A: mutate→stale, B: new obj→updates)
- [Series/author folder back no-op (#4437)](series-folder-back-noop-4437.md) — back arrow dead inside Series/Author folder after cold start; Next.js 16.2 static-export empty-search `router.replace` no-op (same as #3782/#3832); `GroupHeader.handleBack` missed the `group=''` workaround. CDP-verify gotcha: synthetic `el.click()` won't fire React onClick — use trusted `Input.dispatchMouseEvent`
## Library Architecture
- [Book action platform surfaces](book-actions-platform-surfaces.md) — library context menu is **Tauri-desktop-only** (`hasContextMenu` false on web + iOS/Android); cross-platform book actions go in `BookDetailView`'s icon row. #4543 Goodreads search added both surfaces + a built-in web-search provider for highlighted-text lookup
## Architecture Notes
- foliate-js is a git submodule at `packages/foliate-js/`
- Multiview paginator: loads adjacent sections in background, multiple View/Overlayer instances per book
- Style overrides: `getLayoutStyles()` (always), `getColorStyles()` (when overriding color)
- `transformStylesheet()` does regex-based EPUB CSS rewriting at load time
- TTS uses independent section tracking (`#ttsSectionIndex`) decoupled from view
- Safe area insets flow: Native plugin -> useSafeAreaInsets hook -> component styles
- Dropdown menus use `DropdownContext` (not blur-based) for screen reader compat
- [Foliate touch-listener capture phase](foliate-touch-listener-capture-phase.md) — to suppress reader gestures from the app, use `{capture:true}`; the paginator registers bubble-phase doc listeners first (during `view.open()`)
- [iframe cross-realm instanceof](iframe-cross-realm-instanceof.md) — app-bundle code (style.ts, iframeEventHandlers.ts) runs in top realm; `iframeEl instanceof Element` is ALWAYS false → guards silently drop all iframe elements (passes jsdom, dead in app). Duck-type `'closest' in target` instead. Bit PR #4391's touch routing + applyTableStyle dedupe
## Workflow
- [Test file filter](feedback_test_file_filter.md) — use `pnpm test <path>` without `--` to run a single file
- [Always rebase before PR](feedback_pr_rebase.md) — rebase onto origin/main before creating PRs
- [New branch per PR](feedback_pr_new_branch.md) — always create a fresh branch from main for each new PR/issue
- [Upgrade gstack locally](feedback_gstack_upgrade.md) — always upgrade from the project's .claude/skills/gstack, not global
- [No lookbehind regex](feedback_no_lookbehind_regex.md) — never use `(?<=)` or `(?<!)` in JS/TS; build check rejects them
- [Use worktree](feedback_use_worktree.md) — never `git worktree add` directly; always `pnpm worktree:new` before PR review, issue fix, or feature work
- [en/translation.json holds ONLY plural variants + proper nouns](feedback_en_plurals_manual.md) — non-plural strings stay out (defaultValue: key is the en source); plural strings (`_('...', { count })`) need hand-added `_one`/`_other` entries or the singular renders as "1 days"
- [Never push on every change](feedback_dont_push_every_change.md) — hold pushes during active bug iteration; commit locally only until user confirms or work hits a clean done-state
- [No test seams in production code](feedback_no_test_seams_in_prod.md) — production must never import or call `__reset*ForTests`; cross-module test resets belong in the test file's beforeEach/afterEach
- [Dependabot transitive fixes](dependabot-pnpm-overrides.md) — pin patched min-version in `pnpm-workspace.yaml` `overrides:` (NOT package.json `pnpm.overrides`, which pnpm 9+ ignores); watch for existing too-low pins; alert#≠issue# so no `Closes #` (PR #4523)
- [CI/PR delivery + push keepalive](ci-pr-delivery-and-push.md) — package small PRs from a dirty dev tree via temp-index plumbing (no worktree); slow pre-push hook (~55s full suite) + SOCKS-proxy SSH → idle "Broken pipe", fixed with `ServerAliveInterval`; `--no-verify` safe once the hook already passed (always `git ls-remote` to confirm a push landed)
- [Book action platform surfaces](book-actions-platform-surfaces.md) · [menu append race #4389](tauri-menu-append-race-4389.md)
- TXT: [author recognition #4390](txt-author-recognition-4390.md); [chapter measure-word FP #4658](txt-chapter-measure-word-4658.md)
- [Cover stale (in-place mutation)](cover-stale-inplace-mutation-memo.md)
- [Series/author back no-op (#4437)](series-folder-back-noop-4437.md)
- [Library/reader separate texture #4743](library-reader-separate-texture-4743.md) · [list view series overflow #4796](list-view-series-overflow-4796.md)
- [Recently-read shelf (#3797)](recent-read-shelf-3797.md)
- [Auto-import watched folders (#3889)](auto-import-watched-folders-3889.md) per-folder opt-in
## Architecture & Patterns
- foliate-js submodule at `packages/foliate-js/`; multiview paginator preloads adjacent sections
- [Turso "concurrent use forbidden"](turso-concurrent-use-forbidden.md) `op_lock` async mutex
- Markdown: [.md support #774](markdown-md-support-774.md); [resume position #4862](markdown-resume-position-4862.md)
- Style: `getLayoutStyles()` always, `getColorStyles()` when overriding; `transformStylesheet()` rewrites EPUB CSS
- TTS `#ttsSectionIndex`; insets: native plugin → useSafeAreaInsets → styles; Dropdowns `DropdownContext`
- Stale settings closure: persist `useSettingsStore.getState().settings` ([#4780](webdav-connect-nullified-4780.md))
- [Page margins not live #4898](page-margin-live-update-4898.md) in-place mutation froze memo
- [Foliate touch-listener capture phase](foliate-touch-listener-capture-phase.md)
- [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
- [Commit messages English-only](feedback-commit-message-english-only.md) no CJK, no em/en dashes
- 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)
- [Test file filter](feedback_test_file_filter.md) `pnpm test <path>` no `--`
- [No test seams in prod](feedback_no_test_seams_in_prod.md) · [no lookbehind regex](feedback_no_lookbehind_regex.md)
- i18n: [en plurals manual](feedback_en_plurals_manual.md); [i18n:extract prunes keys](i18n-extract-prunes-keys.md)
- [Dependabot transitive fixes](dependabot-pnpm-overrides.md) `pnpm-workspace.yaml` `overrides:`
- [Upgrade gstack locally](feedback_gstack_upgrade.md)
@@ -0,0 +1,26 @@
---
name: android-bg-tts-media-session-fix
description: Android background TTS regression - startService() dies backgrounded; use in-process service calls; + lock-screen scrubber/seek
metadata:
node_type: memory
type: project
originSessionId: 052bb3f3-27fe-4eb0-95c3-699a3122083a
---
Branch `fix/android-bg-tts-media-session` (worktree `/Users/chrox/dev/readest-fix-android-bg-tts-media-session`), 2026-07-07. **PR readest/readest#4994 MERGED** (2026-07-07; worktree + local branch cleaned up. rebased onto origin/main; foliate-js submodule re-synced to f6dced2 after rebase per [[worktree-rebase-submodule-drift]]). 5 commits: 15817fc4b in-process IPC, 04e4b4fe6 duration scrubber+seek, 67c22b72b FGS hardening+diagnostic logs, 27e224bcc keepAppInForeground removal (the real fix), a8643ec12 Edge edge-fade click fix. Verified on-device (Xiaomi/MIUI/Android 15). Diagnostic Log.d traces left in 67c22b72b (offered to strip).
**Regression (commit 1, `fix(android): keep background TTS media controls alive when backgrounded`):** after #4941 (session decoupling) + #4931 (Edge WebAudio engine), Android background TTS lost the lock-screen control and audio died when backgrounded. Logs: `Not allowed to start service Intent { act=UPDATE_PLAYBACK_STATE ... MediaPlaybackService }: app is in background`.
Root cause: `NativeTTSPlugin.update_media_session_state`/`update_media_session_metadata` pushed updates to the already-running foreground service via `activity.startService(intent)`. Android 8+ (BSSR) rejects `Context.startService()` from the background unless an active foreground service exempts the app; each per-sentence update threw, so the FGS notification stopped refreshing (lock-screen control went stale) and the audio route was lost. **Fix pattern: never `startService()` to talk to a running service - call the live instance in-process.** `MediaPlaybackService` already had the pattern: static `@Volatile instance` + `requestDeactivation()` posting to it on the main thread. Added companion `pushMetadata`/`pushPlaybackState` (update statics, post to `instance` via `Handler(Looper.getMainLooper())`) + private instance `applyMetadata`/`applyPlaybackState`; removed the dead `UPDATE_METADATA`/`UPDATE_PLAYBACK_STATE` intent branches + the now-unused `serviceScope`/`kotlinx.coroutines.*`. `startForeground()` to *update* an already-foregrounded service is allowed from background (unlike `Context.startForegroundService()` to *start* one).
Secondary: #4941 dropped the `keepAppInForeground`/notification titles from `TTSMediaBridge.bind()`'s `setActive({active:true})`. `keepAppInForeground` gated `requestPostNotificationPermission()` in `mediaSession.ts`, and it defaults false (`alwaysInForeground` in constants.ts), so POST_NOTIFICATIONS was never requested. Fix: `setActive` requests it on EVERY activation (no-op once decided), not gated on the setting - else the FGS media notification (= the lock-screen control) is silently suppressed on Android 13+.
**Feature (commit 2, `feat(android): show section duration and enable seek on the TTS media session`):** user asked to show estimated section duration + seek from the media session. JS half was already there - `ttsMediaBridge.#updatePositionState` already sends `{playing, position, duration}` (ms) every mark, and `mediaSession.ts` already listens for a `media-session-seek` event -> `handlers['seekto']` -> `controller.seekToTime(pos/1000)`. Native side never used them. Added: `METADATA_KEY_DURATION` in the session metadata (Android reads scrubber length from METADATA, thumb from PlaybackState), `ACTION_SEEK_TO` in `setActions`, and `SessionCallback.onSeekTo(pos)` -> `pluginEventTrigger("media-session-seek", {position})` + optimistic thumb move. Bare play/pause updates omit position/duration, so `pushPlaybackState(playing, position: Long?, duration: Long?)` preserves last-known statics (else scrubber snaps to 0 on pause). **Section timeline is Edge/WebAudio ONLY** (`TTSController` comment "position/duration/seek (Edge client only)"; `getPlaybackInfo()` returns null for native TextToSpeech) - native TTS leaves duration 0 so no scrubber appears, which is correct.
**On-device (Xiaomi/MIUI, targetSdk 36) round 1 FAILED - two findings:** (1) tested APK was STALE - logcat still showed `startService(act=UPDATE_METADATA/UPDATE_PLAYBACK_STATE)` which the fix removes, so the fix wasn't built in (likely built from main tree, not the worktree). (2) Deeper root cause the IPC fix does NOT touch: `W/ActivityManager: Stopping service due to app idle: ...MediaPlaybackService` = the service was NEVER promoted to a foreground service (FGS services aren't idle-stopped; readest uid never appears in FGS-type logs). Also MIUI hostile: uid 10186 (SecurityCenter) repeatedly sets readest `post_notification` appop to `ignore`; `Force stopping service`. Audio plays via WebView (`org.chromium.content.browser.AudioFocusDelegate` holds focus), and the service ExoPlayer also requests AUDIOFOCUS_GAIN - possible focus-steal conflict (unconfirmed).
**Commit 3 (`fix(android): harden TTS foreground-service promotion + add diagnostics`):** `showNotification` now uses `ServiceCompat.startForeground(this, id, notif, ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK)` (explicit type, targetSdk 34+) wrapped in try/catch+Log. `setActive` (mediaSession.ts) decoupled: POST_NOTIFICATIONS request in its own try/catch so a throw/hang can't abort `set_media_session_active` (the FGS start). Trace logs added: `set_media_session_active: startForegroundService` (plugin), `activateSession (wasActive=)`, `startForeground ok`/`failed`. Next device run: build FROM the worktree + `adb uninstall com.bilingify.readest` first; set MIUI Autostart ON + battery No-restrictions + lock in recents; grep logcat for those tags to see where the FGS path breaks. If `startForeground ok` but audio still dies backgrounded -> WebAudio AudioContext suspension (test native voice: survives = confirms WebView issue).
**ACTUAL ROOT CAUSE found round 3 (WebView console `[INFO:CONSOLE]` via `adb logcat` chromium tag):** `Failed to set media session active state: invalid args payload for command set_media_session_active: missing field keepAppInForeground`. The Rust `SetMediaSessionActiveRequest` (models.rs) had `keep_app_in_foreground: bool` as a REQUIRED serde field (all other fields `Option`); #4941's `ttsMediaBridge.bind()` sends `setActive({active:true})` without it, so **Tauri rejected the invoke at the serde layer before the command ran** -> `set_media_session_active` never executed -> FGS never started -> no notification + Android 15 `AS.AudioService: AudioHardening background playback would be muted` killed background audio. Every earlier fix (in-process IPC, FGS hardening, POST_NOTIFICATIONS decouple) was downstream of this and couldn't help because the command never ran. Diagnostic trap: native tags (MediaPlaybackService/NativeTTSPlugin) were absent from logcat because the service was never touched; the answer was only in the WebView JS console (grep logcat for `CONSOLE`). Commit 4 (`fix(android): drop required keepAppInForeground so the TTS service starts`, 27e224bcc): removed `keepAppInForeground` ENTIRELY (dead everywhere - no platform read it; FGS always starts, POST_NOTIFICATIONS now unconditional) from Rust/Kotlin/iOS/TS payloads per user request ("default true"). Follow-up commit 0b8843012 also removed the now-dead `alwaysInForeground` setting + its Android "Background Read Aloud" library-menu toggle (settings.ts/constants.ts/SettingsMenu.tsx + tests) and pruned the i18n key across 33 locales via `pnpm i18n:extract`. **Lesson: on a failed Tauri mobile command, capture the WebView console (logcat `CONSOLE` tag) FIRST - serde arg-rejection surfaces only there, not in native logs.**
Verified: `pnpm test` (7022 pass), `pnpm lint` clean, `cargo check/fmt/clippy -p tauri-plugin-native-tts` clean. **Kotlin NOT compiled/device-verified** - worktree `src-tauri/gen/android` lacks `tauri.settings.gradle` so the plugin's `app.tauri.plugin.*` deps don't resolve standalone; needs `pnpm tauri android` on a real Android 13+/14 device (logcat: foreground -> background -> lock screen). Related: [[tts-background-session-decoupling]], [[edge-tts-webaudio-engine]], [[native-ios-tts-4676]].
@@ -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.91.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]]
@@ -11,7 +11,7 @@ Android "Open with Readest" / "Send to Readest" file-intent pipeline and the #45
**Pipeline (ACTION_VIEW = "Open with", ACTION_SEND = "Share"):**
- `NativeBridgePlugin.kt::handleIntent` is the real handler (NOT `MainActivity.kt` — its ACTION_SEND branch is legacy/redundant). → `emitSharedIntent("VIEW"|"SEND", uris)` → JS `useAppUrlIngress` `shared-intent` plugin listener → `app-incoming-url` event → `useOpenWithBooks`.
- VIEW `openTransient` → straight to reader (ephemeral book, `deletedAt` set, `filePath` = the content:// URI, no library write/upload). SEND`window.OPEN_WITH_FILES``library/page.tsx::processOpenWithFiles` (full ingest + force cloud upload on mobile).
- VIEW routing is now gated by `autoImportBooksOnOpen` (PR #4747, issue #4746): `shouldOpenTransient(action, autoImportBooksOnOpen)` in `helpers/openWith.ts` → only `VIEW` with the setting OFF goes `openTransient` (ephemeral book, `deletedAt` set, `filePath` = content:// URI, no library write/upload); `VIEW` with it ON falls through to the SEND path. SEND (and VIEW-with-import-on)`window.OPEN_WITH_FILES``library/page.tsx::processOpenWithFiles` (full ingest + force cloud upload on mobile). The setting defaults TRUE on mobile (`DEFAULT_MOBILE_SYSTEM_SETTINGS`, desktop default still false) and its "Auto Import on File Open" toggle is now shown on mobile too. `useOpenWithBooks.handle` reads it via `appService.loadSettings()` (disk), NOT the settings store — the store is unhydrated during the cold-start intent replay and would wrongly fall back to transient. So the Telegram default is now import-to-library (persists past the dying URI grant); transient is opt-out.
- content:// read: `nativeAppService.openFile` → if URI contains `com.android.externalstorage` → direct `NativeFile` (real path); else `copyURIToPath``contentResolver.openInputStream` → copy to Cache → `NativeFile`. `basename` here is LEXICAL (`@tauri-apps/api/path`), not a ContentResolver `DISPLAY_NAME` query — but EPUB format is sniffed by zip magic (`document.ts isZip()`), so an extension-less content URI still opens.
- The Tauri deep-link plugin's `getCurrent()`/`onOpenUrl` only fire for configured deep-link domains (`https://web.readest.com`, `readest:`); `content://`/`file://` VIEW intents are filtered out by `DeepLinkPlugin.isDeepLink()`, so file opens flow ONLY through the native `shared-intent` channel, never the deep-link plugin.
@@ -0,0 +1,52 @@
---
name: android-themed-icon-4733
description: "Android Material-You themed (monochrome) launcher icon — restoring it (#4733), the gen/android force-commit pipeline, and emulator verification"
metadata:
node_type: memory
type: project
originSessionId: 6bc82dac-a705-4ef2-ab28-c13b43f48a46
---
Issue #4733 = add Android themed (Material You / monochrome) launcher icon. It had
existed (#2122/#2153 added `ic_launcher_monochrome.png`) but PR #2353 ("fixed
launcher icon size") rewrote the committed adaptive icon to inset the foreground
22% and **silently dropped the `<monochrome>` layer**, so themed icons stopped
working. Fix = re-add `<monochrome><inset android:drawable="@mipmap/ic_launcher_monochrome" android:inset="22%"/></monochrome>`
to `ic_launcher.xml` + ship the monochrome mipmaps.
**Android icon pipeline (non-obvious).** `src-tauri/gen` is gitignored, BUT specific
customized res files are **force-added** (tracked): `mipmap-anydpi-v26/ic_launcher.xml`,
`drawable/ic_launcher_background.xml`, `values/themes.xml`, `splash_icon.png`. CI
(release/nightly/android-e2e) does `rm -rf gen/android``tauri android init`
`tauri icon ../../data/icons/readest-book.png`**`git checkout .`** (restores the
tracked customizations) → build. So **the committed gen files are the build's source
of truth.** `tauri icon` (CLI 2.10.1) writes gen mipmaps + a DEFAULT `ic_launcher.xml`
(foreground+background only) and does NOT emit a monochrome layer — so the monochrome
PNGs (or a vector drawable) MUST be force-committed into `gen/.../res/` to survive
`git checkout .`. `git add -f apps/.../gen/.../mipmap-*/ic_launcher_monochrome.png`.
`src-tauri/icons/android/*` is the historical master but is NOT what the build reads.
**Themed tint = SRC_IN (alpha only).** The launcher replaces the monochrome layer's
RGB with the wallpaper tint, keeping only alpha → any fully-opaque artwork flattens
to a solid blob (the original desaturated-logo monochrome lost all detail). Convey
character via negative space. For Readest we kept the existing artwork and carved a
**narrow vertical center-gap (spine)** via an alpha-multiply mask (ImageMagick:
`magick src -alpha extract a.png; magick -size WxH xc:white -fill black -draw "roundrectangle ..." g.png; magick a.png g.png -compose multiply -composite na.png; magick src na.png -alpha off -compose CopyOpacity -composite out.png`),
gap ≈ centered, width ~4% of content, from ~3%→84% of content height (pages stay
joined at the binding). Preview-as-themed = tint `-colorize`, inset to central 56%
(=22% inset), composite over dark bg, circular mask.
**Emulator verify (Pixel_9_Pro AVD, Google Play image, NexusLauncher).** Themed icons
toggle: Wallpaper & style (`am start -n com.google.android.apps.wallpaper/com.android.customization.picker.CustomizationPickerActivity`)
→ "Home screen" tab → "Themed icons" switch. Only the **home screen/dock** is themed;
the **app drawer keeps full color** (expected, not a bug). `uiautomator dump` returns
"null root node" on the wallpaper picker (SurfaceView) → navigate by screenshot
coords. Build for emulator = `pnpm tauri android build --debug --target aarch64 --apk`
(NDK_HOME must be set). Gradle-standalone (`./gradlew :app:assembleUniversalDebug`)
fails: the `rustBuild*` task shells `pnpm tauri ...` which panics at
`tauri-cli/src/mobile/mod.rs:403` unless driven by `tauri android build`. Confirm the
APK packaged it: `aapt2 dump xmltree --file res/mipmap-anydpi-v26/ic_launcher.xml app.apk`
should show an `E: monochrome` node. Regression guard:
`src/__tests__/android/themed-icon.test.ts` (asserts `<monochrome>` in the XML + a
tracked monochrome mipmap per density). Related: [[dict-lookup-browser-hijack-4559]]
(Android resource/manifest gotchas), [[android-cdp-e2e-lane]].
@@ -0,0 +1,25 @@
---
name: annotator-onload-listener-leak-paragraph-mode
description: "Paragraph mode degrades over chapters on Android (#4735) — Annotator onLoad leaked renderer-scroll + native-touch listeners on long-lived objects; per-view fix + the reusable per-section-leak pattern"
metadata:
node_type: memory
type: project
originSessionId: 980f8d79-9360-4402-bd49-8dd389200c1e
---
PR #4735 (`fix/annotator-input-listener-leak`). Reported: reading a 3000-chapter web novel in **paragraph reading mode** on Android (Z Fold 7), paragraph transitions get sluggish after a few chapters and keep degrading until app restart. Classic per-section-transition resource leak.
**Root cause — Annotator `onLoad` attaches listeners to objects that OUTLIVE the section.** `Annotator.tsx`'s `onLoad` (wired via `useFoliateEvents(view, { onLoad })` to the foliate `load` event, which fires once per section document load) did:
- `view.renderer.addEventListener('scroll', handleScroll)` — never removed
- `view.renderer.addEventListener('scroll', () => repositionPopups())` — anonymous, unremovable
- Android: `eventDispatcher.on('native-touch', handleNativeTouch)` — never `off`'d
`view.renderer` is created ONCE per book (`createElement('foliate-view')` + `view.open()` in `FoliateViewer.tsx`), lives the whole session; the global `eventDispatcher` too. So every `load` permanently adds listeners. **foliate fires `load` for PRELOADED neighbour sections too** (`paginator.js#loadAdjacentSection``dispatchEvent(new CustomEvent('load',…))`, `#preloadNext` loads up to 8), so the accrual is several-per-chapter, not one. The doc-scoped `detail.doc.addEventListener(...)` listeners do NOT leak (the section iframe is destroyed by foliate's `#destroyView`, taking them with it). Only the renderer-/dispatcher-scoped ones leak.
**Why paragraph mode + Android specifically.** Every paragraph advance calls `renderer.goTo({index, anchor})` (`focusCurrentParagraph`), which scrolls the renderer container → `paginator.js:~1161` `this.#container.addEventListener('scroll', () => { if(!#isAnimating) dispatchEvent(new Event('scroll')) })` → runs ALL accumulated scroll listeners. Normal paginated reading scrolls only on occasional page turns, so the same leak is far less felt. Cost is REAL on Android: `handleScroll` (useTextSelector.ts, the `#873` selection-pin workaround) early-returns unless `osPlatform==='android'`, then calls `getViewSettings`; `native-touch` is Android-only. Restart recreates the view/renderer → cleared (the reporter's workaround).
**Fix = `useRendererInputListeners(view, {...})` hook** (`src/app/reader/hooks/`): registers the renderer `scroll` + (Android) `native-touch` listeners ONCE per view in an effect keyed `[view, enableNativeTouch]`, with cleanup; handlers routed through refs so re-renders never re-subscribe. The native-touch handler now resolves the CURRENT primary section's doc/index at fire time (`view.renderer.getContents().find(c=>c.index===primaryIndex)`) instead of capturing a load's doc/index (a load may be an off-screen preload — and the old code fan-fired EVERY loaded section's handler per touch, calling handleTouchEnd/handlePointerUp N times; new code fires once = strictly more correct). Dropped the redundant `scroll→repositionPopups` (a dedicated effect already repositions popups on scroll). `listenToNativeTouchEvents()` just sets one global `window.onNativeTouch` that re-dispatches `native-touch` via eventDispatcher — idempotent, fine to call once/view.
**Reusable pattern (the lesson).** Listeners attached inside a per-section / per-event handler (`onLoad`, `load`, relocate, create-overlay) to an object that outlives that event (`view.renderer`, global `eventDispatcher`, `window`, `document`) LEAK one set per event. Audit `addEventListener`/`eventDispatcher.on` inside `onLoad`-style handlers: if the target isn't the per-section `detail.doc` (which dies with the iframe), it must move to a per-view `useEffect` with cleanup. `eventDispatcher` (`utils/event.ts`) stores async listeners in a per-event `Set` keyed by callback reference; fresh closures each call never dedupe → unbounded.
**Verify gotchas.** Hook unit-tested with `renderHook` + a `MockRenderer extends EventTarget` tracking scroll listeners + a mocked `eventDispatcher` Set; assert size stays 1 across 20 re-renders, latest-handler routing, unmount→0, Android gate. NOTE: creating the mock `view` INSIDE the renderHook callback churns view identity → the `[view]`-keyed effect re-runs each render (still no leak — cleanup keeps Set at 1 — but `listenToNativeTouchEvents` call-count grows); hoist `view` outside to mirror the real stable `getView(bookKey)`. Needs on-device Android verification (Android-gated paths). Related: [[paragraph-mode-toggle-resume-4717]], [[tts-sync-paragraph-rsvp-3235]], [[android-nativefile-remotefile-io]].
@@ -0,0 +1,51 @@
---
name: auto-scroll-teleprompter-4998
description: "Auto Scroll teleprompter mode (#4998, PR#4999): PacedScroller core, useAutoScroll hook, control pill centered on gridcell, scrolled-mode-only View menu toggle"
metadata:
node_type: memory
type: project
originSessionId: 129a72a3-6d52-4f4c-a499-972c0055b4e3
---
Auto Scroll reading mode (#4998), PR #4999 MERGED 2026-07-08 (merge
f8ad47a41); worktree and local branch cleaned up.
Teleprompter scrolling for scrolled mode only, toggled from the View menu
(Shift+A, `onToggleAutoScroll`), dispatches `autoscroll-toggle` events.
Key structure:
- `PacedScroller` added to `src/app/reader/utils/autoscroller.ts` alongside the
middle-click `Autoscroller` ([[middle-click-autoscroll-4951]]): constant
velocity, whole-pixel emission + fractional carry, injected raf/now for
tests, `PACED_SCROLL_MAX_FRAME_MS` dt clamp (background tab resume). A
scrollBy callback may stop() the scroller mid-tick; #tick re-checks active
before re-arming (test covers it).
- `useAutoScroll(bookKey, viewRef)` in reader/hooks, mounted in FoliateViewer:
scrolls `renderer.containerPosition += sign * delta`; sign = -1 when
`renderer.scrollProp === 'scrollLeft'` (scrolled+vertical), matching foliate
paginator.js `offset = -offset` for scrolled vertical (vertical-lr is a known
upstream FIXME). Manual wheel/drag composes with the paced steps by design
(no pause-on-wheel). Tap pause/resume consumes `iframe-single-click` via
eventDispatcher.onSync (same swallow mechanism as middle-click). Stall
detection: containerPosition unchanged ≥800ms → `view.next()` (hops sections
under noContinuousScroll) or stop + 'End of book' toast when
`renderer.atEnd`. Session state mirrored to readerStore
`viewState.autoScrollEnabled` (new setter) for the ViewMenu checkmark;
session never persisted, speed IS: `autoScrollSpeed` percent in BookLayout
(default 100 = 20 px/s base, 25-500 step 25, constants in
services/constants.ts).
- `AutoScrollControl` pill reuses the ParagraphBar chassis but positioned
`absolute` (NOT `fixed`): maintainer explicitly wants it centered on the
book's gridcell, not the viewport — pinned sidebar pushes the reading column
off window center. (ParagraphBar's #4474 comment argues the opposite for
paragraph mode; the two are intentionally different.) Fades after 2.5s while
scrolling, wakes on mousemove/pause, hidden while hoveredBookKey shows bars.
- Adding a field to readerStore ViewState breaks two test fixtures that build
ViewState literals (reader-store.test.ts, tts-auto-advance.browser.test.tsx).
- i18n: 6 new keys (Auto Scroll, Toggle Auto Scroll, Slower, Faster, Exit Auto
Scroll, End of book) hand-translated across all 33 locales following each
locale's existing Scrolled Mode / RSVP Slower-Faster terminology; scanner
extraction only touched trailing commas (no pruning this time).
Verified live in dev-web with claude-in-chrome (localhost:3001): 20 px/s at
100%, menu gating, pill geometry (pillCenterX == gridcell center != viewport
center).
@@ -0,0 +1,16 @@
---
name: backup-windows-zip-paths-4703
description: Backup zip exported on Windows failed to restore anywhere — backslash separators in zip entry names
metadata:
node_type: memory
type: project
originSessionId: dd015419-996e-466b-8039-f2d98312d9d6
---
#4703: Backup `.zip` exported on Windows wouldn't restore on any platform (Web/Android/Windows) — books restored with metadata but missing files/covers.
**Root cause:** `appService.readDirectory` returns paths with the host separator. On Windows `nativeAppService.readDir``getRelativePath` strips the base prefix but leaves backslashes, so `file.path` is `hash\cover.png`. `addBackupEntriesToZip` used `file.path` verbatim as the zip entry name. Restore (`restoreFromBackupZip`) matches a book's files by `e.filename.startsWith(`${hash}/`)` (forward slash) → backslash names never match → all files silently skipped. (The "garbled Unicode" reported in zip viewers was just the `\` rendered oddly.)
**Fix (export side only):** normalize the zip entry name to forward slashes — `file.path.replace(/\\/g, '/')` — in `addBackupEntriesToZip` (`src/services/backupService.ts`). Keep `file.path` (host separators) for `readFile`. Test: `backup-windows-paths.test.ts` drives the now-exported `addBackupEntriesToZip` with a capturing ZipWriter stub + Windows-style backslash listing (no zip.js workers needed; the forced `useWebWorkers`/`useCompressionStream` config makes a real round-trip impractical under jsdom).
**General lesson:** `readDirectory`/`readDir` paths carry host separators; normalize to `/` at any cross-platform boundary (zip entries, sync keys, anything serialized for another device). Already-broken Windows backups still need re-export — restore was left unchanged (minimal fix; matches the issue's expected behavior). See [[platform-compat-fixes]].
@@ -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]].
@@ -0,0 +1,23 @@
---
name: calibre-plugin-push-4863
description: "readest-calibre-plugin (#4863) pushes calibre books+metadata to Readest cloud; key protocol facts (OAuth localhost relay, /sync explicit-null carry-over)"
metadata:
node_type: memory
type: project
originSessionId: 5d4d83a0-0aee-4200-852f-555df5243bed
---
`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:
- **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.
- **OPF embedding + uuid dedup** (v2, per maintainer request): metadata IS embedded into a temp copy at upload (`calibre.ebooks.metadata.meta.set_metadata` — deterministic for EPUB, writes custom columns as `calibre:user_metadata`). Dedup keys: calibre uuid in row `metadata.identifier` (survives byte changes) + `metadata.calibreSourceHash` = raw library-file partialMD5 (change detection, no local state; v1 rows fall back to `book_hash` which equals the raw hash). File changed → replace flow: upload new blob, push new row (carry-over) + tombstone old in one /sync POST, best-effort delete old cloud files. Metadata-only edit → row update, no re-upload (embedded OPF goes stale until next file upload).
- **POST /sync explicit-nulls absent fields** (transformBookToDB) — updates must carry over `uploadedAt`, `groupId/Name`, `progress`, `readingStatus*`, `coverHash` from the pulled server row (`wire.py::merge_for_push`); same lesson as koplugin syncbooks.lua.
- **Upload key** `Readest/Books/{hash}/{hash}.{ext}`; app's `{title}.{ext}` downloads resolve via download.ts hash+extension fallback. cover.png stores *original* bytes (app never converts formats, bookService.ts:568), so calibre's cover.jpg bytes upload as-is; coverHash = partialMD5 of those bytes.
- **OAuth from a non-app client works**: `{supabase}/auth/v1/authorize?provider=X&redirect_to=http://localhost:PORT` is whitelisted (readest-app's Flatpak/custom-OAuth production path uses it). Tokens arrive in the URL *fragment*; serve a page whose JS relays `location.hash` to `/callback?...` (tauri-plugin-oauth trick). Implemented in `oauth.py`.
- Pure modules (`api.py`, `wire.py`, `oauth.py`) are calibre-free; `make test` runs 56 unittests; `make zip` builds; smoke-test inside calibre with `calibre-debug -c` after `from calibre.customize.ui import find_plugin` (initializes the `calibre_plugins` namespace).
- **Release packaging** (PR #4918): `build-calibre-plugin` job in release.yml mirrors the koplugin job; perl-stamps `PLUGIN_VERSION` from readest-app package.json, `make zip``Readest-<version>.calibre-plugin.zip` release asset. Committed version stays the (0, 1, 0) dev placeholder.
- **Pushing workflow files**: gh's OAuth token lacks `workflow` scope (HTTPS push of .github/workflows/* rejected); SSH push works (transient hangs — retry with ConnectTimeout/ServerAliveInterval).
Related: [[koplugin-cover-upload]], [[grimmory-native-sync]], [[ci-pr-delivery-and-push]]
@@ -0,0 +1,43 @@
---
name: captured-turn-instant-highlight-scrolllock
description: Captured slide/curl page turns ignored the instant-highlight still-hold gate; fixed by honoring renderer.scrollLocked like the push paginator
metadata:
node_type: memory
type: project
originSessionId: 871c7b42-61c0-44e7-a1d6-8edb35d80300
---
Instant Highlight's 300ms still-hold gate ([[instant-highlight-tap-paginate]])
worked in **push** mode but NOT in **slide/curl** — a swipe after the hold turned
the page (with the slide/curl effect) instead of extending the highlight.
**Root cause: two independent swipe paths.** foliate's native `#onTouchMove`
(paginator.js) bows out at `if (this.hasAttribute('no-swipe')) return` (~2149),
THEN checks `if (this.#scrollLocked) return` (~2162), THEN the `#layeredTurn` VT
drag (~2179). So:
- **push** (no turn-style, no no-swipe) → native swipe, honors `#scrollLocked`. ✅
- **VT-layered slide** (`turn-style='slide'`, no no-swipe; engines with nested VT
groups) → native swipe → layered turn, still AFTER the scrollLocked check. ✅
- **captured curl (always) / captured slide (Tauri w/o full VT support)** →
`applyPageTurnAttributes` sets `no-swipe`, so native swipe returns early and the
APP-side captured-turn touch interceptor in `useCapturedTurn.ts` (priority 5,
driven by `iframe-touchmove``dispatchTouchInterceptors`) is the swipe handler.
It began a drag on any >15px horizontal move WITHOUT checking scrollLocked. ❌
`useTextSelector.startInstantAnnotating` sets `view.renderer.scrollLocked = true`
when the hold engages. The captured interceptor is a parallel reimplementation of
swipe-to-turn and must honor the same lock independently.
**Fix (app PR readest#5000 + foliate readest/foliate-js#51, tests:
`useCapturedTurn-scrollLock.test.ts`):**
1. foliate `paginator.js`: add `get scrollLocked()` — it was setter-only, so JS
couldn't read it back (app `src/types/view.ts` already declared it a readable
boolean). foliate PR #51 MERGED (squash → `ba57ec8` on foliate main); app
#5000 bumps the submodule pointer to `ba57ec8` (mergeable, awaiting merge).
2. `useCapturedTurn.ts` touch interceptor, `move` phase, before starting a drag
(`!state` branch): `if (currentView.renderer.scrollLocked) return false;`.
**Why the `!state` branch is sufficient:** a captured drag needs >15px horizontal
travel, but `maybeCancelInstantHoldOnMove` cancels the hold at >10px — so a drag
can never already be in progress when instant annotation engages; no need to gate
an in-flight drag. See [[page-turn-styles-viewtransitions-555]].
@@ -0,0 +1,32 @@
---
name: cloud-sync-provider-selection-plan
description: "APPROVED /autoplan-reviewed plan making third-party sync (WebDAV/Drive) a first-class selectable provider; quota scoped to Readest Cloud (#4959/#4380)"
metadata:
node_type: memory
type: project
originSessionId: c0549d91-7f40-46a8-b110-628964be195b
---
Plan APPROVED 2026-07-06 after full /autoplan review (CEO+Design+Eng dual voices, 43 logged decisions). Plan file: `~/.claude/plans/research-on-https-github-com-readest-rea-velvet-meteor.md` (contains registries, UI state matrix, eng hardening, coverage diagram, 26 tasks). CEO doc: `~/.gstack/projects/unknown/ceo-plans/2026-07-06-cloud-sync-provider-selection.md`.
**Architecture:** policy layer over TWO engines (native DB-sync + FileSyncEngine) — Readest Cloud is NOT wrapped in FileSyncProvider (would regress server merges #4634/#4544/#4678). New `src/services/sync/cloudSyncProvider.ts`: pure `getCloudSyncProvider(settings)` derived from `webdav/googleDrive.enabled` (device-local) + separate `resolveCloudSyncGate(settings, plan)` w/ cached plan accessor (isCloudSyncAllowed needs async JWT — can't be settings-pure). Guard trips → PAUSED state + prompt, never silent readest fallback. Native gating = one branch in `syncCategories.isSyncCategoryEnabled` (book/progress/note); binary gating = `transferManager.queueUpload` returns null. Account channels (settings/stats/replicas/translations/Send) always native.
**Sequence (user-ruled at gate):** PR1 quota decouple (#4959 hotfix: gate + quota-403 no-retry + BATCH toast dedupe — spam is N-books×1-toast after retries, verified transferManager.ts:376/395) → PR1.5 file-engine parity (tags+readingStatus in mergeBookConfig/mergeBookMetadata+wire, BEFORE gating) → PR2 exclusive gating + mixed-fleet detection → PR3 chooser UI.
**Gate rulings:** UC3 = `syncBooks` AUTO-ENABLES on third-party selection (closes books-backed-up-nowhere hole; opt-out shows warning). UC1 = derived/device-local kept + read-only `/api/sync?since=providerSelectedAt&limit=1` probe → one-time "another device still syncs" banner + Sentry provider tag. UC2 = parity before gating. Switch-back = new-imports-only auto-upload (NO 675-book burst); metadata rows DO re-push (intended).
**PR1 IMPLEMENTED (2026-07-06):** commit `f6e5d7740` on branch `fix/cloud-sync-quota-decouple` (worktree `/Users/chrox/dev/readest-fix-cloud-sync-quota-decouple`), 22 files, LOCAL ONLY (not pushed, per confirm-before-push). Full suite 6900 pass + lint clean; new suites: `cloudSyncProvider.test.ts` (18), `transfer-manager-gating.test.ts` (19). i18n extraction deliberately SKIPPED in this PR (scanner pruned ~1350 live translations, e.g. "Read Aloud" — run the dedicated /i18n pass later; new strings fall back to English keys). Deviations from plan, all sound: paused toast centralized in `handleBookUpload` (both manual surfaces route through it); useTransferQueue default-param hazard fixed by the manager-level settings barrier instead of signature churn; migration passes the settings snapshot into `runMigrations(lastVersion, settings)` and mutates in place because `Settings.loadSettings` re-reads disk (subclass post-save would clobber an independent save). **SERIES FULLY MERGED (2026-07-07): #4971 (PR1 quota) + #4973 (PR1.5 parity) + #4975 (PR2 exclusive routing, closes #4380) + #4976 (PR3 chooser UI).** Worktrees removed, local branches deleted. **LIVE-VERIFY BUG FOUND+FIXED = #4981 OPEN** (`3a0af54dd`, fix/file-sync-auth-abort): expired Drive web token → engine swallowed AUTH_FAILED on index pull → remoteIndex=null read as FIRST SYNC → attempted 682-book re-upload march; latent hazard: null index skips the peers-tombstone union in the final re-push (#4860 class — transient pull failure could resurrect deletions). Fix: unreadable index (throw) aborts (404→null stays first-sync); terminal AUTH_FAILED latch stops runPool + skips index push + rethrows; web auto-sync preflights hasValidWebDriveToken. KEY ENGINE INVARIANT going forward: FileSyncError AUTH_FAILED is terminal — rethrow, never aggregate.
**i18n PASS = #4980 OPEN** (`237953cc2`, fix/cloud-sync-i18n, worktree `readest-fix-cloud-sync-i18n`): 22 strings x 33 locales + CLDR plural forms + en `_one`/`_other`, appended WITHOUT the scanner (removeUnusedKeys would prune live keys), additions-only diff. REMAINING: live verification checklist (real WebDAV 192.168.2.3:6065: exclusive e2e, syncBooks auto-enable on connect, fleet banner, switch-back no-burst, two-window switch), TODOS.md follow-ups (Sentry Rust tag, server quota error code, download-all-before-switch, library sync indicator, account chip, stats/viewSettings parity, Manage-Sync binary-gating mismatch). Note: GitHub reports 5 dependabot vulns on default branch (1 high) — pre-existing.
**PR3 contents:** activation moved to `src/services/sync/cloudSyncActivation.ts` (accepts 'readest'; component cloudSync.ts is a re-export shim); pure status matrix `cloudSyncStatus.ts` (getReadestCloudRowStatus/getThirdPartyRowStatus, fully tested — paused renders on the THIRD-PARTY row, not Readest row as plan sketch had it); Cloud Sync section (Readest-first radio rows, scope subtitle, role=radiogroup); Readest Cloud inline sub-page (Quota + NavigationRow to Account, never navigateToProfile from the row); premium branch keeps Readest row; capability Tips both directions in webdav/gdrive sub-pages; FileSyncForm Upload Book Files relabel; SyncCategoriesSection 'Managed by {{provider}}' description swap (toggles stay live).
**REMAINING (user/ops):** push 2 branch stacks + open PRs (PR1.5 independent; PR2/3 stacked on PR1); dedicated /i18n pass for ~20 new strings (extraction pruning hazard — run /i18n which handles it); live verification per plan (real WebDAV 192.168.2.3:6065: exclusive mode e2e, syncBooks auto-enable, fleet banner, switch-back no-burst); TODOS.md follow-ups (Sentry Rust tag, quota error code, etc.). Discard uncommitted TODOS.md duplicate in main checkout.
**PR2 IMPLEMENTED (2026-07-06):** commit `95fd33f0a` on `feat/cloud-sync-exclusive-gating`, STACKED on PR1 in the same worktree (`/Users/chrox/dev/readest-fix-cloud-sync-quota-decouple`), 27 files +869/-99, LOCAL ONLY. Full suite 6923 pass + lint clean. Contents: syncCategories provider gate (book/progress/note, runtime override, user toggles persist); `persistActiveCloudProvider` single write path (chooser + both connect/disconnect flows + gdrive OAuth callback which had bypassed broadcast); minimal switch-only broadcast (`{enabled, providerSelectedAt}` — never credentials/cursors); **found+fixed PR1 integration bug: buildWebDAVConnectSettings pre-set `enabled:true` so fresh-connect never triggered the syncBooks auto-flip — builder is now activation-agnostic**; fileSyncStore `lastError` + `fleetNoticeShown`; `runActiveFileLibrarySync()` shared runner (menu tap + pull-to-refresh + BackupWindow all route via pullLibrary's provider branch — fixes "undefined book(s) synced"); SettingsMenu "Synced via {{provider}}" + quota caption + Auto-Upload hidden (also command palette `action.autoUpload` filtered, BookItem badge, TransferQueuePanel Upload All); mixed-fleet read-only probe (`pullChanges(providerSelectedAt,'books',...,1)` in useBooksSync's throttled interval, once-per-session toast); `providerSelectedAt` in both provider types + backup blacklist. Sentry cloudSyncProvider tag DEFERRED to TODOS (tagging is Rust-mediated via set_webview_info pattern — needs src-tauri command).
**PR1.5 IMPLEMENTED (2026-07-06):** commit `f19fc6fa1` on `feat/file-sync-metadata-parity` (worktree `/Users/chrox/dev/readest-feat-file-sync-metadata-parity`, branched off origin/main independent of PR1), 4 files +253/-26, LOCAL ONLY. Full suite 6869 pass + lint clean. KEY FINDING: library.json already serializes FULL Book objects — tags/readingStatus were on the wire all along; the drop was `mergeBookMetadata`'s overlay (same gap as #4942 groups) + the reconcile predicate not firing on status-only changes. Fix: tags join the metadata LWW subset (raw assignment, removals propagate); readingStatus merges on its own `readingStatusUpdatedAt` clock (client mirror of #4634); new `shouldApplyRemoteBookMetadata` predicate (either clock) replaces `isRemoteBookMetadataNewer` in the engine reconcile filter (the old predicate stays exported). NO wire changes needed. PR2 stacks on PR1 (needs cloudSyncProvider.ts) — merge PR1 first or stack branches.
**Key traps found in review:** `BACKUP_SETTINGS_BLACKLIST` does NOT exclude enabled flags/webdav.deviceId (plan text was wrong; PR1 adds deviceId/lastSyncedAt to blacklist); settings broadcast must carry ONLY `{enabled}` (password would leak; routine lastSyncedAt writes could revert a switch via slice LWW); Drive OAuth callback writes via appService.saveSettings bypassing broadcast → centralize `activateCloudProvider()`; `useTransferQueue()` DEFAULT params (`libraryLoaded=true`) in SettingsMenu/TransferQueuePanel are the real unguarded init path (barrier = `settings.version`); cancelled needs structured `cancelReason` + queue schemaVersion (`retryAllFailed` resurrects cancelled rows today; failed-includes-cancelled copy-pasted in 5 places); fileSyncStore is process-local — durable lastSyncedAt lives in provider settings.
See [[webdav-filesync-refactor-plan]] · [[gdrive-provider-multipr-status]].
@@ -0,0 +1,47 @@
---
name: cover-bg-image-texture-suppression
description: Cover painted via body background-image vanished under an active bg texture (parchment) because textureAwareBackground misclassified it as transparent
metadata:
node_type: memory
type: project
originSessionId: 9d32520c-53be-4871-9104-d93617736e30
---
EPUB cover pages that paint the cover via a `<body>` CSS `background-image`
(EPUB sets `background-color` transparent + `background-size:100% 100%`, no
`<img>` — e.g. Sigil/duokan样书《商梯》) showed the **background texture instead
of the cover** on the first page. Reported "Xiaomi only" but it's
texture-only, not Android-only.
Root cause (verified on-device via adb+CDP, Xiaomi 13 WV147): foliate
`packages/foliate-js/paginator.js` `textureAwareBackground(resolved, hasTexture)`.
foliate captures the body bg into `view.docBackground` via
`getComputedStyle(body).background` (the SHORTHAND), which always serializes the
transparent background-*color* first: `rgba(0, 0, 0, 0) url("blob:…") no-repeat
fixed 50% 50% / 100% 100% …`. The old `isTransparent` regex
`/^\s*(transparent|rgba\(0,\s*0,\s*0,\s*0\))/` matched that prefix → under an
active texture (`--bg-texture-id` != none) it returned `''` → no bg segment in
the host `#background` → texture (`.foliate-viewer::before`) showed through. With
no texture it worked (returns the cover bg unchanged), which is why desktop/
default looked fine.
Fix: a bg that carries an image is NOT transparent. Add `hasImage =
/\burl\(/i.test(resolved)` and gate `isTransparent` on `!hasImage`. A full-page
cover should occlude the texture; plain `none` transparent pages still drop so
the texture shows through. Helps scrolled (line ~1464) and paginated (~1482)
callers alike. Test: `paginator-background-segments.test.ts` (added the
url()-keeps case; kept the existing `none`-drops case).
NOT the bug (ruled out on-device): Rust `parse_epub_metadata` cover EXTRACTION
(library thumbnail was correct), shorthand serialization (WV147 emits the url
fine), the cover blob URL (loads 1200x1800 fine), `background-attachment:fixed`
(Android falls back to scroll but the segment sets `background-attachment:
initial` anyway). Related: [[paginated-texture-occlusion-4399]],
[[dark-mode-texture-body-bg-4446]], [[paginator-swipe-bg-flash]].
CDP verify recipe: pid changes per app restart — re-derive socket from
`/proc/net/unix` (`webview_devtools_remote_<pid>`), `adb forward tcp:9333
localabstract:…`; curl mishandles WV HTTP framing → raw-socket fetch `/json`;
pure-python WS client (omit Origin for M111+); paint a 50%-width test segment
with the cover blob bg into `#background` + `Page.captureScreenshot` to see
cover-vs-texture side by side.
@@ -0,0 +1,57 @@
---
name: cross-page-selection-autoturn-4741
description: Cross-page selection/highlight in paginated mode via extracted useAutoPageTurn; all four selection gestures drive the corner-dwell turn
metadata:
node_type: memory
type: project
originSessionId: 33b70e98-fb55-467a-b03f-e4065491bc7e
---
#4741: in paginated (non-scrolling) mode, extend a selection/highlight past the
page edge by turning the page mid-gesture. Branch `feat/cross-page-highlight-autoturn`.
**Extracted `src/app/reader/hooks/useAutoPageTurn.ts`** from `useTextSelector`
the corner-dwell auto page-turn (#1354), now **decoupled from the DOM selection**
so selection-less gestures can drive it. API: `notePoint`/`noteAutoTurnPoint`
(window-coord engagement point), `cancel`, `isAutoTurning`, `onAfterTurn(cb)`
(Set of subs), `cornerAtPoint`, `readingAreaRect`. Liveness at dwell fire-time is
an injected predicate, not `doc.getSelection()`: `noteCorner(corner, isInCorner)`.
`useTextSelector` keeps the dual-signal native liveness (`pointerCornerNow ||
caretCornerNow`); point-only callers use `noteAutoTurnPoint` (last-point liveness).
Pure exports `getReadingAreaRect`, `turnForFocusBeyondPage`, `keyboardTurnDirection`.
**Key trap:** the old `armDwell` required a valid DOM selection to turn. Instant
Highlight (`user-select:none` + CFI overlay) and AnnotationRangeEditor (CFI
overlay) have **no** DOM selection, so the machine refused to turn for them. The
decoupling is what makes them work at all.
**Four gestures, all feeding the one machine** (`useTextSelector` re-exposes
`noteAutoTurnPoint`/`cancelAutoTurn`/`onAutoTurn` to the editors via `Annotator`):
1. Instant Highlight drag — `handlePointerMove`/`handleNativeTouchMove` feed the
finger corner. `useInstantAnnotation` now **DOM-anchors the start** (`startPosRef`
= `{node,offset}` at pointer-down; `buildRangeFromAnchor` builds anchor->end each
move) so it survives the scroll; relaxed the pointer-up `distance<10` cancel with
`&& !previewAnnotationRef.current`. See [[instant-highlight-tap-paginate]].
2. `SelectionRangeEditor` handle drag — already DOM-anchored the fixed end; just
feed `noteAutoTurnPoint(point)` + cancel + re-emit.
3. `AnnotationRangeEditor` handle drag — `useAnnotationEditor` changed from
`handleAnnotationRangeChange(startPt,endPt)` (`buildRangeFromPoints` resolved BOTH
ends from window coords -> lost previous page) to `applyAnnotationRange(range,...)`;
component anchors the non-dragged end (`fixedAnchorRef`) + builds via
`rangeFromAnchorToPoint` like SelectionRangeEditor.
4. `Shift+Arrow` keyboard adjust (#4728) — `useBookShortcuts.adjustTextSelection`,
after `extendSelectionFromContents`, **immediate turn-on-cross** (no dwell):
`keyboardTurnDirection(contents, getReadingAreaRect(...))` -> `view.next()/prev()`
when the extended focus leaves the page. Desktop-only; gated `!scrolled`.
**After-turn re-emit:** active gesture subscribes `onAfterTurn` to rebuild its range
from the held point onto the new page immediately (instant: `reapplyInstantAnnotation`;
editors: `subscribeAutoTurnReemit` -> `updateFromDraggedPoint(lastPoint)`). Native
selection does NOT subscribe (browser extends its own). The Android #873 scroll-pin
(`selectionPosition`) is re-anchored after every turn via `onAfterTurn` in useTextSelector.
`focusCaretWindowPos` promoted `useTextSelector` -> `src/utils/sel.ts` (keyboard reuse).
Scope: within-section column turns only (a Range can't span two iframe docs).
Tests: `useAutoPageTurn.test.ts` (21), `useTextSelector-instantTurn.test.ts`,
`useInstantAnnotation.test.ts`, `useAnnotationEditor.test.ts`; existing autoTurn/
instantHold suites stay green (regression net for the extraction).
@@ -0,0 +1,14 @@
---
name: customize-toolbar-eink-black-bar-4839
description: Customize Toolbar preview rendered as a solid black bar in e-ink; preview surfaces copying bg-gray-600 need eink-bordered
metadata:
type: project
---
#4839: the Customize Toolbar sub-page (`AnnotationToolbarCustomizer.tsx`) toolbar **preview** Zone copied the live popup's `selection-popup bg-gray-600 text-white` but rendered as an unreadable solid black bar under `[data-eink='true']`.
**Why:** the real reader popup earns its e-ink chrome from `.popup-container` (globals.css `[data-eink] .popup-container``bg base-100` + 1px `base-content` border). The preview Zone is a plain `<div>` with NO `popup-container`, so the dark `bg-gray-600` survived in e-ink; the base-content (inverted via `[data-eink] button`) chip icons then sat black-on-black.
**How to apply:** any e-ink "preview" surface that mimics the live popup must scope the dark fill to non-e-ink (`not-eink:bg-gray-600 not-eink:text-white`) and add `eink-bordered` so e-ink renders it as `bg-base-100` + 1px `base-content` border (don't just rely on `eink-bordered`'s `!important` to override the gray — drop the gray in e-ink outright). Also fix copied white hint text (`text-white/70``not-eink:text-white/70 eink:text-base-content`) since the surface turns base-100. Chip icons need no change — they are `<button>`s, already inverted to base-content by the global `[data-eink] button` rule. Guard: render test asserts `.selection-popup` element carries `eink-bordered`. Verify rendered colors via `getComputedStyle` under `[data-eink]` (set `data-theme='default-light'` first or theme vars are unresolved → transparent); note daisyUI returns **oklch** not rgb — e-ink correct = bg `oklch(1 0 0)`, border/icon `oklch(0.2 0 0)`. PR #4841.
Same feature as [[customize-toolbar-global-serializeconfig]]; e-ink conventions in [[feedback_design_system_doc]].
@@ -0,0 +1,48 @@
---
name: customize-toolbar-global-serializeconfig
description: Customize Toolbar applied per-book not global; root cause = serializeConfig compared viewSettings by reference (!==) so array values were always stored as stale per-book overrides
metadata:
node_type: memory
type: project
originSessionId: c6601464-9463-4ac3-99c0-e7527e4051b5
---
Customize Toolbar (annotation bar, #4014, shipped v0.11.12) changes only applied
to the book where edited, not globally. Fixed in PR #4760 (MERGED, squashed onto
main as 7da5f8321).
**Root cause:** `serializeConfig` (`src/utils/serializer.ts`) decides which per-book
viewSettings to persist as overrides via `globalViewSettings[key] !== value` — a
*reference* compare. It deep-clones the config first (`JSON.parse(JSON.stringify)`),
so any **array/object** viewSettings value (`annotationToolbarItems`, and latently
`paragraphMode`, `proofreadRules`, `ttsHighlightOptions`, `noteExportConfig`) is a
fresh reference ≠ global → stored as a per-book override on **every** save (progress
autosave serializes with settings each relocate). On reopen the merge
`{ ...globalViewSettings, ...perBookOverrides }` lets the stale override shadow
global → a global toolbar change never reaches already-saved books.
**Fix (final — minimal, general, no special-casing):** compare viewSettings values
by content, not reference. Added `isSameViewSettingValue(a,b) = a===b ||
JSON.stringify(a)===JSON.stringify(b)`, used in the viewSettings reduce ONLY
(searchConfig left on `!==` — it holds functions / large `results`). The field
stays `annotationToolbarItems` in `AnnotatorConfig` (normal per-book viewSettings,
honors the isGlobal "Apply to This Book" toggle). PR diff is just serializer.ts +
serializer.test.ts.
**Iteration history (user steered):** (1) a `GLOBAL_ONLY_VIEW_SETTINGS` exception
forcing global save + strip/ignore per-book — rejected "don't make it an exception";
(2) move field to `SystemSettings.globalReadSettings` — rejected "too much";
(3) rename `annotationToolbarItems``annotationToolbar` for a clean slate — rejected,
keep the original name (it's synced in globalViewSettings). Landing point: keep the
name, fix only the serializer reference-compare bug.
**Known limitation (no rename clean-slate):** existing books may carry a per-book
`annotationToolbarItems` override from the buggy v0.11.12 build. The value compare
stops new ones and drops an existing one on next save when it matches global, but
does NOT retroactively clear an override whose content differs from current global —
those books keep the stale toolbar until re-saved while equal to global. A follow-up
one-time migration (clear persisted per-book toolbar overrides) would close this if
needed.
Tests: `src/__tests__/utils/serializer.test.ts` — array setting equal to global is
not persisted; differing array still persisted.
@@ -0,0 +1,45 @@
---
name: dict-popup-font-size-4443
description: Adjustable dictionary popup font size via ::part() + em-rebasing; the only cross-shadow font hook for MDict
metadata:
node_type: memory
type: project
originSessionId: b105ba93-61b7-4d28-a269-1201a7be89bd
---
#4443 — adjustable dictionary popup font size (independent of the reading view).
SHIPPED: merged to main via PR #4734.
**The lever** = `DictionarySettings.fontScale` (number, default 1), set in
Settings → Language → Dictionaries (`SettingsSelect`, 85175%). Stored in the
dictionary settings; SYNCED by adding `dictionarySettings.fontScale` to
`SETTINGS_WHITELIST` (whole-field LWW, like providerOrder). `setFontScale` in
`customDictionaryStore` + default-merge in `loadCustomDictionaries`
(`?? DEFAULT_DICTIONARY_SETTINGS.fontScale`).
**Plumbing**: `useDictionaryResults` returns `fontScale`; `DictionaryResultsBody`
puts `data-dict-content` + inline `--dict-font-scale` on each per-tab container
(the `setContainerRef` div). All CSS lives in `globals.css`.
**Two non-obvious CSS facts that drove the design:**
1. **MDict renders into a Shadow DOM** (`shadowHost.attachShadow`, the only
provider that does) → its body is unreachable by ordinary popup CSS.
`::part(dict-content)` is the ONLY hook. So `mdictProvider` sets
`body.setAttribute('part','dict-content')` AND adds a stable host class
`dict-shadow-host` (the `::part()` rule needs a host selector subject).
`--dict-font-scale` inherits across the shadow boundary, so the outer rule
`…::part(dict-content){font-size: calc(var(--dict-font-scale,1) * 0.875rem)}`
resolves it. The dict's own shadow CSS never targets our wrapper `<div>`, so
no cascade fight — em-based dict content scales from it, px-based stays fixed
(expected for a font-size lever).
2. **Light-DOM providers size text with Tailwind `text-*` = root-relative `rem`**,
which a container `font-size` can't move. Fix = re-base the utilities to `em`
WITHIN `[data-dict-content]` only: `[data-dict-content] .text-sm{font-size:.875em}`
etc. Higher specificity than the bare utility + declared after `@tailwind
utilities` → wins, no `!important`. Container itself = `calc(scale * 1em)`.
**Verify**: the CSS contract (em-rebasing + `::part` + var inheritance) needs a
real browser — jsdom has no layout. Covered by
`dict-popup-font-size.browser.test.ts` (scale 1 → 18/14/14px, scale 1.5 →
27/21/21px, incl. the shadow body). Provider/store/whitelist sides have jsdom
unit tests. See [[css-style-fixes]].
@@ -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,26 @@
---
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.
- Inter-sentence CLICKS/POPS = the silence trim (`findSpeechBounds`) cuts at an amplitude threshold (0.005), NOT a zero crossing, so each buffer edge is a non-zero sample; the source steps to/from silence → click. NOT WSOLA (no-op at rate 1.0, cross-fades internal splices). Fix (commit a8643ec12, branch fix/android-bg-tts-media-session): `applyEdgeFade` in `pcm.ts` ramps ~3ms at both ends of the buffer's OWN copy (`buffer.getChannelData(0)` after `createMonoBuffer`) — never the `trimmed` subarray view (rate 1.0 aliases the decoded buffer). Trim + gap kept. Removing the trim instead would work (WSOLA DOES scale silence, so gaps stay rate-scaled) but doubles each gap to Edge's tail+lead silence.
- `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,30 @@
---
name: eink-screen-refresh-pageturner-4687
description: "Page-turner \"Refresh Page\" action that deep-refreshes the e-ink panel (clear ghosting) on Android, via generic reflection across BOOX/Tolino/Rockchip"
metadata:
node_type: memory
type: project
originSessionId: 742b1517-392b-4735-8355-32b57fbfa400
---
Issue #4687 — added a bindable **"Refresh Page"** page-turner action that triggers a deep e-ink full refresh (GC16) to clear ghosting. Shipped as **PR #4822 (MERGED)** (`feat/eink-screen-refresh-pageturner` → main, 55 files +470/-41), built in an isolated worktree off origin/main (worktree + branch since removed). Rebase note: origin/main's Drive-sync PR #4821 added `secure_item` native-bridge commands at the exact anchors I used (end of COMMANDS / handler list / structs / impls), so all 7 plugin files (build.rs, default.toml, commands.rs, desktop.rs, lib.rs, mobile.rs, models.rs) conflicted on apply — resolved "keep both" by re-adding `refresh_eink_screen` after the secure_item code; locales re-derived via script on main's current files; autogenerated permission files regenerated via `cargo check -p tauri-plugin-native-bridge`.
**Frontend** (reuses the existing hardware page-turner binding machinery — see [[keyboard-selection-adjust-4728]] / `src/utils/keybinding.ts`):
- `keybinding.ts`: `'refresh'` added to `PageTurnAction` + `PAGE_TURN_ACTIONS` (so `resolvePageTurn` matches it). `matchesBinding` now accepts `undefined`.
- `types/settings.ts`: `HardwarePageTurnerSettings.bindings.refresh?: KeyBinding | null` — OPTIONAL (older persisted settings lack it; never migrate, optional-chaining handles absence). Default `refresh: null` in `constants.ts`.
- `PageTurnerSettings.tsx`: refresh slot rendered ONLY when `appService?.isAndroidApp && viewSettings.isEink` (the user-facing Eink-mode view setting, not just hardware detection).
- `usePagination.ts` `handleHardwarePageTurn`: branch `if (action === 'refresh') { if (appService?.isAndroidApp) refreshEinkScreen().catch(()=>{}); return true; }` BEFORE the page/section side/mode logic. Also added `bindings.refresh?.source === 'native'` to `hasNativeBinding` + the effect dep array so a media key bound to refresh still acquires page-turner key interception.
- `bridge.ts`: `refreshEinkScreen()``invoke('plugin:native-bridge|refresh_eink_screen')`.
**Native generic refresh** (`EinkRefreshController.kt`, new) — the answer to "compatible with most e-ink devices, generic interface not brand SDK". Android has NO public e-ink API; each vendor patches `android.view.View`. Probe via reflection, stop at first success (patterns from KOReader android-luajit-launcher EPD controllers):
1. **Onyx BOOX (Qualcomm)**: `View.refreshScreen(0,0,w,h, 34)` instance method. `34 = FULL(32)+GC16(2)`.
2. **Tolino/Nook (NTX/Freescale)**: `View.postInvalidateDelayed(0L,0,0,w,h, 34)`.
3. **Rockchip (Boyue clones)**: `View.requestEpdMode(View$EINK_MODE.EPD_FULL, true)`.
Deliberately do NOT bundle the Onyx SDK (`com.onyx.android.sdk.*` classes aren't on-device unless bundled — reflection would always fail) and do NOT call Onyx `setWaveformAndScheme`/None (KOReader does, but it owns the update loop; Readest leaves system auto-update in place, so switching to manual mode could FREEZE later updates). Run on UI thread against `activity.window.decorView`; `success:false` (no controller) is a soft no-op, not an error. iOS Swift stub resolves `{success:false}`.
**Plugin wiring** added across `models.rs`/`commands.rs`/`mobile.rs`/`desktop.rs`/`lib.rs` + `build.rs` COMMANDS + `permissions/default.toml` `allow-refresh-eink-screen` (build regenerates `reference.md`/`schema.json`/`commands/refresh_eink_screen.toml`). App uses `native-bridge:default` so no capability edit needed.
**Verified on real hardware**: ONYX BOOX Leaf5 (`ro.product.manufacturer=ONYX`). `pnpm dev-android` build+install; via adb+CDP invoked `plugin:native-bridge|refresh_eink_screen` directly in the WebView → `{success:true}`, logcat `EinkRefresh: onyx full refresh requested` (the Onyx/Qualcomm `View.refreshScreen` path, decor view), and the user visually confirmed 5/5 full GC16 screen flashes in the reader. So the onyx path works on modern BOOX without SDK bundling or `setWaveformAndScheme` priming. (CDP socket is pid-bound `webview_devtools_remote_<pid>`; re-forward when the WebView process recycles — see [[cdp-android-webview-profiling]].)
**i18n**: ran into [[i18n-extract-prunes-keys]] (scanner `removeUnusedKeys:true` deleted ~314 dynamic keys / huge churn). REVERTED the scanner output and added the single `"Refresh Page"` key MANUALLY to all 33 non-en locales (en is key-as-content, needs no entry), aligning each translation with the locale's existing `"Reload Page"`/`"Next Page"` terminology. `check:translations` green.
@@ -0,0 +1,46 @@
---
name: empty-highlight-leak-on-annotate-cancel-4791
description: Annotate eagerly creates a highlight placeholder; cancelling the note must tear it down
metadata:
node_type: memory
type: project
originSessionId: 1c75c865-8e1b-4641-ac20-81692d3ff20b
---
#4791 — clicking **Annotate** on a selection eagerly creates a highlight (`note:''`)
as the note anchor (`handleAnnotate``handleHighlight(true)` in `Annotator.tsx`),
so the selection stays visible while the NoteEditor is open. Cancelling the note
(Cancel button, overlay, Escape, switching books, closing the notebook) left that
empty highlight leaked into config → showed as a stale card in the left-sidebar
Booknotes list + a phantom yellow highlight.
**Fix:**
- `handleHighlight` now returns the created `BookNote` only when it pushes a NEW
record (returns `null` when it restyles/toggles an EXISTING one — that record
predates the flow and must survive a cancel).
- `handleAnnotate` stores `created?.id` via `setNotebookNewHighlightId` (new
`notebookStore` field). This tracked id is what distinguishes a removable
placeholder from a pre-existing highlight; do NOT identify it by cfi (a fresh
selection can collide with an existing highlight's cfi).
- `removeEmptyAnnotationPlaceholder(booknotes, id, now)` in `annotatorUtil.ts`
tombstones (`deletedAt`) the live annotation with that id ONLY if it still has
no note text, and returns it so the caller tears the overlay down with
`removeBookNoteOverlays` across ALL views (`getViewsById`, symmetric with how
`handleHighlight` drew it).
- Cleanup is **presentation-driven**, not threaded through every cancel path:
`Notebook.tsx` runs `handleCancelNewAnnotation` from an effect whenever the
creation editor stops being presented (`!(isNotebookVisible && notebookNewAnnotation)`)
— catches Cancel/Escape/overlay/close/swipe/navigate — plus a second effect's
cleanup on `sideBarBookKey` change / unmount for book-switch (pinned) and
reader-close.
- Save survives the guard (placeholder gains note text) and also clears the
tracked id. `handleCancelNewAnnotation` has stable identity (empty deps) so the
effects don't re-fire mid-edit; it reads settings fresh via
`useSettingsStore.getState().settings` (stale-closure guard, see [[webdav-connect-nullified-4780]]).
**Why id-set-LAST in handleAnnotate matters:** `setNotebookNewHighlightId` is
called after `setNotebookVisible(true)` + `setNotebookNewAnnotation`, so no
intermediate render has (editing=false AND a fresh placeholder id) — prevents the
presentation effect from deleting the placeholder it just created.
Related: [[instant-highlight-delete-orphan-4773]], [[customize-toolbar-global-serializeconfig]].
@@ -0,0 +1,18 @@
---
name: fastlane-apple-appstore-submission
description: "fastlane lanes for iOS/macOS App Store + TestFlight submission, and two gotchas (Tauri notarization trigger, fastlane cwd)"
metadata:
node_type: memory
type: project
originSessionId: 6604c57a-dee4-4a6e-8624-540162f41a80
---
Readest's Apple App Store + TestFlight submission via fastlane (root `fastlane/Fastfile`, alongside the existing Android `upload_to_play_store` lanes). Builds are unchanged (`pnpm run release-ios-appstore` / `release-macos-universial-appstore``tauri build` + `xcrun altool --upload-app`); fastlane only does the post-upload App Store version + review submission and TestFlight distribution on the already-uploaded build.
Lanes (per-platform, each does App Store review submit AND TestFlight, sharing a `submit_apple_build` helper): `release_ios`, `release_macos`. App Store via `upload_to_app_store(skip_binary_upload: true, ipa:/pkg:, platform: "ios"/"osx", submit_for_review: true, automatic_release: true, force: true, skip_screenshots: true, skip_metadata: false, release_notes:{"en-US"=>...}, promotional_text:{"en-US"=>...})`; TestFlight via `upload_to_testflight(distribute_only: true, app_platform: "ios"/"osx", distribute_external: true, groups:["Beta Testers"])`. App Store submit runs FIRST (it waits for build processing, which the TestFlight distribute then needs). `release_notes_text` parses `apps/readest-app/release-notes.json` (latest version by `Gem::Version`, drops notes matching `/\b(?:Android|Windows|Linux)\b/i`, prefixes each ` `). Auth: `app_store_connect_api_key`. Commands: `pnpm run submit-appstore-ios` / `submit-appstore-macos`.
GOTCHA 1 (Tauri notarization): `tauri build` auto-notarizes the macOS App Store bundle whenever the FULL App Store Connect API key trio (`APPLE_API_KEY` + `APPLE_API_ISSUER` + `APPLE_API_KEY_PATH`) is in the build env. Notarization REJECTS App Store builds ("not signed with a valid Developer ID certificate" / "no secure timestamp") because they use an Apple Distribution cert — App Store apps are NOT notarized. So `APPLE_API_KEY_PATH` must stay OUT of `.env.apple-appstore.local` (the macOS build env). `asc_api_key` instead DERIVES the `.p8` path from the key id: `repo_path("apps/readest-app/private_keys/AuthKey_#{key_id}.p8")` (the keys are named `AuthKey_<KEYID>.p8`, same convention altool uses; honors an explicit `APPLE_API_KEY_PATH` when set, e.g. the iOS build env which DOES need it and iOS doesn't notarize).
GOTCHA 2 (fastlane cwd): fastlane changes cwd to the `./fastlane` folder when EXECUTING a lane (`__dir__` is just "."), so raw `File.read("./apps/...")` breaks with "No such file". `fastlane lanes` only PARSES (doesn't run lane bodies) so it won't catch this — verify path-dependent lanes by actually RUNNING one. Fix = `repo_path(rel) = File.expand_path(rel, File.expand_path("..", __dir__))`, route every path (release-notes.json, .p8, ipa, pkg) through it.
GOTCHA 3 (dotenv shadowing): bare `dotenv` on PATH is the Ruby gem (`-f` syntax); package.json scripts use the npm `dotenv-cli` (`-e` syntax) resolved from `apps/readest-app/node_modules/.bin`. The submit scripts run `dotenv -e .env.apple-appstore.local -- bash -c 'cd ../.. && fastlane release_*'` — the `cd ../..` is required because fastlane does NOT search upward for the `fastlane/` dir (pnpm runs scripts from `apps/readest-app`).
@@ -0,0 +1,40 @@
---
name: fixed-layout-paginated-scroll-reset-4683
description: "Fit-width tall fixed-layout page opens scrolled-to-end on WebKit page turn (#4683); Blink unaffected; fix = explicit scrollTop=0 on page-turn render"
metadata:
node_type: memory
type: project
originSessionId: 780a4235-5498-42c8-8286-7021c6fcf1ed
---
#4683: in paginated fixed-layout (PDF / fixed-layout EPUB) **fit-width** mode, when a
page is scaled taller than the viewport (`isOverflowY` true, host gets a vertical
scrollbar), turning to the next page opened the new page **scrolled to the bottom**
instead of the top. Root cause: `FixedLayout` host (`:host{overflow:auto;align-items:center}`
in `packages/foliate-js/fixed-layout.js`) scrolls vertically; `#render`'s `transform`
re-centered `container.scrollLeft` on every render but **never reset `container.scrollTop`**.
On a page turn the freshly-shown page inherited the previous page's offset (≈ bottom, since
the reader scrolled down to finish, and same-size pages share maxScrollTop).
**Engine-specific — WebKit only.** WebKit (Linux WebKitGTK, iOS, macOS WKWebView)
*preserves* a scroll container's offset when `#showSpread` swaps the flow content
(old frame → `position:absolute;visibility:hidden`, new frame appended). **Blink**
(Android WebView, Chrome, WebView2) *resets* scrollTop to 0 on that swap, so the bug
never manifests there. Reporter was on Ubuntu/WebKitGTK `WebView 605.1.15`.
**Fix:** new exported pure helper `computePaginatedScroll({elementWidth,containerWidth,scrollTop,pageTurn})`
`{scrollLeft:(elementWidth-containerWidth)/2, scrollTop: pageTurn?0:scrollTop}`.
Thread a `pageTurn` flag into `#render(side, pageTurn=false)`; set `true` ONLY at the
3 navigation entry points (`#showSpread`, `#goLeft`, `#goRight`). Plain re-renders
(ResizeObserver, zoom/scale-factor attr, pageColors, goToSpread same-index re-render)
keep `pageTurn=false` so resize/pinch-zoom of a tall page does NOT jar to the top.
Test: `src/__tests__/document/fixed-layout-paginated-scroll.test.ts` (pure-helper pattern,
like [[booknote-view-autoscroll-4352]] sibling fixed-layout helper tests — the custom
element can't be instantiated in jsdom: no ResizeObserver + getBoundingClientRect=0).
**Verification recipe (the bug is NOT Android-reproducible):** CDP on Xiaomi showed
`view.next()` already yields scrollTop 0 on Blink → can't distinguish fix on Android.
Proved on REAL WebKit instead: auto-running HTML mirroring host CSS + `#showSpread` swap,
opened via `open -a Safari file://…`, screenshot. Safari `AppleWebKit/605.1.15` (== reporter)
showed scrollTop 420/440 (bug) without reset, 0 with reset. readest fixed-layout page turn
goes through `view.next()`/`view.prev()` (`usePagination.ts`), the same path.
@@ -0,0 +1,37 @@
---
name: fxl-portrait-autospread-offcenter-4984
description: PDF/FXL auto-spread in portrait rendered the lone page off-center and made taps turn pages
metadata:
node_type: memory
type: project
originSessionId: f24a5890-de13-4767-bb33-97621f332e44
---
Issue #4984: in fixed-layout (PDF) `spread='auto'` + portrait viewport, the page
was shoved into one half of the screen ("weirdly separate") and almost every tap
turned the page instead of opening the menu.
Root cause (verified in Chrome): `FixedLayout.#render` in
`packages/foliate-js/fixed-layout.js` already hides the non-target page in
portrait (`if (portrait && frame !== target) display:none`) and scales the shown
page as a single page, BUT it kept the spread-centering one-sided inline margin —
left page `marginInlineStart:auto`, right page `marginInlineEnd:auto`. With no
partner page to meet at the spine, that auto margin stranded the lone page in one
half of the viewport whenever it was narrower than the viewport (any zoom < 100%,
e.g. the issue's 50% zoom; or a page whose fit-scaled width < viewport width).
The off-center page then sat over a page-turn tap zone (tap zones are
view-relative: center 0.375-0.625 = menu, else turn — see `usePagination.ts`), so
taps turned the page. Symptom 2 was a consequence of symptom 1.
Fix MERGED (readest PR#4992 + foliate-js PR#50 squash -> foliate main f6dced2, readest submodule bumped to it): added pure `computeSpreadInlineMargins(portrait)`; in portrait
both inline margins are `auto` (centered), in landscape one-sided (pages meet at
spine). It sets BOTH margins explicitly (opposite side cleared to '') because
frames are re-styled in place on rotation (ResizeObserver -> `#render`, no
`#respread`), so a stale `auto` would otherwise linger. NOT fixed by forcing
`spread='none'` in portrait — that duplicates the existing portrait-single-page
path, needs app-layer orientation swapping + `#respread` (cache clear + re-nav),
and overrides the user's chosen setting.
Test: `src/__tests__/document/fixed-layout-portrait-single-page.test.ts`. Related:
[[fxl-spread-spine-seam-4857]] shares this render branch;
[[pdf-text-selection-fontscale-4480]].
@@ -0,0 +1,29 @@
---
name: gdrive-fullwalk-every-sync-no-source-cursor
description: Google Drive file sync re-probes all 646 books every run (focus/Sync Now) because uploadedHashes never records no-source books; plus supabase focus events re-fire pullLibrary
metadata:
node_type: memory
type: project
originSessionId: 894e0d6d-ce01-402b-8f2d-0f0670986a88
---
Diagnosed 2026-07-07 (web dev, valid Drive session). "Uploading N / 646" on every tab refocus and every Sync Now = full per-book Drive probe sweep (`files?q=name='<title>.epub' and '<hashdir>' in parents`, ~1 req/book), no actual byte re-upload.
Two compounding causes:
1. **File cursor never records books absent from this device.** #4856's `uploadedHashes` in library.json is only added on `uploaded` or `remote-matches` (needs local bytes for size compare) in `FileSyncEngine.syncLibrary` push loop (engine.ts ~line 806-815). On web, non-downloaded books → `loadBookFile` null → `no-source` → NOT recorded even though the HEAD probe already proved the remote file exists. So `needsFilePush` stays true for all 646 forever → O(library) every run. Toggle test: Upload Book Files off → 15 reqs (config cursor `isLocalNewer` works); on → 646.
Fix v1 (record remote-present no-source books, commit 900af1df1 on dev) proved INSUFFICIENT: Drive API inspection showed 654/690 hash dirs hold only cover.png+config.json, NO book file (only 36 files ever uploaded) — so there was nothing to record and the probe storm persisted.
Fix v2 (the real fix, on dev 2026-07-07, initially uncommitted): reorder `pushBookFile` to resolve the LOCAL source before any remote probe (`probeRemoteHead` closure, lazy); `no-source` now costs zero requests and the `remoteExists` plumbing from v1 was removed again. Test: 'spends no remote request on a no-source book' in engine-sync-paths.test.ts. The 654 books stay in booksToPush (progress counter still shows them) but the sweep is network-free. Their files land on Drive only when a device that HAS the bytes (desktop) syncs with Upload Book Files on; that device records the hashes and everyone skips thereafter.
Worktree was discarded per user; work continues directly on the bare repo dev branch (dev server localhost:3000 runs from there).
Round 5 (dev, uncommitted): Tauri plugin:fs|exists storm (5726 IPC/sync) killed by making the LIBRARY ROW the ground truth for local file presence in `needsFilePush`: gate `hasLocalFile(b) = !!(b.downloadedAt || b.filePath)` (import/download/delete all stamp downloadedAt; mergeBookMetadata keeps it device-local, verified) + session-scoped `noSourceVerdicts` WeakMap (provider-keyed, updatedAt-keyed) that suppresses re-probes of DRIFTED rows (row claims file, fs disagrees). Per user decision: NO automatic row-correction — Full Sync is the single split-brain healer (bypasses gate + memo + uploadedHashes and audits the real fs). Incremental sync with clean state = zero local and zero remote per-book probes. Harness in engine-sync-paths.test.ts: row-gate test, drifted-row memo tests, fullSync bypass; #4856 fixtures stamped downloadedAt.
Round 4 (dev, uncommitted): per-book cloud buttons (Book Details + bookshelf + open-non-local-book) route to the selected provider instead of the gated Readest Cloud queue ("Uploads to Readest Cloud are paused..." toast). `FileSyncEngine.downloadBookFile` (hash-dir listing resolves filename; stream on Tauri, buffered on web; cover+config best-effort) + `runActiveFileBookUpload/Download` in runLibrarySync.ts (stamps downloadedAt; caller persists via updateBook + toasts, existing transferMessages i18n keys). Reader hint parity same day: `remoteProgressApplied` in useFileSync dispatches 'Reading Progress Synced' hint on applied remote position. NOT done: provider path has no transfer-queue/progress UI; uploadedAt not stamped (means Readest-Cloud backup; provider uploaded-state could later key off index uploadedHashes).
Round 2 optimizations (dev, uncommitted as of 2026-07-07 03:10): provider memoized per connection key in `createFileSyncProvider` (warm Drive idCache across reader hook / library auto-sync / Sync Now; `resetFileSyncProviderCache()` called on Drive connect/disconnect); `writeBinary` PATCHes cached id without files.list lookup (404 evict+fallback); dev-only request diagnostics `[gdrive] op ...` / `[gdrive] #n ...` in GoogleDriveProvider.
Remaining per-run budget after round 2 (no-change run ~11 req, ~550 kB): index GET 269 kB + index PATCH 269 kB every run; books/ listing 40 kB; ~8 file-less orphan hash dirs (in neither index nor library) re-listed by discovery every run.
Round 3 (dev, uncommitted, all TDD in engine-sync-paths.test.ts): (1) etag short-circuit — `remoteIndexCache` WeakMap keyed on the memoized provider in engine.ts; head(library.json) etag (Drive md5/WebDAV ETag) vs cached → reuse structuredClone'd index, skip GET + ENTIRE discovery scan (peer changes always rewrite library.json; legacy no-index uploads still found on session-first run + fullSync); cache dropped after own push. (2) no-op push skip — `indexDirty` check (syncedHashes/failures/uploadedHashes-set/emptyDirs-set/any local row absent-or-newer-or-tombstone-mismatched vs remote index); skipping also keeps peers' etags stable (a restamped copy would defeat fleet-wide change detection). (3) `emptyDirs` optional index field (wire.ts) — file-less candidate dirs recorded once, skipped by discovery unless uploadedHashes says the file arrived or fullSync; pruned only against a listing that ran. Idle run = 1 stat request; local-change run = stat + config pull/push + index PATCH (no GET, no discovery). engine-deletion-sync 'preserves remote tombstone' test updated to force a dirty run.
2. **Every tab focus re-runs the library file sync.** supabase-js emits SIGNED_IN/TOKEN_REFRESHED on visibilitychange; `AuthContext.syncSession` does `setUser(newObject)` each time → `pullLibrary` (deps include `user`) recreated → `useBooksSync` effect `[user, useSyncInited, libraryLoaded, pullLibrary]` refires → `runActiveFileLibrarySync` (third-party provider path). Fix would be: key on `user?.id` / latch the initial pull. User decided 2026-07-07 to LEAVE THIS AS IS ("sync on focus is fine now that runs are O(changed)") — only cause 1 was fixed.
Related: #4981 fixed the adjacent expired-token variant (aborting instead of marching with remoteIndex=null). Web Drive token is sessionStorage-scoped (tab-local, no refresh). See [[cloud-sync-provider-selection-plan]], [[webdav-filesync-refactor-plan]].
@@ -0,0 +1,82 @@
---
name: gdrive-provider-multipr-status
description: "Google Drive file-sync provider — phased multi-PR build status, what shipped in PR1 and what each later PR adds"
metadata:
node_type: memory
type: project
originSessionId: 50e2c2b8-ca61-4c33-acae-cd5d2c9aa93f
---
Adding **Google Drive as a second `FileSyncProvider`** for the merged file-sync engine (the WebDAV refactor, PR #4784). Approved plan: `/Users/chrox/.claude/plans/floating-chasing-feather.md`. Research + reuse map: [[gdrive-sync-provider-research]]. Author of the reference (`ratatabananana-bit/Readest-google-drive-mod-patcher`, AGPL-3.0) granted explicit reuse permission; adapted files carry attribution headers.
**Shipped across multiple PRs (decided at the autoplan gate; no BYO client, official iOS-type client only).**
**PR1 — DONE (built, all gates green, committed locally, NOT pushed).** Branch `feat/gdrive-sync-core` (worktree `/Users/chrox/dev/readest-feat-gdrive-sync-core`), commit `1a0065818`. 25 files / ~2.6k lines, ~81 new unit tests, full suite 6377 passing + lint + format clean. Contents under `src/services/sync/providers/gdrive/`:
- `GoogleDriveProvider.ts` — Drive v3 over `FileSyncProvider`; id-addressed resolution + per-instance id cache; create-then-name upload; real `ensureDir`; `files.list` pagination; Retry-After 429/5xx backoff; per-path folder-creation locks + deterministic dup-collapse (smallest id); stale-id eviction; `mapDriveError` (403 split rate-limit→NETWORK vs permission→AUTH_FAILED). Factory `createGoogleDriveProvider(auth, fetchFn, {sleep?})`; streaming omitted.
- `auth/``pkce`, `parseRedirect` (target + CSRF, takes `expectedRedirectUri`), `reverseDnsRedirect`, `tokenStore` (no client secret), `oauthFlow` (DI).
- `PersistedDriveAuth.ts` — single-flight refresh + re-check, carries old refresh_token, one save; `accountLabel` via `about.get`.
- `driveTokenStore.ts``TokenPersistence` + `KeychainTokenPersistence` over keyed secure-KV; `createDriveTokenPersistence()` returns null off-Tauri (NO ephemeral fallback for refresh token).
- `driveRest.ts` — pure builders + pagination + `aboutUrl`.
- `buildGoogleDriveProvider.ts` (env client id + keychain), `file/providerRegistry.ts` (`createFileSyncProvider`/`getEnabledFileSyncBackends`).
- Shared `file/providerSemanticContract.ts` test helper run for BOTH WebDAV + Drive.
- `utils/bridge.ts` — TS wrappers `set/get/clearSecureItem` (`plugin:native-bridge|*_secure_item`).
**DEVIATION from plan:** the native keyed secure-KV implementation (Rust desktop/mobile + Kotlin + Swift + permissions) was DEFERRED out of PR1 — nothing in PR1 calls it (no UI/sync wiring), and 4 languages of un-runnable native code don't belong in a "CI-testable, no-platform" PR. The TS contract exists + is mock-tested. Native impl lands with **PR3 (desktop OAuth)**, which first exercises it and can live-verify.
**PR2 — DONE (foundation only; committed `9ba097ea2`, UNPUSHED, on same `feat/gdrive-sync-core` branch).** Full suite 6403 passing + lint + format clean.
- `GoogleDriveSettings` type (mirrors WebDAVSettings minus URL/creds/rootPath, +`accountLabel`) in `types/settings.ts` + `SystemSettings.googleDrive`; `DEFAULT_GOOGLE_DRIVE_SETTINGS` in `constants.ts`.
- `googleDrive.deviceId`/`lastSyncedAt` added to `BACKUP_SETTINGS_BLACKLIST` (backupService.ts) + backup-settings test.
- `webdavSyncStore``store/fileSyncStore.ts`: per-backend progress keyed by kind + GLOBAL library-sync mutex (`beginSync(kind,label)` returns false if another holds lock). Migrated `WebDAVForm` + `IntegrationsPanel`; WebDAV behavior unchanged. `fileSyncStore.test.ts`.
- **DEFERRED to PR3 (deliberate):** `useWebDAVSync``useFileSync` hook generalization + `WebDAVForm``FileSyncForm` extraction + visible Drive Integrations row/connect UI. Rationale: until Drive connects (needs OAuth), the multi-provider hook paths can't run and `FileSyncForm` would be a single-use abstraction (violates YAGNI); also the autoplan gates these on a live WebDAV Sync-now check. Do them WITH PR3.
**PR3 — IN PROGRESS (3 commits, all gates green: full suite 6411 passing + rust fmt/clippy/test + lint/format). UNPUSHED on `feat/gdrive-sync-core`.**
- `ff1ffe717` native keyed secure-KV: `set/get/clear_secure_item` across Rust desktop (keyring keyed by item key) + mobile forward + models/commands/lib/build/default.toml + Kotlin (EncryptedSharedPreferences `readest_secure_items_v1`) + Swift (Keychain, service `com.bilingify.readest.secure-items`). Rust compiles+clippy+fmt clean; permission files regenerated (passphrase preserved).
- `602f41406` desktop OAuth machinery: `auth/oauthDesktop.ts` (`runDesktopDeepLinkOAuth`, DI, 3 tests) + `src-tauri/src/spawn_fresh_browser.rs` (registry default-browser cold-spawn on Windows / no-op macOS+Linux; winreg Windows-only dep; pure-helper tests; registered `#[cfg(desktop)]`) + `connectGoogleDrive.ts` (`connectGoogleDrive`/`disconnectGoogleDrive`, fail-loud token save, 4 tests). `DRIVE_FILE_SCOPE='https://www.googleapis.com/auth/drive.file'`.
- `5efbe6b2f` ingress filter: `isGoogleOAuthRedirectUrl` (scheme-prefix match) + filter in `useAppUrlIngress` dispatch so the reverse-DNS redirect never reaches book-import consumers (OAuth runner catches via own listeners). Tested.
**Official client id PROVISIONED:** `209390247301-ctpmep68ppfa56r1b8tr35e4qi4p60kq.apps.googleusercontent.com` (iOS type, no secret, `drive.file`). Baked as default in `getGoogleClientId` (env `NEXT_PUBLIC_GOOGLE_CLIENT_ID` overrides); reverse-DNS scheme `com.googleusercontent.apps.209390247301-ctpmep68ppfa56r1b8tr35e4qi4p60kq` registered in `tauri.conf.json` desktop+mobile deep-link. Commit `7a2ac3671`.
**Drive UI DONE (commit `c657c34f0`):** `FileSyncForm` (shared sync controls extracted from WebDAVForm, parameterized by kind, builds provider via registry; WebDAVForm refactored to use it, behavior unchanged) + `GoogleDriveForm` (OAuth Connect/account/Disconnect + FileSyncForm) + `googleDriveConnect.ts` (assembles env client id + keychain + desktop runner) + IntegrationsPanel "Google Drive" row gated on `appService.isDesktopApp`. Full suite 6412 green.
**Cloud Sync redesign DONE (commit `1a31a8cbd`):** new "Third-party Cloud Sync" Integrations section with a unified "Cloud Sync" sub-page (`CloudSyncForm`) — WebDAV + Google Drive MUTUALLY EXCLUSIVE via `withActiveCloudProvider` (enabling one disables the other). Radio picker (AIPanel pattern) + shared `FileSyncForm`. WebDAVForm/GoogleDriveForm refactored to embeddable panels; Drive has a "configured-but-inactive" state (`accountLabel` present, `enabled=false`) with frictionless "Use Google Drive" re-activate (no re-OAuth); explicit Disconnect clears the keychain token. Temp concurrency probe removed (upload was already concurrency-4, confirmed).
**Reader auto-sync DONE (commit `f5e07e50b`):** `useWebDAVSync``useFileSync` — the reader auto-syncs the single ACTIVE provider per-book while reading (pull-on-open, debounced push, cover/file). Async engine build (Drive keychain probe) held in state, pull-on-open waits for it; engine keyed on connection-relevant settings (not lastSyncedAt) to avoid re-probing keychain; deviceId/lastSyncedAt write the active provider slice; events renamed `*-file-sync`. WebDAV reader behavior unchanged.
**Drive feature is functionally complete on desktop:** connect, manual Sync now, auto-sync while reading, exclusive provider switching. Live-verified: connected + synced a 675-book library.
**DESKTOP PR OPENED: readest/readest#4821** (`feat/gdrive-sync-core`, rebased onto origin/main, all gates green incl. rust). Covers provider + OAuth + native KV + redesign (exclusive Third-party Cloud Sync section, inline radio switch) + reader auto-sync + **premium gating** (any paid plan via `isCloudSyncInPlan`; free sees upgrade CTA; reader auto-sync off for free). Rebase needed `git -c protocol.file.allow=always submodule update --init packages/foliate-js` (foliate-js drift, index wanted `6f1a190`).
**PR #4821 review fix (pushed `5769682c5`):** CodeQL flagged `escapeDriveLiteral` (driveRest.ts) for not escaping backslashes — fixed (escape `\``\\` FIRST, then `'``\'`). Was the only review comment.
**Both branches REBASED onto origin/main `324bb8a36` (was `7e78f80e1`). UNPUSHED, both gates green (lint+format+full suite: mobile 6483, resumable 6486). foliate-js submodule drift on rebase: origin/main now wants `0fa407c4c` (not in local submodule clone whose origin is the main checkout's modules dir); fix `git -C packages/foliate-js fetch https://github.com/readest/foliate-js.git 0fa407c4c... && git -C packages/foliate-js checkout 0fa407c4c...` (the `submodule update --init` shortcut FAILS here — local origin lacks the commit; must fetch from GitHub URL). Current commits: mobile `6728c94f0`(Android)+`8b3dd1cd5`(iOS); resumable `f7a1e5117`.**
**Branch `feat/gdrive-mobile-oauth` (Android+iOS OAuth) — no longer stacked, off main. PR not opened yet.**
**Android OAuth (PR4) DONE (commit `eb8e22081`, was `5583c9b38` pre-rebase).** `auth/oauthAndroid.ts` (`runAndroidOAuth` via existing `authWithCustomTab`, DI, 2 tests) + platform dispatch in `googleDriveConnect` (`osType()==='android'`→Custom Tab, else desktop) + Drive row shown on Android. NATIVE (device-verify pending, no Android toolchain in CI): `NativeBridgePlugin.kt` `handleIntent` resolves `com.googleusercontent.apps.<id>:/oauthredirect` via the same `pendingInvoke` as the Supabase callback; matching BROWSABLE intent-filter added to `gen/android/.../AndroidManifest.xml`.
**iOS OAuth (PR5) DONE (commit `1230fb291`).** `auth/oauthIos.ts` (`runIosOAuth` via `authWithSafari({authUrl, callbackScheme})`; callbackScheme = `deriveReverseDnsRedirectScheme(clientId)` = bare `com.googleusercontent.apps.<id>` — ASWebAuthenticationSession matches on SCHEME not path; DI, 2 tests) + `AuthRequest.callbackScheme?` (nativeAuth.ts; Supabase keeps native `readest` default) + `resolveOAuthRunner` `os==='ios'`→runIos + Drive row on iOS (`isDesktopApp||isAndroidApp||isIOSApp`). `createDriveTokenPersistence` already works on iOS (Keychain via secure-KV). NATIVE (device-verify pending, no iOS toolchain in CI): Swift `auth_with_safari` uses `args.callbackScheme ?? "readest"` (`SafariAuthRequestArgs.callbackScheme: String?`); `Info-ios.plist` CFBundleURLTypes gains the reverse-DNS scheme. macOS Drive uses the desktop deep-link runner (NOT authWithSafari), so no macOS native change. Full suite 6477 green + lint + format + plutil OK.
**Drive streaming upload/download DONE — own branch `feat/gdrive-resumable-upload` off origin/main (commit `0c9cc1a22`, UNPUSHED).** `uploadStream`+`downloadStream` on GoogleDriveProvider so book files stream from/to disk instead of buffering the whole file in the JS heap (buffered marshal of a large book across the WebView↔Rust bridge crashes the renderer on mobile — this unlocks Drive book sync on Android/iOS and flattens heap on desktop too). `driveRest.resumableCreateUrl`/`resumableUpdateUrl`; `uploadStream` opens a Drive resumable session (POST new `{name,parents}` / PATCH existing `{name}`, metadata in initiation so NO reparent follow-up), reads `Location` session URI, PUTs bytes via `tauriUpload`; `downloadStream` GETs `alt=media` to disk via `tauriDownload` + bearer. Attached **Tauri-only** (`isTauriAppPlatform()`); web keeps buffered fallback. Both swallow→`false` per provider contract (engine retries once). REUSES `@tauri-apps/plugin-upload` already shipped for WebDAV — NO new native code. Single-shot streaming PUT (not chunked mid-stream resume) — sufficient for the heap/OOM fix; chunked-resume-on-failure is a further enhancement. Full suite 6484 green + lint + format. **NOTE: changes desktop Drive book sync from buffered → streaming (previously live-verified buffered); device-verify the streaming path on desktop + mobile.**
**ALL PRs MERGED to main/dev (dev @ `c6f2a83d9`).** Worktree `feat/gdrive-*` branches no longer needed; work continues in the MAIN repo `/Users/chrox/dev/readest` on `dev` (tracks `origin/main`; there is NO `origin/dev`).
- **#4821** desktop Drive cloud sync + premium Third-party Cloud Sync section.
- **#4824** Drive resumable streaming upload/download.
- **#4823** mobile OAuth (Android Custom Tab + iOS ASWebAuthenticationSession).
- **#4827** Android sync fix: retry THROWN transport errors in `withBackoff` (was 429/5xx only); `mapDriveError` classifies transport throws (incl. Tauri plugin's plain `error sending request` Error) as NETWORK. Root cause: Android pooled keep-alive connection to googleapis.com goes stale mid-sync → every files.list after the first batch threw; sync recovered on its own after ~3-4 min (reqwest evicting dead conns). The retry forces a fresh connection so recovery is fast + kills the error spam.
**CODE COMPLETE + MERGED.** REMAINING (human/ops-only): (1) on-device re-verify with #4827 in the build — Android sync should no longer stall ~3-4 min / spam `failed to inspect hash dir`; iOS OAuth sign-in; desktop streaming book-sync re-check; (2) Google consent screen → Production (testing caps 100 users). NOTE: Android build auto-generates a deep-link intent-filter for the gdrive reverse-DNS scheme in `gen/android/.../AndroidManifest.xml` (duplicates the manual `gdrive-oauth` filter) — benign build drift, don't commit.
**Google Drive on WEB via FULL-PAGE REDIRECT OAuth — DONE on branch `feat/gdrive-web-oauth` (was `feat/gdrive-web-gis`; local/unpushed; suite 6516 green).**
- **GIS popup ABANDONED:** `src/middleware.ts:55` sets `Cross-Origin-Opener-Policy: same-origin` on every web doc (Turso WASM/SharedArrayBuffer needs `crossOriginIsolated`). COOP same-origin SEVERS a cross-origin popup's opener handle → GIS's `popup.closed` poll reads true instantly → `popup_closed` fires while the popup is still open (diagnosed live). Can't relax COOP (breaks Turso); can't scope it (connect happens over Turso routes). So no popup OAuth on web.
- **Web flow:** full-page redirect (no `window.opener`, works under COOP). `auth/webRedirectFlow.ts` (implicit `response_type=token` — secretless Web client can't code-exchange; CSRF state+returnPath in sessionStorage; parse token from callback fragment) + `auth/webTokenStore.ts` (sessionStorage access token, no refresh token) + `WebDriveAuth.ts` (reads stored token, expired→AUTH_FAILED, `accountLabel` via about.get) + `app/gdrive-callback/page.tsx` (validates state, stores token, `withActiveCloudProvider(settings,'gdrive')`+label via `appService.load/saveSettings`, routes back). `buildGoogleDriveProvider` web branch: `new WebDriveAuth(globalThis.fetch)` (Drive REST CORS-ok; streaming Tauri-only→web buffered). `googleDriveConnect` web: Connect=`beginWebDriveRedirect` (navigates away, never resolves), Disconnect=`clearWebDriveToken`.
- **Official Web client id BAKED** `209390247301-585tc3dohg4c02588uvah5d32hg6dneq` (`getGoogleWebClientId`, env `NEXT_PUBLIC_GOOGLE_WEB_CLIENT_ID` overrides). **NO auto-refresh** (secretless browser client → no refresh token; Google blocks hidden-iframe silent renewal) → user reconnects per session; true auto-refresh needs a server-side token broker (Worker holds secret+refresh token) — deferred ("A for now").
- **OPS REMAINING:** add `https://web.readest.com/gdrive-callback` + `http://localhost:3000/gdrive-callback` to the Web client's **Authorized redirect URIs** (JS origins already set). Then live-verify `pnpm dev-web`.
**PR3 REMAINING:**
- **LIVE VERIFICATION (needs the user — real Google sign-in):** `pnpm tauri dev` → add own Google account as a Test user in the consent screen (Testing mode caps + gates) → Settings → Integrations → Google Drive → Connect → browser → grant → "Connected as <email>" → add book / Sync now → confirm `Readest/books/<hash>/{config.json,cover.png}` in Drive. Windows cold-browser fallback.
- **Reader-hook auto-sync (deferred):** generalize `useWebDAVSync``useFileSync` (per-provider state maps, async Drive provider build in the hook) so Drive auto-syncs per-book while reading like WebDAV. Manual Sync-now already works without it; do after live-verifying the base.
- Consent screen → Production before GA (testing caps 100 users).
- PR4 Android OAuth (Custom Tab + manifest scheme), PR5 iOS OAuth (authWithSafari scheme param + Info-ios.plist). Later: Drive resumable upload for `syncBooks` on mobile.
- Ops/launch blocker: create Google Cloud project (iOS client, `drive.file`) + consent screen to production (testing caps 100 users).
- PR4 Android OAuth, PR5 iOS OAuth. Later: Drive resumable upload to unlock `syncBooks` on mobile.
- Ops/launch blocker: create the Google Cloud project (iOS client, `drive.file`) + set consent screen to production (testing caps 100 users).
@@ -0,0 +1,42 @@
---
name: gdrive-sync-provider-research
description: Research on the ratatabananana-bit Google Drive mod for building a Drive FileSyncProvider; OAuth approach + reuse map
metadata:
node_type: memory
type: reference
originSessionId: 50e2c2b8-ca61-4c33-acae-cd5d2c9aa93f
---
NEXT TASK (research done, not yet built): add **Google Drive as a `FileSyncProvider`** for the merged file-sync engine ([[webdav-filesync-refactor-plan]] / PR #4784). Researched reference: `github.com/ratatabananana-bit/Readest-google-drive-mod-patcher` (AGPL-3.0, same as Readest → can adapt WITH attribution). Reference patch saved at `~/.../scratchpad/gdrive-ref/` (extracted modules under `extracted/`).
**The repo is a PATCHER**, not a fork: the whole impl is one squashed diff `tooling/mod/mod.patch` (13k lines) against Readest v0.11.12. Design/plan docs live in a SIBLING repo `readest-gdrive-sync-mod` (referenced in MOD.md, likely private — not in the patcher).
**Their architecture = REPLACE Readest's native cloud sync with Drive** (library/progress/notes/stats). Two layers:
- `src/services/cloudprovider/` — REUSABLE: a backend-agnostic provider seam + OAuth. `CloudProvider.ts` (their interface), `GoogleDriveProvider.ts` (Drive v3 REST impl), `FakeCloudProvider.ts`, `buildDriveProvider.ts` (assembly), `googleAuth/*` (the OAuth layer).
- `src/services/drivesync/` — SKIP for us: their integration with the native-sync data model (driveMerge, statsMerge, DriveSyncClient, DriveBlobStore, jsonl, layout). We REPLACE this with our `FileSyncEngine`.
**KEY: their `CloudProvider` is ~1:1 with our `FileSyncProvider`.** Map: getText↔readText, getBinary↔readBinary, putText/putBinary↔writeText/writeBinary, list↔list, stat↔head, deleteFile↔deleteDir. Their `CloudEntry` even carries `md5` (Drive checksum) — stronger than our size-only HEAD short-circuit. Extra on theirs: `isAuthenticated()`/`accountLabel()` (auth state) + `putBinary` `onProgress`. Missing on theirs: `ensureDir` (Drive auto-creates folders on write).
**Recommended fit for US = Drive as a parallel `FileSyncProvider`** (like WebDAV), NOT replacing native sync. Reuses the whole engine (incremental/concurrency/merge). Build = (1) `createGoogleDriveProvider(settings): FileSyncProvider` adapting their `GoogleDriveProvider` (rename methods, map CloudEntry→FileEntry, head from stat, deleteDir from delete-folder-by-id, ensureDir = no-op since write auto-creates, rootPath='/'), (2) reuse `googleAuth/*` OAuth nearly as-is, (3) token persistence (the ONE big gap — see below), (4) settings UI + provider registry.
**Drive specifics (vs WebDAV path-addressing):**
- **Drive is ID-addressed, not path-addressed.** Resolve a logical path (`Readest/books/<hash>/config.json`) segment-by-segment via `files.list` (name+parent queries), cache folder/file ids in a `Map<path,id>`. `driveRest.ts` = pure query/URL builders; `GoogleDriveProvider` owns resolver+cache.
- **`drive.file` scope** = app sees only files it created → Drive root is a safe private namespace (no appdata hidden folder; a visible "Readest" folder). Non-sensitive scope = no Google verification needed (unverified-app warning shows once).
- **Upload = create-then-name:** `uploadType=media` carries no metadata, so POST bytes to root → PATCH name + reparent (addParents=folder, removeParents=root). Overwrite = media PATCH on the existing id (preserves id/links).
- Endpoints: metadata `drive/v3/files`, media `upload/drive/v3/files?uploadType=media`. Folder MIME `application/vnd.google-apps.folder`.
**OAuth (the hard part — every gotcha you flagged is CONFIRMED + implemented):**
- **One iOS-type Google client** (Bundle ID only, NO secret, NO SHA-1, App Check OFF) for BOTH Windows + Android. Redirect = reverse-DNS `com.googleusercontent.apps.<id>:/oauthredirect` (SINGLE slash) + PKCE. Client id derives the scheme (`reverseDnsRedirect.ts`). Client id is committed (not a secret). App Check must stay OFF (Android can't produce iOS attestation → would break everyone).
- Loopback dead for iOS clients (Google blocked 2022); embedded WebView blocked (`disallowed_useragent`). Reverse-DNS is the only no-SHA native redirect Google accepts.
- `oauthFlow.ts` — provider-agnostic orchestration, platform mechanics injected (DI, headless-testable). Arms `awaitRedirect` BEFORE `openUrl` (race fix). PKCE + `state` CSRF via `parseRedirect.ts`.
- **Android** (`oauthAndroid.ts`): Chrome Custom Tab via Readest's EXISTING native bridge `authWithCustomTab` (same as Supabase login) — NOT external browser (keeps Tauri Activity foregrounded so in-flight auth survives memory pressure; redirect resolves via a native Kotlin field that survives WebView reload). Register the client scheme as a BROWSABLE intent-filter (patcher injects into `tauri.conf deep-link.mobile`). MUST filter the OAuth redirect out of Readest's deep-link ingress (`useAppUrlIngress` via `matchesReverseDnsRedirect`) or it triggers a /library reload that kills the flow. `tauri android init` wipes the manifest → restore MANAGE_EXTERNAL_STORAGE etc.
- **Windows/desktop** (`oauthDesktopDeepLink.ts` + `spawn_fresh_browser.rs`): system browser + self-registered scheme (`deep_link().register_all()`, no installer/admin). Capture via `single-instance` (url=args[1]) + `onOpenUrl`. THE WINDOWS SUBTLETY: a browser process snapshots protocol associations at launch, so a browser already running before scheme-registration silently drops the redirect. Fix: open default browser first; if no redirect in `DEFAULT_FALLBACK_DELAY_MS=25_000`, re-open in a freshly-spawned COLD browser (`spawn_fresh_browser` Rust cmd: resolve default browser from registry UserChoice → if Chromium-family spawn with `--user-data-dir=<isolated>` → else fall back to Edge). Hard deadline `CONNECT_DEADLINE_MS=15min` rejects an abandoned sign-in. Whichever browser returns first wins.
- `tokenStore.ts` = PKCE token exchange + `refreshAccessToken` (Google omits refresh_token on refresh → keep the old one). `pkce.ts` = PKCE pair + `buildAuthUrl`.
**GAPS / NOT in the reference (we'd build):**
1. **Token persistence is a stubbed interface** (`TokenPersistence` load/save/clear) — they explicitly left the secret store (Tauri secure storage / Android Keystore) as a later task. WE implement it.
2. **No resumable/streaming upload** — simple `uploadType=media` buffers the whole file in JS heap (same OOM risk our WebDAV `uploadStream` avoids). For large book files we'd add Drive resumable upload (`uploadType=resumable`); configs/covers are fine buffered. Our engine's streaming is optional (falls back to buffered).
3. **accountLabel is a placeholder** ('Google Drive'); real email needs a userinfo call.
4. **iOS/macOS** not covered (Windows + Android only).
**License call:** AGPL→AGPL is compatible. **The author (ratatabananana-bit) granted EXPLICIT permission** (2026-06): "feel free to do whatever you want with the code (it's the AGPL fork - Drive sync + the recently-read shelf)." So we can copy-adapt freely; keep attribution/credit. The OAuth platform glue is the high-value, hard-to-reproduce part → adapt with credit. Note the author also mentions a "recently-read shelf" feature in the same fork (separate, potential bonus).
@@ -0,0 +1,26 @@
---
name: grimmory-native-sync
description: Grimmory (Booklore fork) sync API surface + CORS analysis for adding native grimmory sync to Readest
metadata:
node_type: memory
type: project
originSessionId: ef2f9371-2968-4f81-abe4-a9349547542b
---
Goal: add **native grimmory sync** to Readest (vs the current OPDS+KOReader-compat detour, which causes 3-way KOReader↔Kobo↔grimmory desync — see discussion grimmory-tools/discussions/1417). Grimmory repo at `/Users/chrox/dev/grimmory` (Java/Spring backend, package `org.booklore`).
**Native API (use this, not KOSync):** JWT bearer. `POST /api/v1/auth/login` `{username,password}``{accessToken,refreshToken,expires}` (2h/30d); `POST /api/v1/auth/refresh`. Progress: `POST /api/v1/books/progress` `{bookId, fileProgress: BookFileProgress{bookFileId, progressPercent 0-100, positionData (CFI for EPUB), positionHref, ttsPositionCfi}, dateFinished}`; `GET /api/v1/books` to list — **the Book DTO does NOT expose the file hash** (no native hash lookup endpoint; match by metadata, see below). Annotations `/api/v1/annotations/**`, bookmarks `/api/v1/bookmarks/**`, download `/api/v1/books/{id}/download` (Range OK), cover `/api/v1/media/{id}/cover`. KOReader-compat path exists at `/api/koreader/**` (X-Auth-User + X-Auth-Key=md5(pw)) but is the thing we're replacing.
**CORS (`SecurityConfig.java`): per-filter-chain only — NO global CorsFilter/addCorsMappings.** Policy (`:340-368`): origins default `*` (env `ALLOWED_ORIGINS`, uses `setAllowedOriginPatterns` so `*`+credentials valid); methods all; **allowed-headers is a FIXED whitelist** = `Authorization, Cache-Control, Content-Type, Range, If-None-Match, If-Modified-Since` (NOT `*`, and **excludes X-Auth-User/X-Auth-Key**); allowCredentials true. Chains WITH `.cors()`: jwtApi (order10: `/api/**` minus whitelist → books/progress/annotations/bookmarks/reading-sessions/koreader-users), bookDownload(8), epub/audiobook/custom-font/ws(5-9). Chains WITHOUT `.cors()`: opds(1), komga(2), **koreader(3)**, kobo(3), **media/cover(4)**, **catch-all static(11)**. CRITICAL GAP: `/api/v1/auth/login` + `/auth/refresh` are whitelisted OUT of order10's matcher (`:265-289`) so they hit order11 catch-all = **no CORS** → cross-origin browser login fails (invisible to grimmory's own SPA, served same-origin from `classpath:/static/`).
**What it means for Readest:** Tauri desktop/mobile = CORS irrelevant (`@tauri-apps/plugin-http` is native, all endpoints work incl. login). Readest **web build** = JWT data endpoints work cross-origin once token obtained, but **login + koreader need a server-side proxy** (same pattern as existing `/api/kosync`, `/api/opds/proxy`) or same-origin reverse proxy.
**Readest extension points (template = KOSync):** new `src/services/grimmory/GrimmoryClient.ts` (connect/getProgress/updateProgress mirroring `KOSyncClient.ts`), `src/app/reader/hooks/useGrimmorySync.ts` (mirror `useKOSync.ts`), `GrimmorySettings` in `src/types/settings.ts`, `GrimmoryForm.tsx` wired into `IntegrationsPanel.tsx`. Progress mapping: Readest `BookProgress.location` (CFI) ↔ grimmory `BookFileProgress.positionData`; grimmory has `EpubCfiService` for CFI↔XPointer. Related: [[kosync-cfi-spine-resolution]], [[kosync-connect-false-positive-4692]].
**STATUS: NOT shipped.** A full vertical slice was built on 2026-06-23 (GrimmoryClient + useGrimmorySync hook + `/api/grimmory` proxy + GrimmoryForm/IntegrationsPanel + settings/types; metadata-match identity cached in BookConfig; native `/api/v1/books/progress`; tests+lint green) then **REVERTED at the maintainer's request ("not ready yet")**. Working tree fully restored (all grimmory files deleted, the 7 edited shared files reverted; lint + test green). Re-attempt later — the design below + the two findings below are the distilled learnings. Reverted because the native-progress identity story was judged immature; the more robust paths (OPDS acquisition capture, or mirroring the official koplugin) hadn't been built yet.
**FINDING A — how the OFFICIAL koplugin (`github.com/grimmory-tools/grimmory.koplugin`) maps identifiers (it does NOT use `/api/v1/books/progress`).** Local SQLite `book(book_path, partial_md5, grimmory_id)` stores BOTH ids per file. TWO paths: (1) native `grimmory_id` (= book.id) for sessions/downloads/shelves, resolved by **ISBN13/ISBN10/ISBN/ASIN only** (`doc_metadata.lua isBook` — NOT title/author), persisted via `repository.upsertBook(path, book.id)`; sessions → `POST /api/v1/reading-sessions` keyed by grimmory_id. (2) reading **PROGRESS via the KOReader-compat endpoint** `GET/PUT /api/koreader/syncs/progress[/{partialMD5}]`, keyed by KOReader's own `util.partialMD5(book_path)` (NOT grimmory_id), with creds auto-provisioned from native `GET/PUT /api/v1/koreader-users/me` (`getKoreaderCredentials``md5(secret)` → X-Auth-User/X-Auth-Key). ⇒ The proven progress path reuses our existing KOSync XPointer/partial-MD5 machinery against `/api/koreader/...`, not the native progress API. Caveat: backend `FileFingerprint.generateHash` samples i=-1 at `1024L<<-2` → Java overflow to offset **0**, vs KOReader LuaJIT `bit.lshift(1024,-2)` → offset **256**; first block MAY differ ⇒ partial-MD5 progress-by-hash could silently mismatch — VERIFY (hash one real downloaded file both ways) before relying.
**FINDING B — OPDS acquisition-time capture (the chosen "best match", not yet built).** Grimmory OPDS fingerprints (`OpdsFeedService.java`): every `<id>` is `urn:booklore:*` (root `urn:booklore:root`, books `urn:booklore:book:{bookId}`); feed `<title>Booklore Catalog`; self/start link `/api/v1/opds`. The book acquisition link encodes BOTH ids: `<link href="/api/v1/opds/{bookId}/download?fileId={fileId}" rel="http://opds-spec.org/acquisition">`. So at OPDS download (`src/app/opds/page.tsx` ~line 505 has `url`; already persists sourceUrl via `upsertOPDSSourceMapping`) parse `bookId` (path) + `fileId` (query); corroborate via `urn:booklore:` entry id OR same-origin with configured grimmory serverUrl; write the ids into config. Authoritative, no metadata guessing — best identity strategy for grimmory-sourced books.
Identity options ranked (native path): (1) OPDS acquisition capture [authoritative]; (2) cached ids; (3) ISBN/ASIN exact; (4) gated title+author (require format + fileSizeKb match, abstain on ambiguity — wrong match corrupts another book's progress). `fileSizeKb` IS exposed on BookFile (size corroborator); hash is NOT.
@@ -0,0 +1,21 @@
---
name: hardcover-progress-edition-id-4792
description: Hardcover progress sync parse-failed — edition_id falls back to book_id; invalid edition rejected by Hasura Action
metadata:
node_type: memory
type: project
originSessionId: 6273b46d-b22d-4d48-9295-7420b251a197
---
Issue #4792 (v0.11.12) — FIXED in PR #4794 (branch `fix/hardcover-progress-edition-id`). "Hardcover sync fails completely despite successful API key auth." Auth (`GetUserId`) works; progress push fails with:
`GraphQL Errors: [{"message":"parsing Hasura.GraphQL.Execute.Action.Types.ActionWebhookErrorResponse failed, key \"message\" not found","extensions":{"code":"parse-failed"}}]`
**Root cause (verified live in Chrome, account chrox, book "Crime and Punishment"):** `HardcoverClient.pushProgress``MUTATION_UPDATE_READ` (`update_user_book_read`) sent `edition_id: 713309`, which is the **book_id**, not a real edition id. `update_user_book_read`/`insert_user_book_read` are Hardcover **Hasura Actions**; an invalid edition makes the Action handler throw and return a non-conforming error body, which Hasura surfaces as the generic `parse-failed` (`ActionWebhookErrorResponse` missing `message`). HTTP status is 200 — the error is GraphQL-level only.
**Why edition_id == book_id:** title-search path in `fetchBookContext` (`HardcoverClient.ts`). `QUERY_SEARCH_BOOK` (`per_page:1`, returns raw `results`) does **not** select `featured_edition_id` — confirmed the hit `document` has no such key. So `searchBookByTitle` does `editionId = featured_edition_id ?? bookId` → always `bookId`. Then `QUERY_GET_BOOK_USER_DATA` only resolves a real edition via `selectedEdition` (the user_book's / read's `edition`); here both were `null` (user added the book with no specific edition), so `editionId` stays `bookId`. Broad impact: any no-ISBN (title-matched) book whose Hardcover library entry has no edition selected sends `edition_id = book_id`.
**Fix shipped (PR #4794):** `BookContext.editionId` is now `number | null`; `searchBookByTitle` drops the `?? bookId` fallback (null when no `featured_edition_id`); `$edition_id` made nullable (`Int`) in `MUTATION_INSERT_READ`/`MUTATION_UPDATE_READ`/`MUTATION_INSERT_JOURNAL`; `insert_user_book` omits `edition_id` when null. Verified live: book id → `parse-failed`; real edition id → `error:null`; `edition_id:null``error:null` and is a no-op (does NOT clear an existing edition).
**NOT a recent Readest regression:** the buggy `editionId = featured_edition_id ?? bookId` fallback + `edition_id: context.editionId` in the read mutations exist unchanged since the original feature #3724 (2026-04-03). It surfaces now because auto-sync (#4614, 2026-06-16, shipped v0.11.10/v0.11.12) made progress-push run automatically on every page turn (debounced) and via the BookMenu "Hardcover Sync → Push Progress". Possibly compounded by Hardcover tightening server-side edition validation. Secondary: title search also mis-matches (e.g. matched a Harold Bloom study guide, not Dostoevsky's novel) — separate match-quality concern.
Files: `src/services/hardcover/HardcoverClient.ts` (`fetchBookContext` ~306-426, `searchBookByTitle` ~286-289, `pushProgress` ~499-536), `src/services/hardcover/hardcover-graphql.ts` (`QUERY_SEARCH_BOOK`, `MUTATION_UPDATE_READ`/`MUTATION_INSERT_READ` ~131-155). Proxy: `src/app/api/hardcover/graphql/route.ts` forwards client `authorization` header.
@@ -0,0 +1,36 @@
---
name: i18n-extract-prunes-keys
description: "pnpm i18n:extract (removeUnusedKeys) deletes valid keys not statically in the branch; don't commit that churn"
metadata:
node_type: memory
type: feedback
originSessionId: afe50e44-d394-4301-bd81-1368df66f90b
---
`pnpm run i18n:extract` (i18next-scanner, `i18next-scanner.config.cjs` has
`removeUnusedKeys: true`) can DELETE ~30+ valid-looking keys from every non-`en`
locale on a feature branch — keys whose source usage isn't statically present in
the current branch (e.g. `"Sync History"`, `"downloaded {{n}} book(s)"`,
`"Match Whole Words"`). The extract diff then shows huge churn (~1000 +/- lines)
unrelated to your change.
**Why:** the committed locales can be ahead of the branch's source (strings from
features not yet on this base, or built dynamically/in non-scanned modules), and
`removeUnusedKeys` strips anything the scanner can't find. `en/translation.json`
is a tiny key-as-content file (~70 lines, only plural/proper-noun overrides), so
new keys never land there anyway — it stays out of the diff.
**How to apply:** for a feature that adds a few strings, do NOT commit the
scanner's deletions into an unrelated PR.
1. Run `pnpm run i18n:extract` (optional — only confirms which keys are new).
2. `git checkout -- apps/readest-app/public/locales` to drop ALL the churn.
3. Add ONLY your new keys manually to each locale in `i18n-langs.json` with real
translations. The files are exactly `JSON.stringify(obj, null, 2) + "\n"`, so
a Node script that `JSON.parse`s, appends new keys (insertion order preserved),
and rewrites that way yields a zero-extra-diff result. Skip `en` (key-as-content).
Match each locale's existing terminology (grep the file for a related key, e.g.
`"Export Annotations"` / `"Annotations"`, before translating). Verify with
`grep -rn '"<Your Key>"' apps/readest-app/public/locales | wc -l` == number of locales.
Related: [[feedback_en_plurals_manual]].
@@ -0,0 +1,51 @@
---
name: iframe-double-click-word-select
description: Double-click / touch double-tap on a word selects it and fires the instant action or annotation toolbar
metadata:
node_type: memory
type: project
originSessionId: bac4ae5d-047f-4b4f-8a04-b239beb4d7d7
---
Double-tap (touch) / double-click (mouse) on a word now selects that word — like
a long-press — then runs the configured instant quick action, or raises the
annotation toolbar if none is set. Verified live on Xiaomi 12 (Android).
**The gap:** `iframe-double-click` was posted by `handleClick`
(`src/app/reader/utils/iframeEventHandlers.ts`, gated on `!doubleClickDisabled`)
but had **no consumer** — a touch double-tap did nothing (Android has no native
double-tap word-select; desktop double-click already selects natively via the
`handlePointerUp` path).
**Impl (3 files):**
- `src/utils/sel.ts`: `getWordRangeAt(node, offset)` expands a caret to the
word-like segment via `Intl.Segmenter` (CJK + Latin), `[start,end]` inclusive
so a boundary caret still selects the adjacent word; `getWordRangeFromPoint(doc,x,y)`
resolves the caret (`caretPositionFromPoint`/`caretRangeFromPoint`) then delegates.
- `useTextSelector.ts`: `handleDoubleClick(doc, index, x, y)` selects the word and
routes through the existing `makeSelection` (guarded so the programmatic
`selectionchange` echo is ignored). **Guard `if (isValidSelection(sel)) return`**
— on desktop the browser already selected the word natively (flows through
`handlePointerUp`), so synthesize ONLY when nothing is selected (touch double-tap).
No `isUpToPopup` latch: a double-tap is two taps both consumed by double-click
detection, so no trailing single-click follows that would dismiss the popup.
- `Annotator.tsx`: window `message` listener for `iframe-double-click` resolves the
visible section doc/index like `handleNativeTouch` (`renderer.getContents()` +
`primaryIndex`), then sets **`pointerDownTimeRef.current = 0`** before calling
`handleDoubleClick` so the deliberate double-tap bypasses `handleQuickAction`'s
`quickActionMinHoldMs` (300ms) long-press gate (mouse already uses 0). Coords:
`clientX/clientY` from the iframe click are already section-doc-relative, exactly
what caretFromPoint wants — no window↔frame mapping (unlike `rangeFromAnchorToPoint`).
The branch decision (instant action vs toolbar) reuses the existing Annotator
`selection` effect: `enableAnnotationQuickActions && annotationQuickAction &&
isTextSelected.current ? handleQuickAction() : handleShowAnnotPopup()`. Default
config has `annotationQuickAction: null` → toolbar.
**Tests:** unit `sel.test.ts` (getWordRangeAt/FromPoint), `useTextSelector-doubleClick.test.ts`
(selection routing + desktop guard); e2e `double-click.android.test.ts` + `doubleTap`
helper in `helpers/adb.ts` (two `input tap` in one shell, < 250ms apart). Live CDP
verify: toolbar branch (`.popup-container.selection-popup`) and instant-action
branch (set quick action to Dictionary via header dropdown → `.popup-container.select-text`,
toolbar absent). See [[dblclick-drag-pageturn-4524]], [[instant-highlight-tap-paginate]],
[[tap-to-open-image-table-4600]].
@@ -0,0 +1,16 @@
---
name: image-zoom-trackpad-flicker-4742
description: "Trackpad pinch-zoom flickered the image viewer; macOS pinch = ctrl+wheel stream, disable CSS transition during continuous gestures"
metadata:
node_type: memory
type: project
originSessionId: affbfa14-0152-4d69-8fce-f7e0b9ee97a3
---
ImageViewer (`src/app/reader/components/ImageViewer.tsx`) flickered when zooming an open image with a MacBook trackpad pinch (#4742, PR #4748).
**Root cause:** on macOS a trackpad pinch-to-zoom is delivered to the WebView as a rapid stream of `wheel` events with `ctrlKey: true` (NOT touch events), so it flows through `handleWheel`. The zoomed `<img>` kept its `transition: transform 0.05s ease-out` whenever `isDragging` was false. Pinch wheel events fire faster than 50ms apart, so each event restarted the in-flight transition from its interpolated mid-point — the transform constantly lagged and caught up = visible flicker. Same root cause as the #4451 pan flicker, which only fixed the pan path and (via `isDragging` set in `onTouchStart`) the touch-pinch path; the wheel-zoom path was the only continuous gesture left with the transition on. That's why touch pinch on iPhone was smooth but trackpad pinch flickered.
**Fix:** added an `isWheelZooming` state set on each `handleWheel` event and cleared on a 200ms debounce (wheel has no explicit gesture-end). Transition is `isDragging || isWheelZooming ? 'none' : 'transform 0.05s ease-out'`. Discrete zoom (buttons, double-click, keyboard) keeps the smoothing.
**General pattern:** never run a CSS `transition` on a transform that's being updated by a high-frequency continuous input stream (drag, touch pinch, trackpad/`ctrl+wheel` pinch) — the interrupted-transition restart flickers. Gate the transition off for the duration of the gesture. Maintainer couldn't repro on macOS 15.6.1 (WebKit) while reporter hit it on macOS 26.5.1 / WebKit 605.1.15; the fix is version-independent. Related: [[instant-highlight-tap-paginate]].
@@ -0,0 +1,18 @@
---
name: in-place-delete-wiped-originals
description: "Deleting a \"Read books in place\" book from Readest used to permanently delete the user's original source file; fixed (PR #4696) to never touch external sources"
metadata:
node_type: memory
type: project
originSessionId: 432bbb95-47b4-4d9c-825b-528168e2cfb7
---
User report (v0.11.12 Windows): imported a folder via "Import From Directory" with **Read books in place**, later deleted the books in-app, and Readest **permanently deleted the original local files** (not even sent to Recycle Bin). Files were unrecoverable; cloud sync hadn't uploaded them yet ("Book File Not Uploaded").
**Root cause:** `deleteBook` in `src/services/cloudService.ts`. For `local`/`both`/`purge`, it called `resolveBookContentSource` (`src/services/bookContent.ts`) and, when `source.kind === 'external'` (i.e. `book.filePath` set, base `'None'` — the user's own file from an in-place or transient import), unconditionally `fs.removeFile(source.path, source.base)`. `book.filePath` is set in `bookService.ts importBook` whenever `transient || inPlace`.
**The trap:** this was NOT an accidental bug — it was **deliberately coded AND tested**. `cloud-service.test.ts` had a whole `in-place (book.filePath set)` describe block asserting the source file IS removed, with a comment rationalizing it as "symmetric with deleting Books/<hash>/<title>.epub for a normal book." Don't assume tested == intended; the maintainer reversed the decision.
**Fix (PR #4696):** never `removeFile` an `external` source. Only `managed` sources (our Books/<hash>/ copy) and app-generated sidecars (cover.png, and the whole Books/<hash>/ dir on `purge`) are Readest's to delete. Removed the `external` branch entirely; flipped the in-place tests to assert the source is preserved (cover sidecar still removed on `both`, sidecar dir still wiped on `purge`). Also fixed the misleading JSDoc in `ImportFromFolderDialog.tsx` (`readInPlace`) that documented the destructive behavior as intended.
Out of scope but noted in the support thread: deletion flow lacks a warning/disclaimer, and delete doesn't use the OS Recycle Bin. See [[bug-patterns]].
@@ -0,0 +1,50 @@
---
name: instant-highlight-delete-orphan-4773
description: Deleting a just-made highlight leaves the overlay drawn (gone only after reopen); a stale memoized annotationIndex re-draws it
metadata:
node_type: memory
type: project
originSessionId: 3a58d242-3867-414c-869a-95a23714b361
---
#4773 (Android, instant highlight): highlight a word, delete it "within a very
short time" → the mark stays painted on the page, vanishing only after reopening
the book. Booknote IS soft-deleted (`deletedAt` set, gone on reopen) but the
**overlay was re-drawn after removal** → orphan.
**Root cause — stale memoized index re-draws a deleted annotation.**
`Annotator.tsx` re-applies per-location annotations on every relocate via the
memoized `annotationIndex` (`useMemo(buildAnnotationIndex(config.booknotes), [config.booknotes])`)
`selectLocationAnnotations(index, location)``view.addAnnotation(a)`.
`buildAnnotationIndex` filters `deletedAt` at BUILD time, but
`selectLocationAnnotations` trusted that and did NOT re-check. The delete
(`handleHighlight(false)`) stamps `existing.deletedAt = Date.now()` **in place**
on the same booknote object that's still sitting in the index bucket, and
removes the overlay (`addAnnotation(existing, true)`). If the re-apply effect
scheduled from the popup-open render flushes AFTER the delete (the "very short
time" window — passive effects deferred on Android WebView under rapid taps),
`selectLocationAnnotations` returns the now-deleted object from the pre-deletion
snapshot and `addAnnotation` re-draws it → overlay orphaned. Annotator does NOT
re-render on booknote changes (subscribes only to the stable `getConfig` fn), so
the memo stays stale until some other state change recomputes it.
NOT instant-specific in the data layer — instant highlight (`useInstantAnnotation`)
just makes it easy to hit (no popup friction, fast gesture). Delete + re-apply
(where the fix lives) is shared with normal highlights. `onCreateOverlay` reads
`getConfig` FRESH so it's safe; FoliateViewer onLoad re-draw only fires on
section load (not a quick delete).
**Fix:** re-check `deletedAt` at the READ site, not just at index build:
- `selectLocationAnnotations` (annotationIndex.ts): `if (item.deletedAt) continue;`
before classifying — covers both the annotations and notes lists.
- The sibling `annotationIndex.globals` loop in the Annotator re-apply effect:
`if (annotation.deletedAt) continue;` before `expandAllRenderedSections` (same
stale-snapshot hazard for global highlights).
Test: `src/__tests__/utils/annotation-index.test.ts` — build index with a styled
note, then `highlight.deletedAt = 123` in place, assert `selectLocationAnnotations`
returns `{ annotations: [], notes: [] }` (red before fix). Verified on Xiaomi 13
Pro (fuxi, WebView) via the CDP lane: real create→delete→immediate-relocate over
4 iterations left overlay count 6→7→6 each time (no orphan); overlay-count metric
proven non-blind by a stray-overlay sanity probe. See [[android-cdp-e2e-lane]].
Related: [[instant-highlight-tap-paginate]], [[global-annotation-pageturn-perf-4575]].
@@ -0,0 +1,43 @@
---
name: instant-highlight-tap-paginate
description: Instant Highlight quick action swallowed tap/swipe-to-paginate on Android; fixed with a 300ms still-hold gate
metadata:
node_type: memory
type: project
originSessionId: d92c120f-6272-4366-92b8-e2d8f32dfd52
---
After the 2026-06-19 update, Android users reported tap-to-paginate failing in
paginated mode: tapping TEXT didn't turn the page, only tapping the empty side
MARGINS worked. Trigger = **Instant Highlight** quick action enabled (3rd toolbar
icon / highlighter; setting = `enableAnnotationQuickActions && annotationQuickAction === 'highlight'`).
**Root cause:** `useTextSelector.handlePointerDown` called `ev.preventDefault()` +
`startInstantAnnotating()` on EVERY pointer-down over selectable text. The
`preventDefault` suppressed the native click that drives tap-to-paginate (iframe
`handleClick``iframe-single-click` → usePagination). Margins worked only because
`handleInstantAnnotationPointerDown``isSelectableContent` returns false there.
The synthetic-mousedown fallback in `handlePointerUp` is dead on Android because the
native-touch `touchend` calls `handlePointerUp(doc, index)` with NO `ev` (Annotator.tsx
`handleNativeTouch`), and `if (isInstantAnnotating.current && ev)` skips.
**Fix (PR/commit on `dev`):** gate instant-highlight engagement behind a still hold
for touch/pen — `INSTANT_HOLD_MS = 300`, `INSTANT_HOLD_MOVE_PX = 10` in useTextSelector.ts.
- `armInstantHold` (touch/pen) records the press, starts a 300ms timer, does NOT
preventDefault. A tap releases first (`handlePointerUp`/`handlePointerCancel`
`cancelInstantHold`) → native click → paginate. A swipe moves first
(`maybeCancelInstantHoldOnMove`, called in BOTH `handlePointerMove` and
`handleNativeTouchMove`, compares window-coord `pointerPos` vs `instantHoldStartWindow`)
→ native swipe → paginate. Only a still hold fires the timer → `startInstantAnnotating`.
- Mouse path unchanged (immediate `preventDefault` + start) — click vs. press-drag is
already unambiguous; matches the existing "mouse shouldn't be time-gated" stance.
- Refactor: `startInstantAnnotating(target, startPoint)` / `stopInstantAnnotating()` no
longer take `ev`; the down `target` is stored in `instantAnnotationTarget` so the exact
element gets `user-select` restored (pointerup target may differ after the finger moves).
Two parallel instant-highlight mechanisms share the same enable flag: (1) the
`useInstantAnnotation` live drag-to-highlight (this fix), and (2) the
quick-action-on-selection deferred path (`beginGesture`/`deferredQuickActionRef`/
`pointerDownTimeRef` in Annotator.tsx) which ALREADY long-press-gates touch on
iOS/desktop but not Android. Test: `useTextSelector-instantHold.test.ts`. See
[[keyboard-selection-adjust-4728]] for the adjacent `isPointerDown`/`handleSelectionchange` logic.
@@ -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,22 @@
---
name: keyboard-selection-adjust-4728
description: "After a reader text selection, keystrokes land in the PARENT (container focus), not the iframe — fix Shift/Ctrl/Alt+Arrow selection refine in useBookShortcuts"
metadata:
node_type: memory
type: project
originSessionId: 9ebaeccc-0436-4c7b-a81e-1a4aa3de64dd
---
#4728: standard desktop selection shortcuts — `Shift+←/→` refine selection by character, `Ctrl/Alt(Option)+Shift+←/→` by word — implemented in the **parent** shortcut system, not the iframe.
**Critical gotcha (cost a full redesign):** after a text selection, `Annotator.handleShowAnnotPopup` calls `containerRef.current?.focus()` on desktop, so `document.activeElement` is a **parent-document DIV**, not the book iframe. Real OS keystrokes therefore go to the parent `window``useShortcuts` (native keydown path) → page-turn shortcuts (`shift+ArrowRight`=`onGoNext`/`onGoForward`). A fix inside the iframe `handleKeydown` is **bypassed** for real keystrokes — it only fires if focus is in the iframe (e.g. quick-actions config). JS-dispatched `KeyboardEvent`s into the iframe doc DO hit the iframe handler, so they falsely "pass" — only a **real OS key** (`computer.key`) reveals the parent-focus path. Always verify with a real keystroke, not a synthetic dispatch.
**Fix shape:**
- `utils/sel.ts`: pure `getKeyboardSelectionAdjustment(KeyModifiers)``{direction:'left'|'right', granularity:'character'|'word'}|null` (Shift=char, Ctrl||Alt=word, metaKey→null so native Cmd+Shift line-select survives; 'left'/'right' visual dir for RTL). `extendSelectionFromContents(contents, ev, extend)` walks `view.renderer.getContents()` (`{doc}[]`), finds the non-collapsed `doc.defaultView.getSelection()`, and (if `extend`) `sel.modify('extend', dir, gran)`; returns whether a selection was found.
- `helpers/shortcuts.ts`: new `onAdjustTextSelection` (section 'Selection') with keys `shift+Arrow{Left,Right}` + `ctrl/alt+shift+Arrow{...}`.
- `useBookShortcuts.ts`: `adjustTextSelection` wired **first** in the `useShortcuts` actions map so it intercepts before `onGoNext/Prev/...`. Native keydown (parent focus) → extend ourselves; forwarded iframe-keydown MessageEvent (iframe already extended natively) → `extend:false`, just report presence to suppress nav. Returns true ⇒ `processKeyEvent` stops ⇒ no page turn.
- `useTextSelector.handleSelectionchange`: desktop normally defers to pointerup; relaxed the gate to `!isAndroid && !isTouchInput && isPointerDown.current` (new `isPointerDown` ref set in pointerdown, cleared in pointerup/cancel) so a keyboard-driven `selectionchange` (no pointer drag) refreshes the popup/range. This realm-agnostic gate refreshes for BOTH the parent-modify and native-iframe-modify paths.
**Selection.modify test artifact:** in browser-lane tests build the starting selection with `setBaseAndExtent` (or collapse+extend), NOT `addRange``addRange` leaves the selection directionless so backward `modify('extend','left'/'backward')` silently no-ops; `setBaseAndExtent` establishes anchor/focus like a real mouse drag.
Verified live on Chrome with real keystrokes (Alice EPUB, scrolled mode): `Shift+→` "Queen"→"Queen." no turn; `Opt+Shift+→` "two"→"two miles" (word); popup follows; no selection ⇒ `Shift+→` still scrolls (nav preserved). Paginated auto-scroll-to-follow when extending past the page edge is NOT wired (foliate's `isKeyboardSelecting` scrollToAnchor only fires for iframe-focus keydowns; parent-focus has none) — minor known limitation. See [[layout-ui-fixes]].
@@ -0,0 +1,19 @@
---
name: koplugin-bulk-download-4751
description: "koplugin Library \"Download all books\" bulk download — entry point, candidate query, and the sync/async coroutine bridge"
metadata:
node_type: memory
type: project
originSessionId: b474b24d-cfa5-4f32-b6f2-d6a35f27cadd
---
Issue #4751: bulk "download all" for the readest.koplugin Library view (parity with Readest web/desktop "download all"). Branch `feat/koplugin-bulk-download-4751`, PR #4765 (base main).
- Entry point: view-menu Actions section in `library/libraryviewmenu.lua` → calls `require("library.librarywidget").downloadAll()` (no args; reads `M._opts`/`M._store` like `M.refresh()`).
- Candidate set: new `LibraryStore:listCloudOnlyBooks()` = `cloud_present=1 AND local_present=0 AND deleted_at IS NULL AND uploaded_at IS NOT NULL` (phantom records with no uploaded file are excluded, same as `listBooks`). Whole library, ignores active search/group. Test-first in `librarystore_spec.lua`.
- Orchestration `M.downloadAll()`: sequential reuse of `syncbooks.downloadBook`, inside `Trapper:wrap`. Progress + cancel via `Trapper:info("Downloading %1 of %2…")` — it yields to UIManager, so a tap queued during the previous (blocking) download is processed at the book boundary and raises Trapper's Abort/Continue confirm (returns false → cancel). Skip per-book failures, count them, show a summary toast. Only `Trapper:clear()` when NOT cancelled (abort path already closed the widget).
- **Sync/async cb bridge** (the non-obvious bit): `downloadBook`'s callback fires exactly once but may be synchronous (token fresh) OR async (after token refresh). In the cb, resume the coroutine only `if coroutine.status(co) == "suspended"`; capture result + a `finished` flag, and only `coroutine.yield()` `if not finished`. This avoids "resume non-suspended coroutine" errors in the sync case and correctly awaits in the async case. Reusable for any callback-style KOReader API awaited inside a Trapper coroutine.
- i18n: 6 new `_()` strings, `T(_("… %1 …"), ...)` interpolation (`local T = require("ffi/util").template`). Ran `node scripts/extract-i18n.js`; translated all 33 locales via [[i18n-koplugin]] flow. Verify: placeholders `%1/%2/%3` preserved (no `%s/%d`), `…` U+2026 kept.
- Note: the per-book long-press sheet already had a "Download All" (cover+file for ONE book) — left as-is; distinct from the new bulk "Download all books".
Gates: `pnpm lint:lua` + `pnpm test:lua` (see [[verify-format-check-gate]] / verification.md). No JS/TS/Rust changes.
@@ -0,0 +1,55 @@
---
name: koplugin-library-open-mosaic-cache-4954
description: koplugin Library slow open on large libraries — group-cover mosaics recomposed every paint; fixed by availability-keyed cache + async compose
metadata:
node_type: memory
type: project
originSessionId: 7e7dbb83-cffb-495d-9778-bf94ccb45d8b
---
Issue #4954 (PR #4974, MERGED 2026-07-07): opening the KOReader plugin Library
was slow on large libraries (~1000 books) while navigation stayed fast.
**Root cause (measured, not guessed).** Added open-path timing instrumentation
(`ui/time` + `elapsed_ms` helper) to `library/librarywidget.lua` (initial
`build_item_table`, `lightScan`, post-scan refresh, total synchronous open,
cloud-sync elapsed) and a step breakdown in `library/localscanner.lua`. On a
685-book library the synchronous open was ~300ms, dominated by a **254ms
post-scan refresh** = `library/group_covers.lua` recomposing each folder's 2x2
cover **mosaic from scratch on every paint** (up to 4 MuPDF decodes+scales per
cell), with no cache, and again on the post-sync refresh. `build_item_table`
(7ms) and `lightScan` (28ms, only 16 sidecar reads) were NOT the bottleneck —
my initial hypotheses (defer lightScan / incremental history) were refuted by
the log. Why "slow to load, fast to navigate": the root Groups view is mosaics;
drilling into a group shows single covers (cheap, BIM-cached). Soft-scales with
size (fuller groups → 4 covers/mosaic vs 1). Data-side pagination is NOT
possible (KOReader `Menu` derives page count from `#item_table`).
**Fix (mirror `cloud_covers` async pattern in `group_covers`).**
- Cache composed master bb per group, keyed by `mosaic_cache_key` = ordered
child hashes + a per-child **cover-availability bit** (`child_cover_available`
`cloud_covers.cover_exists(hash)` or local file stat). Serve `copy_bb` on
hit. The availability bit fixes the historical "partial composite served
forever" bug that killed the prior on-disk cache: a late cover flips the key
and recomposes once.
- **Cache the `nil` result too** (critical): a coverless group whose children
aren't downloaded makes `compose` return nil; if not cached it re-enqueues +
`schedule_refresh` on every refresh → infinite recompose/refresh loop (eink
flashing). Caught this in the second emulator log (`group_nameLanguagegrid`
missing every refresh). Cache nil under the availability key → placeholder
served, no re-enqueue.
- Compose off first-paint: miss enqueues a single-slot background job (one
mosaic per UI `nextTick`, `_pump_scheduled` coalesces), returns nil so the
cell paints its FakeCover placeholder; completions coalesce into one refresh.
- `clear_cache()` on Library close (via `libraryitem.set_visible_hashes(nil)`)
frees masters (~0.7MB each).
Result: synchronous open 300ms→151ms, post-scan refresh 254ms→89ms (now just the
4 visible single cloud-book cover decodes + placeholders, mosaics deferred).
**Left out (follow-ups noted in PR):** single cloud-book covers
(`cloud_covers.load_cover_bb`) still re-decode from disk each refresh (~89ms/4)
— same copy-on-serve cache could apply; deferred cloud sync uses synchronous
HTTP that briefly freezes UI after the menu appears (elapsed 1.6-6.8s, network
variance). Instrumentation kept intentionally (Library open is infrequent).
See [[koplugin-stats-duplicate-book-rows-4861]], [[koplugin-library-stale-synced-cursor-4934]].
@@ -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,20 @@
---
name: kosync-connect-false-positive-4692
description: "KOSync connect() accepted any 2xx (even an HTML web-UI page) as login → misconfigured Server URL silently \"connects\" but never syncs"
metadata:
node_type: memory
type: reference
originSessionId: 43e853c2-58ea-42f0-97ed-66aa3f65e4d1
---
#4692 (PR #4711): KOReader Sync to a self-hosted Grimmory/Booklore server failed on Android (worked on iOS). Root cause was a **misconfigured Server URL** that resolved to the host's static web UI instead of the sync endpoint, made undebuggable by a Readest gap.
**Server-side tell (the smoking gun):** Android `PUT /syncs/progress` was handled by Spring's `ResourceHttpRequestHandler``HttpRequestMethodNotSupportedException: Request method 'PUT' is not supported`. That handler is Booklore's SPA/static fallback — so the request reached the server but **missed the koreader controller** and hit the catch-all static handler. GET requests (auth/pull) silently get the HTML index with 200; only PUT errors (static handler rejects non-GET/HEAD).
**Readest gap:** `KOSyncClient.connect()` treated any 2xx from `/users/auth` (or `/users/create`) as success. An HTML web-UI page returns 200 → false-positive "connected"; then pulls show 0% and pushes fail with no error surfaced. Matches the classic "no errors reported, still 0%" report.
**Fix:** validate the auth/registration response is an actual koreader JSON object (real server → `{"authorized":"OK"}`; HTML fails `response.json()`), else return "Not a KOReader Sync server. Check the Server URL." (`isKoSyncJsonResponse` helper in `KOSyncClient.ts`). Catches misconfig at setup, when actionable.
**Still silent (intentional follow-up, not done):** per-sync push/pull failures. `getProgress`/`updateProgress` collapse "request failed" and "no remote data" into the same `null`/`false`; naive toasting would fire on every transient auto-push (5s). Needs noise-aware design before surfacing.
KOSync settings are **per-device** (no Readest account → not synced across devices), so iOS vs Android URLs are entered independently — the #1 suspect when one platform syncs and the other doesn't. Related: [[kosync-cfi-spine-resolution]], [[empty-start-cfi-sync]].
@@ -0,0 +1,18 @@
---
name: library-reader-separate-texture-4743
description: "Separate library vs reader background texture (#4743); shared-style-element + two gotchas"
metadata:
node_type: memory
type: project
originSessionId: dfdb7b38-1869-4fb4-b869-c32301c80128
---
#4743: library and reader shared one background texture; split so each is set independently.
**Architecture**: ONE global `<style id="background-texture">` paints `body::before` (covers library) plus reader containers (`.foliate-viewer/.sidebar-container/.notebook-container ::before`). Library and reader are separate routes (only one mounted), so the split = store two values + have each page apply its own on activation, not separate style elements. New device-local `SystemSettings.libraryBackground{TextureId,Opacity,Size}` (NOT in settings sync whitelist — texture *selection* is per-device like reader's `backgroundTextureId`; only image binaries sync via `texture` replica kind). `getLibraryViewSettings(settings)` in `helpers/settings.ts` resolves each field with `?? globalViewSettings.<field>` so the bookshelf inherits the reader texture until decoupled (no migration). `ColorPanel` is context-aware via `isLibraryContext = !bookKey`: library context writes `libraryBackground*` via `saveSysSettings`, reader context unchanged via `saveViewSettings`. Applied at boot (`Providers`) + on every library mount (`library/page.tsx` effect).
**Gotcha 1 — `useBackgroundTexture` early-returned on `'none'` WITHOUT unmounting.** Since library+reader share the one style element, switching a page to None must actively clear a texture the OTHER page mounted. Fixed: always delegate to `applyTexture(envConfig, textureId || 'none')` (it unmounts on 'none'); only set CSS vars / addTexture for a real texture. Also fixes the symmetric reader case (opening a 'none' book after a textured one).
**Gotcha 2 — `useSettingsStore` initializes `settings: {} as SystemSettings`.** So `settings.globalViewSettings` is `undefined` on the first renders before `appService.loadSettings()` runs. Any NEW effect/deps that deep-derefs `settings.globalViewSettings.<x>` crashes the library with "Cannot read properties of undefined (reading 'backgroundTextureId')". Caught only in a hard reload (HMR kept old store state, so first nav didn't repro). Fix = optional-chain in effect deps + make the resolver tolerate missing globalViewSettings (fallback to 'none'). Relates to [[cover-stale-inplace-mutation-memo]].
Verified end-to-end in dev-web: library moon texture, reader stays none, round-trip persists, None clears live. Related: [[wordlens-feature]] i18n (recent feature commits ship `_()` strings WITHOUT running `i18n:extract`; translations are batched separately — don't commit locale churn in a feature PR).
@@ -0,0 +1,18 @@
---
name: list-view-series-overflow-4796
description: "Library list view series + description text overlapped/clipped under fixed h-28, worsened by Android system font scaling"
metadata:
node_type: memory
type: project
originSessionId: 8645710b-673d-422a-ad8a-e3f385057f49
---
PR #4799 (branch `fix/list-series-overflow-4796`). Reported on Pixel 10 Pro / Android 16: in library **list view**, a book that belongs to a series shows its series line and description preview overlapping and cut off.
**Root cause:** `BookItem.tsx` list-mode container used a fixed `h-28` (112px) with `overflow-hidden`. The right column stacks title + authors + (optional) series + description + a progress/actions row (`useResponsiveSize(15)` → ~19px on phones). Without a series it fits 112px; the optional series line (added in #4593/#4612) pushes the total over 112px, so the lines collide and clip. **Android applies the system accessibility font-size scale to WebView CSS text**, inflating line heights — that's what made it bad enough to report (matched the issue screenshot at ~130% scale).
**Fix:** `h-28``min-h-28` (one class). Row grows to fit; non-series rows keep 112px. List is `Virtuoso` with measured (not fixed) heights, so variable row heights are fine.
**Verification:** jsdom can't measure layout, so reproduced the exact flex markup in a real browser at normal + 130% font scale (before = overlap, after = clean). Lint + full `pnpm test` (6324 pass) + `format:check` pass.
Lesson: fixed-height list/card rows are fragile against optional metadata lines AND user font scaling. Prefer `min-h-*` when the row can virtualize. Related: [[cover-stale-inplace-mutation-memo]].
@@ -0,0 +1,40 @@
---
name: markdown-md-support-774
description: Markdown (.md) reading via in-memory foliate book (no EPUB); split-at-H1; foliate book-object contract gotchas
metadata:
node_type: memory
type: project
originSessionId: a82e979b-0edb-4964-91fd-3677ecfe5679
---
Issue #774: render standalone `.md` files at runtime (NO EPUB conversion). **MERGED as
PR #4816** (branch `feat/markdown-support`). Built test-first via `/autoplan` (CEO+Eng
dual-voice review). Suite green (6365), lint + format clean; live-verified in web app
(import via drop, split TOC, cross-section nav, GFM rendering, pagination).
**Where:** `src/utils/md.ts` `makeMarkdownBook(file)` builds an in-memory foliate book
modeled on `packages/foliate-js/fb2.js`. Routed in `src/libs/document.ts` `open()` via
`isMd()` **before `isTxt()`** (a `.md` served as `text/plain` would otherwise hit TXT→EPUB).
`'md'` added to `SUPPORTED_BOOK_EXTS` (constants.ts). `sanitize.ts` `sanitizeHtml` gained
`'class'` (code `language-*` theming) + `del`/`ins` tags. Pipeline: strip YAML frontmatter →
`marked`(gfm) → `sanitizeHtml` → split at `<h1>` (preamble = pre-first-H1 content) → nested
heading-outline TOC.
**Non-obvious foliate book-object contract (cost us the CRITICAL review finding):**
- `section.id` and `splitTOCHref()` output MUST be the SAME type. readest nav
(`services/nav/index.ts:133`) does `new Map(sections.map(s=>[s.id,s]))` then `.get(sectionId)`
where sectionId = `splitTOCHref(href)[0]`. `SectionItem.id` is typed `string`, so use
STRING ids + `splitTOCHref => href.split('#')`. fb2.js uses numbers consistently (works
only because it's untyped JS); do NOT copy fb2's `Number(x)`.
- Fragment CFIs / TOC sub-anchors require `section.loadText` (nav skips sections without it,
index.ts:153). Provide it.
- `SectionItem.cfi` is non-optional → set `cfi: ''` (foliate falls back to `CFI.fake.fromIndex`).
- `createDocument()` parses `application/xhtml+xml`; marked's HTML5 void tags (`<br><hr><img>`)
are parse errors there → serialize sections with `XMLSerializer` (not innerHTML). `load()`
and `createDocument()` must derive from the SAME string (CFI round-trip).
- `resolveHref` returns `null` for unresolved anchors (never index 0). jsdom lacks
`URL.createObjectURL``load()` is lazy + tests stub it.
**Deferred follow-ups (open issues):** relative image resolution (web File-objects have no
sibling access — needs the bundle model); Markdown folder/zip "package" model; footnotes/math/
Mermaid/wikilinks; syntax-highlight token colors. Plan: `.claude/plans/2026-06-26-markdown-md-support-774.md`.
@@ -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,21 @@
---
name: mobile-reading-widgets
description: "Home-screen reading widgets (#1602, PR"
metadata:
node_type: memory
type: reference
originSessionId: 7f7f8218-4656-4863-972e-ea6204c130fa
---
Mobile home-screen reading widgets (issue #1602, merged PR #4842). Code lives in the **native-bridge plugin**: `src-tauri/plugins/tauri-plugin-native-bridge/{android,ios}/` (Android `ReadingWidgetProvider.kt` + `res/`; iOS writer `ReadingWidgetWriter.swift`) and the iOS WidgetKit extension at `src-tauri/gen/apple/ReadestWidget/`. App publishes a snapshot + downsized cover thumbnails via the `update_reading_widget` command to iOS App Group `group.com.bilingify.readest` / Android `SharedPreferences`. Widget hook: `src/hooks/useReadingWidget.ts`; payload builder `src/services/widget/readingWidget.ts`; tap opens `readest://book/{hash}` via `useOpenBookLink.ts`.
Durable, non-obvious gotchas (each cost a debugging round):
- **iOS widget missing from gallery = stale `.xcodeproj`.** `gen/apple/project.yml` defines the `ReadestWidget` target, but **Tauri's iOS build does NOT re-run xcodegen**, so a newly-added target is silently omitted from the build. Fix: `cd src-tauri/gen/apple && xcodegen generate`. Also: iOS builds from the **MAIN repo** `/Users/chrox/dev/readest` (complete gen/apple), NOT the `pnpm worktree:new` worktree (its gen/apple is incomplete — missing `Sources/`, `Assets.xcassets`, `Externals`, `LaunchScreen.storyboard` — so xcodegen fails there).
- **Android RemoteViews allow only @RemoteView widgets.** Plain `<View>` (and `<Space>`) is NOT allowed → launcher inflate fails → "Can't load widget". Use an empty `FrameLayout` for spacers. Covers: badge + progress bar are **baked into the bitmap** (Canvas in `writeThumbnail`) because RemoteViews can't clip/overlay reliably; shown via `fitCenter`. Responsive sizing by grid cells: `n = (minWidthDp + 30) / 70` (Android cell formula); one book per column, cap 3.
- **Background TTS progress freeze.** `book.progress` (libraryStore) AND `readerProgressStore` are both written by the same `setProgress`, inside `commitRelocate`**`requestAnimationFrame`**, which Android pauses for a backgrounded WebView → both freeze during background TTS. No store-only fix (page-based progress needs rendering). Fix: in `FoliateViewer.progressRelocateHandler`, commit synchronously when `document.visibilityState === 'hidden'` (relocate still fires; only the rAF commit was deferred). Confirmed working on device.
- **Android crash: "cannot use a recycled source in createBitmap" (exact-2:3 covers).** In `ReadingWidgetStore.writeThumbnail`, `Bitmap.createBitmap(src, x, y, w, h)` returns the SAME instance when the crop covers the whole *immutable* source (`decodeFile` bitmaps are immutable) — which happens when the cover decodes to exactly 2:3 (height==width*3/2), making the center-crop a no-op. The old code then did `bitmap.recycle()`, recycling `cropped` too, so the next `createScaledBitmap(cropped, …)` threw. Fix: `if (cropped !== bitmap) bitmap.recycle()` — mirror the `if (scaled !== cropped) cropped.recycle()` guard already 4 lines below. Trace was R8-obfuscated + ran inside `update_reading_widget`'s `pluginScope.launch { withContext(Dispatchers.IO) }`, surfacing as `FATAL EXCEPTION: main` with a `Dispatchers.Main` cancelled-coroutine suppressed frame. **iOS is unaffected**`ReadingWidgetWriter.writeThumbnail` uses ARC-managed immutable `UIImage` + `UIGraphicsImageRenderer`, no manual recycle/aliasing.
- **iOS TTS controls deferred** — interactive widget buttons need iOS 17 App Intents; widget min target is iOS 15 (15/16 widgets can only deep-link, no buttons). Android uses `MediaButtonReceiver.buildMediaButtonPendingIntent` (any version). Follow-up only.
- **`.superpowers/` is NOT gitignored** in this repo → a subagent's `git add` can sweep SDD scratch (`*-report.md`) into a commit; check `git ls-files '.superpowers/*'` before squashing/pushing.
Related: [[android-nativefile-remotefile-io]] · [[tts-fixes]] · build/worktree [[feedback_use_worktree]]
@@ -0,0 +1,16 @@
---
name: multiwindow-settings-clobber-4580
description: Pagination/global settings revert with multiple desktop windows; cross-window broadcast fix
metadata:
node_type: memory
type: project
originSessionId: 4df1808d-e106-4316-9206-b4e606b4b9bf
---
Issue #4580 (fix: PR #4803, branch `fix/multiwindow-settings-revert-4580`): on desktop (Tauri) global view settings (Click/Swipe to Paginate, Show Page Navigation Buttons) "revert to default" — only when multiple windows are open (OP ran `1 + n_opened_books` windows).
**Root cause:** each Tauri window keeps its own in-memory `useSettingsStore.settings`, loaded once at window open. Global settings persist to ONE shared `settings.json`, and every window writes the WHOLE object via the store's `saveSettings`. A window opened before the user customized a global setting holds the default (e.g. `disableClick=false`); when it later saves (notably `handleCloseBooks` on reader-window close in `ReaderContent.tsx`, but ANY settings write) it clobbers the user's value back to default. Explains "reverts to *default*, only with multiple windows". Note: `replicaCursorStore` avoids this by load-modify-saving from disk each time.
**Fix:** cross-window broadcast. `src/utils/settingsSync.ts` (`broadcastGlobalSettings` emits `global-settings-window-sync` with `sourceLabel` + the two global blobs; `subscribeSettingsSync` ignores self; `mergeSyncedGlobalSettings` adopts `globalViewSettings`/`globalReadSettings` and preserves all device/window-local fields). Store `saveSettings` calls `broadcastGlobalSettings` after persisting. `useSettingsSync` (mounted in `Providers.tsx`, the shared root for both library + reader windows) adopts broadcasts via `setSettings`. No-op off Tauri.
Only the two global objects are synced (minimal scope) — covers the reported bug + sibling read settings; top-level scalars left window-local. No save/broadcast loop: receive calls `setSettings` only; the replica publisher subscriber pushes to network (no disk write) and pagination fields aren't in `SETTINGS_WHITELIST` anyway. Live cross-window view update of already-open books is intentionally NOT done (bug is persistence, not live propagation). Related: [[webdav-connect-nullified-4780]] (stale settings closure), [[window-state-sanitize-4398]].
@@ -0,0 +1,91 @@
---
name: native-ios-tts-4676
description: Native local iOS TTS (AVSpeechSynthesizer) mirroring the Android native TTS plugin;
metadata:
node_type: memory
type: project
originSessionId: ec6b5ad5-f187-4615-83b4-33b1a9e77ba7
---
# Native local iOS TTS (#4676)
STATUS: MERGED (PR #4697, into main 2026-06-21). Device-verified by maintainer:
system-voice playback, voice selection, rate/pitch, auto-advance, pause/resume,
stop/disable teardown, and lock-screen controls + metadata for both system and
Edge TTS. Final design = iOS lock screen via `navigator.mediaSession` (NOT the
native plugin); the Swift media-session methods are dead on iOS (Android-only).
Diagnostic logging was stripped before merge.
Goal: give iOS the same on-device TTS Android has (private, offline). The shared
TypeScript `NativeTTSClient` (`src/services/tts/NativeTTSClient.ts`) and the Rust
command/mobile layer (`src-tauri/plugins/tauri-plugin-native-tts/src/{commands,mobile,models}.rs`)
were already platform-agnostic — only the Swift plugin (a `ping()` stub) and two
gates were missing. See [[tts-fixes]].
## What was changed
- **`ios/Sources/NativeTTSPlugin.swift`** — full impl mirroring `android/.../NativeTTSPlugin.kt`.
Commands: init, speak, stop, pause, resume, set_rate, set_pitch, set_voice,
get_all_voices, set_media_session_active, update_media_session_state,
update_media_session_metadata, checkPermissions/requestPermissions.
- **`TTSController.ts:91`** gate: `isAndroidApp``isAndroidApp || isIOSApp` (creates `ttsNativeClient`).
- **`mediaSession.ts` `getMediaSession()`** reorder: check native platforms FIRST
(`(android||ios) && isTauriAppPlatform()``TauriMediaSession`), THEN
`'mediaSession' in navigator`. iOS WKWebView (and Android WebView) expose
`navigator.mediaSession`, but the web session can't drive lock-screen controls
for AVSpeech/TextToSpeech — so it must lose to the native plugin.
- Tests: `tts-controller.test.ts` iOS gate + new `__tests__/libs/mediaSession.test.ts`.
## Non-obvious gotchas
- **`init` is a Swift reserved word.** Tauri iOS dispatch = `perform(Selector("\(command):"))`
(`mobile/ios-api/.../Tauri.swift`), so the "init" command needs selector `init:`.
Solution: `@objc(init:) public func initialize(_ invoke: Invoke)`. Verified it
compiles + `responds(to: Selector("init:"))==true` via swiftc. `perform` doesn't
apply ARC init-family retain rules (those are compile-time, direct-send only).
If it ever misbehaves on-device, fallback = rename the command for iOS in `mobile.rs`.
- **Pause == stop (mirror Android).** JS `NativeTTSClient.pause()` returns `false`,
so `TTSController.pause()` (line 472) does stop + re-speak on resume. The Swift
delegate must emit `end` ONLY on `didFinish`, **never on `didCancel`** (cancel
comes from stop/pause; an `end` there would auto-advance the reader).
- **AVAudioSession is owned by native-bridge.** `useTTSControl` calls
`invokeUseBackgroundAudio({enabled})` (plugin:native-bridge|use_background_audio →
`.playback`) on iOS TTS start/stop. AVSpeechSynthesizer uses the app session
(`usesApplicationAudioSession` defaults true), so the native-tts plugin does NOT
touch the audio session. Background + silent-switch playback comes for free
(Info.plist already declares `UIBackgroundModes: [audio]`).
- **MPRemoteCommandCenter.shared() is app-global and shared** with native-bridge's
`MediaKeyHandler` (hardware media-key page-turns on next/previousTrack). The
native-tts plugin stores its `addTarget` tokens and removes ONLY those on
deactivate. Lock-screen next/previous + the media-key page-turn both fire if
both are active — on-device test point.
- **Rate curve.** JS sends `pow(userRate, 2.5)` (tuned for Android setSpeechRate,
1.0=normal). Swift `avRate()` inverts (`^(1/2.5)`) and rescales onto
AVSpeechUtterance (0…1, `AVSpeechUtteranceDefaultSpeechRate`≈0.5 = normal). Top
speeds saturate at max (AV limitation).
- Voice id = `AVSpeechSynthesisVoice.identifier` (round-trips through set_voice →
`AVSpeechSynthesisVoice(identifier:)`). All iOS voices group under "System TTS"
in the JS `getVoices` (no `_`-prefixed engine id); enhanced/premium quality
appended to the display name to disambiguate same-named variants.
- Permissions already granted: `native-tts:default` (no platform restriction in
`capabilities/default.json`) covers every command.
## Media session on iOS — REVERTED the native reroute (round 3)
- On-device trace confirmed parallel teardown WORKS (`stop: wasSpeaking=true stopSpeaking returned true``set_media_session_active active=false``deactivateRemoteCommands: removed 5 targets, cleared nowPlayingInfo`). The remaining media-session problems were caused by the `getMediaSession()` reroute itself:
- Edge TTS lock screen lost cover + current sentence (REGRESSION): Edge plays via a WebView `<audio>` element → its lock-screen card is driven by `navigator.mediaSession.metadata` (set in `useTTSControl`). Routing iOS to `TauriMediaSession`/`MPNowPlayingInfoCenter` bypassed that.
- System TTS got NO controls: `AVSpeechSynthesizer` is not a WebView media element, so the app never becomes "Now Playing" and the plugin's `MPRemoteCommandCenter` targets never surface. (Edge gets controls because its `<audio>` element makes the app now-playing.)
- FIX: `getMediaSession()` reverted so iOS uses `navigator.mediaSession` (Android still first→`TauriMediaSession` foreground service). iOS system TTS now rides the same WebView path as Edge — the silent keep-alive `unblockAudio` `<audio>` element + `navigator.mediaSession` metadata/action-handlers. OPEN/UNVERIFIED: whether the SILENT keep-alive element registers as Now Playing on iOS (if not, system TTS still shows no card — would need a non-silent keep-alive or a real native now-playing implementation). The iOS Swift media-session methods (set_media_session_active etc.) are now DEAD on iOS (only Android Kotlin uses them via TauriMediaSession); left in place, harmless.
- Heavy Swift diagnostic logging (per-voice dump + per-command enter/resolve + delegate) still present; trim once confirmed.
## Follow-up iOS fixes (same PR)
- **Duplicate voice names**: Eloquence + legacy "novelty" voices (Rocko, Shelley, Grandma, Grandpa, Eddy, Reed, Flo, Sandy…) ship in many regions of one language, all quality=default. JS `getVoices` groups by primary language (`isSameLang`→normalized subtag), so e.g. en-US "Rocko" + en-GB "Rocko" collide in one "System TTS" list. Fix (in Swift `get_all_voices`): count `(primaryLanguage, displayName)`; for collisions append `regionDescription` (localized region, e.g. "Rocko (United Kingdom)"). Unique names stay clean. 192 system voices on a loaded device.
- **First word clipped "sometimes"**: each sentence is a separate `AVSpeechUtterance` spoken after a gap → audio route goes cold between sentences → first phonemes clipped. Same family as the startup `!act` (cannotActivate) `AVAudioSession` error from native-bridge `use_background_audio`. Fix: `utterance.preUtteranceDelay = 0.1` warms the route with silence first.
- **Stop "never tears down" / TTS icon stays blue (native only, Edge fine)**: the icon's blue state is driven by `viewState.ttsEnabled` (footer toggle) AND `isPlaying`/`showIndicator` (floating gradient `TTSIcon`). `handleStop` (useTTSControl) did all of `setIsPlaying`/`showIndicator` THEN `await ttsController.shutdown()` THEN `setTTSEnabled(bookKey,false)` as the LAST line — with NO try/catch. So if native `shutdown()` hangs OR throws, `setTTSEnabled(false)` never runs → footer icon stays blue forever; Edge never hits the stalling native path. ROOT FIX = reset ALL UI/session state (incl. `setTTSEnabled(false)`, null the ref) UP FRONT, then run shutdown/deinit best-effort in try/catch. Couldn't statically prove the exact native hang (every await in shutdown→stop is bounded/resolvable; native stop resolves since set_voice/set_rate use the same `resolve()` and playback works), so ALSO: bounded native stop invoke in `NativeTTSClient.stop()` (1500ms `Promise.race`) + Swift `os.Logger` lifecycle traces (speak/stop/pause/didStart/didFinish/didCancel) to pinpoint on-device — tapping stop should log `stop: requested``stop: resolved` + `didCancel`. Guard tests in `useTTSControl.test.tsx` assert `setTTSEnabled(false)` runs even when `shutdown()` rejects/never-resolves. NOTE: web bundle must be rebuilt for these JS fixes (not just the Swift plugin).
- **Lock-screen media session keeps running after disable (native only) — round 2**: the icon fix moved `setTTSEnabled` early, but `deinitMediaSession()` + `invokeUseBackgroundAudio({enabled:false})` were STILL after `await ttsController.shutdown()`. Native `shutdown()` stalls → those never run → lock-screen Now Playing lingers (Edge unaffected: never hits the stalling native path). The Swift media-session teardown is correct (Edge proves `set_media_session_active(false)``deactivateRemoteCommands` clears `MPNowPlayingInfoCenter.nowPlayingInfo`) — it just wasn't being CALLED. FIX = run shutdown + `invokeUseBackgroundAudio(false)` + `deinitMediaSession()` via `Promise.all` (best-effort, parallel) so media/audio teardown never waits on the controller shutdown. Added `set_media_session_active` os_log + guard test (deinit called even when shutdown never resolves). Still UNCONFIRMED why native `shutdown()` itself stalls (all JS awaits bounded; native stop invoke should resolve) — Swift lifecycle logs will reveal on-device.
## Verification done / pending
- Done (host): `pnpm lint`, `pnpm test` (only pre-existing unrelated
`fixed-layout-paginated-scroll.test.ts` fails — untracked, no impl), swiftc
`-typecheck` of the plugin vs iOS SDK with Tauri stubs (0 errors; Sendable
warning is a standalone-swiftc strict-concurrency artifact, project is Swift 5).
- Pending (on-device, user): build iOS, confirm init/speak/voices/rate/pitch,
auto-advance, pause-resume, lock-screen play/pause/next/prev + now-playing,
background playback, and the MediaKeyHandler interaction.
@@ -0,0 +1,18 @@
---
name: native-tts-offline-autoadvance-4613
description: "Android/iOS System TTS stops at chapter end (or random intervals) offline — controller only auto-advances on 'end', native terminal 'error' dead-ends + wedges state"
metadata:
node_type: memory
type: project
originSessionId: 5ae3d6fc-9082-4ba2-b7d4-e02dd277ee8f
---
# Native System TTS offline auto-advance halt (#4613, #4408)
**Symptom:** With Android System TTS (or iOS) **offline**, read-aloud stops — #4613 "at the end of the chapter, won't go to next chapter" (Samsung S25, Chinese voices); #4408 "random intervals" (GrapheneOS, Supertonic engine). Then the play/headphone controls feel **wedged**; #4408 also flashes the "Please log in to use advanced TTS features" toast on manual restart (separate client-selection path — controller briefly tries Edge).
**Root cause (`TTSController.#speak`):** auto-advance fires ONLY on `lastCode === 'end'`. The native client surfaces an offline engine failure as a terminal **`'error'`** code (Android `UtteranceProgressListener.onError`). Usually a **specific unsynthesizable utterance** (an unsupported CHARACTER — chrox's insight, fits online/offline asymmetry: engines network-fall-back for hard chars when online), hit on the new chapter's first utterance. On `'error'`: no `forward()` → playback dead-ends; `this.state` stays `'playing'` → controls wedge (restart re-errors on the same chunk). Edge/Web throw instead (caught by `error()` → state 'stopped'), so only **native** hits this. Engine-specific: Google local voices emit `onDone` fine, so it doesn't reproduce on every device.
**Fix (PR #4716, `#speak` only):** gate `canSkipOnError = this.ttsClient === this.ttsNativeClient`. On terminal `'error'` (native, playing, !aborted, !oneTime): **SKIP the chunk and `forward()`** — same as `'end'` — because re-speaking deterministically-bad text just fails again (do NOT retry; first attempt was retry-the-same-chunk which is futile for an unspeakable char). Bound `#consecutiveSpeakErrors` (reset on `'end'`); when it exceeds `TTS_NATIVE_SPEAK_MAX_CONSECUTIVE_ERRORS=5``await this.stop()` (graceful: wholly-unusable engine stops instead of silently racing to book end; leaves 'playing' so controls recover). Edge/Web byte-for-byte unchanged. Tests (`tts-controller.test.ts` "native TTS offline error recovery (#4613, #4408)"): skip-advances-past-bad-chunk (forward spied) + cap-stops-gracefully (key off `state.attempts` NOT `state` — controller starts 'stopped' and `forward()` transiently re-enters 'stopped', so `waitFor(state==='stopped')` false-matches).
**On-device verification reality (Xiaomi 13 fuxi, Android 16, WebView 147, Google TTS):** CANNOT reproduce the fault — offline auto-advance works, even offline+screen-off (foreground-audio service keeps the WebView UNthrottled; Google local engine emits onDone offline). Matches maintainer's non-repro. Needs the reporter's engine (Samsung/Supertonic/Chinese-network voice). Force the engine-error path on this device by setting a `*-network` voice offline. See [[cdp-android-webview-profiling]] for the CDP recipe; gotcha: `window.__TAURI_INTERNALS__.invoke`/`runCallback` get RE-INJECTED on Next.js client nav (wrappers revert) — `console.log` wrapping persists, so trace via the `[TTS] speak` / `[TTS] Initialized TTS for section N` logs instead. Related: [[tts-fixes]], [[tts-browser-e2e-harness]].
@@ -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,16 @@
---
name: opds-autodownload-tls-skipssl-4988
description: "#4988 OPDS auto-download failed on self-signed/private-CA servers — native download_file (rustls) needs skipSslVerification like the manual path (#2900)"
metadata:
node_type: memory
type: project
originSessionId: 9066b80b-3cb5-44df-9c4b-7f609cf285a5
---
Issue #4988: OPDS auto-download failed while manual browse/download of the same catalog worked (reporter: iPad + Calibre-Web NextGen behind nginx https). Reporter blamed the credential-less HEAD probes — red herring: those are `probeAuth` challenge probes, their 401 is by design and the Basic header still reaches the GET.
**Real signature:** feed GETs and HEAD probes appear in the server log, download GETs never do → the native `download_file` dies client-side in the TLS handshake. The Tauri http-plugin path (`opdsReq.ts`) always passes `danger: {acceptInvalidCerts: true}`, but `transfer_file.rs` builds its reqwest client with **rustls**, which ignores the OS trust store — self-signed, private-CA, or incomplete-chain certs all fail unless `skip_ssl_verification` is set. Manual download (`page.tsx handleDownload`) got `skipSslVerification: true` in #2900 (for #2871); `autoDownload.ts downloadAndImport` never did.
**Fix (2026-07-08, PR #5002):** pass `skipSslVerification: true` in autoDownload's `downloadFile` call. Test in `opds-auto-download.test.ts`.
**How to apply:** any new code path that downloads via native `download_file`/`tauriDownload` from a user-configured server must mirror the manual path's `skipSslVerification` — TLS behavior differs between the http plugin (danger flags on) and transfer_file (strict rustls by default), so "browse works but download fails, nothing in server logs" = check this first. "curl works without -k" on another machine proves nothing about rustls trust.
@@ -0,0 +1,23 @@
---
name: opds-groups-carousel-4750
description: OPDS feed groups (>=2) render as horizontal virtualized carousels with lazy cover loading
metadata:
node_type: memory
type: project
originSessionId: 3073b2b0-8219-42cc-8e3f-547715b86b01
---
#4750 (PR #4755, merged): when an OPDS `feed.groups.length >= 2`, `FeedView` renders each group's publications/navigation as a horizontal carousel (`src/app/opds/components/GroupCarousel.tsx`) instead of the grid; single-group feeds keep the grid. Matches Thorium.
`GroupCarousel` wraps a horizontal `react-virtuoso` `Virtuoso` (`horizontalDirection`), so only in-view items mount → covers load lazily as you scroll (verified via network: ~12 covers/group fetched regardless of group size; far-right items fetch only after scrolling to them).
Gotchas (cost real debugging):
- `VirtuosoHandle.scrollBy({left})` is a **no-op** in horizontal mode (the handle maps to the vertical axis). Page the arrows by **index** via `scrollToIndex({index, align, behavior})`, tracking the visible range from `rangeChanged`.
- Virtuoso sizes the horizontal track **lazily**, so a pixel `scrollBy` on the scroller element clamps to the currently-rendered width — another reason to scroll by index.
- Arrow visibility comes from `atTopStateChange`/`atBottomStateChange` (top=left, bottom=right). Row height is measured from the first `[data-carousel-item]`; arrows are vertically centered on the cover by measuring the first `<figure>` (cards have title/author below, so centering on the whole row looks low).
- Scrollbar hidden via a scoped `.no-scrollbar` util in `globals.css`; arrows use `eink-bordered`.
- Tests must mock `react-virtuoso` (jsdom has no layout) like the TOCView/BooknoteView tests — render all items via `itemContent`.
`PublicationCard` (shared by carousel + grids) got rounded covers (`overflow-hidden rounded`, matching the library bookshelf) and dropped the inline acquisition/price badge — that badge still renders on the detail page (`PublicationView`).
Related: [[virtuoso_overlayscrollbars]].
@@ -0,0 +1,27 @@
---
name: opds-popular-catalog-dedup-4782
description: "Added popular OPDS catalog still showed in Popular section (looked like a duplicate); filter it out, not just hide its Add button"
metadata:
node_type: memory
type: project
originSessionId: fd07b2a4-290b-4f10-a01d-190281571221
---
Issue #4782: adding a generic "Popular Catalog" (e.g. Project Gutenberg) to My
Catalogs left it ALSO rendering in the Popular Catalogs section → looked like a
duplicate.
Root cause in `src/app/opds/components/CatalogManager.tsx`: on add, only the
**Add button** was hidden (`{!isAdded && ...}`) — the whole card kept rendering
with its Browse button, so the entry visibly appeared in both sections.
Fix: filter added/disabled entries out of the Popular list entirely. New pure
helper `getUnaddedPopularCatalogs(popular, added)` in
`src/app/opds/utils/opdsUtils.ts` dedups by **normalized URL** (trim +
lowercase), mirroring the store's `findByUrl`. Component computes
`popularCatalogs = isOnlineCatalogsAccessible ? getUnaddedPopularCatalogs(POPULAR_CATALOGS, catalogs) : []`;
the section already auto-hides on `popularCatalogs.length === 0`, so once all
popular entries are added the whole section disappears. Tested in
`src/__tests__/app/opds/opds-utils.test.ts`.
Related: [[opds-self-link-metadata-4749]], [[opds-groups-carousel-4750]].
@@ -0,0 +1,16 @@
---
name: opds-preemptive-basic-digest-400
description: "Calibre digest/'auto' servers 400 the preemptive Basic header from PR #4206; fetchWithAuth must bare-retry on 400 to surface the Digest challenge"
metadata:
node_type: memory
type: project
originSessionId: 9066b80b-3cb5-44df-9c4b-7f609cf285a5
---
Calibre's content server in `digest` (or `auto` over http) auth mode responds to a `Basic` Authorization header with **400 "Unsupported authentication method"** — not a 401 challenge. PR #4206 (commit 83607d14e) made `fetchWithAuth` (`src/app/opds/utils/opdsReq.ts`) send Basic preemptively (for Calibre-Web-style servers that return anonymous 200 without a challenge), which dead-ended all digest-mode Calibre servers: the retry logic only fired on 401/403, so users saw "Failed to load OPDS feed: 400 Bad Request" (reported on Android, but platform-independent — web proxy relays the 400 too).
**Fix (2026-07-08, PR #5002):** in `fetchWithAuth`, when the first response is 400 AND preemptive Basic was sent, re-issue the request once *without* credentials to surface `WWW-Authenticate`, then let the existing 401/403 negotiation pick Digest. Direct path strips the Authorization header; proxy path strips the `auth=` query param. Tests in `src/__tests__/utils/opds-req.test.ts`.
**Why:** the two auth-server archetypes conflict — anonymous-200 servers need preemptive creds (#4206), strict digest servers reject them with 400. Only runtime negotiation satisfies both; don't "fix" one archetype by regressing the other.
**How to apply:** any preemptive-auth optimization needs a recovery path for servers that reject the scheme outright (400/4xx without challenge), not just for 401/403 challenges. The app's Digest implementation itself is correct (Calibre's strict parser answers 401, not 400, to its headers). Verify against a real Calibre: dummy creds distinguish malformed (400) from wrong-password (401). Beware: Calibre throttles repeated failed logins with transient 503s. Related: [[security-advisories-web-2026-06]] (the *other* OPDS 400 — dev-LAN SSRF block in the proxy).
@@ -0,0 +1,19 @@
---
name: opds-self-link-metadata-4749
description: OPDS 2.0 summary publications need self-link dereference for full metadata; JSON description is HTML
metadata:
node_type: memory
type: project
originSessionId: 0e1e6ec0-38c1-45a2-aab6-52b78a5ad38a
---
Readest issue #4749 (pglaf/Gutenberg test feed `https://opds-test.pglaf.org/opds/`). Two related OPDS bugs, both fixed together.
**1. Summary publications need a `self`-link dereference.** OPDS 2.0 feeds may list a publication with only minimal metadata + a `rel:"self"` link of type `application/opds-publication+json` (no acquisition links, no description) — the server sends the full record only when the client follows that link on click. Thorium does this; Readest did not.
- New `src/app/opds/utils/opdsPublication.ts`: `getPublicationDetailHref(pub)` finds the `rel:"self"` link whose type is `application/opds-publication+json` or Atom `application/atom+xml;type=entry`; `parsePublicationDocument(text, docURL)` parses JSON or Atom-entry XML (reuses foliate `getPublication`) and **absolutizes** links/images hrefs against `docURL` so downloads/cover resolve regardless of the feed's `baseURL`.
- `page.tsx`: renamed derived `publication``basePublication`; an effect fetches the detail doc (via `fetchWithAuth` + proxy refs) when `selectedPublication` is set AND a detail link exists (skip directly-loaded entry docs — already full); merges as `{ metadata: resolved.metadata, links/images: resolved.* || base.* }` keyed by `source===basePublication` so a stale fetch can't bleed into the next selection. Summary renders immediately, upgrades in place.
**2. JSON `description` is HTML.** OPDS 2.0 keeps the summary in plain `metadata.description` (no typed `<content>`), and pglaf fills it with `<p>...</p>`. `PublicationView` rendered `<p>{description}</p>` → literal tags. Fix: `getOPDSDescriptionHtml(content ?? description)` so the (sanitized) markup renders. See [[bug-patterns]] and prior [[OPDS HTML description (#4503)]] decode-once+sanitize.
**Why:** less data per feed page + faster load; client dereferences on demand.
**How to apply:** when an OPDS publication looks under-populated, check for a `rel:"self"` publication-type link before assuming the feed is the whole record. Related OPDS notes: opds-firefox-strict-xml-4479, opds2-json-search-4502, opds-html-description-4503.
@@ -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,54 @@
---
name: pageturn-bg-replace-reflow-4785
description: "Page-turn frame drops at chapter boundaries (#4785) — per-frame"
metadata:
node_type: memory
type: project
originSessionId: e42ea03e-cda7-4e59-b398-1a28f589b37e
---
Issue #4785: swipe page-turn animation drops frames, worst crossing .xhtml
section boundaries, "first open" (Android/Xiaomi). Repro book is a Taiwan light
novel with custom fonts + `.bg`/`background-attachment:fixed` front-matter.
Root cause (in `packages/foliate-js/paginator.js`, a submodule):
`#replaceBackground()` rebuilt its whole paint context **every frame** of both
swipe phases — `getComputedStyle(<html>)` + `this.size` + one
`getBoundingClientRect()` **per rendered view** + a per-view background-reset
write loop + full `#background` DOM rebuild. Those forced reads scale with the
number of loaded views, which **peaks at a chapter boundary** because adjacent
sections are preloaded there — hence "worst at boundaries". Two callers ran it
per-frame: the snap `syncBackground` rAF loop (`#scrollTo`) and the drag-phase
container `scroll` listener (`#onTouchMove``scrollBy`→scroll event).
Everything `#replaceBackground` reads is **invariant for one gesture** (theme/
texture, bg+container geometry, each view size+bg) — only scroll offset changes.
Fix:
- Split into `#readBackgroundStyle` / `#computePaginatedBgContext` (the reads) +
`#paintPaginatedBackground(ctx, atPosition)` (writes only; calls the unchanged
pure `computeBackgroundSegments`).
- `#bgAnimContext` field snapshots context once: set in `#onTouchStart` (drag)
and at the start of the animated branch in `#scrollTo` (snap); cleared in
`#onTouchEnd` and both animation `.then()`s. `#replaceBackground` uses
`this.#bgAnimContext ?? this.#computePaginatedBgContext()`.
- Also deferred the heavy mid-drag forward preload: added `&& !this.#touchScrolled`
to the scroll-listener `#loadAdjacentSection` gate (columnize/expand on the main
thread janked the drag). The scroll that settles the gesture re-fires the gate
with the finger up, so the buffer still tops up.
Tests: `src/__tests__/document/paginator-background-anim-perf.browser.test.ts`
(real Chromium) drives `next()` (snap) and a synthetic touch drag, spying on the
primary iframe `<html>` getComputedStyle. Pre-fix: 39 reads (snap) / 7 (drag);
post-fix ≤3 / ≤1. Existing `paginator-background-segments.test.ts` (pure
`computeBackgroundSegments`) stays green — visual output unchanged.
Scrolled-mode branch kept inline in `#replaceBackground` (never the per-frame hot
path). Behavior preserved: scrolled set every view bg so the old reset loop was
redundant; `containerSize = containerRect[sideProp]` == old `this.size`.
NOT the cause (ruled out): `computeBookNav`/`nav.json` is awaited before the view
renders, so first/second-open in-memory state is identical — it can't explain
reading-time swipe jank. See [[booknote-view-autoscroll-4352]] neighbors in
Paginator & Scroll. Related: [[paginator-swipe-bg-flash]],
[[global-annotation-pageturn-perf-4575]], [[paginated-texture-occlusion-4399]].
@@ -0,0 +1,20 @@
---
name: paragraph-mode-toggle-resume-4717
description: "Paragraph mode (#4717) Shift+P double-toggle, dialog key handling, and chapter-start rewind — root causes + reusable gotchas (eventDispatcher re-entrancy, foliate lastLocation vs store progress)"
metadata:
node_type: memory
type: project
originSessionId: a19a021a-0e84-4bf6-bde4-02b115b6306f
---
PR #4725 fixed three Shift+P paragraph-mode bugs (#4717). Follow-on to #4723 (Alt+P proofread + initial overlay attempt). Three reusable, non-obvious findings:
**1. `eventDispatcher.dispatch` live-Set re-entrancy (the widest-reach gotcha).** `dispatch()` iterated the live `asyncListeners` Set while `await`ing each listener. A listener that triggers a React state change which re-runs an effect that re-subscribes a handler for the SAME event gets that new handler invoked in the same dispatch loop → the event double-fires. Symptom here: one Shift+P toggled paragraph mode twice (exit→re-enter "flash"); `useParagraphMode`'s `toggle-paragraph-mode` subscription effect has `paragraphConfig.enabled` in deps, so the exit's awaited `dispatch('paragraph-mode-disabled')` re-subscribed mid-loop. Fix = snapshot before iterating: `for (const l of [...listeners])` in `dispatch` (`dispatchSync` already did). **Applies to ANY event whose handler re-subscribes.**
**2. Overlay key handling = dialog/alert pattern, NOT a global window listener.** The maintainer (chrox) explicitly rejected wiring `onEscape` into useShortcuts. Correct pattern: the overlay's container is `role=dialog tabIndex=-1`, gets `containerRef.current.focus({preventScroll:true})` on open, and handles Escape / toggle shortcut / nav in its OWN `onKeyDown` (stopPropagation so the global handler can't double-fire). Add `outline-none` to the programmatically-focused non-tab-stop container or it draws a focus ring around the whole viewport. The old capture-phase `window.addEventListener('keydown',...,true)` + `stopImmediatePropagation()` swallowed global shortcuts and only fired when focus was on the parent doc (not the foliate iframe).
**3. Paragraph-mode resume rewound to chapter start.** Two causes: (a) entering/exiting scrolled the underlying view to the focused paragraph's START via `renderer.goTo`/`scrollToAnchor`; when the page-top paragraph began on the previous page this rewinds a page, and repeated enter/exit accumulates. Fix = `focusCurrentParagraph(align=false)` on resume/first-mount, drop the exit `scrollToAnchor`; navigation keeps `align=true`. (b) resume preferred the rAF-debounced `readerStore` progress and a stored last-paragraph CFI (`view.getCFI(docIndex, paragraphBlockRange)`) that comes out MALFORMED (e.g. `epubcfi(/6/18!,/4/110,/4)`) and `resolveCFI`s to a non-null EMPTY range, shadowing the correct candidate in the `??` chain and sending `findByRangeAsync` to `first()` (the title). Fix = resume from `view.lastLocation.cfi` FIRST.
**foliate `view.lastLocation` vs readerStore progress:** `view.lastLocation = {cfi, range, ...}` is set SYNCHRONOUSLY by foliate on every relocate; the readerStore progress is rAF-debounced (FoliateViewer `commitRelocate`) and lags/desyncs. For any resume/current-position logic prefer `view.lastLocation.cfi` (CFI is document-instance-independent, survives the section iframe being recreated on toggle — a stored `Range` does not). `view.lastLocation` is NOT in the FoliateView TS type by default (had to add it).
**Verification/harness gotchas (claude-in-chrome on the reader):** synthetic keystrokes (`computer key shift+p`) BUFFER/DROP chaotically — single presses vanish then fire in delayed bursts, creating inconsistent multi-toggle states; `ArrowRight` repeat worked, single Shift+P often didn't. Menu clicks (View menu → Paragraph Mode) are reliable for toggling. The content iframe lives in the foliate-view shadow DOM (`iframeCount:0` at top level); focus on `FOLIATE-VIEW` → keydown arrives as an `iframe-keydown` postMessage, focus on parent → real window keydown. Probe live state via `document.querySelector('foliate-view').lastLocation`. Tool returns containing "overlay=" strings sometimes hit a `[BLOCKED: Cookie/query string data]` filter — return JSON objects instead. Related: [[tts-sync-chrome-verification]], [[tts-browser-e2e-harness]].
@@ -0,0 +1,18 @@
---
name: pdf-cbz-contrast-view-menu
description: "Contrast option in View menu for fixed-layout (PDF/CBZ) docs; per-book, CSS filter"
metadata:
node_type: memory
type: project
originSessionId: 94f785c8-9015-4140-b64d-c6177e033189
---
Added a **Contrast** stepper to the reader **View menu** (`ViewMenu.tsx`) for fixed-layout / image docs (PDF/CBZ/FXL-EPUB). Models the existing `invertImgColorInDark` / `zoomLevel` pattern. Increase/decrease/reset (+ / / ◐%), gated inside the `rendition?.layout === 'pre-paginated'` block, placed right under the Zoom Level control.
**Key wiring (mirror this for any future fixed-layout image adjustment — brightness, saturation):**
- Type: `contrast: number` in `BookStyle` (`types/book.ts`, fixed-layout section). Default `contrast: 100` in `DEFAULT_BOOK_STYLE` (`constants.ts`); also `MIN_CONTRAST=50`/`MAX_CONTRAST=300`/`CONTRAST_STEP=10`.
- Filter applied in `applyFixedlayoutStyles()` (`utils/style.ts`) on the `img, canvas` rule. **GOTCHA:** CSS `filter` is a single property — a second `filter:` line overrides the first. Build ONE declaration: collect `invert(100%)` (dark+invert) and `contrast(${c}%)` (c!==100) into an array, join with spaces. invert/contrast commute so order is irrelevant. Contrast applies in light mode too (independent of dark/invert).
- **Local to current document:** `saveViewSettings(envConfig, bookKey, 'contrast', value, /*skipGlobal*/ true, /*applyStyles*/ true)`. `skipGlobal=true` forces the per-book branch (`applyViewSettings(bookKey)`) regardless of `isGlobal`, so it never touches `globalViewSettings`.
- **Re-apply on change:** add `viewSettings?.contrast` to the dependency array of the `FoliateViewer.tsx` effect (~L829) that calls `applyFixedlayoutStyles` on every rendered doc. New pages pick it up via the on-load `applyFixedlayoutStyles(detail.doc, viewSettings)` call (~L321). Re-render is driven by `setViewSettings` updating `bookDataStore` config → parent `BooksGrid` re-renders FoliateViewer.
Test: `src/__tests__/utils/fixed-layout-styles.test.ts` (new) asserts the combined `filter: invert(100%) contrast(150%)` and the no-filter-at-100% cases. The settings dialog `ColorPanel.tsx` was intentionally NOT touched — request was View menu only. Related: [[tap-to-open-image-table-4600]], css/style hub `src/utils/style.ts`.
@@ -0,0 +1,47 @@
---
name: pdf-oom-range-flood-3470
description: "Android/iOS large-PDF import/open OOM (#3470) = unthrottled pdf.js range-request flood, not whole-file load; fix = concurrency cap in foliate makePDF"
metadata:
node_type: memory
type: project
originSessionId: 1f5ecad5-076c-4170-939a-c80438c37f64
---
# Large-PDF OOM on Android/iOS (#3470)
**Symptom:** importing/opening a 50 MB+ PDF crashes (no message) with
`java.lang.OutOfMemoryError ... target footprint 536870912` (512 MB Java heap)
at `RustWebViewClient.handleRequest``shouldInterceptRequest`. Same file is
fine in the official pdf.js viewer on Android Chrome. Repro file: `100个句子记完7000个雅思单词.pdf` (67 MB, 970 pages).
**Root cause (NOT whole-file load):** opening the PDF makes pdf.js fire ~759
small **64 KB** range reads to parse scattered xref/object streams. foliate-js
`makePDF` fulfilled every `requestDataRange` with an **un-awaited**
`file.slice(begin,end).arrayBuffer()` → all dispatched at once (measured
**maxInFlight 753**). On Android each read is a `fetch()` to the `rangefile`
scheme → `shouldInterceptRequest` allocates a Rust `Vec<u8>` + a Java `byte[]`
per request; ~750 simultaneous intercepted requests exhaust the 512 MB Java
heap. The official pdf.js viewer survives because the **browser caps ~6
connections/host**; the custom `rangefile` (and iOS native-file) scheme has no
such cap. Explains "50 MB+" (bigger PDF → more scattered objects → bigger
flood) and "crashes on some devices only" (heap/WebView threshold).
**Fix (RESOLVED — foliate-js#31 squash `e098bc3` + readest#4670, both merged):** `packages/foliate-js/pdf.js` `makePDF` — queue + pump bounding
range reads to `MAX_CONCURRENT_RANGES = 6` (mimics the browser's per-host
limit). One spot covers Android `RemoteFile`, iOS `NativeFile`, web `File`.
Throttling is **free** on speed (6 parallel fetches saturate throughput). foliate-js
is a **git submodule** → commit + push to readest fork, then bump pointer.
Test: `src/__tests__/foliate-pdf-range-concurrency.test.ts``vi.mock('@pdfjs/pdf.min.mjs')` installs a fake `globalThis.pdfjsLib` whose `getDocument` fires a 200-call flood; asserts `maxInFlight ≤ 6` and all served. Fails (200) before, passes after.
## On-device CDP verification recipe (no rebuild)
Release Readest 0.11.10 ships a debuggable WebView (socket
`webview_devtools_remote_<pid>`), so CDP attaches without `run-as`.
- `adb forward tcp:9222 localabstract:webview_devtools_remote_$PID`; page WS from `curl :9222/json`.
- Push file where asset scope allows: `/sdcard/Readest/Books/` matches scope glob `**/Readest/**/*`; app has MANAGE_EXTERNAL_STORAGE → readable. Canonical path `/storage/emulated/0/Readest/Books/x.pdf`.
- rangefile URL: `http://rangefile.localhost/?path=<encodeURIComponent(abs)>&start=&end=` (end **inclusive**, omit=EOF, 8 MB cap, returns 200 + `X-Total-Size`).
- Faithfully replicate `makePDF`: `await import('http://tauri.localhost/vendor/pdfjs/pdf.min.mjs')` (sets `globalThis.pdfjsLib`, same vendored 5.7.284), a file-like `{size, slice(b,e)→{arrayBuffer:()=>fetchRangePart(b,e-1)}}`, `new pdfjsLib.PDFDataRangeTransport(size,[])`, instrument `requestDataRange`, `getDocument({range,wasmUrl:'/vendor/pdfjs/',cMapUrl,standardFontDataUrl,isEvalSupported:false})` then `getPage(1)/getViewport/getMetadata`.
- Java heap via `adb shell dumpsys meminfo com.bilingify.readest` (Dalvik Heap line).
**Verified on Xiaomi 13 (fuxi) / Android 16 / WebView 147 / 8 GB:** this device does NOT OOM (newer WebView; Dalvik only +9 MB) but the flood reproduces: **753 → 6** concurrent, open time **1446 → 1479 ms** (no penalty), 970 pages/title/viewport identical. Gotcha: package installs (`installPackageLI` in logcat) kill the app mid-session → re-discover the devtools socket PID. The makePDF flood alone did NOT crash this device — can't get a live OOM here; rely on the user's WebView-145 log + the bounded-concurrency proof.
Related: [[android-nativefile-remotefile-io]] (rangefile vs asset-protocol Range bug), [[webtoon-mode-3647]] (foliate-js submodule fork-push).
@@ -0,0 +1,27 @@
---
name: pdf-scroll-lag-preload-4795
description: "PDF scrolled-mode rendering lag on Android (#4795/#4031) — fix via widened preload margin + bounded prioritized load scheduler in fixed-layout.js"
metadata:
node_type: memory
type: project
originSessionId: 902324ba-94ee-4c88-804e-ea9f796681f9
---
PDF **scrolled mode** showed blank pages while scrolling on Android (#4795, resurfacing #4031). Reproduced + fixed + CDP-verified on Xiaomi 13 (fuxi).
**Root cause (measured via CDP on-device):** per-page render (`pdf.js` `onZoom``render()`: canvas raster + text layer + annotation layer) ≈ **415 ms** uncached / ~65 ms cached, but the scrolled-mode IntersectionObserver used `rootMargin: '50% 0px'` (~0.5 page of lead). Loads also had **unbounded concurrency** (observer fired `#loadScrollPage` for every intersecting page) and **no viewport prioritization**, so the slow render never finished before the page scrolled into view, and a fling spawned dozens of competing renders. Per-page canvas ≈ **7 MB** at dpr 3 (screen-res, NOT the ~50 MB I first feared) → memory headroom existed; the #3470 OOM was byte-range *parsing* flood (`MAX_CONCURRENT_RANGES`, orthogonal).
**Fix** (`packages/foliate-js/`):
- `fixed-layout.js`: widened observer to `rootMargin: '200% 0px'` (~2 viewports lead); observer now only **flags `page.visible`** and calls new `#scheduleScrollPages()`.
- New pure exported `planScrollModePages({pages, currentIndex, maxLoaded, maxConcurrent, loadingCount})``{load, evict}`: loads **visible+idle pages nearest currentIndex first, bounded by `maxConcurrent - loadingCount`**; evicts **farthest non-visible loaded** beyond `maxLoaded`; **never evicts a visible page** (distance = `|index - currentIndex|`). Unit-tested in `src/__tests__/document/fixed-layout-scroll-scheduler.test.ts`.
- `#scrollMaxLoaded 8→12` (live-canvas cap = memory ceiling), `#scrollMaxConcurrent=3`, `#scrollLoadingCount` tracked in `#loadScrollPage` (inc on start, dec in `finally`, then reschedule so a freed slot pulls the next nearest page). Removed `#evictScrollPages` (scheduler handles it).
- **Terminal `error` state**: a load that throws or returns no src sets `state='error'` (not `'idle'`) so the post-completion reschedule can't retry a persistently failing page in a tight async loop (regression I introduced with reschedule-on-completion).
- `pdf.js`: `MAX_CACHED_PAGES 8→16` (page objects + render blobs are cheap, not the canvas) so back-scroll within the wider window doesn't re-parse.
**Verified (CDP + screenrecord, identical 9-swipe reading-pace test, fresh region):** baseline = mostly blank frames, settled forward lead **+2** (span [-9,+2]); fix = **every frame fully rendered**, forward lead **+4** (span [-7,+4]). Extreme 8-fling (240 pages/2s) still blanks mid-fling (inherent) but settles to rendered content and **no crash**.
**Best-practice cache strategy for scrolled PDF on mobile** (asked during this work): two bounded tiers — live-canvas cap = the hard memory ceiling (sized to window+lead), decoded-page cache a bit larger (cheap); distance/viewport-aware LRU never evicting visible; bound+prioritize loads; release bitmaps eagerly (`canvas.width=0`); the biggest unused lever for low-end devices is **capping effective DPR** (canvas mem ∝ DPR²) — not applied here since 12×7 MB≈84 MB is fine on the Xiaomi.
**CDP on release builds:** the installed Play/release 0.11.12 has **no `webview_devtools_remote_<pid>` socket** — WebView debugging is gated behind the `devtools` Cargo feature (`src-tauri/Cargo.toml`); must build+install `pnpm dev-android` (release + `--features devtools`, same keystore so it updates over the store build, library preserved). CDP `webSocketDebuggerUrl` comes back as `ws://localhost/devtools/...` **with no port** (echoes Host header) → rewrite to `ws://127.0.0.1:9222<path>`; `ws` npm pkg is CJS so import default + destructure. See [[cdp-android-webview-profiling]], [[pdf-oom-range-flood-3470]].
**WIP caveat:** during this work the foliate-js submodule had unrelated uncommitted `paginator.js` WIP (background-anim perf, #4785) — exclude it from any #4795 commit.
@@ -0,0 +1,28 @@
---
name: pdf-scroll-mode-wheel-double-4727
description: Fixed-layout/PDF scrolled mode scrolls 2x (instant lurch) when wheeling over the page vs smooth over the margin
metadata:
node_type: memory
type: project
originSessionId: 063f5588-52bf-4042-92f9-babcf492e378
---
# PDF scrolled-mode wheel double-scroll (#4727)
**Symptom:** In fixed-layout/PDF **scrolled** mode, a mouse-wheel notch scrolls ~2× as far and feels instant when the pointer is **over the page** (the iframe), but a single smooth scroll when over the **page margin**. Reproduces on BOTH web and tauri (reporter saw it only in the WebView2 app, but maintainer reproduced on web too). Paginated mode unaffected.
**Root cause:** `fixed-layout.js` scroll mode (`#loadScrollPage`) attaches a `{ passive: true }` wheel listener to each page iframe's doc that called `this.scrollBy({ top: e.deltaY, behavior: 'instant' })`. The iframe is `scrolling="no"` + `overflow:hidden`, so the browser **already chains** the wheel to the host scroller natively (smooth). The manual `scrollBy` **stacks on top of** that native scroll → 2× distance, the instant jump = the `behavior:'instant'` part, the glide = the native chain. Margin-hover hits the host directly → only the single native scroll → no doubling.
The iframe is interactive (`pointer-events:auto`) only during a 150ms idle window after scrolling settles (`#handleScrollEvent` disables it during active scroll, re-enables 150ms after). A notched wheel slower than ~6/sec puts EVERY notch in that idle window → every notch lands on the iframe → every notch doubles (explains the steady "twice as fast", not just the first tick).
**Fix:** Delete the manual `this.scrollBy(...)`; keep `this.#setScrollIframeInteraction(false)` so the iframe stops intercepting and the rest of the gesture also scrolls the host natively. Native scroll-chaining is the single smooth scroll that matches the margin. The old "forward wheel to host" code wrongly assumed the tick was lost without it.
**Why not preventDefault+manual:** would make page-hover an *instant* scroll, not matching the smooth native margin scroll the user wants. Letting native handle it is the only way to match the margin feel.
**Reproduction / test technique (jsdom can't — needs real layout + real wheel):**
- Standalone Playwright proof: scroll container + `scrolling="no"` srcdoc iframe + the buggy handler, `page.mouse.wheel(0,120)` over the iframe → scrollTop 240 vs 120 over margin; remove `scrollBy` → 120 == 120. (real `mouse.wheel` triggers native chaining; synthetic dispatch does NOT.)
- Committed regression test `src/__tests__/document/fixed-layout-scroll-wheel.browser.test.ts` (browser lane, `pnpm test:browser`): mounts the REAL `<foliate-fxl>` in scrolled mode (minimal fake book: `rendition.viewport`, sections whose `load()` returns `{ src:'srcdoc', data: tallHtml }``src` must be truthy or `#createScrollFrame` returns blank; `data` → srcdoc keeps iframe same-origin so contentDocument is reachable), dispatches a **synthetic** `WheelEvent` on the page iframe doc (synthetic wheel doesn't chain natively, so any movement is the JS handler = must be 0). Fails `120` against the bug, passes fixed.
**CI flake + hardening (2026-07-07):** original assertion set `scrollTop=0`, dispatched, `await setTimeout(60)`, then `expect(scrollTop).toBe(0)` — flaked on slow CI runners with **`expected 4 to be +0`**. Root cause: as sibling scroll pages finish loading, `#loadScrollPage` runs `#restoreScrollModeAnchor` **asynchronously**, which at scrollTop=0/index-0 (fraction 0) snaps `scrollTop` to page 0's `offsetTop` = the **4px `--scroll-page-gap`** margin. The 60ms post-dispatch delay raced that re-anchoring → observed 4. NOT the bug (bug = 120px). **Fix:** the buggy `scrollBy({behavior:'instant'})` is *synchronous* (lands before `dispatchEvent()` returns), so measure `before=scrollTop` / dispatch / `after=scrollTop` with **NO await between** and assert `after===before`. Synchronous read isolates the handler's own effect; immune to the async anchor-restore. Verified: reintroducing the buggy scrollBy → `before=4, after=124` (delta 120, still caught); reverted → stable across repeated runs.
Fix lives in the `packages/foliate-js` submodule (separate repo/commit). Relates to [[fixed-layout-paginated-scroll-reset-4683]], [[webtoon-mode-3647]].
@@ -0,0 +1,52 @@
---
name: pdf-spread-canvas-seam-4587
description: PDF two-page spread shows a 1px white bar at the spine on fractional devicePixelRatio (Windows 150%); canvas bitmap truncation
metadata:
node_type: memory
type: project
originSessionId: 9c176878-7bcd-4411-8c55-5ebce094a73b
---
#4587 — PDF two-page spread shows a one-pixel white bar in the MIDDLE (at the
spine) on "certain zoom levels". Repro condition = fractional devicePixelRatio
(Windows display scale 150% → dpr 1.5); at 100% (dpr 1) no bar. Fixed in
`packages/foliate-js/pdf.js` `render()`.
**Root cause:** `render()` sized the page canvas only via its bitmap
(`canvas.width = viewport.width`). `viewport.width = pageWidthCss * dpr` is
fractional, and a canvas bitmap width must be an integer, so it truncates (FP
error often drops a whole pixel: 522*1.5=783 → viewport 782.9999 → bitmap 782).
The iframe content is displayed scaled by `1/dpr` (the `documentElement`
`transform: scale(1/devicePixelRatio)`), so the truncated bitmap renders up to
~1 device px NARROWER than the page box. The left page's canvas stops short of
the spine → exposes the reader background as a thin seam (white in light
themes; in the dark demo it reads as a dark line). Right page's canvas starts
exactly at the spine, so the gap is the LEFT page's shortfall only. The element
flex boxes are always exactly adjacent (left.elR === right.elL === spine) — NOT
the source; the seam is canvas-vs-box, not box-vs-box.
**Fix:** pin an explicit CSS size to the un-truncated viewport dims so the
bitmap scales to fill the box exactly:
`canvas.style.width = `${viewport.width}px``; same for height. Display =
viewport.width/dpr = exact page box → left canvas reaches the spine. General:
fixes every page-canvas edge shortfall (single page + right page outer edge
too), all dpr/modes. Idiomatic pdf.js HiDPI pattern (bitmap=device px, CSS=
logical size) that the foliate wrapper had omitted.
**Why dpr=2 can't repro (and dpr=1.5 readily does):** equal-width spread pages
split content/2 exactly. At dpr 2, pageW*2 stays integer for even content
widths → clean. At dpr 1.5, pageW*1.5 = content*0.75 is fractional unless
content divisible by 4 → seam of 0, 0.5, or 1.0 device px depending on width.
**CDP dpr=1.5 repro recipe (no device needed):** launch a throwaway desktop
Chrome `--force-device-scale-factor=1.5 --remote-debugging-port=9444
--user-data-dir=/tmp/x`; dev-web seeds demo EPUBs in a fresh profile but no PDF
— import a sample PDF (`apps/readest-app/src/__tests__/fixtures/data/sample-alice.pdf`,
69pp US-Letter) via CDP `Page.setInterceptFileChooserDialog`+`fileChooserOpened`
`DOM.setFileInputFiles` (the readest "Import Books" button opens a MENU; click
"From Local File" to trigger the chooser). `Browser.setWindowBounds` to sweep
ODD inner widths (1283/1284…) to hit fractional pageW. Measure left-page canvas
abs-right vs spine; capture a thin vertical clip at the spine to see the line.
Dev server picks up foliate-js edits on reload (HMR recompiled it; no restart
needed here, contra some older paginator notes). See [[issue-4112-scroll-anchoring]]
neighbors for other paginator/foliate fixes.
@@ -0,0 +1,20 @@
---
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). The OS scales only the glyph **size** (a `font-size`); text-layer **positions** are percentages of the `--total-scale-factor`-sized container and are NOT scaled. So divide the scale out of the glyph-size lever ONLY: after `textLayer.render()`, set the container's `--text-scale-factor = calc(var(--total-scale-factor) * var(--min-font-size) / fontScale)` (that var feeds `font-size` and nothing else — grep the vendored `text_layer_builder.css` to confirm). At font_scale 1.0 the probe returns 1.0 → override skipped, no regression. PDF-only; EPUB is unaffected because its text and overlay scale together.
**Do NOT divide `--total-scale-factor`** (the obvious-but-wrong first fix, PR #49 rev 1): it scales positions AND size, so `scale/F` shrinks the whole text layer toward the top-left origin — glyphs correct-ish size but positions compressed, offset accumulating downward. Verified by measurement: changing `--total-scale-factor` ×1.5 moves a span's top/left AND w/h all ×1.5. This looks "fixed" for the selection highlight (no more margin bleed) but the text layer no longer overlays the canvas; diagnose by coloring `.textLayer span { color: red }` and screenshotting the red-over-canvas overlay.
**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,24 @@
---
name: proofread-enhancements-4700
description: "Proofread/replacement-rule feature — sync, regex UI, Opt/Alt+P shortcut, i18n (issue"
metadata:
node_type: memory
type: project
originSessionId: 41894f93-c46e-457b-be84-847ccf6243d7
---
Issue #4700 (FR: Proofread enhancements) — SHIPPED, merged to main via PR #4708. The proofread (校对/替换规则) find-replace feature lives in: data model `ProofreadRule` in `src/types/book.ts`; store `src/store/proofreadStore.ts`; engine `src/services/transformers/proofread.ts`; selection popup `src/app/reader/components/annotator/ProofreadPopup.tsx`; manager dialog `src/app/reader/components/ProofreadRules.tsx` (mounted in `Reader.tsx`); sidebar entry `BookMenu.tsx`.
What shipped (all test-first, full suite green):
1. **Sync** — added `'globalViewSettings.proofreadRules'` to `SETTINGS_WHITELIST` in `src/services/sync/adapters/settings.ts` (whole-field LWW). ⚠️ CORRECTION (the original "KEY INSIGHT" here was WRONG): book/selection-scope rules were PUSHED (serializeConfig keeps the viewSettings delta) but **silently DROPPED on pull**`useProgressSync.applyRemoteProgress` only consumed `location`/`xpointer` and discarded the rest of the synced config (the "Currently, only reading progress is synced" comment). So per-book/selection rules did NOT actually propagate across devices until the fix below. Library/global rules sync independently via the settings replica. See [[proofread-per-book-crdt-sync]].
2. **Regex** — the transformer ALREADY fully supported `isRegex`; only UI was missing. Added a Regex toggle to the selection popup AND a full "Add Rule" form (pattern/replacement/scope Book|Library/Regex/Case-sensitive) to the manager dialog, validated via `validateReplacementRulePattern`. Popup skips the whole-word validation when regex is on.
3. **i18n** — the whole-word warning in ProofreadPopup was a hardcoded English string (root cause of issue's point #2: Chinese user couldn't read it, thought symbols couldn't be replaced). Wrapped in `_()` + 8 new keys translated across all 33 locales via `pnpm i18n:extract`.
4. **Shortcut** — reused the existing `onProofreadSelection` (`ctrl+p`/`cmd+p`). `handleProofread` in `Annotator.tsx` now opens the rules manager (`setProofreadRulesVisibility(true)`) when there's no active selection, and opens the create-from-selection popup when there is. No new shortcut entry, no first-level toolbar button (maintainer said skip). NOTE: first attempt used a dedicated `opt+p`/`alt+p` action — reverted because macOS Option+P is a dead-key (emits `'π'`, not `'p'`; `useShortcuts` matches on `event.key`). Ctrl+P avoids that entirely. The Annotator selection shortcuts have no unit-test harness (same as onTranslate/onDictionary), so this wiring isn't unit-tested; `setProofreadRulesVisibility` itself is covered by ProofreadRules.test.tsx.
Later additions (same PR): modernized the manager dialog to the design-system primitives (SectionTitle, `card eink-bordered border-base-200`, `input input-bordered`, `btn-contrast` CTA disabled-until-pattern); scrollbar-to-edge via `contentClassName='!px-0'` on Dialog (the body's default `px-6 sm:px-[10%]` was insetting the inner scroll container); **drag-to-reorder** rules per category via @dnd-kit (mirrors `CustomDictionaries.tsx` — sensors, `dragModifiers`, `SortableContext`, drag-handle-only listeners). Reorder persistence = new `proofreadStore.reorderRules(envConfig, bookKey, orderedIds)` that rewrites only the `order` field (index-based) across BOTH stores (book config + global settings) in one call; the manager now sorts both displayed lists by `order` (stable, so default-1000 rules keep insertion order). NOTE: transformer re-buckets by scope (selection→book→library) so cross-scope drag order in the merged "Book Specific Rules" list is cosmetic — only within-scope order affects application; reordering a library rule there changes its GLOBAL order (affects all books).
Gotchas / caveats:
- **`wholeWord` field is a near no-op in the transformer**: `normalizePattern` always wraps ASCII patterns in `\b…\b` regardless of `rule.wholeWord`; `isValidMatch` never reads it. It only gates the popup's pre-create validation (`isWholeWord` on the literal selection). So ASCII substring replacement (e.g. "cat" inside "category") is impossible today — pre-existing, out of #4700 scope.
- **macOS Option+letter dead-key**: `useShortcuts` matches on `event.key`, so any `opt+<letter>` shortcut won't fire on macOS (Option+letter emits a special glyph, not the letter). Avoid `opt+<letter>` bindings; prefer ctrl/cmd. A robust fix would need code-based matching in `useShortcuts` (deferred).
- Test isolation: spying `useProofreadStore.getState().addRule` across multiple tests leaks call counts — add `vi.restoreAllMocks()` in afterEach.
- Run single test files with `npx dotenv -e .env -e .env.test.local -- vitest run <file>` (bare `npx vitest` crashes on supabase `atob`).
@@ -0,0 +1,62 @@
---
name: proofread-per-book-crdt-sync
description: "Per-book/selection proofread rules now CRDT-merge by id on config pull (was dropped); tombstone-on-delete"
metadata:
node_type: memory
type: project
---
MERGED via PR #4781 (2026-06-25, squash commit 79ae8a48).
Per-book + selection-scope proofread rules now actually sync across devices via an
item-level CRDT merge (keyed by rule `id`), mirroring how booknotes merge. Before
this, `useProgressSync.applyRemoteProgress` pulled the full synced book config but
only applied `location`/`xpointer`, dropping `viewSettings.proofreadRules` (and
everything else). Library/global-scope rules sync separately via the settings
replica (`adapters/settings.ts` whitelist, whole-field LWW) — see [[proofread-enhancements-4700]].
**Design (per maintainer):** no new DB table — the rules keep riding the existing
book-config blob; the pull side just stops discarding them and merges by id instead.
What changed:
- `ProofreadRule` (`types/book.ts`) gained `updatedAt?: number` (LWW key) and
`deletedAt?: number | null` (tombstone). No `createdAt` (the existing `order` covers ordering).
- New pure `mergeProofreadRules(local, remote)` in `src/utils/proofread.ts` — by id,
LWW on updatedAt/deletedAt, identical semantics to `mergeNotes` in WebDAVSync.ts.
- `proofreadStore.ts`: stamps `updatedAt` on add/update/toggle/reorder; **`removeBookRule`
now TOMBSTONES (sets deletedAt) instead of splicing** so the per-id merge can't
resurrect a deleted rule from the peer's live copy. `removeGlobalRule` STAYS a
hard-splice — library deletion already propagates via the settings replica's
whole-field LWW (shrinking the array wins), so a tombstone there would just leave
dead entries. Getters (`getBookRules`/`getGlobalRules`/`getMergedRules`) and the
book-scope dedup filter out `deletedAt`.
- `transformers/proofread.ts`: render filter gained `!r.deletedAt`.
- `ProofreadRules.tsx` `useReplacementRules`: filters `deletedAt` so tombstoned rules
don't show in the manager list.
- `useProgressSync.applyRemoteProgress`: merges `syncedConfig.viewSettings?.proofreadRules`
(filtered to scope !== 'library') into the open book's rules, `setViewSettings` +
`saveConfig`, and `recreateViewer` ONLY when the merged array actually differs (guards
a reflow on no-op pulls).
Convergence gotcha (why the push re-uploads the union): `bookDataStore.saveConfig` only
merges `{updatedAt}` into the in-memory config — it does NOT write the passed viewSettings
into `booksData`. The thing that syncs merged viewSettings into `booksData.config` (so the
next `pushConfig``getConfig` serializes the union) is `readerStore.setViewSettings`, but
only when the viewState `isPrimary`. So call order must be setViewSettings → saveConfig
(same as `proofreadStore.updateBookViewSettings`).
**Stable id (`ensureRuleId` in utils/proofread.ts):** the merge keys on `id`, so id-less
rules (legacy / hand-edited / foreign peer) would ALL collide on the Map's `undefined`
slot — distinct rules clobber each other (silent loss, NOT duplication). `ensureRuleId`
backfills a missing id with a content hash `ph-${md5(scope|isRegex|pattern)}` (selection
scope also folds in sectionHref+cfi since it's per-instance), applied on both sides inside
`mergeProofreadRules`. `createProofreadRule` now seeds book/library ids the same way
(`id = scope==='selection' ? uniqueId() : ''` then `ensureRuleId`) so the SAME rule made
independently on two devices dedupes on sync instead of duplicating; selection rules keep
`uniqueId` (per-instance). Identity excludes replacement/case/wholeWord to match the
in-store dedup (pattern+isRegex). Ids are assigned ONCE and frozen — edits never re-key
(updates omit `id`). Limitation: rules already created with the old random `uniqueId` keep
those ids, so pre-existing identical rules across devices are NOT retroactively merged.
WebDAV does NOT carry proofread rules: its wire envelope strips viewSettings (`buildRemotePayload`),
so this only fixes the native cloud sync path. WebDAV would need un-stripping + the same merge.
@@ -0,0 +1,16 @@
---
name: recent-read-shelf-3797
description: "Recently-read carousel at library top (#3797 / PR"
metadata:
node_type: memory
type: project
originSessionId: d5f79cf1-9e58-4ae4-9f8a-46a8e8ca625f
---
Opt-in "Recently read" strip in the library Virtuoso header (PR #4829, issue #3797). `selectRecentShelfBooks(books, count)` in `libraryUtils.ts` (filter `!deletedAt && progress != null`, sort by `updatedAt` desc, slice 12). Setting `libraryRecentShelfEnabled` (default false) + View-menu toggle. Rendered via the Virtuoso `Header` through `BookshelfListContext` (stable identity → no grid re-render churn); list `<Virtuoso>` needs explicit `context={listContext}`.
**Reuse, don't reimplement:** each slide renders the real `BookItem` (identical cover/title/progress/badges). The open path was extracted to `src/app/library/hooks/useOpenBook.ts` (in-place stale-record probe + `makeBookAvailable` on-demand download for cloud-only synced books + navigate) and is shared by `BookshelfItem` AND the recent shelf. Do NOT open via the select-mode `navigateToReader` path — it skips the download, so a recently-read book that synced (progress + `updatedAt`) without its blob fails to open on a second device.
**Alignment gotcha (cost several iterations):** a horizontal flex strip with `basis-1/N` does NOT match a CSS-grid column when the grid has a row gap — CSS Grid subtracts the gap from each track, flex `basis` does not (covers come out too wide at 2/3 cols where `BOOKSHELF_GRID_CLASSES` uses `gap-x-4`; matches at `sm+` where `gap-x-0`). Fix: size each slide with the grid's own formula `flexBasis: calc((100% - (var(--rs-cols) - 1) * var(--rs-gap)) / var(--rs-cols))`, with `--rs-cols` (responsive `3/4/6/8/12` ladder when auto, else `libraryColumns`) and `--rs-gap` (`1rem` base / `0px` sm+, mirroring `gap-x-4 sm:gap-x-0`) set on the row. Also `min-w-0` on each flex item, else image covers expand to intrinsic width. Verified 0.00-0.02px edge diff vs a real CSS grid at N=2/3/4/5 (standalone HTML repro + getBoundingClientRect).
Arrows: plain scroll div + `scrollBy`, shown on overflow (`scrollLeft`/`scrollWidth`, `ResizeObserver`), centered on `.bookitem-main` via measure; `start-2`/`end-2` + `rtl:rotate-180`. Swipe never opens (useLongPress moveThreshold). i18n: `i18n:extract` churns every locale (see [[i18n-extract-prunes-keys]]) — added the 2 keys manually; bo/si/ta/bn best-effort.
@@ -0,0 +1,18 @@
---
name: rsvp-control-bar-overlap-revert
description: RSVP mobile control bar overlap was a REGRESSION — PR
metadata:
node_type: memory
type: project
originSessionId: cc658a96-fce5-4922-b924-361173c57e2a
---
RSVP (Speed Reading) overlay control bar: on narrow phones (Xiaomi 13 = 360px CSS width) the audio (TTS 🔊) toggle + settings ⚙ gear overlapped the right end of the centered transport row, hiding the "skip forward 15" label.
**This was a regression, not a new bug.** PR #4585 (`51fede1a0`, merged 2026-06-14 19:18Z) already fixed it: replaced the `absolute end-0` cluster with a single in-flow `flex items-center justify-between md:justify-center` row — audio toggle far-left, settings far-right, flanking the centered play button; secondary buttons tightened to `h-8 w-8 shrink-0 md:h-9 md:w-9`, skip buttons `px-1.5 md:px-2`. At 360px the 9 controls pack ~340px into a 336px row with zero gaps but **no overlap** (verified on-device).
**Reverted by PR #4589** (`490824504`, `feat/word-wise`, Word Wise inline vocab). Branch created 19:13Z — 5 min BEFORE #4585 merged — and merged ~10.5h later WITHOUT rebasing. The squash carried the stale pre-#4585 `RSVPOverlay.tsx`, so its diff is an exact mirror revert: #4585 = +53/51 src & +29 test; #4589 = +51/53 src & 29 test. #4589 added ZERO Word Wise code to RSVPOverlay — those hunks were purely the revert. It also deleted #4585's guard test (`audioBtn.closest('.absolute')).toBeNull()`), so CI couldn't catch the reintroduced overlap.
**Re-fix (this session):** restored the #4585 block byte-for-byte + re-added a guard test (`audio/settings share the play button's parent`, parent `className` has no `absolute`) in `rsvp-overlay-context.test.tsx`. Pattern to watch: a stale feature branch squash-merged after an intervening fix silently reverts it — see [[security-advisories-web-2026-06]] (shared-target worktree build-cache pollution) for the same stale-branch family.
**On-device verify recipe:** app running → `adb forward tcp:9222 localabstract:webview_devtools_remote_<pid>` → enter RSVP by dispatching synthetic `KeyboardEvent('keydown',{key:'v',shiftKey:true})` on `window` (Shift+V shortcut, listener on window; blur activeElement first) → click "From Current Page" → CDP-mutate the live DOM to preview a layout fix before rebuilding (apply exact source classes + reparent, then `getBoundingClientRect` overlap check + `adb exec-out screencap`). See [[cdp-android-webview-profiling]].
@@ -0,0 +1,24 @@
---
name: russian-hanging-prepositions-nbsp-4769
description: "Russian hanging-preposition NBSP transformer; generic per-language, lang-gated, no toggle"
metadata:
node_type: memory
type: project
originSessionId: 423131fb-8192-4055-b617-3f79d412e258
---
Issue #4769: Russian typography forbids short function words (prepositions/conjunctions/particles) hanging at the end of a line ("hanging preposition"). Fix = a content transformer that inserts U+00A0 after such words so they stick to the next word. Source file is never modified.
**Where:** `src/services/transformers/nbsp.ts` (export `nbspTransformer`, name `'nbsp'`), registered in `transformers/index.ts`, added to the FoliateViewer pipeline AFTER `simplecc`, before `proofread` — must run after `whitespace` (which strips NBSP when `overrideLayout`) or the glue is undone. (Originally named `russianNbsp` / `russianNbspTransformer`; renamed generic so it's the home for NBSP across languages.)
**Generic by language:** internally a `NBSP_LANGUAGES: Record<langCode, {script, shortWords}>` registry; gate = `NBSP_LANGUAGES[normalizedLangCode(ctx.primaryLanguage)]` (so `ru-RU` -> `ru`; returns content unchanged if no entry). Only `ru` configured today; adding another language = one registry entry (its Unicode script name + a 3+ letter function-word list).
**Gating decision (user, via AskUserQuestion):** language gate ONLY, no settings toggle (deliberately skipped the issue's requested toggle to keep scope in `services/transformers`). Belarusian/Ukrainian/Bulgarian (also Cyrillic) are NOT included — `ru` only.
**Algorithm:** regex on the raw HTML string (NOT a DOM round-trip — avoids restructuring XML decl/doctype for every section, unlike `proofread`/`sanitizer` which parse+serialize). `TEXT_OR_SKIP = /<(style|script)\b[^>]*>[\s\S]*?<\/\1>|>([^<]+)</gi` skips style/script blocks and only rewrites text between tags, leaving tags/attrs/entities byte-for-byte intact.
- Glue regex (built per language from `config.script` + `config.shortWords`): `(^|[^\p{L}])(<3+ letter words>|\p{Script=<script>}{1,2}) (?=[\p{Script=<script>}\p{N}])` -> replace `$1$2` + NBSP. 1-2 letter words of the script glue generically; 3+ letter function words need the explicit list (content nouns excluded so we never glue after them).
- No look-behind ([[feedback_no_lookbehind_regex]]): capture+re-emit the boundary char instead. Because the boundary is consumed, consecutive short words ("и в доме") need a loop-until-stable (`do/while result!==prev`); NBSP is in `[^\p{L}]` so a just-inserted NBSP counts as the next boundary.
**Known limitation (accepted):** postfix particles же/бы/ли glue FORWARD (to next word) not backward (to preceding word) — still prevents end-of-line hang, which is the issue's actual concern. Prepositions before digits glue too ("в 2025", "около 5").
**Authoring gotcha:** typing literal NBSP (U+00A0) into tool inputs near Cyrillic silently produced many stray NBSP bytes in source. Always write NBSP as the ` ` escape in JS source; normalize files with a Python `chr(0xA0)->chr(0x20)` pass then restore the one intended escape. Verify with `python3 -c "...read().count(chr(0xA0))"`, not shell `grep $' '` (matches regular spaces). Same applies to test assertions: define `const NBSP = ' '` and build expectations via template literals.
@@ -0,0 +1,20 @@
---
name: s3-r2-sync-provider
description: "S3/R2 file-sync provider (third backend after WebDAV/GDrive) — full vertical slice on dev, uncommitted; aws4fetch SigV4, path-style, generic S3-compatible"
metadata:
node_type: memory
type: project
originSessionId: 894e0d6d-ce01-402b-8f2d-0f0670986a88
---
Built 2026-07-07 (approved design in `.agents/plans/2026-07-07-s3-provider-design.md`), full vertical slice on the bare-repo dev branch, UNCOMMITTED alongside the day's gdrive optimization work.
- **Transport** `src/services/sync/providers/s3/S3Provider.ts`: SigV4 via `aws4fetch` (was already a dep, server-side `utils/r2.ts` uses it; zero new deps). Path-style `<endpoint>/<bucket>/<key>`; keys map 1:1 from logical paths. GET/HEAD/PUT; ListObjectsV2 XML via DOMParser with delimiter + continuation-token draining; ensureDir no-op; deleteDir = list prefix + per-key DELETE (DeleteObjects needs Content-MD5, WebCrypto has none). head etag = md5 → engine's index change-detection works. Tauri streaming via presigned `signQuery` URLs → tauriUpload/Download. Injected fetch (web fetch / tauri plugin-http) + injected sleep; Drive-style error map + backoff. Passes `runSemanticContract` + 10 transport tests (stageAbsent dispatches by request shape: 404 for objects, empty-200 for listings).
- **Settings** `S3Settings` (endpoint/region='auto'/bucket/accessKeyId/secretAccessKey + shared sub-toggles) in types/settings.ts + DEFAULT_S3_SETTINGS in constants.ts; slice `settings.s3`.
- **Derivation/activation**: FileSyncBackendKind gains 's3'; getCloudSyncProvider order webdav > gdrive > s3; withActiveCloudProvider keeps 3 slices exclusive (+syncBooks/providerSelectedAt stamp); CloudSyncProviderFlags in settingsSync.ts gained optional s3 slice (multi-window switch protection, #4580 class).
- **Shared helpers** added to cloudSyncProvider.ts and swept everywhere: `settingsKeyForBackend(kind)` (5 sites) and `cloudProviderDisplayName(kind)` (4 sites) replaced scattered gdrive ternaries.
- **UI**: `S3Form.tsx` (WebDAVForm pattern; Connect probes `list('/Readest')` — 403=auth, 404=bucket, empty-200=ok); IntegrationsPanel: 's3' SubPage + chooser CloudProviderRow (RiDatabase2Line, "S3-Compatible Storage") + deep-link `requestedSubPage === 's3'`; Tips include R2 endpoint format + web CORS requirement.
- **i18n**: 14 new keys translated into all 33 locales (462 entries).
- Everything else (engine, FileSyncForm, fileSyncStore, fleet probe, per-book upload/download routing, reader hint) was already backend-generic and needed zero changes.
NOT done: R2 account-ID preset, multipart upload, virtual-host addressing, remote-browser pane for S3. Live verification against a real R2 bucket pending (user tests on localhost:3000).
@@ -0,0 +1,23 @@
---
name: save-image-to-gallery-android
description: Image-viewer Save button → Android MediaStore (not share); sharekit 0-byte self-copy bug; tsgo misses abstract conformance
metadata:
node_type: memory
type: project
originSessionId: d72184f1-0e4c-412b-9dc9-fb384e189427
---
PR #4680 — image gallery "Save Image" button (`ImageViewer.tsx` + `ZoomControls.tsx`).
**Routing (the button reflects the actual action):** `canShare = !isAndroidApp && canShareText(appService)`.
- Android → `appService.saveImageToGallery(filename, bytes, mimeType)` = new native-bridge command `save_image_to_gallery` (Kotlin `MediaStore.Images` insert into `Pictures/Readest`, scoped-storage = NO permission on API 29+; pre-29 best-effort). Writes a Temp `shared/<name>` staging file, passes its path, removes it after.
- iOS/macOS / web-with-`navigator.share``saveFile({share:true})`.
- desktop / web-no-share → saveDialog / download.
**WHY Android does NOT use the share sheet to "save to file":** Android `ACTION_SEND` only lists apps that *consume* content; NO file manager registers for it. Verified on device: `adb shell cmd package query-activities -a android.intent.action.SEND -t image/png` → 34 apps (Bluetooth/Gmail/WPS/Telegram/Xiaomi-Drive…), zero file managers. "Save to a folder" is `ACTION_CREATE_DOCUMENT` (system `com.google.android.documentsui`), which never appears in a share sheet. So on MIUI the share flow genuinely can't save-to-file.
**sharekit 0-byte self-copy bug (separate fix commit on #4680):** `@choochmeque/tauri-plugin-sharekit` (rust `tauri-plugin-sharekit 0.3`) `shareFile` copies src → `File(activity.cacheDir, sourceFile.name)` BEFORE `ACTION_SEND`. Tauri `Temp` dir IS `activity.cacheDir` = `/data/user/0/<pkg>/cache` (verified `invoke('plugin:path|resolve_directory',{directory:12})`). Writing the shared file to the Temp ROOT makes that a copy onto itself → `FileOutputStream` truncates the source to 0 before `copyTo` reads it → **0 KB shared file**. Fix = write to a Temp `shared/` SUBDIR in `nativeAppService.saveFile`. Also fixed the same latent 0-byte bug in annotation/markdown export.
**tsgo gap (bit me):** `pnpm lint` (tsgo) does NOT flag abstract-class interface conformance — adding a method to the `AppService` interface compiled clean under tsgo but the production Next `tsc` failed (`BaseAppService` missing abstract member). When extending `AppService`: add `abstract` decl in `BaseAppService` (appService.ts) + impls in native/web/**node**AppService + the 2 test stub classes (`app-service.test.ts`, `import-metahash.test.ts`). Run real `npx tsc --noEmit -p tsconfig.json` to catch.
**On-device verify recipe (no run-as on release APK):** `pnpm dev-android` (devtools APK) → CDP invoke `plugin:native-bridge|save_image_to_gallery` with a PNG staged via `plugin:fs|write_file` (body = Uint8Array 2nd arg, `headers:{path:encodeURIComponent(p),options:'{}'}`) → confirm with `adb shell content query --uri content://media/external/images/media --projection _display_name:relative_path:_size --where "relative_path='Pictures/Readest/'"`. Real 252 KB JPEGs from the live UI landed correctly. See [[android-cdp-e2e-lane]], [[cdp-android-webview-profiling]].
@@ -0,0 +1,42 @@
---
name: scrolled-header-title-center-4436
description: "Scrolled-mode header chapter title lagged because getVisibleRange picked the topmost sliver view, not the viewport-center section"
metadata:
node_type: memory
type: project
originSessionId: 0c504495-68fe-4a26-b314-644bbc496581
---
#4436 — In scrolled mode the reader header chapter title was wrong vs paginated
mode while transitioning between sections. Title comes from foliate `tocItem =
TOCProgress.getProgress(index, range)`; `index`/`range` come from the
paginator's relocate detail, ultimately from `#getVisibleRange()`.
**Root cause:** the scrolled branch of `#getVisibleRange` (`packages/foliate-js/paginator.js`)
returned the FIRST overlapping view (lowest index = topmost in scroll order).
When the tail of section K is a thin text-bearing sliver at the very top of the
viewport but section K+1 occupies the centre/majority, it returned K's range →
title showed K while the reader was reading K+1. Paginated mode never shows this
because each page belongs to one section. (`comparePoint` end-boundary logic in
`progress.js` is shared by both modes and was NOT the divergence — the view
choice was.)
**Fix:** prefer the view whose visible band covers the viewport CENTRE
(`center = #renderedStart + size/2`; `center >= off && center < off+vSize`);
keep the first valid non-collapsed range as a `fallback` for when no loaded view
covers the centre (very top/bottom of book). Also fixed `#afterScroll` scrolled
fraction to size against `this.#views.get(index)` (the relocated view) instead of
`#primaryView`, since the relocated `index` can now differ from `#primaryIndex`.
`#detectPrimaryView`/`#primaryIndex` left UNCHANGED (drives preload/trim/bg;
guarded by #4112/#3987 tests) — only the relocate index/range moved to centre.
Accepted side effect: scrolled CFI/anchor now reflect the centre section (reopen
lands at centre section top) — minor, arguably better.
**Test:** `paginator-scrolled.browser.test.ts` "should report the section
occupying the viewport centre…" — real paginator + sample-alice, two adjacent
tall linear sections, `setAttribute('no-preload','')` AFTER fill to freeze view
offsets (else backward-preload scroll-compensation shifts the absolute scrollTop
target), nudge-scroll (first debounced scroll only clears `#justAnchored`; need a
2nd to fire `afterScroll('scroll')`), assert relocate `index` == centre section.
See [[issue-4112-scroll-anchoring]].
@@ -0,0 +1,25 @@
---
name: scrolled-pdf-pinch-zoom-4817
description: Scrolled-PDF live pinch-zoom + the cross-page-pinch vs native-selection tradeoff (readest
metadata:
node_type: memory
type: project
originSessionId: 902324ba-94ee-4c88-804e-ea9f796681f9
---
Live pinch-zoom for scrolled PDF (fixed-layout scrolled mode). The real fix is entirely in foliate-js: PR **readest/foliate-js#43 MERGED** to foliate main as `0fa407c` (on top of #42 `8bcb61e` which already had live pinch + the rect-match anchor + the interactive-when-idle idle-toggle). readest **PR #4817** is therefore **minimal — just the submodule bump to `0fa407c` + one unit test** (`fixed-layout-pinch-zoom.test.ts`, single clean commit `dd39837af`, +39/-1). readest needs NO touch-handling change: `origin/main`'s `useIframeEvents` already detects a two-finger gesture per page (`event.touches` forwarded via `iframeEventHandlers`) and calls `renderer.pinchZoom`. Builds on the scroll-lag scheduler [[pdf-scroll-lag-preload-4795]].
**Abandoned detour (do not re-add):** a host-level cross-page-pinch approach (`multiTouch.ts` `updateSourceTouches`/`flattenSourceTouches`, per-iframe `sourceIndex` binding, `allActiveTouches` reading `e.touches`, and a `usePagination` host-click tap fix) was built then fully reverted. It is unnecessary once cross-page pinch is dropped, and same-page pinch + centre-tap toggle both work through existing `origin/main` code (tap goes iframe -> `iframe-single-click` centre zone; the host-click path is never hit when iframes are interactive).
**Core architectural finding (the crux):** in scrolled FXL, **cross-page pinch and native text selection are mutually exclusive**. Each page is its own iframe; Android **serializes touches across iframe documents** (finger1 on page A gets `touchcancel` the instant finger2 lands on page B — proven via forwarded-touch logs), so a pinch spanning two pages can only be recognized if the *host* owns all touches, which requires `.scroll-page iframe { pointer-events: none }`. But inert iframes kill native selection/taps. So you pick one. User chose **native selection, drop cross-page pinch.**
**Final design (foliate `fixed-layout.js`):**
- `pinchZoom(ratio)` in scroll mode scales the whole `.scroll-container` live (`computeScrollPinchTransform`, transform-origin at viewport centre). `pinchEnd` snapshots the centre page's `getBoundingClientRect` and the commit re-render (`#renderScrollMode`) scrolls it back to that exact rect (`#restorePinchAnchor`) — no jump.
- **No-shift fix:** the inter-page gap must scale with zoom or the committed gaps don't match the transform-scaled preview. `margin: calc(var(--scroll-page-gap,4px) * var(--scroll-zoom,1))` and `#renderScrollMode` sets `--scroll-zoom = scaleFactor`. Verified preview->commit scale MATCH + position jump <=2px.
- Iframes interactive **when idle** (restored `#setScrollIframeInteraction(true)` in `#handleScrollEvent` settle; `#scrolling` flag + interactive-on-load in `#loadScrollPage` so selection works without scrolling first), inert only **during active scroll** (native-smooth). Same-page pinch flows through the per-iframe forwarded-touch path; pdf.js `setupPanningEvents` handles pan (empty-area drag scrolls host) + native selection (text drag). `overflow-x:auto` + `width:max-content` enable horizontal pan of a zoomed page.
**Gotcha — zoom store/attribute desync:** setting the `scale-factor` attribute directly (e.g. a test reset) does NOT update readest's `viewSettings.zoomLevel`. Pinch commit = `round(zoomLevel * lastPinchRatio)`, so a desynced store makes commit diverge from the live transform preview. Real pinches keep them in sync; only direct `setAttribute` breaks it. Cost me a long false-positive "shift" chase — reset zoom via a synthetic pinch, never `setAttribute`.
**Selection re-impl (NOT taken):** host-level selection via `caretRangeFromPoint` + dispatch `selectionchange` on the iframe doc IS viable (readest `handleSelectionchange` -> `makeSelection` -> popup; `getPosition` returns valid coords for scroll-page selections), but loses native OS selection handles/magnifier, and the popup is deferred until a real `touchend` sets `androidTouchEndRef` (Annotator.tsx). Abandoned in favour of native selection.
CDP-verified on Xiaomi (tap toggle, same-page pinch in/out no-shift, vertical scroll, horizontal pan, iframes `pointer-events:auto` when idle). Native selection itself needs a real finger (CDP synthetic touches don't engage the WebView long-press selection gesture).
@@ -0,0 +1,34 @@
---
name: search-modes-4560-and-spoiler-bound-bug
metadata:
node_type: memory
type: project
originSessionId: c416114a-72e6-40ed-a3ed-4b2d5fd7d5f4
---
**#4560 (Calibre-parity search)** was scoped down via `/autoplan` review (both Codex + Claude
agreed the original "foundational Turso-cached engine + searchBook agent tool" was over-scoped).
Decision = **phase it**.
**PR-1 (MERGED: readest#4764 + foliate-js#38):**
adds `regex` + `nearby-words` modes INSIDE the foliate submodule `packages/foliate-js/search.js`
(`regexSearch`, `nearbyWordsSearch`, `mode` dispatch in `search()`/`searchMatcher`); per-word `cfis`
+ annotation dedupe in `view.js`; `BookSearchConfig.mode`/`nearbyWords` + `BookSearchMatch.cfis` +
`SearchExcerpt.segments` in `types/book.ts` (schema v2→v3 in `serializer.ts`, `utils/searchConfig.ts`
helper); sidebar mode selector + greyed modifiers + "within N words" stepper + `searchError` state +
segmented excerpt. Nearby distance = **words** (default 10), via a control — NOT chars, NOT a trailing
number in the query. **foliate-js is a submodule** — search.js/view.js changes must be committed in
the submodule first, then the parent pointer updated.
**Deferred:** PR-2 = perf cache (only if measured; neutral `search.db`, NEVER `reedy.db` — that DB is
opt-in/desktop-gated and its delete-cleanup wouldn't run for non-AI users; FTS ngram is NOT a
guaranteed superset so it must fall back to full scan; run regex in a Web Worker for real backtracking
isolation). PR-3 = `searchBook` agent tool.
**Pre-existing bug to fix in PR-3:** `lookupPassage` spoiler protection is already wrong — it passes
`currentPage` (a rendered page ordinal, `AIAssistant.tsx`) as `spoilerBoundPosition`, but `ReedyDb`
compares it to `c.position_index`, a **global chunk ordinal** (`positionIndex: all.length`,
`BookIndexer.ts`). Page count ≠ chunk count, so the bound is off. Fix searchBook (and lookupPassage)
to spoiler-bound by the current **CFI → (sectionIndex, charOffset)**, not a position integer.
Related: [[koplugin-stats-sync]] is unrelated; see plan at
`~/.claude/plans/the-search-might-be-glistening-mccarthy.md`.
@@ -21,6 +21,8 @@ After both merge: comment on each GHSA noting the fixing PR (pending merge). Not
**C — Tauri native (PR #4639)** GHSA-55vr-pvq5-6fmg: unscoped `download_file`/`upload_file` in `src-tauri/src/transfer_file.rs` → arbitrary local read/write. FIXED: added `app: AppHandle` param + `ensure_path_allowed` (rejects relative + `..` via `has_disallowed_components`, then `fs_scope().is_allowed()`). Chose STRICT `is_allowed` (NOT read_dir's `|| contains("Readest")` substring hatch) because all legit callers (cloud sync, WebDAV, self-updater APK→`'Cache'`, OPDS→`'Cache'`) resolve under static scope ($APPDATA/Readest, $APPCACHE, $TEMP) OR persisted dialog grants (custom root via `setCustomRootDir`→picker→`allow_paths_in_scopes`; external folders re-granted at startup; `tauri_plugin_persisted_scope` makes sticky). Clippy needed `#[allow(clippy::too_many_arguments)]` on download_file (8 args). AppHandle auto-injected → JS invoke unchanged. NOTE: shared `target/` (worktree) was polluted with a deleted sibling worktree's abs plugin-permission paths → build failed `failed to read .../readest-feat-nightly-update-channel/.../fs/permissions/app.toml`; fix = `rm -rf` the `target/debug/build/<pkg>-<hash>` dirs grepping for the stale path, then rebuild. skip_ssl_verification left as-is (OPDS needs it). read_dir's own `contains("Readest")` hatch left untouched (out of scope).
**Regression found 2026-07-08:** the "blocking private hosts removes no functionality" assumption below missed `pnpm dev-web` — in `next dev` the server runs on the developer's own machine and LAN catalogs (e.g. Calibre at 192.168.x.x) are the normal dev workflow; the unconditional `isBlockedHost` made the proxy 400 them ("This URL is not allowed"). CatalogManager already gates its "no LAN URLs" error on `NODE_ENV === 'production'`, so the proxy now mirrors that (PR #5002): `isPrivateHostAllowed() = NODE_ENV === 'development'` skips the blocklist (both preflight and per-redirect-hop). Vitest runs under `NODE_ENV=test` so the SSRF tests still exercise blocking; the dev-exemption test uses `vi.stubEnv('NODE_ENV', 'development')`. Self-hosted production deployments remain blocked (unresolved if anyone complains — would need an env-var opt-in). Also: Calibre's server throttles after repeated failed auth with transient 503s — don't mistake those for a proxy bug.
**Non-obvious decision:** OPDS proxy can't require Readest auth — it's consumed from the browser via `<img src={getProxiedURL(...)}>` (covers) and `window.fetch` WITHOUT a Readest token; the `auth` query param is the *upstream* OPDS server cred, not the user token. So auth would break OPDS browsing/images. SSRF host-filter is the non-breaking high-value fix; residual relay/CORS-bypass on hosted CF (Medium) left for maintainer. On web the proxy is a CF Worker that can't reach a user LAN anyway (desktop bypasses via `needsProxy`), so blocking private hosts removes no functionality.
Test invocation gotcha: `npx vitest run <file>` skips dotenv → `src/utils/supabase.ts:8 atob(...)` throws at import for tests that load the REAL `@/utils/access` (e.g. `send-fetch-url-guard.test.ts`). Use `pnpm test` (wraps `dotenv -e .env -e .env.test.local`) or `npx dotenv -e .env -e .env.test.local -- vitest run`. Tests that mock supabase/access are unaffected.
@@ -0,0 +1,34 @@
---
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` (case-insensitive match on the benign View-Transition rejections: "transition was skipped" (hidden tab READEST-7 + superseded-nav READEST-F) and "aborted because of invalid state" (READEST-G); 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).
**Batch 2 (PR #4962, merged).** READEST-F/G = more benign View-Transition rejections → broadened the `before_send` filter (above). READEST-H = book-import `createDir` was non-recursive check-then-create; two concurrent imports of the same book race → Windows "Cannot create a file when that file already exists". Fix: `fs.createDir(getDir(book), 'Books', true)` (recursive = `create_dir_all`, idempotent). READEST-N = `StatisticsDb.applyRemoteEvents` runs a manual `BEGIN`/`COMMIT`; **the Rust per-op `op_lock` serializes single statements but does NOT make a multi-statement JS transaction atomic** — two concurrent pulls (split-view trackers share the `sharedDb` singleton connection) nest `BEGIN` in `BEGIN` → "cannot start a transaction within a transaction". Fix: a promise-chain mutex on `applyRemoteEvents` (works because JS is single-threaded — the synchronous grab-prev/install-new-promise is atomic; concurrency here is async *interleaving at `await`s*, not threads). General rule: any shared-connection multi-statement transaction needs JS-level serialization on top of the native op_lock. (Cleaner alternative not taken: dedupe the pull so only one tracker pulls the shared DB.) Deferred, need source maps: READEST-J (OPDS page-stream fetch, uncaught native reqwest error), READEST-K (`Failed to fetch`), READEST-M (`null appendChild`).
**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,52 @@
---
name: stripe-plan-highest-active-4694
description: "Stripe plans.plan must be the MAX over active subscriptions, not the last webhook; + live/skipped integration-test pattern and pre-push gotchas"
metadata:
node_type: memory
type: project
originSessionId: 9cf7e8fc-69fb-43c7-a6f5-3d096a87b6ec
---
PR #4694 (merged). Upgrading Plus→Pro on Stripe leaves BOTH subscriptions `active`
for a while (old one not cancelled immediately). `createOrUpdateSubscription`
(`src/libs/payment/stripe/server.ts`) overwrote `plans.plan` with only the
triggering webhook's plan, so whichever event arrived LAST won → a late Plus
event downgraded a Pro user to `plus`. `plans.plan` feeds the JWT → drives
quota/features (`getUserProfilePlan`, `getStoragePlanData` in `utils/access.ts`);
`plans.status` is NOT a feature gate.
**Fix**: `getHighestActivePlan(stripe, customerId)` lists the customer's subs,
keeps `active`/`trialing`, retrieves each, maps via `product.metadata.plan`, and
reduces by `PLAN_RANK` (`free`/`purchase` 0 < `plus` 1 < `pro` 2). Used in BOTH
`createOrUpdateSubscription` AND `handleSubscriptionCancelled` (`webhook/route.ts`)
— cancel now keeps the highest REMAINING active plan instead of always dropping to
`free` (otherwise cancelling the leftover Plus would nuke an active Pro).
- **Apple/Google IAP unaffected**: subscription groups expire the old tier
immediately, so two-active-tiers doesn't arise; left unchanged on purpose.
- **Stripe expand depth cap = 4 levels**: `subscriptions.list` with
`expand:['data.items.data.price.product']` = 5 levels → fails. So list WITHOUT
deep expand, then `retrieve` each active sub with `expand:['items.data.price.product']`
(4 levels, OK).
**Test-infra gotchas (cost real time, will recur):**
- Opt-in live integration test gate: use `it.skipIf(cond)` NOT `describe.skipIf`
`describe.skipIf(true)` registers zero tests → vitest fails the file ("no tests").
- Keep the file import-safe when skipped: `await import('@/libs/payment/stripe/server')`
INSIDE the test body. A static import pulls `@/utils/supabase`, whose TOP-LEVEL
`atob(NEXT_PUBLIC_DEFAULT_SUPABASE_URL_BASE64)` throws when env is absent → crashes
collection. (Mocked unit tests dodge this via `vi.mock('@/utils/supabase')`.)
- `pnpm test -- <file>` does NOT filter (runs the WHOLE suite). To run ONE file with
env loaded: `npx dotenv -e .env -e .env.test.local -- vitest run <file>`. Raw
`npx vitest run` skips dotenv → the supabase `atob` crash above + 28 env-dependent
files fail (sync/crypto/share/wordlens) — NOT a regression, just missing env.
- Mock Stripe in unit tests: `vi.mock('stripe')` returning a constructor fn with a
static `createFetchHttpClient`; chainable supabase `from().select().eq().single()` /
`update().eq()` / `insert()`. `getStripe()` caches its instance but the methods are
stable `vi.hoisted` fns, so per-test reconfig works.
- Pre-push husky hook runs `tsgo --noEmit && biome lint .` over the WHOLE tree, so
unrelated untracked WIP (e.g. #4683 `fixed-layout-paginated-scroll.test.ts` importing
an unimplemented `computePaginatedScroll`) blocks the push → `git push --no-verify`
when your own files independently pass `pnpm test` + `pnpm lint`.
See [[feedback-commit-message-english-only]] (commit/PR titles English-only).
@@ -0,0 +1,20 @@
---
name: sync-statusless-book-rebump-4677
description: Books with no reading status get re-pinned to top of library after every sync (updated_at rebump); PR
metadata:
node_type: memory
type: project
originSessionId: f943703d-f8c5-4ad9-9c2c-fc2c02d8b62c
---
# Statusless books re-pinned to top of library after every sync (PR #4677)
**Symptom:** a fixed set of books stayed pinned at the top of a `updatedAt`-desc ("date read") library. Reading/closing another book moved it to front, but the next cloud sync floated those books back above it. Gone after logout → caused by the sync round-trip.
**Root cause** (`src/pages/api/sync.ts` POST handler, books branch ~line 422): when a pushed book is NOT newer than the server (`clientIsNewer` false), it rewrites `updated_at = new Date().toISOString()` if `statusChanged`. The check was `status.reading_status !== serverBook.reading_status`. A locally-imported book that never got a status sends `reading_status: undefined` (dropped by `JSON.stringify`); the server stores `null`. `undefined !== null` ⇒ true ⇒ spurious rewrite. The rewrite re-writes `undefined` (→ stays `null`), so it NEVER converges. Discriminator is purely client-side: books that round-tripped through a PULL have `readingStatus: null` (set by `transformBookFromDB`) and don't trigger it; never-pulled imports keep `undefined`.
**Amplifiers:** (1) the 1-day re-sync window (`useSync.ts:98` `lastSyncedAtBooks = stored - ONE_DAY_IN_MS`) re-pushes every recently-touched book each sync. (2) the rewrite runs in one batch `upsert` transaction → all affected rows get the SAME `now()` ⇒ identical-to-the-ms timestamps (the tell-tale signature).
**Fix:** `readingStatusChanged(a,b) = (a ?? null) !== (b ?? null)` — treat undefined/null both as "no status". Existing inflated timestamps age out naturally; no migration. NOT a DB trigger (Alice kept her config time, proving the app writes the value).
**CDP verification recipe (Xiaomi, on-device):** the `pnpm dev-android` build = release APK with `--features devtools` → WebView debugging on → `tauri.localhost` + `webview_devtools_remote_<pid>` socket. Discover socket from `/proc/net/unix`, `adb forward tcp:PORT localabstract:<sock>`, drive via Node 24 native `WebSocket` to `/json/list` page target. The page can call `fetch('https://web.readest.com/api/sync?since=0&type=books', {Authorization: Bearer <localStorage token>})` directly to read cloud `updated_at` per book. Decisive evidence = compare in-app `PUSH_SENT` (client sends old ts) vs `PUSH_RETURNED` (server returns fresh identical `now()`) for the statusless books only. Console object args replay as `Array(N)` previews with stale objectIds — log pre-stringified JSON and/or stash into a `window.__SYNCDBG` ring buffer read via `Runtime.evaluate(returnByValue)`. Related: [[android-cdp-e2e-lane]], [[cdp-android-webview-profiling]]. Touches #4634 reading-status field-level merge.
@@ -0,0 +1,26 @@
---
name: sync-synced-at-cursor-4678
description: "Decouple the incremental-pull cursor from updated_at via a server-stamped synced_at column on books (#4678)"
metadata:
node_type: memory
type: project
originSessionId: 5c738b55-09d2-42ea-8af0-ca13dfe2de6e
---
# Decouple sync pull cursor from updated_at — server `synced_at` (#4678, branch feat/sync-synced-at-cursor-4678)
Follow-up cleanup to [[sync-statusless-book-rebump-4677]]. `books.updated_at` was overloaded: (1) incremental-pull cursor (`GET /api/sync?since=…` filters `updated_at>since`; device keeps one global `max(updated_at)`) AND (2) library "date read" sort key. A server-resolved merge (reading_status LWW #4634) had to be written `> every peer's global cursor` to propagate → forced `updated_at=now()` → reordered the date-read library by sync time.
**Decision (user-chosen): server `synced_at`, books-only, koplugin untouched.** Scoped to `books` because it's the ONLY table with the overload — the only server-side `updated_at=now()` bumps are the books status-merge propagation (sync.ts) + progress piggyback writes books rows; configs/notes are never server-bumped and aren't a sort key; `stats.updated_at` is already server-assigned. (Confirmed both scope decisions via AskUserQuestion.)
**Implementation:**
- Migration `docker/volumes/db/migrations/016_add_books_synced_at.sql` + baseline `docker/volumes/db/init/schema.sql`: add `synced_at timestamptz NOT NULL DEFAULT now()`; **backfill `= COALESCE(updated_at,created_at,now())` BEFORE creating the trigger** (else trigger clobbers backfill to now() → full re-sync storm); index `(user_id, synced_at)`; `BEFORE INSERT OR UPDATE` trigger `set_books_synced_at()` forces `NEW.synced_at = now()` (server-authoritative; clients never send it). First trigger on these tables — justified because synced_at must be server-stamped on EVERY write path (insert / client-wins update / status-merge / piggyback) and a trigger is DRY + unforgeable.
- `GET` (`src/pages/api/sync.ts` queryTables): books filters `.gt('synced_at', since)` + orders by synced_at; **drop the `deleted_at` clause for books** (a delete bumps synced_at). configs/notes unchanged (`updated_at`+`deleted_at`).
- `POST` status-merge: extracted pure `buildStatusPropagationRow(serverBook, status)` — grafts fresher status, **removed `updated_at: now()`**. Trigger advances synced_at so peers re-pull; updated_at stays = event time → no reorder. Progress piggyback unchanged (trigger now reliably propagates it too).
- Client `src/hooks/useSync.ts`: exported `computeMaxTimestamp`, keys on `synced_at` first, falls back to `max(updated_at,deleted_at)` when absent. Added `synced_at?: string|null` to `BookDataRecord` (`src/types/book.ts`); `Book.syncedAt` already existed (unused, left unpopulated).
**Why backward-compatible (key insight):** `synced_at >= updated_at` always (backfill makes old rows equal, trigger makes new rows now() ≥ client event time), so `synced_at>since` is a strict SUPERSET of `updated_at>since`. Old web clients AND the koplugin keep working with no data loss — at worst a redundant re-pull of rare server-merged rows (idempotent upsert). **koplugin left unchanged**: its `last_books_pulled_at` is SHARED between pull-cursor (vs server) and push-delta detection (`getChangedBooks` vs local updated_at) — retargeting it to synced_at would need a risky pull/push cursor split (follow-up). koplugin advances books cursor from row `updated_at`/`deleted_at` (syncbooks.lua pullBooks), notes from `os.time()*1000` — both fine under the superset filter.
**Tests** (test-first, pure units; no DB in unit tests): `__tests__/pages/api/sync-synced-at-cursor.test.ts` (buildStatusPropagationRow keeps updated_at), `__tests__/hooks/useSync-cursor.test.ts` (computeMaxTimestamp prefers synced_at, falls back). Full suite + lint + format:check green. No Lua/Rust changed. PR #4712.
**Online-migration gotcha (prod books = 3.8M rows):** the naive `UPDATE … WHERE synced_at IS NULL` (one txn, all rows) DEADLOCKED against live `/api/sync` upserts (`40P01`, both lock books rows in opposite orders); `ALTER COLUMN SET NOT NULL` (full-table ACCESS EXCLUSIVE scan) + plain `CREATE INDEX` (write-blocking SHARE) compound it. Rewrote 016 as an ONLINE migration — **run via psql, NOT in a wrapping txn / NOT the Supabase dashboard editor** (uses `CREATE INDEX CONCURRENTLY` + a `CALL` proc that COMMITs per batch, both rejected inside a txn): (1) ADD COLUMN nullable + `SET DEFAULT now()` up front (inserts during backfill get now()); (2) batched backfill in a PROCEDURE — `WITH todo AS (SELECT ctid FROM books WHERE synced_at IS NULL LIMIT 10000 FOR UPDATE SKIP LOCKED) UPDATE … FROM todo`, `COMMIT` each batch, EXIT when no NULLs remain — **SKIP LOCKED never waits on an app-locked row** so no deadlock; (3) `CREATE INDEX CONCURRENTLY`; (4) trigger LAST (else it clobbers the backfill to now()); (5) hard NOT NULL dropped (default+trigger+backfill keep it populated, client falls back to updated_at) — optional `ADD CONSTRAINT … CHECK (synced_at IS NOT NULL) NOT VALID` then `VALIDATE` (lighter SHARE UPDATE EXCLUSIVE, no full AccessExclusive scan). Note `now()` is STABLE not VOLATILE → `ADD COLUMN … DEFAULT now()` is fast metadata-only (one value for all existing rows) but that = migration-time-now ≠ updated_at, which would force a full re-sync storm — hence the explicit updated_at backfill. COMMIT is allowed in a PROCEDURE-via-CALL but NOT in a DO block nor inside a `BEGIN…EXCEPTION` sub-block.
@@ -13,8 +13,13 @@ EPUBs a single tap on an `<img>` / `<svg>`-with-`<image>` / `<table>` now opens
- **Fixed-layout** (PDF/comics/manga, `bookData.isFixedLayout`) keeps tap-to-turn —
there the tap IS the page-turn gesture.
- **Long-press** is unchanged everywhere; **linked images** (inside `<a>`) still
follow the link (the existing `sup, a, audio, video` skip).
- **Long-press** is unchanged everywhere.
- **UPDATE #4757:** **linked images** (inside a plain `<a>`) now ALSO zoom on single
tap instead of following the link (was the `sup, a, audio, video` skip). Impl:
`postSingleClick` computes `media = !isFixedLayout && !footnote ? detectMediaTarget(element) : null`
up front, and the `<a>` early-return guard gains `!media &&` so a media target bypasses it.
**Footnotes are excluded** (`!footnote`) so footnote anchors keep popup/navigation. The
later dispatch reuses that `media` (no second `detectMediaTarget` call).
Impl in `src/app/reader/utils/iframeEventHandlers.ts`:
- New shared `detectMediaTarget(el) -> {elementType:'image',src} | {elementType:'table',html} | null`,
@@ -0,0 +1,27 @@
---
name: third-party-library-autosync-4835
description: Third-party cloud sync (WebDAV/Drive) library.json auto-sync on import/delete/close — parity with useBooksSync; delete propagation needs full library
metadata:
node_type: memory
type: project
originSessionId: 50e2c2b8-ca61-4c33-acae-cd5d2c9aa93f
---
PR #4835 (`feat/third-party-library-autosync`). Adds library-scoped auto-sync for the active third-party file-sync provider so `library.json` stays current without a manual "Sync now".
**Architecture split (important):**
- `library.json` (the remote index) is written ONLY by `engine.syncLibrary` (`src/services/sync/file/engine.ts`). Before this PR that was called from exactly ONE place: the Settings → "Sync now" button (`FileSyncForm.tsx`).
- The reader's `useFileSync` (`app/reader/hooks/`) is PER-BOOK (progress/notes/cover/file) and NEVER touches `library.json` — it's the analogue of `useProgressSync`, not `useBooksSync`.
- So nothing auto-updated `library.json` on import/delete/book-close. Native sync didn't have this gap because `useBooksSync` is library-scoped.
**Fix:** `useLibraryFileSync()` (`app/library/hooks/useLibraryFileSync.ts`), mounted once on the library page next to `useBooksSync()`. Parity counterpart of `useBooksSync`:
- Single `useEffect([library])` → debounced (5s) `engine.syncLibrary`. import (adds row), delete (sets `deletedAt`), book-close (bumps `updatedAt`) all mutate `library`, so one effect covers all three + initial-load pull.
- Builds engine async (Drive keychain probe), keyed on connection-relevant settings (NOT lastSyncedAt). Stable debounced trigger via `runSyncRef` so it isn't lost on re-creation.
- Gated on global file-sync mutex (`fileSyncStore.beginSync` — skip if a manual Sync now holds it), Sync Strategy, Upload Book Files, and `isCloudSyncAllowed`.
- MUST gate on `libraryLoaded` — syncing a transient empty pre-load library would push an empty index and clobber remote.
**Delete propagation gotcha (the key insight):** `engine.syncLibrary` tombstones a deleted book in `library.json` ONLY if the deleted book (with `deletedAt`) is in the `books` arg → it stays in `allBooksMap` → final index carries the tombstone. If filtered out (the old `FileSyncForm` passed `eligibleBooks = filter(!deletedAt)`), then (1) no tombstone AND (2) the discovery books-dir scan (`!allBooksMap.has(hash)`) RE-DOWNLOADS the just-deleted book (its remote hash dir lingers until the separate GC sweep). So BOTH the hook and `FileSyncForm` now pass the FULL library incl. soft-deleted. Engine tests in `engine-metadata-sync.test.ts`.
**Scope:** pushes the deletion tombstone to the index (peers won't re-pull it). Does NOT auto-remove the book from a peer's LOCAL library — engine reconcile skips `rb.deletedAt` entries (`engine.ts` ~line 430). Peer-side local deletion is a future, riskier change.
See [[gdrive-provider-multipr-status]] · [[webdav-metadata-sync-4756]] · [[webdav-filesync-refactor-plan]].
@@ -0,0 +1,40 @@
---
name: toc-table-heading-clip-4439
description: "#4400 scroll-wrapper overflow:auto clips negative-margin bleed of decorative layout tables; hoist negative margins onto wrapper"
metadata:
node_type: memory
type: project
originSessionId: 6d1d7362-d152-4248-93c0-76f6aef92329
---
#4439: on a decorative TOC page (nested layout tables), the **top half of the
`CONTENTS` heading is clipped** in paginated mode (0.11.4 regression; 0.11.2 fine).
Reporter blamed [[table-dark-mode-tint-4419]] but that's dark-mode only — this is
light mode. Real cause is **#4400** (`scrollable.ts` + `getPageLayoutStyles`):
- v0.11.2 sized wide tables with `transform: scale()`**never clipped**.
- #4391 then #4400 replaced that with wrapping every `<table>` (and display
`<math>`) in `.scroll-wrapper { overflow: auto }` + `table { max-height: var(--available-height) }`.
- These EPUBs lay the contents out as a nested `<table class="bc" style="margin: -1em 0 0 1em">`
with `<p class="lh em16 ...">CONTENTS</p>` (`line-height:1em`, inside `div.em06`=0.6em).
The **negative top margin** pulls the table (and the heading's first line) above
the wrapper's `overflow:auto` content box, which clips it. Measured: heading top
~12.8px above the clip box = exactly `-1em` in the 0.6em context (~58% of the line).
- The `-fit` escape (`SCROLL_WRAPPER_FIT_CLASS``overflow:visible`) only checks
**horizontal** fit (`scrollWidth-clientWidth`). The table's positive `margin-left:1em`
inflates scrollWidth so it never gets `-fit`, stays `overflow:auto`, and clips.
**Fix** (PR for #4439): `hoistNegativeMargins(el, wrapper, win)` in `applyScrollableStyle`'s
`wrap()` — move any NEGATIVE computed margins from the wrapped element onto the wrapper
and zero them on the element. Keeps the box in place, lets the element sit flush so the
overflow box can't clip it; also de-inflates scrollWidth so a genuinely-fitting table
gets `-fit`. Positive/auto margins are left alone (over-wide tables still scroll; centered
tables stay centered). CSS can't do per-axis `overflow-x:auto; overflow-y:visible`
(spec coerces `visible``auto`), so margin-hoisting is the route, not per-axis overflow.
Repro is metric-sensitive (whether the inner table is `-fit`). Tests: browser test
`src/__tests__/document/paginator-table-toc-clip.browser.test.ts` + fixture
`repro-4439.epub` (real foliate paginator, asserts heading top not above its clip box);
unit cases in `scrollable.test.ts`. Verified against the literal book (`321123.epub`,
content-7.xhtml spine idx 12): clipped without fix, clean with it. Related:
[[paginated-texture-occlusion-4399]], [[inline-block-column-overflow]].
@@ -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,20 @@
---
name: tts-highlight-granularity-setting
description: TTS highlight granularity (word/sentence) user setting and its two-point gating in TTSController
metadata:
node_type: memory
type: project
originSessionId: 33ddd196-7404-4af2-99ac-0d3b19b39b4e
---
Settings → TTS → "TTS Highlighting" boxed list has a **Granularity** select (first row, before Style): `Word` (default) / `Sentence`. Field `ttsHighlightGranularity: TTSHighlightGranularity` on `TTSConfig` (`src/services/tts/types.ts`, `src/types/book.ts`), default `'word'` in `DEFAULT_TTS_CONFIG`. UI in `TTSHighlightStyleEditor.tsx` (props `granularity`/`onGranularityChange`), persisted from `TTSPanel.tsx` via `saveViewSettings(..., false, false)` + a value-watching `useEffect` (mirrors `ttsMediaMetadata`).
Word-by-word highlighting only ever happens on **Edge TTS** (`supportsWordBoundaries() === true`); Web/Native always highlight per sentence. We assume every engine supports sentence highlighting, so picking `word` on a non-word-boundary engine naturally falls back to sentence.
**Gating lives at two points in `TTSController` (NOT one helper):**
1. `dispatchSpeakMark` suppression: `#suppressMarkHighlight = ttsClient.supportsWordBoundaries() && #highlightGranularity === 'word'`. With `sentence`, don't suppress → the sentence highlight is drawn at mark dispatch.
2. `prepareSpeakWords` early-return: `if (#highlightGranularity === 'sentence') return;`**gated on granularity only, NOT on `supportsWordBoundaries()`**. Reason: `prepareSpeakWords` is only called by EdgeTTSClient in prod (boundaries present), but `tts-controller.test.ts` calls it directly with the *web* client active (supportsWordBoundaries=false) and expects word highlighting. Adding a `supportsWordBoundaries()` check there would break those existing tests.
Controller learns the value via `setHighlightGranularity()` (called at creation in `useTTSControl.ts` next to `updateHighlightOptions`, and from a `useEffect` on `viewSettings.ttsHighlightGranularity`). Mock `TTSController` in `useTTSControl.test.tsx` must include `setHighlightGranularity: vi.fn()` or the speak path throws and emits no position/state.
Related: [[edge-tts-word-highlighting-4017]], [[tts-word-highlight-singletextnode-drift]], [[tts-sync-paragraph-rsvp-3235]].
@@ -0,0 +1,26 @@
---
name: tts-player-redesign
description: TTS control redesigned to mini-player + Dialog player sheet (Apple Books/ElevenLabs style); replaces floating icon/popup/TTSBar; showTTSBar retired; PR #4996
metadata:
node_type: memory
type: project
originSessionId: d8af2d26-c714-44f4-b2f2-dfe08676fe87
---
TTS player redesign built 2026-07-07, **PR readest/readest#4996 MERGED same day** (squash 17de9357d; worktree + local branch cleaned). Spec + plan in `.claude/plans/2026-07-07-tts-player-redesign{,-plan}.md`. Late tweaks: main-view sheet header label dropped; progress line moved to card BOTTOM edge; eink = 1px hairline track + solid base-content fill + buffer hidden (mini), 1px border on `.tts-scrubber` (sheet); chrox added `audio-track`/`audio-played-part` class hooks + `not-eink:` prefixes on the mini progress divs before merge.
Architecture: `usePlaybackInfo` hook (poll/monotonic-hold/2% total quantization/optimistic seek+rollback, extracted from old TTSProgressRow) feeds `TTSMiniPlayer` (persistent bottom card: 3px progress line w/ buffer-ahead fill from `measuredFraction`, sentence transport, stop, tap-to-expand, exports `TTS_MINI_PLAYER_CLEARANCE=64` consumed by FoliateViewer whenever `ttsEnabled`) and `TTSScrubber` (gradient track: currentColor/40%/15% color-mix; `.tts-scrubber` CSS in globals.css). `TTSPlayerSheet` = Dialog bottom sheet (snapHeight 0.65, desktop `sm:!w-[420px]`) with cover, scrubber, 5-button transport, `SpeedChips` presets (off-preset rate like default 1.3 merges as extra chip), Voice/Sleep-Timer NavigationRow sub-views. Deleted: reader TTSPanel/TTSBar/TTSIcon; `showTTSBar` removed from ViewSettings/constants.
**Why:** chrox asked to "redesign the TTS control like modern TTS apps (ElevenLabs/Kindle/Apple Books)"; chose mini-player+sheet structure, sentence/paragraph transport (works on ALL engines; time seek Edge-only via scrubber), preset speed chips. Absorbed TODOS items: sticky-bar scrubber + buffer-ahead indicator.
**How to apply:**
- Sheet mounts only while open (`showPlayerSheet &&` gate in TTSControl) so hidden hooks don't poll; DictionarySheet is the mounting precedent.
- Transport clusters and scrubbers are `dir='ltr'` (audio-timeline convention) — the final review caught the mini player missing this under RTL; wrap the button cluster, keep timer chip outside.
- Rate persistence reads `useSettingsStore.getState()` at call time (stale-closure class #4780); persists BOTH viewSettings.ttsRate and globalViewSettings.ttsRate.
- Native voices (no timeline): scrubber hidden, show `{{time}} left in chapter` from chapterRemainingSec estimates.
- Live-feedback wave (same day, chrox watching dev-web): sheet controls collapsed to ONE row of speed/voice/timer buttons (speed chips now a 'speed' sub-view; SpeedChips exports formatRate); ttsDuration EMA (alpha 0.2) replaced with CUMULATIVE chars/secs ratio per voice (cap 3600s rescale, legacy {cps,n} migrates as 30s prior) to stop elapsed-time jumping; mini player unmounts while sheet open; TTSController #clearAllHighlights on every section entry (stale last-word leak in preloaded neighbor views) + reapplyCurrentHighlight skips the sentence fallback while playing in word mode (page-turn sentence flash).
- **OPEN BUG seen live:** isPlaying glyph desyncs at section transitions (CTA shows play while audio runs; tapping it calls start() which re-speaks from stored ttsLocation = position jump). Repro: watch CTA across chapter auto-advance. Likely a transit state-change ('paused'-flavored or missed 'playing') in useTTSControl handleStateChange.
- Deferred follow-ups from final review: e-ink visual pass (stale+disabled opacity compounds ~30%), two usePlaybackInfo edge tests, dead `!groups` branch in sheet, two usePlaybackInfo instances don't share seek suppression.
- Verified live in dev-web Chrome: mini player + buffer-ahead fill, sheet + sub-views, drag seek + optimistic hold, back-to-TTS pill, section auto-advance label/timeline reset, stop button, zh-CN i18n. NOT yet: timer countdown chip, native-voice degradation, background NowPlayingBar reattach, e-ink, RTL, mobile gestures. Dev-env traps: stale serwist SW on localhost served year-old locale JSON (unregister + caches.delete); dev-web on port 3001 when 3000 busy.
Related: [[edge-tts-webaudio-engine]], [[tts-background-session-decoupling]], [[feedback_use_worktree]].
@@ -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]].

Some files were not shown because too many files have changed in this diff Show More