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>
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>
* 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>
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>
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>
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>
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>
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>
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>
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>
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>
* 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>
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>
* 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>
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>
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>
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>
* 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>
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>
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>
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>
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>
Fixesreadest/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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
* 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>
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>
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>
* 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>
- 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>
* 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>
* 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>
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>
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>
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>
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>
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>
* 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>
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>
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>
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>
* 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>
* 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>
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>
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>
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>
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>
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>
* 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>
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>
* 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>
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>
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>
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>
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>
* 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>
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>
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>
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>
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>
- 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>
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>
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>
* 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>
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>
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>
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>
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>
* 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>
* 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>
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>
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>
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>
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>
* 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>
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>
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>
* fix(koplugin): repair reading-stats sync push/pull
Reading statistics (statistics.sqlite3 page events) never reached the
Readest sync server. Three stacked defects in the koplugin stats path:
1. push/pull called settings:readSetting/saveSetting on self.settings,
which is the plain readest_sync data table (not a LuaSettings object),
so auto-sync crashed on every book open
("attempt to call method 'readSetting' (a nil value)").
2. pushChanges declares books/notes/configs as required_params, but the
stats push sent only statBooks/statPages, so Spore rejected the request
client-side ("books is required for method pushChanges").
3. statBooks/statPages were listed only under the spec's payload, not as
optional_params. Spore's expected-param set is
required_params + optional_params, so it rejected them too
("statBooks is not expected for method pushChanges").
Fixes:
- Read/persist the cursor as a plain field and save the whole table via
G_reader_settings:saveSetting, mirroring readest_syncauth.
- Send empty books/notes/configs alongside statBooks/statPages; the server
defaults each to [] and processes the stat arrays independently.
- Declare statBooks/statPages as optional_params in readest-sync-api.json.
Also add ReadestStats debug logging across pushBookStats/pullBookStats and
push/pull (cursor, collected counts, dispatch, response status, cursor
advance), and surface the push failure status/body that was previously
swallowed on non-interactive syncs.
Tests: cover push/pull cursor handling, the pushChanges required_params
contract, and a spec-level check that every stats payload key is an
expected pushChanges param.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(koplugin): add interactive Push/Pull stats now menu items
Add "Push stats now" and "Pull stats now" entries to the Readest sync
menu, placed after "Readest library" and before "Push books now" and
gated on being signed in (access_token + user_id) like the other
account-level items. They call pushBookStats(true) / pullBookStats(true)
for a manual interactive sync of reading statistics (the whole
statistics.sqlite3 delta), complementing the automatic pull-on-open /
push-on-close.
Interactive push/pull now also confirm their result (pushed / pulled /
up to date) to match the sibling "now" items, since a silent success
would look like a no-op.
Extract the five new strings into the koplugin .po catalogs (empty
msgstr, English fallback at runtime).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* chore(koplugin): translate reading-stats sync strings (33 locales)
Fill the previously empty msgstr entries for the reading-statistics sync
strings across all koplugin locale catalogs: the two new "Push/Pull stats
now" menu items, the pushed / pulled / up-to-date confirmations, and the
push/pull failure messages. Each locale mirrors its existing
Push/Pull/Failed-to verbs and "reading" terminology for consistency.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds store/ with a reproducible generator for the Web Store listing
screenshots: composites the popup capture onto an on-brand (readest.com)
background with a headline, renders via headless Chromium, and writes 24-bit
no-alpha PNGs at 1280x800 and 640x400 into store/out/ (gitignored).
- store/generate.mjs - config + compositing + render (Playwright + ImageMagick)
- store/popup.png - raw popup capture (700x508); its status strip is blanked
and re-lettered from CONFIG so it stays in sync with code
- store/README.md - usage + requirements
- package.json - adds `pnpm store:screenshots`
- .gitignore - ignores store/out/
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Make the user-facing popup strings em-dash-free and tighten the success copy
(which now matches the Chrome Web Store screenshots).
Source strings:
- "Sent — it will appear in your library shortly." -> "Saved to your library."
- "Sent — N images could not be fetched." -> "Sent. N images could not be fetched."
- "Could not reach {host} — {reason}" -> "Could not reach {host}: {reason}"
Key-as-content i18n means each English change re-keys the string. All 33 locale
bundles were re-keyed and every affected translation refreshed to match the new
wording, with no em dashes in the translated values either (language-appropriate
punctuation; full-width colon/period for CJK). Extractor reports 0 untranslated,
0 orphan. Updated popup.test.ts and the i18n.ts JSDoc example.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Prep for the Chrome Web Store submission of the Send to Readest browser
extension:
- Bump manifest + package version 0.2.0 -> 0.2.1.
- Add a `pnpm zip` script (scripts/zip.mjs) that builds and packages dist/
into a versioned, Web-Store-ready archive: manifest.json at the zip root,
excluding webpack's `.LICENSE.txt` banners and `.DS_Store`. Ignore the
produced `*.zip` in the extension's .gitignore.
- Add STORE-SUBMISSION.md: copy-paste dashboard answers (single purpose,
per-permission justifications, remote-code answer, data-use disclosures,
listing copy) pointing at the canonical privacy policy hosted at
https://www.readest.com/send-to-readest/privacy-policy.
Verified: pnpm test:extension (51 passed), pnpm build-browser-ext, pnpm zip
(valid archive), pnpm lint clean.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Chinese chapter-detection regex treated certain measure words (the
classifiers for "letter" and "book") as chapter units and let a title
attach directly after the unit. As a result, ordinary prose such as
"the first letter" or "the fourth book records..." was split out as
bogus TOC entries when importing Chinese TXT novels.
Split the unit characters into two explicit tiers that share the same
number prefix and stay in one alternation (a single split pass, so a
segment mixing chapter and volume headings is handled together):
- Chapter units may carry a title attached directly, unchanged.
- Volume/measure-word units only start a heading when the title is
introduced by a separator (colon, comma, space, or parentheses) or
the line ends, never a bare noun directly after the unit.
Real volume and chapter headings still match. Adds regex-level and
end-to-end tests covering both reported cases.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Cherry-picked and re-verified the applicable subset of
julianshen/readest@fa1b74a0 (its "address PR #15 bot reviews" commit). The
fork-only AI-annotation-tool change was dropped — readest has no 'ai' toolbar
tool. Each logic fix is covered by a failing-first test.
- TTS position sequence is now an app-wide monotonic counter, so a fresh
TTSController (constructed per `tts-speak`) isn't dropped by consumers holding
`lastSequenceSeen` from a prior session.
- share.ts only swallows AbortError (user cancel); other failures — e.g.
NotAllowedError when a quick action fires without a user gesture — fall back to
the clipboard so the text still reaches the user.
- document.isTxt tolerates MIME params (text/plain;charset=utf-8), uppercase
extensions (BOOK.TXT), and a nameless Blob, so a TXT can't slip onto the
non-text path and yield a null book.
- updater getNightlyPlatformKey matches x86_64/aarch64 explicitly; a 32-bit or
otherwise unknown arch yields no nightly instead of mis-routing to aarch64.
- UpdaterWindow downloadWithProgress resolves on tauriDownload completion even
when Content-Length is absent (no more hang on portable/AppImage/Android).
- nightly_update.rs uses async tokio::fs::read in the async command.
- nightly.yml: serialize runs via a concurrency group (no cancel) and
persist-credentials:false on checkouts.
- edge TTS route only emits the word-boundary header when it fits under ~8KB;
oversized values get dropped by proxies, and the client falls back to [].
- RSVPOverlay drops the contradictory aria-disabled on the functional rate
button (it opens the pace picker).
- nightly verify harness handles artifact stream errors instead of crashing.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Switching into Paragraph or RSVP mode while TTS is already playing now
syncs to the live session instead of forcing a stop + restart inside the
mode. Bundles several related TTS fixes uncovered along the way.
Session reuse (enter from normal mode):
- TTSController.redispatchPosition() re-emits the current position on the
canonical tts-position signal with a fresh sequence.
- useTTSControl answers a new tts-sync-request by replaying the current
position then playback state (position-first so RSVP's paused handler
can't discard it).
- Paragraph & RSVP engage following on entry and dispatch the request;
no-op when no session exists.
RSVP refinements:
- Reusing a session skips the start dialog and the get-ready countdown
(starts externally driven); gated on a live tts-playback-state signal
so the countdown can't flash.
- Stopping TTS now pauses RSVP instead of resuming its own pacing.
Word-sync fixes:
- rangeTextExcludingInert honours the range offsets inside a single text
node, fixing word-highlight drift on middle sentences of single-<span>
paragraphs (Edge word highlighting).
- foliate-js TTS.from() starts at the sentence containing the selection,
not the next one (submodule bump).
- Selecting a word and starting TTS now clears the selection.
- Dev-only [TTS] word-sync trace (stripped from production builds).
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(sync): design spec for syncing reading status (#4634)
Field-level LWW for reading_status (dedicated reading_status_updated_at),
a new 'abandoned' status in the Readest UI, and a koplugin bridge to
KOReader's native summary.status (whole-library apply + capture).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(sync): implementation plan for syncing reading status (#4634)
Bite-sized TDD tasks across 3 parts: A) cloud field-level LWW
(reading_status_updated_at on server upsert + client pull-merge),
B) 'abandoned'/On-hold status in the Readest UI, C) koplugin bridge
to KOReader summary.status (mapping + reconcile + whole-library
apply/capture).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(sync): add reading_status_updated_at for field-level status LWW (#4634)
* feat(sync): stamp readingStatusUpdatedAt on status change in updateBookProgress
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(sync): stamp status timestamp on explicit library status edits
* feat(sync): resolve reading status by its own timestamp in client pull-merge
* feat(sync): resolve reading status by its own timestamp in server upsert (#4634)
* fix(sync): tighten reading-status merge typing + strengthen test (A5 review)
Replace as-unknown-as double-casts at read sites with typed locals
(clientBook/serverBook); retain a single as-unknown-as only at the
server-wins construction site where the static type is too narrow.
Strengthen test 3 to assert both fields with toEqual.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(library): render the 'On hold' (abandoned) status badge
* feat(library): add 'Mark as On hold' actions + i18n for abandoned status
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* i18n: translate 'On hold' and 'Mark as On hold' for the abandoned status (#4634)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(koplugin): add reading-status mapping + reconcile between Readest and KOReader
* feat(koplugin): persist + sync reading_status_updated_at in LibraryStore
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(koplugin): bridge reading status to KOReader summary.status on library sync (#4634)
* test(sync): cover koplugin v1->v2 migration + tighten status-sync tests (final review)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(sync): redesign KOReader first-sync (decisive-only + bootstrap) (#4634)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(koplugin): safe first-sync of reading status (decisive-only + bootstrap) (#4634)
KOReader auto-sets summary.status='reading' on first open, and legacy Readest
statuses have reading_status_updated_at=0, so pure timestamp LWW let opening a
finished book downgrade it. Restrict sync to deliberate statuses (finished/
complete, abandoned/on-hold, unread->clear); never capture KO 'reading'/'New'.
On the unsynced baseline (Readest ts=0) conflicts resolve Readest-authoritative,
then stamp now_ms to exit bootstrap into steady-state LWW. reconcile now returns
write_ko/write_store flags; statussync captures now_ms once and equalizes both
sides (convergent, idempotent, resumable).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(koplugin): cover remaining first-sync graph cells + document sort effect (review)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Move the informational Data pack hints ("Open a book…" / "No data available…")
from the inline trailing slot into the row description so they wrap under the
label instead of stretching the row wide on mobile. Drop the size from the
Download button label and surface it as the row description (matching the
"Downloaded · size" state). Add a "CEFR level" description under the Level row.
Includes translations for the three new i18n keys across all locales.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(library): count only uploaded, non-deleted books in synced toast
The "N book(s) synced" toast shown on pull-to-refresh and the
last-synced menu counted every non-deleted book record a pull
returned, including books indexed in the cloud as metadata only
whose file blob was never uploaded. Those books are never added to
the library (updateLibrary requires uploadedAt && !deletedAt), so
the toast over-reported.
Extract the count into a pure countSyncedRecords(type, records)
helper that excludes deleted records and, for books only, requires
uploaded_at — matching what actually lands in the library.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* chore(memory): record agent notes for recent fixes
Persisted project-memory docs accumulated alongside recently merged
work (biometric app-lock, download-file scope regression, inline-block
column overflow, iOS instant-dict double popup, RSVP RTL words, web
security advisories) plus MEMORY.md/share-feature index updates.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Series sort comparator compared only seriesIndex, ignoring the series
name. When used as the within-group order for "group by Author → sort by
Series", this ranked every book #1 across all series as a block, then every
#2, etc., scattering each series instead of keeping it consecutive.
Compare series name first, then index, so all books of one series appear
together in series order before the next series begins.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
#4639 added a strict `app.fs_scope().is_allowed()` check to download_file/
upload_file. On Android that returns false for the app's own storage, so every
download into the app dir (book covers, dictionaries, books, gloss packs, OPDS
books in the cache dir) failed with "permission denied: path not in filesystem
scope".
Root cause: download_file/upload_file are plain app commands using raw tokio::fs,
so Tauri does not scope file_path. The capability scope patterns that cover the
app's storage ($APPDATA/Readest/**, $APPCACHE/**, **/Readest/**/*) are
command-scoped and absent from the global fs_scope() FsExt exposes (it is
initialized FsScope::default() and only ever gains runtime dialog/persisted-scope
grants), so is_allowed() returns false for the app's own files.
Interim fix mirroring dir_scanner::read_dir: keep rejecting relative and `..`
paths, then accept the path if the fs scope allows it (persisted dialog grants
for custom/external roots) OR it lives inside the app's own storage — matched by
the `Readest` data folder or the app's bundle identifier (app.config().
identifier), which the Android sandbox (/data/user/0/<id>/…, cache dir included)
and the desktop identifier dirs always carry. The `..` rejection keeps the
GHSA-55vr-pvq5-6fmg hardening: foreign targets like ~/.ssh/id_rsa carry neither
segment and stay blocked.
Follow-up (tracked separately): replace the substring fallback with a
BaseDirectory + relative path resolved via app.path(), so targets are in-scope by
construction with no string markers.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
FB2 stores series info as `<sequence name="…" number="…"/>` inside
`<title-info>`, but the foliate-js FB2 parser never read it, so
`belongsTo.series` was always empty and the series name/index never
surfaced in the library or book details. Refresh-metadata and
re-import didn't help since they share the same parser path.
Bumps the foliate-js submodule to pull in the `<sequence>` parsing
(readest/foliate-js#28) and adds a regression test + fixture.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The RSVP word window split each word into before/orp/after spans at the
ORP index and laid them out left-to-right. Slicing an Arabic/Hebrew word
by character index breaks letter shaping (letters stop connecting, some
slices render as notdef boxes) and the LTR layout reverses the visual
order, so e.g. علم showed as disconnected, out-of-order letters.
Detect RTL text and render the word as a single centered span — reusing
the existing CJK Highlight Word path — with dir="rtl" so the browser
shapes and orders it correctly, matching the context panel. ORP anchoring
is meaningless for unsplittable shaped scripts, so RTL always renders
whole; no new toggle.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A highlight and its note are now a single BookNote. Adding a note attaches
it to the highlight at that CFI (or creates one with the current global
style) instead of creating a second record, and a unified record renders as
both a highlight overlay and a note bubble.
- onDrawAnnotation chooses the draw kind from the overlay value prefix
(cfi -> highlight, NOTE_PREFIX -> bubble) instead of annotation.note, so a
record with both a style and a note draws both. Fixes notes synced from
KOReader losing their highlight (#4511).
- handleSaveNote updates the existing annotation at the CFI rather than
pushing a new record (#3870); re-styling preserves the note.
- unifyAnnotations migration (book config schema v1 -> v2, run in
deserializeConfig) collapses existing split highlight+note records into one
survivor and tombstones the redundant record (deletedAt) so the merge syncs
to the cloud and KOReader.
- Sidebar: a note's quoted highlight text uses the theme foreground so it
stays legible on the highlight background.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
On iOS a single long-press emits several selectionchange events, so the
instant quick action fired the system dictionary 2-3 times, stacking
UIReferenceLibraryViewController sheets. Add a once-per-gesture latch to
deferredAction (re-armed by beginGesture on touchstart/pointerdown) so the
action runs at most once per gesture, mirroring the Android
defer-to-touchend coalescing.
Also fix two related cases:
- Tapping outside to deselect after dismissing the dictionary occasionally
re-opened it (~1/3): the deselect tap re-armed the latch and a racy
lingering selectionchange re-fired. Gate the instant action on a
long-press hold (isLongPressHold, 300ms, touch only) so a quick tap can't
trigger it.
- A Word Lens gloss tap ignored the system-dictionary setting (always
opened the in-app popup); route it through handleDictionary so it honors
the system dictionary like the toolbar and instant-quick-action paths.
Verified on Android (Xiaomi) that the instant dictionary still fires once
per long-press and re-arms for the next gesture; iOS double-popup and
tap-to-deselect re-fire confirmed fixed.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The next-section accessibility skip link is injected nested inside each
section's last content element (findSectionEndHost in a11y.ts, added for
#4126). The paragraph-layout rule in getParagraphLayoutStyles() targets
`div:not(:has(*:not(b,a,em,i,strong,u,span)))`, so nesting a <div> made the
enclosing paragraph fail the :has() test and silently lose its line-spacing,
word/letter-spacing, text-indent, and hyphenation overrides — but only for the
last paragraph of every section, and only in <div>-based EPUBs (common in
Chinese-source books). <p>-based books were unaffected because the bare `p`
clause matches regardless of children.
Create the next-section skip link as a <span> instead. <span> is in the
selector's allow-list, so the enclosing paragraph keeps matching. The link is
still position:absolute (an out-of-flow 1x1px box) and focusable, so layout and
NVDA focus behavior are unchanged.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(reader): paginate inline-block-wrapped chapters instead of clipping them (readest/foliate-js#27)
Some EPUBs wrap a large chunk of chapter content in a div the stylesheet
declares as `display: inline-block`. Atomic inline-level boxes can't fragment
across CSS columns, so in paginated mode the tall box overflows the page
vertically and every column past the first is clipped — the chapter jumps
straight to its "Reference materials", silently skipping a large middle
section, while the counter reads "1 page left in chapter".
Bumps the foliate-js submodule with #demoteUnfragmentableBoxes (demotes
over-tall atomic-inline boxes to their fragmentable block equivalents in
column mode) and adds a browser test + repro EPUB fixture.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* chore(deps): bump foliate-js submodule to merged main (#27)
Re-point packages/foliate-js from the PR-branch commit to the squash-merged
main SHA now that readest/foliate-js#27 has landed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Some EPUBs ship an OPF that isn't well-formed XML — a bare, unescaped
`&` in a hand-built manifest id, e.g.:
<item id="Chapter_1213_Search_&_Rescue_153" .../>
A strict XML parser rejects it (`EntityRef: expecting ';'`) and the book
fails to import on every platform: the web/desktop/Android reader-open
path (foliate `EPUB.#loadXML`) and the Android/desktop native-import
bridge (`parseEpubMetadataFromXML`, which parsed unsanitized).
Bump foliate-js to escape any `&` that doesn't begin a valid character
or entity reference, applied at both parse sites (readest/foliate-js#26).
Valid and numeric references are preserved.
Verified end-to-end on the real "Shadow Slave - Vol. 6" EPUB: imports in
the web app (Chrome) and on a physical Xiaomi device via the native path,
both of which previously failed. New unit test covers both
`parseEpubMetadataFromXML` cases.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`download_file` and `upload_file` passed a webview-supplied `file_path`
straight to `File::create`/`File::open` with no validation, so any JS in the
privileged Tauri origin could write or read arbitrary local paths (e.g.
~/.ssh/id_rsa, shell rc files, autostart entries). (GHSA-55vr-pvq5-6fmg)
Validate the path before any file open/create: reject relative paths and `..`
traversal, then require it to be inside the app's filesystem scope
(`fs_scope().is_allowed`), the same mechanism `dir_scanner::read_dir` uses.
Legitimate destinations stay covered — the static capability globs ($APPDATA
/Readest, $APPCACHE, $TEMP) plus persisted dialog grants for custom roots and
external library folders.
AppHandle is injected by Tauri, so the JS invoke surface is unchanged. Adds a
unit test for the traversal/relative-path rejection.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Server-side hardening for three reported web advisories:
- OPDS proxy (/api/opds/proxy): add http(s) scheme allowlist, internal/loopback/
link-local host blocklist, and manual per-hop redirect re-validation so a
public URL can't redirect into an internal address. Move isBlockedHost into
the shared src/utils/network.ts as the canonical blocklist and reimplement
isLanAddress to delegate to it (also tightens the /api/kosync LAN check);
fetch-url.ts re-exports it. (GHSA-c7mm-g2j2-98cx, GHSA-5g3f-mq2c-j65v)
- Storage upload (/api/storage/upload): validate the client-supplied fileName
with a new isSafeObjectKeyName helper before building the object key, so a
name can't escape the caller's own prefix. (GHSA-mfmj-2frf-vhgw)
- Stripe (/api/stripe/check): bind the entitlement to the session owner —
reject a Checkout Session whose metadata.userId differs from the authenticated
caller. (GHSA-pv88-3727-j7v8)
Unit tests added for each path; full suite + lint green. The Tauri-native
advisory (GHSA-55vr-pvq5-6fmg) is handled in a separate change.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Three issues found while debugging shared-book links:
- /s cover was a broken <img>: the page runs under COEP: require-corp (for
Turso SharedArrayBuffer), and the cover redirects to a cross-origin R2
presigned URL that can't carry a Cross-Origin-Resource-Policy header, so the
browser blocked it. R2 already has CORS, but that's a different header — a
plain no-cors <img> needs CORP, which presigned URLs can't set. Serve /s with
COEP: credentialless, which keeps the page cross-origin isolated (the Turso
replica still boots there) while allowing the image. Scoped to /s; every other
route keeps require-corp.
- Android share links (https://web.readest.com/s/{token}) were run through the
article clipper: useClipUrlIngress excluded annotation links but not share
links, so they fell through to clip_url/readability. Skip parseShareDeepLink
URLs — useOpenShareLink owns that path.
- In-app book import failed with "Origin null is not allowed": the importer
fetched /share/{token}/download with the renderer's fetch, and on the app
(tauri.localhost -> web -> R2) the second cross-origin redirect nulls the
request Origin, which R2's CORS rejects. Use the native HTTP client
(tauriFetch) on the app — it follows the redirect and ignores CORS, needs no
server change, and works against the deployed server. Web is unaffected: its
fetch's redirect to R2 is the first cross-origin hop, so the Origin is
preserved and R2 allows it.
Adds unit tests (middleware COEP per route, clipper skips share links, download
route 302, importer uses native HTTP on app and the renderer fetch on web).
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(wordlens): rename ww-gloss CSS class to wl-gloss
Completes the Word Wise → Word Lens rename (#4633) for the gloss ruby class —
the 'ww' shorthand was missed. Renamed consistently across the apply site
(GLOSS_CLASS), the CSS rules in style.ts, the tap hit-test in
iframeEventHandlers, and the browser tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(wordlens): trim hints to first sense + suppress known derivations
Runtime, best-effort gloss-quality pass over the shipped en-zh pack (no
regeneration):
- cleanGloss: strip leading POS tags (now incl. 6-letter `interj.`) and keep
only the first sense, so hints stay short — "Ahem" shows 呃哼, not
"interj. 呃哼"; multi-sense entries collapse to their first sense.
- Derivational reduction (English source only): a would-be-glossed word inherits
a known base form's lower rank when the base exists in the pack AND their
glosses share meaning, so lazily/shyly/sorrowful/downwards/inwards stop being
hinted once lazy/shy/sorrow/… are known. Drifted forms keep their own rank
because their gloss doesn't overlap the base (hardly≠hard, lately≠late).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(wordlens): note hint-quality layer + wl-gloss in agent memory
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
"Word Wise" is a Kindle trademark, so rename the inline-gloss feature to
"Word Lens" throughout the product.
- User-facing strings → "Word Lens" across all 34 locales; brand translated
for Chinese (zh-CN 单词透镜, zh-TW 單詞透鏡) and German (Word-Lens-Daten).
- Code identifiers: WordWise→WordLens, wordWise→wordLens, WORD_WISE→WORD_LENS.
- Files/dirs: src/services/wordwise→wordlens, WordWisePanel→WordLensPanel,
wordwise{Ruby,Section}.ts, build/sync scripts, test dirs/fixtures,
data/wordwise→data/wordlens.
- Storage paths: CDN base, R2 key, on-device cache dir, WORDLENS_R2_BUCKET env,
pnpm wordlens:{manifest,sync}. manifest.json is path-agnostic so its
sha256/bytes stay valid (verified).
- biome.json: point the formatter-ignore at data/wordlens so the generated
one-line gloss packs aren't pretty-printed on commit.
Migration notes:
- Re-run `pnpm wordlens:sync` to upload packs to cdn.readest.com/wordlens/.
- Persisted view-settings keys renamed (wordWiseEnabled/Level/HintLang and
wordWiseAutoDownload) — saved values reset to defaults once on upgrade.
- Cached packs under the old Data/wordwise/ orphan (harmless re-download).
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Deleting a highlight or bookmark in the koplugin never reached the
server. SyncAnnotations:push builds its payload from getAnnotations,
which walks only the live ui.annotation.annotations list, so a deleted
note can never appear in a push — the server kept the row and the next
pull resurrected it. The pull direction (server deletions → koplugin)
was already handled by removeDeletedAnnotations (#4119); this is the
missing push direction.
Capture a tombstone at deletion time instead. onAnnotationsModified
detects a removal (negative index_modified, with the deleted item at
items[1]) and records a deletedAt-stamped descriptor in the per-book
sidecar (readest_sync.deleted_notes). push folds those tombstones into
the payload and clears them only once the server accepts them, so a
failed push retries. Extracted buildNoteDescriptor so the deletion path
derives a note's id identically to the push walk.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Fulltext search showed only the matched word with no surrounding context
when the match fell inside inline-styled text (e.g. <i>/<em>). The root
cause and fix live in the foliate-js submodule's makeExcerpt
(readest/foliate-js#25); bump the submodule pointer to pick it up and add
a regression test covering both the simpleSearch and segmenterSearch paths.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Inside a Series/Author library folder, the back arrow was a no-op after a
cold start. `GroupHeader.handleBack` deleted the `group` query param, leaving
an empty search string; `router.replace('/library')` with an empty search
silently no-ops under the Next.js 16.2 static export (every non-web build).
This is the same root cause as #3782, which was fixed for the breadcrumb
"All" button in #3832 — but the series/author back button never got the
workaround.
It only reproduces after a cold start, when `groupBy` comes from settings
(not the URL) and sort/order/view are at defaults, so `group` is the only
query param; that is why it could not be reproduced within a session.
Fix: set `group=''` instead of deleting it (mirroring
`handleLibraryNavigation`). The resulting `/library?group=` commits, and the
existing cleanup effect in page.tsx strips the trailing empty `group=`.
Verified on-device (Android, WebView 148, static export): tapping back inside
an author folder now returns to the main list.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Backports the Android ANR/stability fixes from the julianshen fork,
adapted to upstream — notably preserving the cold-start shared-intent
replay queue and the #4559 dictionary-dispatch logic, neither of which
the fork kept.
- NativeBridgePlugin: run blocking @Command I/O (copy_uri_to_path,
install_package, get_sys_fonts_list, and show_lookup_popover's
queryIntentActivities) on Dispatchers.IO via a Main-dispatched
pluginScope; startActivity hops back to Main; resolves are
isActive-guarded; onDestroy cancels the scope and clears the static
instance; the system-font scan is cached (@Volatile). The cold-start
shared-intent queue (emitOrQueue / registerListener) is left intact.
- MediaPlaybackService: unmarshal the artwork Bitmap off the main thread
(serviceScope + Dispatchers.Default), isActive-guarded; cancel the
scope in onDestroy.
- ClipUrlController: hold the Activity via WeakReference and check
isFinishing/isDestroyed before presenting, to avoid leaking the
Activity/WebView during the up-to-30s clip window.
- MainActivity (#3297): on Android 14+ the window can gain focus before
the WebView paints its first frame, leaving a blank screen. Force one
repaint when both the window has focus and the WebView exists
(whichever happens last).
Kotlin-only; not exercised by the JS/Rust test suites. Verified via
ktlint parse + a release `tauri android build` and on-device smoke test
(Xiaomi). The touch-event throttle and intent-handling rewrite from the
fork are intentionally NOT backported (they dropped touchmove forwarding
and the cold-start queue).
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Opening a book via Android "Open with" on a cold start could clear the
entire library. `openTransient` built an ephemeral entry on top of the
not-yet-loaded (empty) store; the library page's `length > 0` cached-skip
then skipped loading the real library from disk; and a later
`saveLibraryBooks` persisted the empty/partial set — overwriting both
library.json and its .bak. Introduced by #4407 (transient "Open with"),
made reliably reproducible by #4527 (reliable cold-start delivery) — so
released v0.11.4, which lacks #4527, does not reproduce it.
Two layers of defense:
- saveLibraryBooks now merges with the on-disk library (union by hash,
incoming wins), so a routine save is monotonic: it can add or modify
rows (including `deletedAt` tombstones) but can never drop a book.
Deliberate, authoritative rewrites (restore, tombstone GC, account
reset) opt in via the new `{ replace: true }`. This layer alone makes
the wipe impossible.
- openTransient loads the real library from disk before importing a
transient book — also fixing the cold-start hash-match miss that
re-imported already-imported books — and the library page's load-skip
now gates on the store's `libraryLoaded` flag instead of `length > 0`.
Tests cover the merge floor (no-drop / no-wipe-on-empty / tombstone
preserved / incoming-wins) and both `{ replace: true }` paths.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Resolves#4615. Re-importing updated serials left the app-generated
Books/<hash>/ folder (config.json reading progress/notes, nav.json, cover)
on disk after a normal delete, forcing a manual cleanup. "Purge Data" now
does a Cloud & Device delete AND wipes the whole directory in one action.
The book detail action row is redesigned to Edit · Download · Upload ·
Delete · More (hamburger):
- Goodreads, Share, and Export move into the hamburger "More" menu.
- Share is enabled only when signed in and the local file exists; Export
is enabled when the local file exists (kept on every platform since the
bottom-bar Send is mobile/macOS-only).
- Purge Data is the red entry in the Delete menu, behind a strong confirm.
Implementation:
- DeleteAction gains 'purge'; cloudService.deleteBook('purge') removes the
in-place source file and removeDir's the whole Books/<hash>/ folder,
clearing downloadedAt and leaving the tombstone + queued cloud delete to
the page (mirrors 'both'/'local').
- The library page wires handleBookDelete('purge'); BookDetailModal adds
the purge confirm config + share/export handlers and gates Share on auth.
Tests: cloud-service purge cases, BookDetailView More-menu + Purge tests.
i18n: 9 new keys translated across all 33 locales.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Docker production-stage opts into Next.js `output: 'standalone'` via a
BUILD_STANDALONE env flag, so it ships only the traced runtime (server.js +
hoisted node_modules + static/public) and runs `node server.js` instead of
pnpm over the full source tree. The flag — and `outputFileTracingRoot`,
which traces from the monorepo root so workspace packages are included — is
set only in the Dockerfile build stage. Every other path keeps its original
output: Tauri `export`, local `build-web`, dev, and the Cloudflare/OpenNext
deploy (which forces standalone itself via NEXT_PRIVATE_STANDALONE).
Disable the experimental `turbopackFileSystemCacheForBuild`: a build
interrupted mid-compile leaves a partial cache that the next build
mishandles, fanning out workers until it exhausts host RAM. Remove the
pull-request CI step that cached `.next/cache` for it, now unused.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Resolve a batch of transitive Dependabot alerts on the web lockfile via
pnpm-workspace overrides.
Bumped existing overrides:
- vite >=7.3.5 (#244 high, #245 med; GHSA path within 7.3.x) <- was 7.3.2
- dompurify >=3.4.9 (#249-#255; clears #252 <=3.4.6 by leaving the range)
- protobufjs >=7.6.3 <8 (#233, #247, #248); bounded <8 to stay on the
patched 7.x line (a bare floor let pnpm jump to the 8.x major)
- ws >=8.21.0 (#241 high) <- was pinned 8.20.1
Added overrides:
- form-data >=4.0.6 (#246 high)
- js-yaml >=4.2.0 (#243 med)
- '@babel/core' >=7.29.6 (#242 low)
- '@opentelemetry/core' >=2.8.0 (#256 med)
Not auto-fixable here: #236 @ai-sdk/provider-utils (<=3.0.97, no recorded
fix; the installed 3.0.25 is pinned via a local patchedDependencies patch).
Verified: pnpm test (5685 passed), pnpm lint, pnpm build-web (exit 0).
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Resolve transitive Dependabot alerts on the web lockfile.
esbuild >= 0.28.1 (GHSA-gv7w-rqvm-qjhr #239, GHSA-g7r4-m6w7-qqqr #238):
Deno-module RCE via NPM_CONFIG_REGISTRY and Windows dev-server arbitrary
file read. Forced via a pnpm-workspace override (esbuild is a regular dep
of vite, so the override applies cleanly); bounded to <0.29 to stay on the
verified line. vite 7.3.3 declares ^0.27.0, but the 0.28 JS API is
unchanged for vite's usage -- verified by the full test run and web build.
@vitest/browser >= 4.1.8 (GHSA-g8mr-85jm-7xhm #240): Browser Mode CDP
bridge bypasses allowWrite/allowExec, enabling config overwrite -> RCE.
Bumped the vitest devDep family (vitest, @vitest/browser-*, coverage-v8)
from ^4.0.18 to ^4.1.8; resolves to 4.1.9.
Verified: pnpm test (5685 passed), pnpm lint, pnpm build-web (exit 0).
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(library): add Clear Pending action to transfer queue
Adds a "Clear Pending" button to the transfer queue panel that removes
only pending (including retry-pending) transfers, leaving in-progress,
completed, failed, and cancelled items intact. Wired through the store,
manager (with queue persistence), and useTransferQueue hook.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* i18n: translate Clear Pending and reading-statistics strings across locales
Adds translations for "Clear Pending" (transfer queue) and the two
reading-statistics sync-category strings ("Reading statistics" and its
description) across all 33 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>
Hardcover sync previously only ran when the user opened the reader menu
and tapped "Push Progress" / "Push Notes". Add an opt-in Auto Sync toggle
(default OFF) to the Hardcover settings so progress and notes are pushed
automatically while reading.
- useHardcoverSync: silent debounced (10s) auto-push of progress on page
turns and of notes on annotation/excerpt changes, gated on
enabled && autoSync === true; pending pushes flush on the existing
sync-book-progress close event and cancel on unmount. Manual menu
actions are unchanged (still loud).
- HardcoverSettings.autoSync flag (default false); existing connected
users stay manual until they opt in.
- HardcoverForm: new "Auto Sync" toggle row.
Also backfills two untranslated strings surfaced by i18n:extract from the
reading-stats feature (#4606) across all locales, plus the new
"Auto Sync" key.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
In the library list view, surface each book's series and series number on
their own line, in addition to the description. Previously series info was
only visible by grouping by series or opening a book's details.
- Add `formatSeries(series, seriesIndex)` helper ("Series #N", trims the
name, omits a zero/NaN/negative index) with unit tests.
- In list mode, render a dedicated single-line "Series #N" line above the
description when the book has series metadata.
- Clamp every list line (incl. title) to one line and tighten the row gap
to `gap-1` so the extra line fits the fixed-height row without clipping.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
An OPDS entry with full metadata and a cover image but no acquisition
link — e.g. a Calibre book whose file was removed but kept for tracking
borrowed/loaned titles — was classified by foliate-js as a navigation
item whose href fell back to the cover image link. Tapping it loaded the
image, which is neither XML nor JSON, so the OPDS browser crashed with a
JSON parse error.
- Bump foliate-js to include the getFeed fix that classifies such
metadata-only entries as publications instead of navigation.
- PublicationView: show "No downloadable format available" when an entry
has no acquisition or stream links.
- loadOPDS: defense-in-depth — surface a clear message instead of a raw
JSON.parse SyntaxError when a response is neither XML nor JSON.
- Add tests covering the Calibre no-format entry and a regression guard
that a true navigation entry still classifies as navigation; add the
two new UI strings across all locales.
Closes#4599
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Supersedes #3156. Adds a reading-statistics system whose canonical data model
is KOReader's own (book + page_stat_data), so stats round-trip losslessly
between Readest and KOReader.
- Storage: a cross-platform Turso statistics.db in KOReader's exact schema
(web/Workers, desktop, iOS, Android) — replacing #3156's Node-only
better-sqlite3 + statistics.json.
- Tracking: per-page reading events (time-on-page, idle-capped) flushed on
page-change/idle/hide/close — the KOReader model — not session aggregates.
- Sync: legacy /api/sync extended with a stats type backed by self-contained
Supabase tables (stat_books, stat_pages); union/longer-duration-wins merge
keyed on book_hash. apps/readest.koplugin syncs through the same endpoint.
- Scale & robustness: per-tab singleton connection (avoids OPFS lock
conflicts) + explicit WAL checkpoint; transactional bulk apply; chunked
resumable push; client-driven paged pull with trailing-ms completion;
paginated/scoped server merge.
Verified: 5668 unit tests, 155 koplugin busted tests, biome+tsgo + luacheck
all green; web OPFS DB verified live.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The license, release, and last-commit badges all query GitHub's API
through shields.io's shared instance, which intermittently fails with
"Unable to select next GitHub token from pool" when shields.io's own
token pool is rate-limited. A README author can't supply a token to the
hosted badges.
- License is fixed at AGPL-3.0, so use a static badge that makes no
GitHub API call and can never hit the token-pool error.
- Switch the release badge from the deprecated `github/release`
endpoint (which 301-redirects) to `github/v/release`.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Drop the Sponsors subsection and its lone TestMu AI logo from the
Support section.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(reader): open image gallery & table zoom on single tap
In reflowable EPUBs, a single tap on an image or table now opens the same
viewer a long-press opens, so the image gallery / table zoom is reachable by
both gestures. Fixed-layout books (PDF/comics/manga) keep tap-to-turn, and
long-press is unchanged everywhere.
Reuses the existing iframe-long-press -> handleImagePress/handleTablePress
flow via a new shared detectMediaTarget() helper (also adopted by the
long-press path so the two entry points can't drift). handleClick now takes
an isFixedLayout flag; the tap branch sits after the link/footnote/drag/
long-hold/Word-Wise guards so linked images still follow links and a
long-hold or double-tap won't double-trigger.
Context: #4584 (single taps stop registering after picture zoom on some
WebView builds) - this adds a second, independent way into the viewer rather
than fixing that root cause.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(reader): rename iframe-long-press message to iframe-open-media
The message is now posted for both a long-press (any book) and a single tap on
an image/table (reflowable books), so the long-press-specific name was
misleading. Rename the message type to `iframe-open-media` and the consumer
hook `useLongPressEvent` -> `useOpenMediaEvent`. The long-press detector
(`addLongPressListeners`/`handleLongPress`) keeps its name since it still
detects a long-press specifically.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Re-importing a folder via the in-place option used to reopen, parse,
and partial-MD5 every file before its byHash entry could short-circuit
the import. Worse, the byHash short-circuit treated every hit as "user
dropped a fresh file matching a known book", so it refreshed
createdAt/updatedAt/downloadedAt and cleared filePath/deletedAt. For a
user re-scanning the same external folder, that quietly rewrote sort
order and wiped soft-delete state. And once the import returned, the
ingest pipeline still ran group / tag / upload work — including a
path-derived empty groupId that silently clobbered manual
GroupingModal assignments on every re-scan.
This change adds an explicit byFilePath fast path at the top of
`ingestFile` so a re-scan returns the existing library entry verbatim,
before any I/O AND before any downstream side effect:
byFilePath hit -> the same on-disk source is being re-scanned in
place; the right answer is no-op. Don't open the
file, don't touch any timestamps, don't re-cover,
don't run the group / tag / upload steps.
byHash hit -> a different source path resolves to a known book
(e.g. the user dropped a copy from elsewhere, or
a soft-deleted book is being revived); the
existing "refresh timestamps + clear deletedAt"
behavior in importBook is correct here and is
left intact.
Implementation:
- BookLookupIndex carries byHash / byMetaKey / byFilePath, with
byFilePath built only from non-deleted books that have an absolute
filePath. normalizeFilePathForIndex is the shared key function so
shouldImportInPlace and the index agree on case-insensitive
filesystems (macOS / iOS / Windows). osPlatform threads through
buildBookLookupIndex and BaseAppService.importBook so the renderer
and the importer compute the same key for the same file.
- ingestService.ingestFile gains a byFilePath fast path before its
importBook call: when `inPlace` was decided positive, the source
is a real on-disk path (not a PSE stream / URL / content URI),
and lookupIndex.byFilePath has a hit, return the existing Book
directly. Returning here — rather than inside importBook — is
deliberate: it skips the downstream group / tag / upload steps so
a re-scan can never silently overwrite a manual GroupingModal
assignment via a path-derived empty groupId.
- ingest-service.test.ts covers both halves: an in-place re-import
short-circuits importBook entirely (no call, existing object
returned, createdAt / updatedAt / groupId / groupName all
untouched); a copy-mode import (no external library folders) with
the same byFilePath entry still goes through importBook so dedup
falls back to byHash. import-metahash.test.ts retains the
BookLookupIndex builder test that deleted and url-backed books
are excluded from byFilePath.
Net effect: re-importing a folder of N already-imported books does
zero file opens, zero parses, zero MD5 passes, and leaves every book's
groupId / createdAt / deletedAt / cover untouched.
donate.readest.com now lists every donation method, so the Support
section links there instead of enumerating GitHub Sponsors, Stripe,
and crypto separately.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Relocate the update and telemetry toggles out of the library settings
menu into the Behavior (Control) panel, where global app settings live:
- New "Update" boxed-list (gated on hasUpdater): Check Updates on Start
+ Nightly Builds.
- New "Privacy" boxed-list: Help improve Readest (telemetry).
- Behavior section order: Update → Security → Privacy.
- Rename "Nightly Builds (Unstable)" → "Nightly Builds" and drop the
"; may be unstable" note (the channel stays off by default).
- Updater dialog now shows the full version name (e.g.
0.11.4-2026061506) instead of a parsed date.
- Extract + translate the new strings (Update, Privacy, Nightly Builds,
Early daily builds) across all 33 locales.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(reader): Word Wise — inline native-language vocabulary hints
Kindle-style Word Wise: a short native-language gloss renders above difficult words
as you read (always-on ruby), gated by a CEFR vocabulary-level slider (A1–C2);
tapping a glossed word opens the existing dictionary.
- Pipeline: CEFR→frequency-rank difficulty, inflection-aware gloss index, pure
offset-aware planner (EN regex + jieba for CJK).
- Rendering: <ruby cfi-skip>…<rt cfi-inert> injected per occurrence — CFI-transparent
(verified), so highlights/bookmarks/progress are unaffected; kept out of TTS word
offsets and find-in-book.
- Delivery: gloss packs are version-controlled in data/wordwise/, mirrored to R2, and
downloaded on demand into local storage (sha-verified, single-flight) when enabled.
- Settings: a Word Wise sub-page under Settings → Language (enable, level, hint
language, per-pack download/manage, auto-download toggle).
- Build tooling: scripts/build-wordwise-data.mjs (ECDICT / CC-CEDICT+HSK / WikDict +
FrequencyWords, with lemmatization) and scripts/sync-wordwise-r2.mjs.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* data(wordwise): bundled gloss packs + manifest + attribution
13 frequency-trimmed gloss packs (en↔中文 + es/fr/de/pt/it/ru↔en, ~19 MB) generated
by build-wordwise-data.mjs from ECDICT (MIT), CC-CEDICT + HSK, and WikDict +
FrequencyWords (CC-BY-SA). Source of truth, mirrored to the CDN via `pnpm wordwise:sync`.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The read-along audio toggle + settings gear sat in an `absolute end-0`
cluster overlaid on the centered transport row. After the #3235 read-along
feature grew that cluster from a single gear (~36px) to ~81px (audio +
divider + gear), it covered the right end of the transport on narrow
phones, hiding the audio button behind the "skip forward 15" control.
Lay the playback controls out as a single full-width flex row: the audio
toggle moves to the far left and the settings gear stays far right,
symmetrically flanking the centered play button (justify-between on
mobile, justify-center on md+). Tighten the secondary buttons on mobile
(h-8, px-1.5) and add shrink-0 so the row fits without overlap; the
symmetry keeps the play button centered.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The assemble-manifest job promoted the manifest with single-file
`rclone copyto` + `moveto`. Before a single-file upload rclone issues a
CreateBucket probe (PUT /<bucket>), which the object-scoped RELEASE_R2_*
token can't satisfy -> 403 AccessDenied, so nightly/latest.json was
never published (the build legs and the stable release flow were fine
because they use a directory `rclone copy`, which PUTs the object
directly without that probe).
Mirror the release flow (upload-to-r2.yml): copy a one-file directory
into nightly/. R2 PutObject is atomic, so the .tmp + server-side move
added nothing. Verified against the live bucket with the current token:
directory copy -> 200 OK; single-file copyto -> CreateBucket 403.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Dictionaries that store only base headwords (e.g. Oxford Dictionary of
English) miss inflected selections like `ran`, `mice`, `children`, or
`analyses` even though the lemma (`run`, `mouse`, `child`, `analysis`) is
present. Add a language-aware lemmatizer whose base-form candidates are
appended to the existing lookup candidate chain, after the exact/case
variants, so an exact/case match always wins and the lemma is only tried
once those miss.
- New pluggable `lemmatize/` registry keyed by primary language subtag;
add a language by registering one lemmatizer, no caller changes.
- English lemmatizer: irregular-form table (suppletive verbs, irregular
plurals/comparatives) + regular suffix rules (plural/past/gerund/
comparative/possessive). Over-generates on purpose — the dictionary
lookup is the validator, so bogus stems simply miss.
- Unknown/missing book language defaults to English (no-op on non-ASCII);
an explicit non-English language with no registered lemmatizer is a
no-op.
- Applies centrally to all definition providers (mdict/stardict/dict/slob
and the online builtins) via `buildLookupCandidates`.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The nightly Linux legs build with `cargo tauri build` WITHOUT `--target`
(no matrix `args`), so cargo emits bundles under `target/release/bundle/`
(host-target default) rather than `target/<triple>/release/bundle/`. The
macOS/Windows legs DO pass `--target`, so they legitimately get the triple
subdir — but the "collect artifacts" step reused `${rust_target}` for the
Linux AppImage path too, looking under
`target/x86_64-unknown-linux-gnu/release/bundle/appimage/` where nothing
exists. The build succeeded; only the collect step failed with
"missing artifact or signature for linux-x86_64-appimage".
Drop the `${rust_target}` subdir from the Linux AppImage path so it points
at the host-target default location where the bundle actually lands.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Highlighting recurring text (e.g. main-character names) as global
annotations made page turning very laggy. The `progress` effect
re-fans-out every global annotation across every rendered section on
EVERY page turn, and each pass re-walks the section DOM, recomputes
`view.getCFI()` for every occurrence, and tears down + recreates an SVG
overlay per match. The overlays already exist after the first pass, so
this is pure wasted work — profiled at ~25–45ms of synchronous
main-thread time per page turn for 6 names / 226 occurrences across 2
rendered chapters, multiplied on slower mobile hardware.
Memoize, per live section `Document`, which global notes have been
expanded (signature embeds `updatedAt`/style/color/text). Subsequent
page turns short-circuit to ~0ms. Keying on the `Document` makes
invalidation automatic: a re-rendered section gets a fresh document (and
fresh overlayer) so its overlays are rebuilt, while edits/recolors bump
`updatedAt` and toggling global off clears the memo.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(reader): TTS-sync design spec for paragraph mode + RSVP (#3235)
Hardened via brainstorming + /autoplan (CEO/Design/Eng dual-voice review).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(tts): emit canonical tts-position event from TTSController (#3235)
Controller emits { cfi, kind, sectionIndex, sequence } alongside the existing
tts-highlight-mark/-word events. Monotonic sequence lets downstream consumers
(paragraph mode, RSVP — later slices) drop out-of-order positions. Additive;
existing events untouched.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(reader): containment+cursor CFI->index mappers for TTS sync (#3235)
RSVPController.syncToCfi + setExternallyDriven: containment match (fixes
mid-token skip), monotonic cursor + binary search (avoids O(N)-per-word jank,
no per-word getCFI), -1/no-op on no match (no silent jump to word 0), timer
suspension while externally driven.
ParagraphIterator.findIndexByRange: hinted + binary-search containment mapper
returning -1 on no match (never first()).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(tts): forward tts-position + tts-playback-state onto the app bus (#3235)
useTTSControl republishes the controller's canonical tts-position (tagged with
bookKey) via a dedicated listener — NOT inside the suppression-gated highlight
handlers, so page-follow suppression can't silently desync the modes. Adds
tts-playback-state (playing/paused/stopped) so RSVP can track playback without
the hook-local isPlaying. Verified by extending the real-foliate-view browser
harness.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(reader): paragraph mode follows TTS playback (#3235)
When paragraph mode + TTS are both active, the focused paragraph follows the
spoken position (sentence granularity, all engines). Section-generation contract
(stash cross-section position, apply after the iterator re-inits); sync-focus
path that does NOT arm isFocusingRef (avoids the relocate-eaten wrong-section
paragraph-0 bug); stale-sequence drop; decouple on manual nav, re-engage on next
playing. Start-alignment + visible indicator deferred to later slices.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(rsvp): speed reader follows TTS playback (#3235)
Edge word-boundary voices: RSVP shows the spoken word via syncToCfi. Non-Edge
(sentence-only) voices: sentence-paced estimator (clamp 60..600 wpm from voice
rate, hold at +60 words cap, snap to first word on each new sentence mark).
RSVP auto-advance suspended while TTS-driven. Decouple on manual nav via a
rsvp-manual-nav signal; re-engage on next playing. Cross-section positions
re-extract then apply. Pure decideRsvpTtsPosition helper unit-tested.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(reader): fixed-layout gate + ttsSyncStatus for TTS sync (#3235)
Gate sync to reflowable books (D7): fixed-layout reports 'unsupported' and
never engages. Both modes expose ttsSyncStatus (idle/following/syncing/
decoupled/unsupported) as the data source for the upcoming indicator. RSVPControl
now forwardRef-exposes the status via an imperative handle. Cross-bookKey events
ignored (regression-tested).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(reader): 'following audio' indicator for TTS sync (#3235)
5-state pill (following/syncing/decoupled, idle+unsupported render null) shown
top-center in the paragraph overlay and as a status row in the RSVP overlay.
Decoupled state is the tap-to-resume control; first decouple fires a one-time
toast. eink-bordered, glyph+text (no color-only), RTL logical props, touch
targets, safe-area top inset. RSVP 'plain' variant matches its themed surface;
non-Edge shows '· estimated'. New i18n keys need extraction before merge.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(rsvp): in-overlay TTS toggle + audio-paced speed control (#3235)
Voice-glyph audio toggle in the RSVP control row (trailing, by the gear) starts/
stops read-along from inside the full-screen overlay, start-aligned to the current
word (range validated against the live doc). While TTS-driven, the WPM control
shows a locked 'Audio pace' affordance that opens a compact rate picker; rate
changes go through a new tts-set-rate bus event reusing the existing throttled
setRate path. Pure buildRsvpTtsSpeakDetail helper unit-tested.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(reader): e2e paragraph mode follows TTS across a section boundary (#3235)
Real <foliate-view> browser e2e: with paragraph mode active, the focused
paragraph follows the TTS walk and re-targets to the new section after a Ch4->Ch5
boundary (proves no stuck wrong-section paragraph-0 / isFocusingRef trap).
Asserts on the owning section of the current range. Test-only.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* i18n(reader): translate TTS-sync strings across 33 locales (#3235)
Following audio / · estimated / Resume audio / Stopped following audio /
Play audio / Pause audio / Audio pace / Speed follows audio.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(reader): resolve TTS CFI anchors across iframe realms (#3235)
RSVP and paragraph follow silently failed to track the spoken word: the CFI
anchor from view.resolveCFI(...).anchor(doc) is a Range created in the book
iframe's realm, so 'anchor instanceof Range' (top realm) was always false
(cross-realm instanceof) -> resolveCfiToRange/applySyncCfi returned null ->
syncToCfi never advanced. Add isRangeLike() duck-type (cloneRange is unique to
Range) and use it at all 4 CFI-resolution sites. Confirmed live via CDP: before
= syncToCfi false (frozen); after = exact word map + RSVP follows Edge TTS at
~171 wpm (audio pace). Unit tests reproduce the cross-realm anchor (jsdom is
single-realm so the old code passed there but died in the app).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(rsvp): stop estimator/word fight + map transport to TTS play/pause (#3235)
Two read-along refinements (verified live via CDP with Edge TTS):
1. No more jump-ahead-then-snap-back flashing. Word-boundary engines (Edge) emit
BOTH sentence marks and word boundaries; RSVP was routing sentence -> the
estimator (self-paces ~190xrate, up to +60 words ahead) while word positions
snapped it back. Now once a word position is seen, sentence positions are
ignored and any running estimator is stopped, so words alone drive RSVP.
2. The RSVP transport (center play/pause, Space, center-tap) maps to TTS
play/pause while read-along is engaged (tts-toggle-play), instead of RSVP's
own suspended timer. Pausing TTS keeps RSVP suspended (no runaway); a full
stop releases it.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(rsvp): keep indicator on pause + reach dict management from RSVP (#3235)
- Pausing read-along no longer dismisses the 'following audio' indicator / 'Audio
pace' lock (layout shift). New 'paused' sync status keeps the indicator row and
WPM lock present while TTS is engaged-but-paused; only a full stop clears them.
Verified live via CDP: pause keeps the layout, no shift.
- Dict management is reachable from RSVP: the settings dialog is z-50, far below
the full-screen RSVP overlay (z-[10000]), so it opened invisibly behind it.
handleManageDictionary now exits RSVP first (position saved/resumable) so
management shows over the reader.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(rsvp): show dict management over RSVP instead of exiting it (#3235)
Per feedback: opening dictionary management from the RSVP lookup popup no longer
closes the speed reader. The settings dialog is raised above the full-screen RSVP
overlay (z-[10000] -> SettingsDialog !z-[10050]) so it shows on top, and RSVP's
capture-phase keyboard handler bails while the settings dialog is open so its
inputs accept Space and Escape closes settings (not RSVP). Verified live via CDP:
management opens over RSVP, RSVP stays active behind it, Escape returns to it.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(dict): only apply drag-handle margin compensation when the handle shows (#3235)
The dictionary sheet header used -mt-4 to compensate for Dialog's drag handle,
but that handle is sm:hidden (shown only below sm). On sm+ the handle is
display:none, so -mt-4 pulled the header up into the top edge (broken layout
when the lookup renders as a sheet on a short/wide window). Mirror the handle's
breakpoint: -mt-4 sm:mt-0. Verified live via CDP.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(reader): in-mode TTS audio toggle for paragraph mode (#3235)
Paragraph mode already follows TTS, but there was no way to start read-along
from inside it. Add an audio toggle to the ParagraphBar (mirroring RSVP's): it
starts TTS start-aligned to the focused paragraph (range validated live, +
section index) and stops it. Track session-active vs playing so a pause keeps
the indicator ('paused' status) instead of collapsing to idle. Pure
buildParagraphTtsSpeakDetail helper unit-tested. Verified live via CDP: tapping
the icon starts audio from the focused paragraph and the focus follows speech.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(reader): highlight current TTS word/sentence in paragraph mode (#3235)
Paragraph mode follows TTS by advancing the focused paragraph, but the spoken
word wasn't highlighted within it like normal mode. The overlay renders a CLONE
of the paragraph, so the iframe's TTS highlight isn't visible there — reproduce
it on the clone with the CSS Custom Highlight API (no DOM mutation, spans inline
boundaries natively, leaves the fade-in animation untouched).
- TTSController already tags tts-position with kind word|sentence. The hook
decides granularity: word boundaries (Edge) drive a per-word highlight; once
seen, the coarse sentence event is skipped so the whole sentence doesn't
flicker over the current word. Engines without word boundaries
(WebSpeech/Native) fall back to the sentence highlight.
- Offsets are computed relative to the paragraph start (so they map 1:1 onto the
clone's text) and tagged with the paragraph index so a stale highlight never
paints the wrong paragraph. Cleared on stop / section change / disabled.
- The ::highlight() style mirrors the user's ttsHighlightOptions color+style.
Pure helpers (offset math, word/sentence decision, css builder) unit-tested.
Verified live via CDP: word highlight tracks Edge word-by-word and follows
across paragraph boundaries (news -> ... -> ladies), matching the TTS color.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Mounts the real foliate <foliate-view> with sample-alice.epub, renders
the real useTTSControl hook with the real stores, and mocks only the
speech client. Starts TTS at the last paragraph of chapter 4 and
verifies the reading auto-advances into chapter 5, the page turns, and
the "Back to TTS Location" badge never appears (the TTS location stays
in view).
The mock client's speak() only needs to yield `end` — the real
TTSController drives forward() and the real view.tts walks the document
across the section boundary, so the cross-chapter navigation and badge
suppression are genuinely exercised rather than re-implemented in the
test.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(library): refresh book cover after editing metadata
Editing a book's cover in Book Details and saving showed the old cover
until a full reload, in two render paths:
- Library grid: handleUpdateMetadata mutated the book object in place,
so the memoized <BookCover> compared fields off the same (mutated)
reference and skipped re-rendering. Build a new book object via the
new getBookWithUpdatedMetadata helper instead of mutating.
- Book Details view: BookDetailView renders cover/title/author from the
modal's `book` prop, which the parent never re-passed after save.
BookDetailModal now tracks the saved book locally (displayBook) and
renders the view from it.
Adds a unit test for the immutable helper, a BookDetailModal regression
test (edit cover -> save -> view reflects it), and a sample-alice.txt
fixture for TXT import testing.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* chore(agent): add cover-refresh stale-render memory
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* fix(reader): open TXT files shared via "Open with" by converting to EPUB
The Android "Open with Readest" (VIEW intent) transient path hands the
reader the original .txt file (its filePath points at the content:// URI),
unlike the managed library which stores the already-converted EPUB. The
DocumentLoader had no branch for a raw .txt, so open() returned
{ book: null } and initViewState crashed with
"TypeError: Cannot read properties of null (reading 'metadata')",
leaving the user stuck on the library splash.
Add an isTxt() check that converts the raw .txt to EPUB in-memory (the
same TxtToEpubConverter the import path runs) and parses that. The
converter emits a .epub-named file, so the importer's own
DocumentLoader.open() on the converted file is unaffected.
Verified on-device (emulator, warm + cold start): the TXT now opens and
renders in the reader instead of crashing.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(settings): allow adjusting highlight opacity in e-ink mode
Drop the isEink prop that disabled the highlight Opacity slider under
e-ink. Opacity is still meaningful on e-ink, so let users change it.
Removes the prop from HighlightColorsEditor, its ColorPanel call site,
and the test render helper.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(send): mock fetch to fix flaky article conversion test
The article/page conversion paths fetch a favicon + author image for the
synthetic cover via globalThis.fetch. In jsdom that hit the real network:
a live fetch to the sample URL can hang up to faviconFetcher's 6s timeout,
exceeding the 5s test timeout and intermittently failing the suite. Stub
fetch so the cover falls back to its initial-letter tile (the pattern other
tests in this suite already use). Article test: ~5003ms hang -> ~80ms.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* chore(agent): update annotation-share-toolbar memory
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* docs(spec): annotation Share tool + customizable toolbar (#4014)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(plan): implementation plan for Share tool + customizable toolbar (#4014)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(annotator): add 'share' annotation tool type and button (#4014)
* feat(annotator): add pure toolbar order/visibility helpers (#4014)
* feat(annotator): add annotationToolbarItems view setting (#4014)
* feat(annotator): add shareSelectedText ladder helper (#4014)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(annotator): render Share tool and honor toolbar order in selection popup (#4014)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(settings): add drag-and-drop annotation toolbar customizer (#4014)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(settings): open the toolbar customizer from the Behavior panel (#4014)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* chore(i18n): extract and translate annotation share/toolbar strings (#4014)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(annotator): extract canShareText helper, preserve hidden Share on cross-platform edit (#4014)
Addresses final-review findings: de-duplicate the triplicated canShare
definition into share.ts::canShareText, trim ShareCapableService to the
fields actually read, and stop the toolbar customizer from dropping a
synced 'share' tool when edited on a non-share-capable device.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(settings): WYSIWYG drag-and-drop toolbar customizer (#4014)
Rework the customizer per live testing:
- Render 'In toolbar' as a faithful, content-width, start-aligned preview of
the real selection popup (gray bar, icon-only buttons); 'Available' tools
show as labeled chips.
- Multi-container dnd-kit pattern: in-place dragging (no DragOverlay, which a
transformed modal offsets), pointerWithin collision so empty zones accept
drops, live onDragOver reparent, itemsRef to dodge dnd-kit's drag-start
handler-capture stale closure.
- Add 'Add all' (canonical predefined order) and 'Clear all' shortcuts.
- Align zone labels with the SubPageHeader breadcrumb.
- Empty toolbar now suppresses the selection popup entirely (no empty bar),
while still allowing highlight-edit/notes popups.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* chore(i18n): translate Add all / Clear all toolbar shortcuts (#4014)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(annotator): size selection popup to visible tool count (#4014)
With the customizable toolbar a fixed-width popup looked sparse for a 2-3
tool toolbar (buttons spread to the corners). Size the popup to the number
of visible tools (responsive) capped at the previous max; annotated
selections keep the max width since they show highlight options / notes.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(settings): reword empty-toolbar hint to 'No tools, drag one here' (#4014)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(annotator): render default tools (not Share) in popup layout screenshot (#4014)
The visual regression test rendered every annotationToolButtons entry, so
adding the Share tool shifted the toolbar to 9 buttons and broke the
baselines. Share is hidden by default (added via Customize Toolbar), so the
popup screenshot should mirror the default-enabled set — filter to
DEFAULT_ANNOTATION_TOOLBAR_ITEMS, keeping the existing baselines valid.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Highlight each word as it is spoken (Edge TTS only) instead of keeping the
whole sentence highlighted, and keep the view tracking the spoken word
across page boundaries.
Word boundaries
- Capture Edge's audio.metadata WordBoundary frames (offset/duration in
100ns ticks plus the verbatim input-text span) in the Tauri, browser, and
Cloudflare-Workers WebSocket transports.
- Carry boundaries through the authenticated HTTPS proxy route via an
X-TTS-Word-Boundaries response header (percent-encoded JSON, ASCII-safe),
so word highlighting works on the web where the browser cannot open the
wss connection directly. Cache them alongside the audio blob URL.
Highlighting
- Sync a requestAnimationFrame loop to audio.currentTime against the
boundary table and highlight the word sub-range within the spoken
sentence. Synthesis stays sentence-level (natural prosody); only the
visual highlight is word-level.
- Suppress the sentence highlight when the active client reports word
boundaries and draw the first word immediately, so the whole sentence
never flashes before the first word. Fall back to the sentence highlight
when a chunk has no boundaries (other engines, empty metadata).
- Re-apply the current word (not the sentence) when the view relocates.
Page following
- Turn the page as soon as the spoken word crosses a page boundary (a
tts-highlight-word event scrolls only when the word is outside the visible
range), instead of waiting for the next sentence.
- Check the word's position for the "back to TTS location" badge so it no
longer appears while the view follows the word onto the next page.
Also fixes a pre-existing bug where the browser WebSocket was constructed
with an options object (valid only for the Node ws package), which threw in
browsers and made the wss path unusable on the web.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
On targetSdk 36, ACTION_PROCESS_TEXT handlers were hidden by Android 11+
package-visibility filtering — only auto-visible web browsers resolved the
intent, so system-dictionary lookups landed in the OEM browser (VIVO/iQOO)
even with a dictionary like Eudic installed. Add a <queries> declaration so
dictionary apps are visible, and filter web browsers out of the handler set
so an OEM browser that registers PROCESS_TEXT can't swallow the lookup:
- no browser among handlers → unchanged implicit dispatch (keeps native Always)
- browser + one dictionary → launch it directly (explicit component)
- browser + several dictionaries → chooser excluding browsers, remembering the
pick via EXTRA_CHOSEN_COMPONENT so later lookups go straight through
- only a browser installed → report unavailable instead of opening it
Routing is a pure, JUnit-tested decideLookupDispatch(). Adds get/clear
lookup-dictionary commands + an Android-only reset row in the dictionary
settings to switch the remembered app.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The voice panel filtered voices by the full locale of the currently
speaking text (v.lang.startsWith(locale)), so a book mixing region
variants of one language flip-flopped its voice list: Standard Ebooks
tag their boilerplate front matter en-US (17 Edge voices) while the
body text is en-GB (5 Edge voices).
Filter by primary language instead (isSameLang) in all three TTS
clients so every English variant yields the same voice set, and sort
voices matching the requested locale first
(TTSUtils.sortVoicesPreferLocaleFunc) so default-voice resolution via
getVoiceIdFromLang still picks an exact-locale voice. This also fixes
languages whose tags never matched a voice locale prefix at all (e.g.
zh-Hans books previously got an empty Edge voice list).
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
The body.theme-dark catch-all from #4392 painted every section iframe's
body with the opaque theme bg in dark mode, occluding the host
background texture and poisoning foliate's docBackground capture (so
paginated segments and scrolled view backgrounds resolved opaque too).
Force transparent instead: the dark page fill already comes from the
paginator container / reader grid cell, and book-forced light page
backgrounds stay neutralized since the theme-dark fill shows through.
Unconditional rather than texture-gated because docBackground is
captured once per section load and a gated rule would go stale on live
texture toggling.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* perf(cfi): bucket booknotes per chapter and batch-collapse location matcher
When iterating a list of CFIs against the same currentLocation (Annotator
on every page turn, useSearchNav, useBooknotesNav), the standalone
isCfiInLocation collapses the location twice per CFI. With 1000+
booknotes -- which a heavy user reported -- that's 2000 CFI parses
per page turn. The foliate epubcfi.js chunk showed up as ~15% of
self time in Bottom-Up profiles of the release Android build.
Fix:
- createCfiLocationMatcher(location) collapses once and returns a
matches(cfi) predicate that reuses the cached bounds. O(N) calls
become 1 collapse + N compares.
- getCfiSpinePrefix(cfi) extracts the spine path via pure string ops
(no CFI.parse round-trip) for use as a chapter bucket key.
- Annotator builds annotationIndex = { bySection, globals } via
useMemo([config.booknotes]) once when booknotes change, not per
page turn. The progress-driven effect then only scans the current
chapter's bucket -- ~50 CFIs in a typical book instead of all 1000.
globals are pre-filtered too.
- useSearchNav / useBooknotesNav switch to the batched matcher for
the same reason.
Includes parity tests covering empty/malformed inputs, equality
shortcut, prefix shortcut, in-range, and out-of-range cases.
* fix(annotator): keep note-only annotations in the per-chapter bucket
The booknote bucketing gated entries on `item.style`, which dropped
note-only annotations (a `note` with no highlight style/color, created
via the Notebook flow) from the per-relocate re-apply path. Their note
bubble was no longer redrawn on relocate or when booknotes changed while
a section stayed rendered.
Restore the original two-list semantics: bucket on style OR note, then
classify per location (annotations need a style, notes need a note).
Extract the logic into a dedicated, unit-tested `annotationIndex` module
(buildAnnotationIndex + selectLocationAnnotations) instead of inlining it
in Annotator, matching the reader/utils domain-named convention.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Huang Xin <chrox.huang@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* perf(reader): coalesce relocate events and memoize BookCell to stop per-swipe storm
FoliateViewer
-------------
foliate fires `relocate` multiple times during a swipe burst (snap
steps + intermediate stabilize). Each one ended up in setProgress,
which writes to readerProgressStore + bookDataStore. Coalesce them to
a single commit per animation frame so only the final viewport state
is persisted.
Earlier this used requestIdleCallback, but profiling on Android showed
"Fire Idle Callback" ballooning to 2.0+ s of total time per ~28 s
session: rIC backed up under sustained pressure and dumped the whole
queue into the post-swipe pause, producing exactly the "feels sluggish
right after I let go" jank we were trying to fix. rAF runs once per
frame, gets scheduled by the browser's normal vsync loop, and doesn't
accumulate when the page is busy.
BooksGrid -> BookCell
---------------------
Previously BooksGrid subscribed to the entire progresses map and
rendered every book inline. The map changes on every page turn, so the
whole bookKeys.map(...) body re-ran for every swipe. On top of that
inset-related objects (gridInsets, contentInsets) were rebuilt every
render and threaded as fresh references into 7+ children, so even
unchanged children couldn't bail out. That accounted for ~27% of
main-thread time in the Bottom-Up profile ("Animation Frame Fired"
2.6s / 27%).
Extract BookCell as its own React.memo'd component:
- Each cell subscribes only to its own book's progress via
useBookProgress(bookKey). A page turn re-renders one BookCell, not
the grid.
- viewInsets / contentInsets are memoized off their numeric inputs so
children get stable prop references across renders.
- BookCell uses per-field selectors internally for the same reason
spelled out in store/readerProgressStore.ts header.
- Dropdown handlers are wrapped in useCallback so HeaderBar's props
object stays stable.
* fix(reader): subscribe BookCell to its own viewState so settings/ribbon toggles apply live
BookCell subscribed reactively only to useBookProgress and read
viewState/viewSettings imperatively. Settings that save with
applyStyles=false (Show Header/Footer, Double Border, Border Color) and
the bookmark ribbon toggle write no progress, so the cell didn't
re-render and the chrome it gates (SectionInfo, ProgressBar, DoubleBorder,
Ribbon) only updated on the next page turn.
Subscribe to the per-book viewStates[key] slice. This is safe now that
progress lives in its own store — viewStates[key] only bumps on
low-frequency events (settings toggles, ribbon, init, sync), never on
the per-swipe relocate path — so it does not reintroduce the commit
storm the progress-store split removed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Huang Xin <chrox.huang@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
In scrolled mode the notch-area masks the top safe-area inset with
opaque bg-base-100 so content scrolling under the status bar is hidden,
but it painted over the background texture (.foliate-viewer::before at
the z-0 layer), leaving a flat untextured strip across the unsafe
header area.
Give the mask its own texture ::before (.notch-masked in textures.ts)
and make the element span the grid cell, clipped down to the inset
strip with clip-path — background-size cover/contain resolves against
the element box, so the full-cell box is what keeps the mask's tiles
aligned with the viewer's at the seam. clip-path also clips
hit-testing, so the click target stays the inset strip only.
Verified on a Xiaomi 13: the strip now renders the texture with a
pixel-continuous seam (row-to-row MAE at the boundary dropped from
11913 to 230, the level of ordinary texture rows), and
elementsFromPoint confirms the notch is hit-testable only inside the
strip.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
setProgress was called multiple times per swipe burst, each call writing
into readerStore.viewStates[key].progress. ~65 places in the reader
subtree subscribed to useReaderStore() without a selector, so every
setProgress fan-out re-rendered all of them -- even the 51 that didn't
care about progress. On Android release builds this showed up as
Layout = 9.8% and Function Call = 9.6% of main-thread self time in
Chrome DevTools' Bottom-Up profile during a reading session.
Fix:
- New tiny store store/readerProgressStore.ts holds the per-book
BookProgress map. setBookProgress only fires its own subscribers.
- readerStore.setProgress now writes progress to the new store and only
touches bookDataStore for the primary view (secondary parallel views
shouldn't overwrite the shared config).
- readerStore.getProgress is kept as a delegating facade so existing
imperative call sites don't break.
- Components / hooks that genuinely need to react to progress changes
subscribe via the new useBookProgress(bookKey) hook. The handful of
call sites that just want a one-shot read use getBookProgress(key) so
they don't subscribe at all.
- readerStore.clearViewState calls clearBookProgress so the map doesn't
grow unbounded across book opens/closes.
See store/readerProgressStore.ts header for the full rationale.
useProgressAutoSave fires saveConfig ~once per second of reading. Two
write-time sources were doubling its IPC cost:
1. saveConfig wrote the WHOLE library.json (+ backup) on every call, so a
user with N books paid 2*JSON.stringify(N) per save. Chrome DevTools'
Bottom-Up profile on a release Android build showed processIpcMessage
chewing ~25% of main-thread time during a reading session.
2. nativeFileSystem.writeFile / copyFile defensively called plugin:fs|exists
before every write to ensure the parent dir existed. Same dir gets
probed once per save -- ~50% of IPC time per save was just exists()
round-trips against directories that have been there since the book
was opened.
Fix:
- LIBRARY_SAVE_THROTTLE_MS=30s coalesces a swipe burst into a single
library.json write. Per-book config.json is still written eagerly --
it's the sync source-of-truth and is small. flushPendingLibrarySave()
is called on hook unmount + window blur so closing the book always
flushes.
- In-process knownExistingDirs Set caches verified directories per app
session. createDir adds, removeDir (incl. recursive) clears. Cold
start still does the original exists+createDir dance once per dir.
* perf(reader): batch keepTextAlignment reads/writes to avoid layout thrashing
keepTextAlignment iterates every <div>, <p>, <blockquote>, <dd> in a
freshly-loaded section and tags each with an aligned-{center,left,
right,justify} class based on its computed text-align. The previous
implementation read getComputedStyle and wrote classList.add inside
the SAME forEach pass, which is the textbook layout-thrashing
anti-pattern: classList.add invalidates the document's style cache
(class-based selectors can affect descendants), so the next
getComputedStyle call forces the browser to recompute style for the
whole document.
For a long chapter (~hundreds of p/div/blockquote/dd elements — a
typical Harry Potter section), that turned the loop into N x layout
recalcs. On a release Android build it surfaced as:
- Browser console violation: 'Forced reflow while executing
JavaScript took 1210ms'
- The dominant chunk of the open-book Bottom-Up profile's
Layout = 32.8% / Recalculate Style = 17.5% of TBT (2503ms total)
- The 'load' handler also tripped a 1249ms violation, dominated by
keepTextAlignment running inside it
Fix: split into a read pass (O(N) getComputedStyle into an array) +
a write pass (O(N) classList.add). The browser computes style once
for the document at the start of the read pass and reuses that
result for every subsequent getComputedStyle call; the write pass
then batches all class mutations together so style invalidation
happens at most once at the end.
* perf(reader): back-fill annotation pages off the open-book hot window
Each call to view.getCFIProgress(cfi) synchronously decompresses the matching section's XHTML from the EPUB zip and walks its text nodes (foliate-js progress.js #getCache), costing 100-300ms per cold section on a release Android build. For users with annotations spread across many chapters that's seconds of zip-IPC + main-thread work that was happening inside the open-book TBT window.
First attempt scheduled the back-fill via requestIdleCallback. On Android Tauri the WebView fires rIC aggressively while the main thread is still doing layout/style work for the freshly-opened book — the Bottom-Up profile after that change still showed 1.5s+ of sendIpcMessage -> readData -> loadDocument -> getCFIProgress chains nested under "Fire Idle Callback" inside the same hot window.
New strategy:
- Hard gate on the renderer's first 'stabilized' event so the back-fill can't possibly start before the open-book paint settles.
- Add a 5s grace timer after stabilized so the user's first page-turns and paginator's adjacent-section preload can finish without contention.
- Process annotations one at a time with a 250ms setTimeout gap between each, instead of chained idle callbacks. Each getCFIProgress shows up as its own short task with input-handling slots in between.
- 10s safety-net fallback if 'stabilized' never arrives, plus full cleanup on unmount.
- Batch the saveConfig write at the end (one IPC instead of N).
- Skip entirely when there are no annotations missing a page.
The page field still only feeds the secondary 'p NN ·' label in the sidebar BooknoteItem, so the on-screen highlight rendering paths (progress-driven addAnnotation in the [progress] effect, plus onCreateOverlay on section load) are completely independent and unaffected by this change.
On some Android devices the SAF picker returns an opaque, extension-less
content:// document URI (e.g. .../downloads.documents/document/msf%3A20).
Dictionary bundle grouping derived each filename from getFilename() — a pure
string-parse of the URI — so no .ifo/.idx/.dict marker was found, every file
was orphaned, and the user saw "Skipped incomplete bundles" even though the
bundle was complete. Devices whose URI happens to embed the name (e.g.
primary%3ADictionaries%3A21cen.dict.dz) worked, which is why it reproduced
only on some Android devices. The same string-parse also wrote the bundle
files (and synced metadata / contentId) under the mangled URI-segment names,
so a re-import elsewhere did not dedupe.
tauri's Android path.file_name (basename) special-cases content:// / file://
URIs and queries the content resolver for the real DISPLAY_NAME — the same
call AppService.openFile already relies on. Resolve the display name once at
selection time, store it on SelectedFile.name, and have bundle grouping
classify by that name instead of re-parsing the URI. The old extension filter
already used basename but discarded the resolved name; threading it through
removes that divergence.
Also fix the Settings -> Dictionaries "+" badges (Import Dictionary / Add Web
Search) collapsing to a black spot in e-ink mode by adding eink-inverted,
mirroring the font import button (#4454).
Fixes#4489Fixes#4472
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Highlights spanning paragraphs and a bullet list painted the
paragraphs but not the list items: the overlayer split ranges with a
hard-coded 'p, h1, h2, h3, h4' selector before collecting client
rects, so li/blockquote/td text fell into no sub-range and produced
no SVG rects. Bump foliate-js to split by text nodes (plus img/svg)
instead, which covers every block type while still excluding the
block border boxes that over-highlight blank space.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Add a Documentation section to the README pointing to the official docs at https://readest.com/docs, with a matching entry in the top navigation and a reference-style link.
Also bundle accumulated agent memory updates under apps/readest-app/.claude/memory/.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* ci(pull-request): cache the vendored tauri workspace crates
build_tauri_app rebuilt the whole tauri stack every run. The fork is wired via
[patch.crates-io] to path crates (packages/tauri, packages/tauri-plugins) plus
local src-tauri/plugins/*, all workspace members that Swatinem/rust-cache prunes
by default (cache-workspace-crates: false). Every crates.io plugin depending on
the patched `tauri` then rebuilt transitively, while unrelated deps stayed cached.
Set cache-workspace-crates: true so those sporadically-updated submodule crates
are cached, and bump the cache key (tauri-cargo -> tauri-cargo-ws) so the old
workspace-crate-less cache is invalidated and repopulated (rust-cache won't
re-save on a full key match). The first run after this is a full rebuild;
subsequent runs reuse the cached tauri stack.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* ci(pull-request): keep a single rust-cache for build_tauri_app
build_tauri_app ran two rust-cache actions: actions-rust-lang/setup-rust-toolchain's
built-in one (cache-workspace-crates: false) plus the explicit Swatinem/rust-cache.
They doubled cache storage and competed over the shared target/. Set cache: false on
setup-rust-toolchain so the explicit cache — the one configured with
cache-workspace-crates for the vendored tauri fork — is the only one.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* ci(security): pin android-emulator-runner action by commit SHA
Scorecard Pinned-Dependencies flagged the two
reactivecircus/android-emulator-runner@v2 usages in android-e2e.yml as
third-party actions not pinned by hash (code-scanning alerts #116, #117).
Pin both to the full commit SHA the v2 tag currently resolves to
(e89f39f = v2.37.0), matching the @<sha> # <version> convention already
used by every other action in this workflow.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* ci(pull-request): shard web unit tests and split out test_extensions
The jsdom unit suite was ~115s of the 280s test_web_app job — the slowest
check on every PR. Split it across two parallel shards with vitest --shard,
and move the browser-extension + koplugin tests into a new test_extensions
job.
- test_web_app: matrix shard [1, 2] running `vitest run --shard=N/2`;
Playwright + browser tests run on shard 1 only.
- test_extensions: extension tests + browser-ext build run always; the
koplugin Lua tests (and their ~45s LuaJIT/busted install) run only when
apps/readest.koplugin/** changed, detected via dorny/paths-filter
(pinned by SHA; needs pull-requests: read to list PR files).
- package.json: add test:pr:web:unit so CI can append --shard;
test:pr:web still runs the full sequence locally.
Cuts the PR critical path from ~280s toward ~188s (now build_tauri_app).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* ci(pull-request): isolate koplugin lint + LuaJIT install into test_extensions
build_web_app was the slowest PR job and installed LuaJIT + ran the koplugin
syntax check on every PR. Move all koplugin tooling into test_extensions,
gated on apps/readest.koplugin/** like the koplugin Lua tests already are:
- build_web_app drops the LuaJIT install.
- test_extensions installs LuaJIT/busted and runs `pnpm lint:lua` + `pnpm
test:lua` only when the koplugin sources changed.
- `pnpm lint` is now web-only (tsgo + biome); `lint:lua` stays a standalone
script that test_extensions (and local koplugin work) calls directly. This
also drops koplugin lint from the pre-push hook.
- verification rule updated to match.
Most PRs now skip the koplugin toolchain entirely.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a 'Reference Pages' reading progress style that shows physical book
page numbers in the footer progress info:
- When the book carries a page list (EPUB3 nav page-list or EPUB2 NCX
pageList — foliate-js already parses both and resolves the current
pageItem on relocate; it was just never consumed), display the current
page label and use the highest numeric label as the total, so a
trailing roman-numeral index page can't corrupt the total (#672).
- When the book has none, a per-book 'Reference Page Count' input
appears; the reading fraction is mapped linearly onto the entered
count (#4542). The count is saved per book only and never propagates
to global view settings.
- Falls back to percentage display when neither source is available.
Verified with the sample books from #672: Caleb's Crossing (EPUB3
page-list, 419 pages) and Count Zero (EPUB2 NCX pageList/page-map,
346 pages — chapter 2 lands exactly on page 22 per its page-map), plus
a stripped no-pagelist copy for the manual-count path (175/350 at 50%).
Closes#672Closes#4542
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Adds a quick "Search on Goodreads" action so readers can jump straight to
Goodreads to track a book instead of retyping the title there.
- Library: a Goodreads button in the Book Details view (works on web,
desktop and mobile) searching the book's title + author, plus a
"Search on Goodreads" item in the desktop right-click context menu.
- Reader: Goodreads is added as a built-in web-search provider so
highlighted text (e.g. a short-story title inside a magazine) can be
looked up on Goodreads. Disabled by default like the other built-ins;
enable it in Settings -> Dictionaries.
Both surfaces are used because the native context menu is desktop-only;
the Book Details button covers web and mobile. Adds a shared
openExternalUrl() helper and translates "Search on Goodreads" across all
locales (the Goodreads brand name is kept verbatim).
Closes#4543
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- #4478: add a configurable pre-start countdown (Off / 1s / 2s / 3s, default
3s) that now ticks at honest one-second intervals and applies to start,
resume, and page loads; Off starts instantly.
- #4476: add manual next/previous word controls (buttons flanking Play plus the
"," / "." keys) that pause playback and step exactly one word.
- #4475: allow selecting text in the context panel to look it up in the
dictionary (anchored popup on desktop, bottom sheet on small screens),
reusing the reader's dictionary view; auto-scroll/seek are suppressed during
selection and an outside click dismisses the popup.
- #4473: add a Speed Reading keyboard shortcut (Shift+V), shown on the View
menu item; ignore repeat triggers while a session is active.
Adds i18n strings for the new UI across all locales.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
PR #4383 inlined custom `@font-face` rules at the very front of the iframe
stylesheet, ahead of the `@namespace epub` declaration that lived inside
`getPageLayoutStyles`. Per the CSS spec a `@namespace` rule is only honored
when it precedes every style and `@font-face` rule; a misplaced one is
silently ignored. That dropped the namespaced
`aside[epub|type~="footnote"]` hide rule, so EPUBs whose footnote `<aside>`
carries a `border: 3px #333 double` rendered a stray horizontal line below
the annotation marker — but only for users who had custom fonts loaded
(otherwise `customFontFaces` is empty and `@namespace` stayed first).
Hoist the `@namespace` declaration to the very start of the assembled
stylesheet, before the inlined custom `@font-face` rules, and drop it from
`getPageLayoutStyles`. Custom faces still precede the `--serif`/`--sans-serif`
font lists that reference them, preserving #4383's first-paint behavior.
Verified in Chromium against the reported book's CSS: the aside goes from
`display: block` (3px double border visible) back to `display: none`.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Paragraph mode could be exited by accident just by tapping a bit too high
or low on the screen: a tap on the empty area around the centered
paragraph hit the overlay backdrop, which closed the mode. Tapping the
neutral center of the paragraph did nothing, and once the control bar
auto-hid there was no touch gesture to bring it back — so removing the
stray-tap exits alone would have stranded touch users with no way out.
- Backdrop and center-zone taps now dispatch `paragraph-show-controls`
instead of exiting; the bar re-appears so the explicit exit button
stays reachable on touch.
- ParagraphBar listens for that event (scoped by bookKey) and re-shows.
- Exit now only happens via the bar's exit button, Escape/Backspace, or a
deliberate double-tap on the paragraph (kept as a power-user shortcut).
- Center the bar with `fixed` instead of `absolute`: it was centered on
the gridcell, which a pinned sidebar pushes off-center, while the
paragraph centers on the viewport via the `fixed inset-0` overlay.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Bump foliate-js to include readest/foliate-js#22. The scrolled-mode
scroll container (#container) lost its compositing layer in the GPU-hint
cleanup, so on Windows' always-on scrollbars the scrollbar appeared on
open then vanished once adjacent-section preloading changed the content
height. Restoring transform: translateZ(0) on the scrolled #container
keeps the scrollbar composited so it repaints across content-size
changes.
Closes#4470
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
RSVP displayed the focal word in a hardcoded monospace font, ignoring the
reader's configured font. Resolve the reader's body font-family (serif or
sans-serif chain, per the "Default Font" setting, including the chosen
typeface, CJK font, and any user-imported custom font) and apply it to the
RSVP word display.
Custom and additional fonts are already mounted in the top document where
the overlay renders, so the resolved family resolves the same typeface. The
monospace fallback is kept only when no font setting is available.
Extracts the font-family list building from getFontStyles into a shared
buildFontFamilyLists helper and exposes getBaseFontFamily for top-level UI.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
On the web, double-clicking a word and then dragging to extend the
native selection also turned the page. The first click's deferred
single-click timer fires 250ms later while the second click's button is
still held during the drag, so it posts iframe-single-click and flips
the page. A plain double-click escapes this because its fast second
click updates lastClickTime in time.
Track the mouse-button state in iframeEventHandlers and suppress the
deferred single click while the button is held (a drag is in progress).
A normal single click is unaffected: its button is already released by
the time the deferred timer fires.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(reader): turn automatically when highlighting across pages (closes#1354)
* refact: Refactor time retrieval to use Date.now()
* fix(reader): rework auto page-turn as a corner-dwell gesture
Rework the initial #1354 implementation into a deliberate corner-dwell
gesture that works across platforms, and fix popup positioning for
cross-page selections.
Trigger:
- While a text selection is active, hold any engagement signal — the
pointer (web/desktop/iOS), the Android native touchmove, or the
selection caret — inside a screen corner for 500ms to turn one page:
bottom-right goes to the next page, top-left to the previous.
- One turn per engagement: a signal must leave the corner and return to
turn another page, so the user controls it one page at a time.
- The corner is a quarter-ellipse of radius 15% of each axis, measured
against the reading frame (the <foliate-view> rect) inset by the page
content margins, so the zone lands on the text — not the margin/footer
or a sidebar — and the pointer can actually reach it.
Per-platform signals:
- web/desktop/iOS: the iframe pointermove, mapped to window coordinates
via the iframe element's on-screen rect.
- Android: the selection caret (the only signal during a native handle
drag, where the handles live in a separate window so their touches
never reach the Activity) plus a throttled (~10/s) native touchmove
added in MainActivity.dispatchTouchEvent for content drags.
Android scroll-pin (#873): an active selection pins the container scroll,
which reverted the turn; suspend the pin during the turn and re-anchor it
to the page we land on.
Popup positioning: getPosition decided which selection end was on-screen
using window bounds, so a cross-page selection's off-screen start (which
maps behind the sidebar but inside the window) read "in view" and pinned
the popup off the visible page. Test visibility against the reading frame
instead, and for a multi-page selection anchor to the last on-screen line.
Also: logical view.prev()/next() (RTL-correct); skip in scrolled mode;
pass contentInsets down to the annotator.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Huang Xin <chrox.huang@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Closing a book within the SYNC_PROGRESS_INTERVAL_SEC (3s) auto-sync
debounce window saved progress locally but dropped the pending Readest
cloud push on teardown. The `sync-book-progress` close handler only
reset the pull gate and re-pulled — it never pushed — so other devices
stayed on the previous cloud-synced position until the book was
reopened (issue #4532).
Flush the debounced push at the start of `handleSyncBookProgress`,
before the pull gate is reset, so the latest local position reaches the
cloud before the view tears down. `syncConfig` reads `configPulled`
synchronously, so flushing while the gate is still open takes the push
branch. Mirrors the existing KOSync close-time `pushProgress.flush()`.
The manual Sync button shares the event and now becomes a true two-way
sync (push local, then pull remote).
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
On Tauri, section.loadText() drives a plugin-fs open/read/close round trip per call. The unbounded Promise.all in computeBookNav and enrichTocFromNavElements can fire 200+ concurrent IPC chains against a long-spine EPUB, saturate the JS↔Rust bridge or the fd pool, and cause individual reads to reject. The zip.js TextWriter then transitions to ERRORED, surfacing as 'Cannot close a ERRORED writable stream' and silently dropping TOC fragments for the affected sections. In the worst case the rejection propagates through Promise.all and prevents the reader from opening the book.
Hoist the OPDS module's runWithConcurrency to utils/concurrency.ts (zero behaviour change for OPDS) and reuse it in computeBookNav and enrichTocFromNavElements, capped at 128. The cap was binary-searched against the worst-case repro (Android emulator + dev mode + 250-section EPUB): 30/64/128 pass, 200 fails. Section-internal loadText/createDocument dedupe is unchanged.
The worker pool also isolates per-section failures: the outcome shape ({item,result}|{item,error}) lets us log and skip the offending section instead of aborting the entire build as Promise.all did. Even if a future workload pushes past the cap, the reader still opens.
* feat(reader): random-access file reads on Android via rangefile scheme
NativeFile's per-chunk Tauri IPC (open+seek+read+close) is slow on Android, and RemoteFile can't replace it because the WebView mishandles Range requests on intercepted custom-protocol responses — it re-applies the offset to the already-sliced body, so any non-zero-start range returns corrupt data or net::ERR_FAILED (Chromium 40739128, tauri-apps/tauri#12019/#3725).
Add a `rangefile` custom URI scheme that carries the byte range in the URL query (?path=&start=&end=) instead of a Range header. With no Range header the WebView delivers the 200 body verbatim, while bytes still stream through the network stack rather than the IPC bridge. The handler is scope-gated by asset_protocol_scope (same boundary as the asset protocol) plus an explicit traversal/NUL/relative guard.
RemoteFile.fromNativePath() drives the scheme on Android (query-carried range, X-Total-Size for size); nativeAppService.openFile routes Android reads through it with a NativeFile fallback. Verified on-device (Android 16 / WebView 147) via CDP: byte-equal reads at every offset, ~1.8x faster small scattered reads, real book opens/renders; all out-of-scope/traversal/NUL paths rejected 403.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* ci(rust): run cargo unit tests in rust_lint
The rust_lint job ran only fmt + clippy, so the crate's ~40 Rust unit tests (parsers, parser_common, and the new range_file tests) never executed in CI. Add `cargo test -p Readest --lib` to rust_lint — the frontend dist is absent there, but generate_context! already compiles without it (clippy proves this) and the unit tests run headless.
Also add a `test:rust` pnpm script and document it as verification done-condition #6.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Tapping an EPUB in the system file browser and choosing Readest could
silently fail to open the book in two distinct scenarios on Android:
1. Cold launch — the system delivers the ACTION_VIEW intent to
onCreate / onNewIntent before the JS layer has finished hydrating
and called addPluginListener('native-bridge', 'shared-intent', ...).
The upstream Tauri Plugin.trigger() drops events when the per-event
listener list is empty, so the intent vanishes. Fix this in
NativeBridgePlugin by queueing emits whose event has no listener,
then overriding registerListener so the queue is drained whenever
a listener becomes available.
2. React strict-mode re-mount — useAppUrlIngress had a one-shot
listened.current ref guard meant to avoid double registration. In
strict mode (and any subsequent effect re-run) the cleanup
unregister()'d the underlying native plugin listener but the next
mount short-circuited on the ref and never re-registered. The
shared-intent listener list ended up empty for the rest of the
session, so any subsequent Open-with intent went into the queue and
never came out. Drop the guard and let the effect register on every
mount; cleanup balances each registration.
Pulls in the foliate-js fix that guards Paginator#scrollBy and
Paginator#snap against an uninitialized #scrollBounds. Without the
guard, a swipe that lands before the first #scrollToPage seeds the
bounds (e.g. a fast swipe right after the reader mounts, or while a
section is still loading) crashes with
TypeError: undefined is not iterable (cannot read property
Symbol(Symbol.iterator)) at Paginator.snap
The submodule fix bails out of both entry points when the bounds aren't
ready yet, so the swipe is dropped rather than fatal; subsequent
settled scrolls reseed the bounds and swipe handling resumes.
Insert a "Current position" row in the TOC sidebar directly under the
highlighted section, indented one level deeper, with an open-book icon
and the live reading page number. Clicking it navigates to the exact
current reading location (progress.location) — distinct from the section
header, which jumps to the section start.
Implemented via a pure buildTOCDisplayItems() helper that injects the
synthetic row after the active item, keeping the active item's index
stable so the existing TOC auto-scroll logic stays untouched. The page
number uses the same muted color as the other rows.
Also fills in the missing "File Path" i18n translations across all
locales (surfaced by i18n:extract) and records project memory notes.
Closes#4513.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Raise the pnpm overrides so the patched transitive versions are pulled:
- shell-quote >=1.8.4 fixes GHSA-w7jw-789q-3m8p / CVE-2026-9277 (critical):
quote() failed to escape newlines in object .op values, allowing shell
command injection. Pulled in via cpx2.
- qs >=6.15.2 fixes GHSA-q8mj-m7cp-5q26 / CVE-2026-8723 (medium):
qs.stringify DoS on null/undefined entries in comma-format arrays with
encodeValuesOnly. The prior >=6.14.2 pin still allowed vulnerable 6.15.1.
Pulled in via express, body-parser, googleapis-common.
Resolves Dependabot alerts:
- https://github.com/readest/readest/security/dependabot/237 (shell-quote)
- https://github.com/readest/readest/security/dependabot/235 (qs)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* perf(epub): add native EPUB parser in Rust
Introduce a Rust-side EPUB pre-parser exposing three Tauri commands:
* parse_epub_metadata - title/author/cover + partialMD5 in one
shot, for the import hot path
* parse_epub_full - OPF + nav.xhtml + toc.ncx bytes plus a
manifest size table, for the reader open
hot path
* extract_epub_cover_full - full-resolution cover bytes, for the
lock-screen wallpaper writer
All three avoid ferrying multi-MB blobs across the JS<->Rust IPC
boundary. Cover bytes returned by parse_epub_metadata are downscaled
to a webview-friendly JPEG when the long edge exceeds the library
thumbnail size.
No JS callers yet -- wired up in the following commits.
* perf(import): use native EPUB parser and downscale covers on Tauri targets
On Tauri (desktop/iOS/Android), importBook now forwards EPUB
metadata + cover extraction to the Rust parse_epub_metadata
command and reuses the partialMD5 it returns, skipping the
foliate-js full archive parse and the second pass over the file
for hashing.
As a side effect, the cover written to cover.png is downscaled
to a webview-friendly JPEG (long edge <= 512px), shrinking the
on-disk thumbnail from multi-MB to ~30-60KB per book. To keep
the lock-screen wallpaper feature unchanged, useAutoSaveBookCover
now pulls the original full-resolution cover via the Rust
extract_epub_cover_full command instead of copying the (now
downscaled) cover.png; falls back to the thumbnail when the
native path is unavailable.
Web targets and non-EPUB formats keep the existing path.
* perf(reader): prefetch EPUB OPF/nav from Rust on book open
When opening an EPUB on Tauri targets, DocumentLoader now calls the
Rust parse_epub_full command up-front to pull the OPF, EPUB3 nav,
NCX and the central-directory size map in a single IPC. The
foliate-js zip loader is wrapped so that loadText() of these
entries (and a synthetic META-INF/container.xml) is served from
that in-memory cache without inflating through zip.js, while
all other assets keep flowing through the original loader.
A small in-flight dedupe is added to the spine-text loader so the
nav pipeline (loadText + createDocument back-to-back on the same
href) doesn't pay for two zip.js inflate calls per chapter on
first open.
Reader store / app service plumbing: readerStore.openBook now
resolves an absolute on-disk path via the new
appService.resolveNativeBookFilePath / bookService.resolveNativeBookFilePath
helper and threads it into DocumentLoader as nativeFilePath so
the prefetch can fire. Web targets, non-EPUB formats and books
without a managed/external on-disk path skip the prefetch and
take the original code path.
* perf(nav): parallelize section scans and memoize fragment lookups
computeBookNav now processes sections via Promise.all instead of
a sequential for-loop, and within each section issues loadText()
and createDocument() concurrently. Combined with the in-flight
loadText dedupe added to the zip loader, each chapter pays for a
single zip inflate per nav build, and the inflates of different
chapters overlap.
enrichTocFromNavElements is restructured into two concurrent
phases: a cheap '<nav' substring filter on the inflated text, and
a parsed-document walk for the survivors. Most chapters fall out
in phase 1 without ever being parsed.
In fragments.ts, calculateFragmentSize now consults a
per-section position cache (makeFragmentPositionCache) so the
N-fragment loop is O(N) over the chapter HTML instead of O(N²).
A small isCfiAddressable guard is added to skip elements that
foliate-js's CFI generator can't address (documentElement, body
itself, detached nodes, nodes outside <body>) — these previously
threw and spammed console.warn for every fragment, now they
silently fall back to the section CFI.
* perf(import): use native MOBI/AZW/AZW3 parser on Tauri targets
On Tauri (desktop/iOS/Android), importBook now forwards
MOBI/AZW/AZW3/PRC metadata + cover extraction to the Rust
parse_mobi_metadata command and reuses the partialMD5 it returns,
skipping the foliate-js full-buffer parse and the second pass over
the file for hashing. Mirrors the existing EPUB native fast-path
added in e3fc4767 — bookService tries EPUB first, then MOBI; both
bridges fall back to the foliate-js DocumentLoader when the native
path is unavailable (web target, parse error, format mismatch).
The new mobi_parser is built on the mobi crate (KF7+KF8 reader,
zero JS-side touch). It reads title, author, publisher, ISBN, ASIN,
publish date, language, subjects and description from the MobiHeader
+ EXTH records, resolves the EXTH 201 cover offset against the PDB
image-record table (with ThumbOffset / first-image fallbacks), and
strips KindleGen's HTML wrapping in EXTH 103 so the description goes
into the library DB as plain text. The parsed cover is funneled
through the same maybe_resize_cover path as EPUB, so MOBI library
thumbnails are also clamped to a 512px-long-edge JPEG.
Cover-resize / partialMD5 / RawCoverImage are extracted into a new
parser_common module shared between epub_parser and mobi_parser, so
a single tweak (e.g. raising the thumbnail target) applies to every
native importer and the partialMD5 implementation can't drift between
the two paths (a divergent algorithm would silently re-import every
existing book under a new hash on the first run).
Web targets and non-Kindle formats keep the existing path.
* test(tauri): verify native Rust EPUB parser parity with foliate-js
Add a Tauri WebView parity suite (epub-parser-parity.tauri.test.ts) that
cross-checks the native Rust parser against foliate-js on the same fixtures:
parse_epub_metadata / parse_epub_full (title, author, language, identifier,
publisher, published, subjects, partialMD5, OPF + per-entry size table), and
that opening with the native prefetch produces the same BookDoc and
computeBookNav (TOC) output as the pure-JS path.
Fix a parity divergence the suite caught: the Rust OPF parser mapped
dcterms:modified onto `published`, but foliate-js keeps them separate and
leaves `published` empty -- so EPUB3 books carrying only the mandatory
dcterms:modified got a bogus publication date on the native import path. Map
only dc:date now; add regression tests.
Test infra:
- vitest.tauri.config.mts: add optimizeDeps (mirroring vitest.browser.config)
so foliate-js-importing tauri tests load -- otherwise esbuild's dep scan
can't resolve '@pdfjs/pdf.min.mjs', pre-bundling is skipped, and the CJS
deps fail to import ("Importing a module script failed").
- capabilities-extra/webdriver.json: fix __test__ -> __tests__ fs scope typo
so import tests can open fixtures under src/__tests__/.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(import): foliate-js owns EPUB/MOBI metadata via standalone extractors
Rust contributes only the mechanical work that's expensive on a
WebView — partialMD5, the downscaled cover, and (for EPUB) the raw
OPF bytes Rust already had to read for cover resolution. Metadata
extraction is delegated to foliate-js's two new standalone entry
points (`parseEpubMetadataFromXML`, `readMobiMetadata`) so the
import-path BookDoc and the reader-path BookDoc share a single
parser implementation.
EPUB
- `parse_epub_metadata` returns
`{ partialMd5, cover, coverMime, opfPath, opfBytes }`. OPF bytes
are a free byproduct of the cover-resolution scan.
- `tryNativeParseEpub` runs `parseEpubMetadataFromXML` on the OPF
bytes and assembles a lightweight BookDoc stub (metadata +
getCover). The importer doesn't drive `DocumentLoader.open()`, so
no zip central-directory scan, no nav/ncx inflate, no spine walk.
- `coverMime` is preserved so `bookService.importBook`'s
`cover.type === 'image/svg+xml'` branch still routes SVG covers
through svg2png.
MOBI / AZW / AZW3 / PRC
- `parse_mobi_metadata` returns `{ partialMd5, cover, coverMime }`.
`tryNativeParseMobi` runs foliate's `readMobiMetadata` on the
same File, which uses `MOBI.open(file, { metadataOnly: true })`
to parse PalmDB + MobiHeader + EXTH and short-circuit before the
MOBI6 / KF8 init() that walks every text record.
- `Book.metadata.identifier` is foliate's `mobi.uid.toString()`
(PalmDB UID), the canonical MOBI identifier the reader path uses.
bookService.importBook
- EPUB and MOBI native branches consume the bridge's BookDoc stub
directly. The stub's `getCover()` returns the Rust-downscaled
blob, falling back to foliate's own `getCover` thunk when Rust
didn't extract a cover.
Other
- Drop the unused `base64` Rust dependency: cover bytes go over IPC
as `Vec<u8>` (Tauri 2 transports them natively, like opfBytes /
navBytes / ncxBytes).
- Drop the `nativePrefetch` option on `DocumentLoaderOptions`; no
caller passes it. `nativeFilePath` keeps driving `parse_epub_full`
on the open hot path.
Tests
- vitest.tauri parity test asserts byte-equal partialMD5, cover
presence parity, OPF bytes that decode to a real `<package>`
document, and that `parseEpubMetadataFromXML` on those bytes
produces the same user-visible metadata fields (title / author /
language / identifier / published) as `DocumentLoader.open()`.
* test(tauri): add War and Peace MOBI fixture for native parser parity
The .tauri parser-parity suite previously had no .mobi/.azw3 asset, so the native MOBI parser (metadata + EXTH cover resolution) was uncovered. Adds a real KF8 MOBI ("War and Peace") to enable MOBI parity coverage against foliate-js.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* chore(foliate-js): bump submodule to readest/foliate-js main (91191ca)
Replaces the ad-hoc 02f435a with the merged main commit 91191ca, which lands the standalone OPF/MOBI metadata extractors (parseEpubMetadataFromXML, readMobiMetadata) the import fast-path depends on (foliate#19), plus the RTL multi-view rect-mapper fix (foliate#20). The extractor code is byte-identical to 02f435a, so the bridges are unaffected.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Huang Xin <chrox.huang@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(opds): add getOPDSNavLink helper + subject/author link types (#4504)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(opds): make subject/author links clickable in detail view (#4504)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
In-place imports point at a file the user keeps under one of their
external library folders (book.filePath set), as opposed to hash-copy
imports that live anonymously under Books/<hash>/. The book details
view didn't surface where an entry actually lives on disk, so users had
no way to tell the two storage modes apart or locate the source file.
Add a 'File Path' row to the metadata grid that renders only when
book.filePath is set, breaks long paths across lines, and exposes the
full string via a hover title for paths that overflow the row.
OPDS 2.0 JSON feeds advertise search as a templated link with
type `application/opds+json`, `templated: true`, and an RFC 6570 URI
template href (e.g. `/search{?query}`). `isSearchLink` only recognized
OpenSearch/Atom types, so `hasSearch` was false and the navbar search
input stayed disabled (greyed out). Even when enabled, `handleSearch`
only handled OpenSearch/Atom, so a query would not reach the server.
- Recognize templated `application/opds+json` search links.
- Add `expandOPDSSearchTemplate` to expand the URI template (reusing
foliate-js/uri-template.js) with the typed term placed in the primary
text variable (query/searchTerms/q), then resolve and navigate.
Expansion happens before resolveURL, which would otherwise mangle the
`{?query}` braces.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(opds): render HTML in publication descriptions, closes#4503
OPDS publication descriptions showed raw HTML tags (literal `<p>`,
`"`, `'`) instead of rendering them. Some aggregator feeds
serve the description as an Atom `type="text"` summary whose HTML has
been escaped twice; foliate's getContent only un-escapes `type="html"`/
`"xhtml"`, so the markup survives parsing as entity text and the detail
view dumped it straight into an unsanitized `dangerouslySetInnerHTML`
(also an XSS sink for untrusted feed content).
Add `getOPDSDescriptionHtml`: decode one extra entity level only when the
value is entirely escaped markup (mixed content like `<p>see <code>`
is left literal), then sanitize with the shared DOMPurify sanitizer.
Wire it into PublicationView and render the sanitized HTML.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor: consolidate HTML sanitizers into @/utils/sanitize
sanitizeHtml/sanitizeForParsing are generic DOMPurify wrappers, not
specific to Send-to-Readest. Now that OPDS description rendering also
needs sanitizeHtml, move them out of services/send/conversion into the
shared @/utils/sanitize module (alongside sanitizeString) so neither
consumer reaches across the other's feature boundary.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Flatpak mounts the app directory read-only, so the bundled Tauri updater
can download a new version but never apply it, leaving the user stuck on
the old build with no working install path. Update management belongs to
the Flatpak runtime / system package manager.
Detect the sandbox via FLATPAK_ID or /.flatpak-info and fold it into the
existing `updater_disabled` flag, which propagates to `hasUpdater` and
suppresses the in-app updater window. Release notes still surface as an
informational-only path.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
On Android, the four 80x80 page-navigation buttons stay mounted on top of the foliate viewer even when hidden (opacity-0). pointer-events:none can't be used on Android (it breaks touch propagation to the iframe), so the prev/next-section buttons already have an h-4 w-4 fallback for the hidden state. The prev-page / next-page buttons were missing this fallback and therefore kept covering ~80x80 hot zones in the lower-left and lower-right of the page, swallowing long-press touches on the first/last words of the bottom two lines so they could neither be highlighted nor open the toolbar. Apply the same h-4 w-4 fallback to those two buttons.
Bumps foliate-js to pick up the RTL multi-view rect mapper fix
(readest/foliate-js#20).
Reproduced with an Arabic EPUB: closing the book at e.g. chapter 2
page 14 and reopening it briefly landed near the saved position, then
jumped several chapters forward as foliate-js's #fillVisibleArea
pre-loaded adjacent sections; the wrong location was then auto-saved,
overwriting the user's actual reading progress on disk.
The Hungarian MEK catalog (a PHP backend) returns a valid Atom feed
followed by trailing junk after </feed> — a stray PHP warning, an extra
tag, or text. Chrome's DOMParser ignores it, but Firefox's strict parser
fails with "junk after document element" and replaces the whole document
with a <parsererror>. The reader then sees a non-feed root, treats the
response as HTML, finds no OPDS link, and silently navigates back, so
browsing the catalog on Firefox web is broken on nearly every subpage.
Add parseOPDSXML(): on a parser error, re-parse the slice from the root
element's start tag to its last matching end tag, dropping any leading
prolog and trailing junk. If recovery still fails the original error
document is returned, so callers fall through to their existing
HTML/non-OPDS handling. Wire it into the three OPDS XML parse sites:
the reader (page.tsx), validateOPDSURL (adding a catalog), and the
subscription/auto-download feed checker. feedChecker also switches its
text.startsWith('<') detection to looksLikeXMLContent so the MEK feed's
leading newlines (no <?xml?> declaration, #4181) are recognized.
jsdom mirrors Firefox's strict behavior (same parsererror namespace), so
the regression tests run in the normal unit suite.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The desktop mouse-drag handlers were bound to the moving <img>, so the
cursor crossing the (transition-lagged) image boundary fired onMouseLeave
and repeatedly aborted/restarted the drag — the flicker. Touch was fine
because it tracks on the full-screen container.
Track the drag on `window` while dragging (mirroring the touch path),
disable the transform transition during the drag so the pan is 1:1, and
set will-change: transform (the transform-gpu class is overridden by the
inline transform, so its GPU hint was lost).
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The selected custom-font card used `bg-primary/50`, whose opacity suffix
dodges the e-ink `.bg-primary` normalizer — leaving a dark primary fill
under force-black `text-base-content` text, i.e. black-on-black (#4454).
Add `eink-bordered` so the selected card gets the same white-bg /
black-border / black-text treatment every other selected surface gets,
while staying distinct from the faint-bordered unselected cards.
The Import Font "+" badge had the same class of bug: e-ink's substring
matchers catch its `group-hover:bg-base-content` and `text-base-content/60`
utilities and paint a black glyph on a black circle. Pin the badge to an
intentional base-content circle with a base-100 glyph so the "+" stays
legible.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Cover uploads were nested inside the `syncBooks` toggle in both the
batch path (`syncLibrary`) and the per-book reader path
(`pushBookFileNow`). With the default `syncBooks: false`, covers
silently never reached the WebDAV server even though `config.json`
did, so receiving devices ended up with progress + notes synced but
no shelf art.
Covers are conceptually metadata, not bytes:
- they're tiny (~30–60 KB after the import-time downscale);
- they cannot be regenerated on a fresh device that doesn't hold
the book bytes (custom covers from metadata services in
particular are completely unrecoverable without sync);
- cloudService already treats them as metadata-grade in its
download path (`downloadBookCovers`, `downloadBook(onlyCover)`).
Two changes:
1. `WebDAVSync.ts::syncLibrary` — moved `pushBookCover` out of the
`if (options.syncBooks)` block; it now runs alongside
`pushBookConfig`, before `pushBookFile`. Step ordering in the
header doc-comment was updated to match.
2. `useWebDAVSync.ts` — extracted a standalone `pushBookCoverNow`
callback (gated only on `allowPush`, with its own
`coverSyncedRef` for per-instance dedupe), and dropped the cover
ride-along that lived at the tail of `pushBookFileNow`. The
open-book effect now fires `pushBookCoverNow` and
`pushBookFileNow` in parallel via `Promise.all` (different remote
paths, no reason to serialize), and the manual-push event handler
triggers both independently.
The WebDAV pull path was already independent of `syncBooks`, so no
changes are needed there — receiving devices will pick up the newly
mirrored covers automatically.
KOReader sync displayed the "Reading Progress Synced" notification as a
centered info toast that blocks the text for fast readers, while Readest's
own cloud sync uses an unobtrusive top-right hint.
Route the notification through the same 'hint' event (HintInfo, top-right,
~2s auto-dismiss) that useProgressSync uses, instead of the centered 'toast'.
This covers both KOSync paths that apply remote progress (the auto-apply
receive/silent flow and the conflict-resolved "use remote" flow); the
interactive conflict-resolution dialog is unchanged.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Translate 4 new keys across all 33 locales:
- Send
- Book file is not available locally
- Failed to send book
- Highlight Current Sentence
Also commit pending .claude/memory bug-fix notes.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
On a multi-column spread a coloured page's background bled into the outer margin gutter (the --_outer-min track) while an adjacent transparent/image page did not, shifting cover/title spreads off-centre. Bump foliate-js to clamp each background segment to its column instead of stretching into the gutter, keeping the symmetric margins intact. Update the computeBackgroundSegments regression tests.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Virtualizing BooknoteView (#4352) dropped the auto-scroll that centers the
note nearest the current reading position, leaving the list stranded at the
top. The single scrollToIndex that replaced the per-item useScrollToItem was
missing the machinery TOCView already uses for virtualized auto-scroll:
- Re-apply the scroll inside the OverlayScrollbars `initialized` callback
(read via a ref): its deferred init resets the viewport scrollTop to 0, and
the lastScrolledCfiRef guard otherwise blocked any retry (reload case).
- Mount Virtuoso natively centered via initialTopMostItemIndex with a
skip-gate, so opening the panel while reading doesn't fire a scrollToIndex
that races and wedges the freshly mounted, unmeasured list (tab-switch case).
- Jump instantly (behavior 'auto') for far moves and on eink, animating
'smooth' only for short in-session updates — mirroring TOCView.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The dark-mode `table *` color-mix tint in getColorStyles was applied
unconditionally since #4055, so plain tables — and the invisible spacer
cells some books use for vertical TOC layout — rendered a few shades off
the page background, and the spacing between words appeared to change.
Restore the `overrideColor` gate that #2377 originally added. Illegible
light/zebra table backgrounds (the #4028 case #4055 targeted) are now
handled separately by the dark-mode light-background rewriters from
#4392, so the blanket tint is no longer needed by default. The
standalone blockquote tint stays unconditional in dark mode.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Long-pressing an image in the zoom viewer on Android triggers the
WebView's native image callout (context menu / drag / magnifier) at the
same time as the viewer's own pinch/pan/zoom touch handlers, locking up
the whole app until restart. Same root cause as the book-cover freeze
(PR #4345): `-webkit-touch-callout: none` doesn't inherit, so the class
must sit on an ancestor of the `<img>`.
Apply the existing `.no-context-menu` class to the viewer container so
the `.no-context-menu img` rule reaches the zoomed image and disables
the native callout. Harmless on desktop (the property is a no-op there
and right-click-save still works).
Closes#4420
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The book "Send" flow had to pass `new ArrayBuffer(0)` to saveFile purely to
satisfy the type-checker: the content arg is ignored on the native share
path when `options.filePath` points at an already-on-disk file. Widen
saveFile's content parameter to `string | ArrayBuffer | null` across the
AppService contract so callers can hand off a file by path without buffering
it into memory, and pass `null` from the Send flow instead of a throwaway
empty buffer.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Chinese web-novel TXT files are commonly named 【书名】1-129 作者:起落.txt and
carry a noisy metadata block at the top of the file. Two author-recognition
failures resulted after importing them:
1. Author missing — extractTxtFilenameMetadata only pulled an author from
《》-wrapped names, so 【】-style names yielded no filename author, and when
the file header had no clean "作者:X" line the author came out empty.
2. Irrelevant content as author — the greedy file-header capture
(/作者…(.+)\r?\n/) grabbed a publication blob like
"2024/08/01发表于:是否首发:是 字数1023150字…" and surfaced it as the author.
Fix:
- extractTxtFilenameMetadata now extracts the labeled "作者:X" form from any
filename (title stays the full name; only the labeled form is safe so a
leading 【title】 isn't mistaken for the author).
- Validate the header-matched author (isPlausibleAuthorName) and fall back to
the filename author when it looks like a metadata blob — embedded field
separator, long digit run, or excessive length. Applied to both the small-
and large-file conversion paths.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The bookshelf right-click menu built itself with un-awaited
`Menu.append()` calls. Each append is an async IPC round-trip to the
Tauri backend, so the concurrent fire-and-forget requests resolved in
non-deterministic order on the Rust side and the native menu items
landed shuffled on every open (only reproducible on the native app,
invisible in jsdom).
Build the items in order and create the menu in a single
`await Menu.new({ items })` call for both the book and group handlers.
Order and conditional inclusion are unchanged.
Extract the order/inclusion logic into a pure `getBookContextMenuItemIds`
helper in `libraryUtils.ts` so the deterministic ordering is unit-tested
without mounting the component or mocking Tauri.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Change Data Location dialog rendered its Cancel/Close (secondary)
buttons with `btn-outline`. Under `[data-eink='true']`, globals.css
inverts both `.btn-outline` and `.btn-primary` to the same base-content
fill + base-100 text, so Cancel and Start Migration collapsed into two
identical black buttons and became indistinguishable on e-ink screens.
Switch the secondary buttons to `btn-ghost`, matching the design-system
rule (DESIGN.md): the primary CTA keeps its solid fill while the ghost
cancel reads as borderless next to it, restoring the hierarchy.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(library): send book file from bookshelf selection popup
Adds a Send button to the bottom popup that appears when one or more
books are selected on the bookshelf. Hands the actual book file
(epub/pdf/...) to the OS share sheet via tauri-plugin-sharekit
(UIActivityViewController on iOS, Intent.ACTION_SEND on Android,
NSSharingServicePicker on macOS), so users can fire the file off to
Mail / Messages / WeChat / AirDrop / etc.
This is intentionally distinct from the per-item context-menu "Share
Book", which uploads the book to the readest backend and generates a
public link. "Send" is offline file egress; "Share Book" is remote
collaboration. They share zero infra.
Resolution rules mirror bookContent.resolveBookContentSource: managed
copy under Books/<hash>/ first, then the device-local in-place import
path. Cloud-only books warn rather than silently no-op.
Path is handed to shareFile via options.filePath. Without that,
saveFile() falls back to writing a temp copy under BaseDirectory.Temp,
which on Android resolves to /data/local/tmp/ — the app sandbox has
no write permission there and the call fails with EACCES ("failed to
open file at path: /data/local/tmp/...epub Permission denied (os error
13)"). Passing the absolute path also avoids re-buffering the entire
epub/pdf into memory.
On macOS the NSSharingServicePicker is anchored to the selected
book's cover rather than to the Send button — the user's visual
focus is on the cover they just tapped, not on the bottom toolbar.
BookshelfItem stamps a data-book-hash attribute on its root div so
the Send handler can locate the cell via querySelector and pass its
rect through saveFile's sharePosition option. preferredEdge='bottom'
maps to NSMinYEdge, so the popover renders above the cover (and only
auto-flips below when there's no room above). iOS / Android share
sheets are modal and ignore sharePosition, so the same code is a
no-op there.
The button is hidden on Linux (no system share sheet), Windows
(WebView2 share UI deadlocks the main thread, see #4343), and web
browsers (no "send file to <app>" affordance for arbitrary downloads).
* fix(share): don't fall back to saveDialog when shareFile is cancelled
The native saveFile({ share: true }) path used to swallow any error
from sharekit's shareFile() and fall through to saveDialog. The plugin
treats user cancellation the same as a failure (it rejects with
'Share cancelled' on Android when the user dismisses the share sheet),
so cancelling a share popped up an unwanted 'Save As...' dialog right
after the user explicitly chose not to share.
Mirror what webAppService already does for the navigator.share()
AbortError path: once we entered the share branch, return true
regardless of whether the share completed or was cancelled. The
saveDialog path is now reserved for Linux/Windows desktop, which never
hit the share branch in the first place (wantShare gates them out).
If a future caller wants 'try share, fall back to save on hard
failure', that decision belongs to the caller — saveFile shouldn't
silently override an explicit share intent.
The always-on page-info footer (`.progressinfo`) is a decorative
`role='presentation'` element, but it carried `tabIndex={-1}`, which made
it focusable. On Android, long-pressing the footer focused the div and the
WebView painted its default focus ring (`outline: auto`). Because the
element is pinned `absolute bottom-0` at book-view width, the ring rendered
as a content-column-wide line across the bottom of every page and persisted
until focus cleared.
Remove the `tabIndex` so the decorative element can no longer receive
focus. The `onClick` (tap-to-cycle progress mode / dismiss popup) still
fires regardless of tabindex, nothing focuses it programmatically, and the
translated `aria-label` keeps it exposed to screen readers unchanged.
Closes#4397
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
On Android, tapping an EPUB/MOBI/AZW3 in the system file browser and choosing Readest only opens the library — the book itself never opens in the reader. Now Readest can open the book now.
navigator.clipboard.writeText is unreliable inside the Tauri Android
WebView, so tapping Copy in the Reader's selection popup silently
no-ops. Route the write through @tauri-apps/plugin-clipboard-manager
on Tauri targets (Android, iOS, macOS, Windows, Linux), with a
graceful navigator.clipboard / execCommand fallback for the web
build and older WebViews.
- Add tauri-plugin-clipboard-manager (Rust + JS)
- Register the plugin in lib.rs
- Grant clipboard-manager:allow-write-text / allow-read-text
- New utils/clipboard.ts wrapper with platform-aware fallback chain
- Annotator handleCopy and handleConfirmExport use the wrapper
Switch ReadwiseClient to @tauri-apps/plugin-http when running in
Tauri desktop mode, matching the pattern already used by WebDAV,
Hardcover, AI providers, translators, OPDS, and KOReader sync.
In a Tauri webview context the standard window.fetch is still subject
to CORS preflight rules and—on Android—the platform's cleartext-traffic
policy. @tauri-apps/plugin-http sends the request from the Rust side
via reqwest, bypassing the renderer entirely (no Origin header, no
preflight, no cleartext block). ReadwiseClient was one of the few
remaining services that had not yet adopted this transport, so users
on the desktop build could hit CORS or network errors when validating
tokens or pushing highlights.
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Custom fonts (and textures) disappeared a few seconds after opening a book
when logged into cloud sync. Under the replica layer's CRDT remove-wins
semantics, deleting a font writes a server-side tombstone that a plain
field upsert cannot revive — only a reincarnation token whose HLC beats
the tombstone does. Re-uploading the same file (same contentId) cleared
`deletedAt` locally but published the upsert with no token, so the tombstone
survived and the next pull (boot/periodic/book-open/visibility) re-applied
the soft-delete via softDeleteByContentId, making the font vanish. Logging
out stopped the pull, which is why it only reproduced while signed in.
addFont/addTexture now mint a reincarnation token when re-adding an existing
entry that is either soft-deleted or still-live with the same contentId (and
has no token yet), preserving any existing token. This mirrors the
dictionary's reincarnation handling (dictionaryService) and OPDS's token
style, fixing both the local re-import-after-delete case and the multi-device
stale-local race. The token is inert when there is no tombstone, so live
re-imports remain safe.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The background texture (mounted on the reader container as
`.foliate-viewer::before`) showed in scrolled mode but was absent in
paginated mode: the paginator painted an opaque #background container
over it. Bump foliate-js to leave that container transparent under a
texture, and add a regression test for the shared textureAwareBackground
helper.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Wide or tall tables, code blocks and display equations overflowed the reading
column and a scroll gesture over them turned the page instead of scrolling the
content (#4400).
- Wrap tables and display equations in a horizontally/vertically scrollable
container; route touch + wheel along the box's scrollable axis so it scrolls
the box and never turns the page, even at the edge (both axes).
- A box that fits its column is marked fit (overflow:visible) so it never clips
or captures gestures; the fit decision is measured once after layout via a
self-disconnecting ResizeObserver, so it never relayerizes during a page turn.
- The scroll wrapper carries a new cfi-skip attribute that makes it transparent
to CFI: epubcfi.js hoists a cfi-skip node's children into its parent (unlike
cfi-inert which drops the subtree), and xcfi.ts mirrors this for CFI<->XPointer
so existing highlights, bookmarks and KOSync positions inside a wrapped table
or equation still resolve. The sanitizer whitelists cfi-skip.
- Bump foliate-js submodule (cfi-skip support + raf fallback for large sections).
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Why:
- Dark mode sets theme foreground on html/body and rewrites black text,
but EPUB callout boxes often keep white/light backgrounds from inline
styles or publisher CSS unless override book color is enabled.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(reader): scroll wide tables horizontally instead of scaling
Why:
- Wide EPUB tables (e.g. many columns without cell widths) overflowed the
page because CSS scale only applied when widths were known.
- Paginated mode stole horizontal swipes for page turns over table content.
Refs:
- Replaces transform-based applyTableStyle scaling with a scroll wrapper
and capture-phase touch routing (same pattern as gesture brightness).
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(reader): keep wheel/trackpad table scrolling from turning the page
Horizontal scrolling of a wide table in paginated mode also turned the page:
- applyTableTouchScroll only routed touch events. Trackpad/mouse wheel
(readest forwards iframe wheel -> 'iframe-wheel' -> pagination) was
never intercepted, so a horizontal wheel both scrolled the table and
flipped the page.
- findWrapper used `instanceof Element`, which is always false for iframe
event targets because this module runs in the top-window realm. The
touch routing therefore never fired either.
Add a capture-phase wheel handler that consumes horizontal wheels over a
scrollable table -- including at the scroll edge, so the gesture (and
trackpad momentum) never chains into a page turn -- and make findWrapper
cross-realm safe via duck-typing on `closest`.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(reader): don't show a spurious scrollbar on layout tables
Wrapping every table for horizontal scrolling made tables that fit the
column (e.g. character/glossary layout tables) show a spurious horizontal
scrollbar, because the wrapper was always scrollable.
Now the wrapper clips (no scrollbar) for a table that fits within a few px
of the column, and a ResizeObserver re-evaluates this as the column width
settles. A table genuinely wider than the column always scrolls and is
never clipped; one that wraps to fit shows no scrollbar. Touch/wheel
routing engages only scrollable wrappers.
Add a Chromium browser test over sample-table-layout.epub (layout tables
must not scroll) and sample-table-wide.epub (a too-wide table must scroll,
not clip).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Huang Xin <chrox.huang@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A `.window-state.json` containing the Windows minimized sentinel
(x/y = -32000) or a 0×0 size makes WebView2 reject the restored bounds
with 0x80070057 ("The parameter is incorrect"), so the app fails to
launch until the file is deleted by hand.
Add a small `window-state-sanitizer` plugin, registered before
tauri-plugin-window-state, that strips window entries with invalid
geometry (non-positive size, or a position past the -16000 off-screen
cutoff) from the state file before the plugin loads it. Affected windows
fall back to default geometry instead of crashing.
Defense-in-depth: the bundled plugin (2.4.1) already guards against
writing these values, so a bad file is almost certainly stale from an
older build; this self-heals it on next launch.
Refs #4398
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The AppStream metainfo had no <requires>/<supports> relations, so software
stores such as Flathub listed Readest as "Desktop only". Readest is adaptive
and runs on desktop (keyboard/mouse) as well as phones and tablets (touch).
Declare a 360px display_length baseline plus keyboard, pointing and touch
controls so stores surface both Desktop and Mobile.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
In scrolled mode the ruler's geometry-cache effect re-ran on every
relocate — including those fired continuously while scrolling — and
re-snapped the band to the next line forward from a screen-fixed
anchor, so the band crept down the page as the reader scrolled.
Place the band once on mount (and after a viewport-dimension change),
but never re-snap on a plain scroll relocate. Click-driven snapping
(the reading-ruler-move handler + pendingScrollAlign realign) is
unchanged, and paginated mode is unaffected.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
uploadBook only attached a cover.png when one was already cached under
readest_covers/<hash>.png from a prior cloud download. Books that
originated locally in KOReader were never downloaded, so the cover step
was silently skipped and they synced to Readest with no cover.
Add extractLocalCover, which renders the book's embedded cover via
coverbrowser's FileManagerBookInfo:getCoverImage(nil, file_path) and
writes it as PNG. uploadBook now falls back to it when no cached cover
exists, caching the result under covers_dir so the Library view reuses
it like a downloaded cover.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(reader): inline custom @font-face rules in iframe stylesheet
The reader iframe's first paint resolved with the default serif/sans
fallback and only swapped to the user's configured custom font a moment
later, producing a visible font flash when opening a book.
Custom font @font-face rules were registered via mountCustomFont on the
host document only, while paginator's setStyles writes its CSS into
the iframe synchronously before the first 'load' event. The iframe had
no knowledge of the user's custom fonts at that point, so font-family
declarations in the stylesheet matched nothing and fell back.
Inline the @font-face rules for every loaded custom font (blob URLs
already in memory, no network round-trip) at the front of getStyles
output, so paginator delivers them to the iframe atomically with the
rest of the stylesheet. Defensive try/catch around createFontCSS keeps
a single bad font from breaking the whole stylesheet.
* refactor(reader): pass custom fonts into getStyles instead of reading the store
getStyles lives in src/utils, where every other file is a pure function;
it was the only one importing a store (useCustomFontStore). Keep the
util pure: accept the loaded custom fonts as a parameter and let the
reader components — which already own the font store — supply them.
- style.ts drops the useCustomFontStore import and the SSR/store-error
guards in getCustomFontFaces; the helper is now a pure CustomFont[] ->
CSS transform.
- getStyles(viewSettings, themeCode?, customFonts = []) inlines the
@font-face rules for the passed fonts.
- The first-paint call sites (FoliateViewer, FootnotePopup) pass
getLoadedFonts(); settings-panel re-styles keep the default [] since
custom fonts are already mounted as persistent <style> elements there.
- Add tests that exercise the font-face inlining path (the existing
suite never did, since the store is empty under jsdom).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Huang Xin <chrox.huang@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
pdfjs-dist 5.7.x (bumped 5.4.530 -> 5.7.284 in #4143) moved the JBIG2
image decoder -- the codec used by virtually every black-and-white
*scanned* PDF -- from pure JS into a WebAssembly module the worker
fetches at runtime from `wasmUrl` (/vendor/pdfjs/). The `copy-pdfjs-wasm`
script only copied an explicit allow-list ({openjpeg.wasm,qcms_bg.wasm})
and silently dropped jbig2.wasm. cpx does not error on an empty glob, so
the missing decoder went unnoticed: scanned PDFs rendered blank (pages
still turned) in CI builds, while local `tauri build` reused a stale
pre-bump public/vendor copy and worked. Regression between 0.11.1 and
0.11.2.
Copy the whole wasm/ dir (mirrors the {cmaps,standard_fonts}/* fonts
pattern) so future decoders moving to wasm can't be dropped again, and
ship the *_nowasm_fallback.js files for graceful degradation.
Adds a regression test asserting every .wasm the bundled pdf.js
references is covered by copy-pdfjs-wasm.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A cover with `data-duokan-page-fullscreen` on <html> renders in
paginated mode but is blank in scrolled mode; only the first cover is
affected, other images are fine in both modes.
The paginator's fullscreen branch pins such images with
position:absolute and height:100% and forces their ancestors to
height:100%. That fills the fixed-height page when columnized, but in
scrolled mode the container height is `auto`, so height:100% resolves
to 0 and the cover collapses out of view.
Bump foliate-js to gate the fullscreen treatment on column mode and
reset any stale absolute pinning when a fullscreen-cover doc is laid
out scrolled (so toggling paginated -> scrolled also recovers). Add a
browser regression test + repro-4379.epub fixture.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Android and Windows-portable jobs fetched the existing updater
manifest with `curl -sL .../latest.json -o latest.json`. Without `-f`,
curl writes the server's 404 body ("Not Found") into the file and exits
0, after which `gh release upload --clobber` uploads that invalid JSON as
the release's latest.json.
tauri-action's updater step then downloads the existing latest.json and
runs `JSON.parse(...)` to merge new platforms in. Parsing "Not Found"
throws `Unexpected token 'N', "Not Found" is not valid JSON`, failing
every build-tauri matrix leg after its bundles were already uploaded.
Use `curl -fsSL` so an HTTP error fails the step instead of writing the
error body, and validate the download with `jq empty` before merging, so
a corrupt manifest can never be clobbered onto the release again.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
# 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
@@ -114,6 +115,12 @@ Stay tuned for continuous improvements and updates! Contributions and suggestion
- Linux users can also install [Readest on Flathub][link-flathub].
- Web: Visit and use **Readest for Web** at [https://web.readest.com][link-web-readest].
## Documentation
Guides, tutorials, and FAQs for installing and using Readest live in the official documentation:
📖 **[https://readest.com/docs][link-docs]**
## Requirements
- **Node.js** and **pnpm** for Next.js development
@@ -295,15 +302,7 @@ Readest is open-source, and contributions are welcome! Feel free to open issues,
## Support
If Readest has been useful to you, consider supporting its development. You can [become a sponsor on GitHub](https://github.com/sponsors/readest), [donate via Stripe](https://donate.stripe.com/4gMcN5aZdcE52kW3TFgjC01), or [donate with crypto](https://donate.readest.com). Your contribution helps us squash bugs faster, improve performance, and keep building great features.
### Sponsors
<p align="center">
<a title="Browser testing via TestMu AI" href="https://www.testmuai.com/?utm_medium=sponsor&utm_source=readest" target="_blank">
If Readest has been useful to you, consider supporting its development at [donate.readest.com](https://donate.readest.com), where you'll find all available donation methods, including GitHub Sponsors, card payments, and crypto. Your contribution helps us fix bugs faster, improve performance, and keep building great features.
## License
@@ -334,8 +333,8 @@ We would also like to thank the [Web Chinese Fonts Plan](https://chinese-font.ne
- [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)
- [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
- [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
## Feature Notes
-[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
- [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/`
## 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
## 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
-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()`)
## 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
- [Grimmory native sync](grimmory-native-sync.md) — Booklore-fork REVERTED; id by ISBN/ASIN + koreader-hash
-KOSync: [CFI spine resolution](kosync-cfi-spine-resolution.md) convert via CFI's own spine; [connect() false-positive #4692](kosync-connect-false-positive-4692.md)
- [Empty-start CFI sync](empty-start-cfi-sync.md) — `isMalformedLocationCfi` → discard synced value
- [WebDAV connect nullified (#4780)](webdav-connect-nullified-4780.md) — catch+finally saved STALE closure;
- [WebDAV credential sync (#4810)](webdav-credential-sync-4810.md) — `webdav.*` missing from
- [Multi-window settings clobber (#4580)](multiwindow-settings-clobber-4580.md) — stale window overwrites shared
- Google Drive: [research](gdrive-sync-provider-research.md) Drive as `FileSyncProvider` token-persist+resumable; [multi-PR status](gdrive-provider-multipr-status.md)
- [Hardcover progress edition_id (#4792)](hardcover-progress-edition-id-4792.md) — `edition_id` fell back to
## Build, Testing & CI
-[format:check separate gate](verify-format-check-gate.md) — `pnpm format:check` own gate before push
-[Worktree rebase submodule drift](worktree-rebase-submodule-drift.md) — rebase leaves foliate-js submodule old; `git
- Stale settings closure: store-hook `settings` stale across `await`; persist `useSettingsStore.getState().settings` ([#4780](webdav-connect-nullified-4780.md)
- [Foliate touch-listener capture phase](foliate-touch-listener-capture-phase.md) — suppress gestures via
New test tier (PR #4545, merged 2026-06-12): `pnpm test:android` → `scripts/test-android.sh` → `vitest.android.config.mts` (node env, serial, retry 1) → `src/__tests__/android/*.android.test.ts`. Helpers in `src/__tests__/android/helpers/`: `adb.ts` (tap/longPress/`motionGesture` = one-shell DOWN/MOVE/UP chain), `cdp.ts` (forward `webview_devtools_remote_<pid>`, node:http discovery with Host header, `CdpPage.evaluate` async-IIFE), `reader.ts` (fixture open + probes). Soft-skips without adb/device/app. Covers the [[android-hyphen-selection-bounds-1553]] cases: prone long-press → app handles, drag repair clamp, tap dismissal, handle-drag extension, mid-paragraph native handles, cross-page corner-dwell auto-turn.
Design principles (per chrox): discover-don't-assume (find a hyphenated on-screen paragraph at runtime, start in main text via `gotoChapter('chapter\\s*4')`), force hyphenation by injecting `p{hyphens:auto!important;text-align:justify!important}` into section docs (app settings irrelevant), poll-don't-sleep (`waitFor`), fixture `sample-alice.epub` opened TRANSIENTLY via MediaStore VIEW intent.
Gotchas:
- MediaStore `_data` is the canonical `/storage/emulated/0/...` path — query `_data LIKE '%/<basename>'`, NOT the `/sdcard/` symlink you pushed to. `content query --projection` takes ONE column or space-separated (not comma). VIEW with `--grant-read-uri-permission` works on a permissionless fresh install.
- Multi-section books: each section is its own iframe — record + restore pagination via the TARGET section's frame x (`c.index === sectionIndex`), not `contents[0]`.
- Corner auto-turn (#1354) zone is the reading area INSET by content margins — a drag point in the bottom margin is ignored by `cornerAt`; aim ~4% inside the text area.
- adb `input motionevent` 5px moves are under touch slop → no pointermove; make post-turn drag movements large.
- Verified green on Xiaomi 13 (physical) AND fresh Pixel_9_Pro AVD (`emulator -avd Pixel_9_Pro`, install the aarch64 dev APK), ~21 s.
CI: `.github/workflows/android-e2e.yml` — ubuntu-latest + KVM udev rule, debug x86_64 APK (`tauri android build --debug --target x86_64`; gradle skips keystore.properties when absent so NO signing secrets), `reactivecircus/android-emulator-runner@v2` (api 34, AVD snapshot cached), nightly + workflow_dispatch + `e2e-android` PR label; not PR-blocking. NOTE: emulator-runner not SHA-pinned yet (repo convention pins by SHA).
description: "#1553 Android selection breaks on first word of hyphenated paragraphs — Blink generated-hyphen bounds bug, full RCA + app-side repair/suppress fix"
Issue #1553 root cause (verified live on Xiaomi 13, WebView 147, via [[cdp-android-webview-profiling]]):
**Upstream**: filed as crbug **522869957** (2026-06-12, by chrox, with full analysis + repro + screenshot). Pre-existing same-root-cause report: crbug **41496034** (Jan 2024, "­" framing — why searches missed it; P2 Available, on "Rendering Core 2026 Fixit" hotlist; MS triager confirmed soft-hyphen cause in Feb 2024). Cross-link comments posted on both.
**Blink bug** — `LayoutSelection::ComputePaintingSelectionStateForCursor` (third_party/blink/renderer/core/editing/layout_selection.cc) compares `paint_range_` offsets (paragraph **IFC text-content space**, via `OffsetMapping::GetTextContentOffset`) against `position.TextOffset()` — but auto/soft-hyphen fragments are **layout-generated text with self-relative offsets {0,1}**. So a touch selection starting at IFC offset 0 (first word of a paragraph) makes EVERY hyphen fragment in that paragraph report `kStart` → each records a start bound (`SelectionBoundsRecorder`, last paint wins) → **native start handle is drawn at the paragraph's LAST hyphen**. The highlight itself is correct because the sibling path `ComputeSelectionStatus(InlineCursor&)` HAS the `IsLayoutGeneratedText()` remap; the bounds path lacks it. Still unfixed upstream as of Chromium main (June 2026); seemingly unreported.
Key facts:
- Trigger: touch selection (handles visible — `ShouldRecordSelection` gates on `IsHandleVisible()`, so desktop/mouse unaffected; iOS=WebKit unaffected) + selection start at IFC offset ≤1 + generated hyphens in the same paragraph. **Multicol NOT required** (reproduced in a plain top-document div).
- Worse than cosmetic: long-press **drag-extension re-anchors the base by hit-testing the bogus bound** → observed anchor jump 0→325 (offset just before last hyphen), selection became [53,325] instead of [0,53]. Explains "select upward works" workaround (upward drags anchor on the correct end bound).
- Auto-hyphens show up as separate ~0.3em rects in `Range.getClientRects()` on hyphenated lines — usable as a generated-hyphen detector.
- **JS `removeAllRanges()+addRange()` does NOT hide already-visible touch handles synchronously** — the empty selection must commit through one painted frame (double-rAF in the iframe window) before re-adding; then handles stay hidden for all later JS selection updates.
Fix (**PR #4545, MERGED 2026-06-12**; worktree cleaned): detection utils in `src/utils/sel.ts` (`isHyphenHandleBugProneRange`, `repairJumpedSelectionRange`, `hasTrailingHyphenRectPattern`); gesture-initial anchor capture + touchend sanitize in `useTextSelector` (repair jumped anchor → suppress handles via empty-commit → `makeSelection(handlesSuppressed)`); `SelectionRangeEditor.tsx` renders custom drag handles (reuses `Handle` + extracted `buildRangeFromPoints`/`getHandlePositionsFromRange` in annotatorUtil) for suppressed selections. Gated to Android app + exact bug condition; flipping to always-custom-handles later = drop the proneness gate.
Gotcha: `input motionevent DOWN/MOVE/UP` (adb) simulates long-press-drag; `input swipe x y x y 700` simulates plain long-press.
Two post-fix races found by chrox & fixed (commits c6e9f48, 9a63157):
1.**Tap-dismiss resurrection** — an Android tap doesn't clear the selection, it COLLAPSES it to a caret at the tap point, with selectionchange ~10-20ms AFTER touchend; the tap's touchend re-entered sanitize (fallback prone-check on the still-valid old selection), the collapse raced into the double-rAF window (also MUTATING the held Range in place), and makeSelection committed a collapsed range as a suppressed selection → "empty custom handles at the tap spot". Fix = gesture gate (`if (!initial) return`) + post-rAF abort when `sel.rangeCount > 0 || finalRange.collapsed`.
2.**Extent overshoot** — the corrupted drag has TWO modes: base re-anchors at the bogus bound (anchor jump, Argentina test) OR the EXTENT lands there while the anchor stays at 0 (range then CONTAINS the initial anchor → jump-repair doesn't fire; observed +1013 chars). Fix = `rangeFromAnchorToPoint`: rebuild [gesture-initial anchor → caret at last native touch position] (`pointerPos`, reset per-gesture in handleTouchStart), fallback to jump-repair.
Verify-build gotcha: `pnpm dev-android` snapshots the Next bundle at build START — an amend after kickoff ships the PRE-amend frontend (grep of out/ chunks for comment text is a FALSE-POSITIVE check; compare out/ mtime vs commit time instead).
description: "Why NativeFile is slow on Android, why RemoteFile (range fetch) can't replace it (asset-protocol Range is broken), measured CDP numbers, and the viable speedups"
On-device investigation (Xiaomi 2211133C, Android 16, WebView/Chrome 147, wry 0.54.4) of `src/utils/file.ts``NativeFile` vs `RemoteFile` Android I/O. Verified live via CDP injection into the running app's WebView.
**Why NativeFile is slow (root cause):**`NativeFile.readData` → `#readAndCacheChunkSafe` does `open()+seek()+read()+close()` = **4 Tauri IPC round-trips per chunk**, opens a FRESH handle every chunk (never reuses `this.#handle`), and `read()` ships raw bytes across the Android Kotlin↔JS IPC bridge (serialization cost = the unresolved tauri-apps/tauri#9190). Code already notes "~400 ms per IPC round-trip" at `nativeAppService.ts:313`.
**Can RemoteFile replace NativeFile on Android? NO** — and whole-file load is NOT an alternative (RemoteFile's whole point is random access WITHOUT loading the file into RAM). The Tauri/wry Android **asset protocol mishandles Range requests** (still true on WebView 147), which is exactly `RemoteFile.fetchRange`'s mechanism:
-`Range: bytes=START-…` with **START ≥ 1024 → hard `TypeError: Failed to fetch`**; `0 ≤ START < 1024` → body truncated to `1024-START` bytes. Reading the zip central directory (EOF) / OPF / cover = non-zero offsets = all fail. "Known issue" at `nativeAppService.ts:244` — confirmed STILL broken.
- **ROOT CAUSE (localized):** Tauri's `crates/tauri/src/protocol/asset.rs` range logic is CORRECT (seek+read [start,end], 206, Content-Range, Content-Length; `MAX_LEN=1000*1024` cap is BY DESIGN — RemoteFile already chunks at the same `MAX_RANGE_LEN`). The bug is in **wry `src/android/binding.rs`**: it STRIPS the `Content-Length` header ("WebResourceResponse will auto-generate") and hands Android a `ByteArrayInputStream` of the already-sliced partial body + a `Content-Range` header. The Android WebView then **double-applies the offset** (skips another `start` bytes) → `1024-start` truncation, empty body for start≥1024. **Unchanged through wry 0.55.1**, so bumping wry won't fix it; needs an upstream wry patch (or local vendor/patch) and fights Android's intercepted-206 quirks.
- Plain `fetch(assetUrl)` (no Range) returns the full file fast — but loading the whole file defeats RemoteFile's purpose, so NOT a fix.
**Measured (10 MB mobi, 1 MB chunks):** native fresh-handle **44 MB/s** (222 ms) · native one-handle **100 MB/s** (98 ms) · asset plain-fetch **281 MB/s** (35 ms, full file correct). Per-call 4 KB scattered read via NativeFile ≈ **16 ms/op** (kills imports doing many small reads). So: plain-fetch is **6.3×** native and **2.8×** one-handle; just reusing the handle is **2.3×**.
**Per-IPC decomposition (warm):** open 1.33 ms, seek 0.60 ms, read(4 KB) 3.02 ms (read carries ~2.4 ms fixed bridge-serialization beyond the round-trip = the tauri#9190 ceiling), seek+read(1 MB) 8.18 ms.
**SOLUTION (implemented, branch `feat/android-rangefile-protocol`, verified on-device):** a custom `rangefile` URI scheme (`src-tauri/src/range_file.rs`, registered via `register_asynchronous_uri_scheme_protocol`) that carries the byte range in the URL **query** (`http://rangefile.localhost/?path=&start=&end=`) instead of a `Range` header. With NO `Range` header the WebView does no offset re-application and delivers the 200 body verbatim — while bytes still stream through the WebView network stack (not the IPC bridge). Returns 200 + `X-Total-Size` (no `Content-Range`); scope-gated by `asset_protocol_scope().is_allowed()` (same security as asset protocol). TS side: `RemoteFile.fromNativePath(absPath)` (query-range mode, reads `X-Total-Size` on open, `&start=&end=` per fetch, no Range header); wired into `nativeAppService.openFile` Android branch with NativeFile fallback; CSP += `http://rangefile.localhost`.
- **Verified on Xiaomi/Android 16 via CDP:** byte-equal to NativeFile ground truth at ALL offsets (0,1,1024,64K,1M,5M,EOF) — the non-zero starts that failed via asset protocol now work; cache-safe across distinct ranges; real library book opens & renders end-to-end (50 rangefile requests, restored mid-file position). **1.83× faster** small scattered 4KB reads (5.2 vs 9.5 ms); bulk-sequential ≈ par (RemoteFile rarely does whole-file reads; native copyFile fast-path handles those). Why this beat the "200 trick" idea: pre-test showed the WebView re-applies the offset to 200 responses too — it's the *Range request header* that triggers it, so removing the header (range-in-URL) is the actual fix.
- Why this isn't the "single-call IPC command": IPC still pays the tauri#9190 bridge serialization; the rangefile path streams via the network stack. The IPC command (`open+seek+read+close` → 1 IPC, ~2× small reads) remains a valid simpler fallback if the custom scheme ever regresses.
-`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 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.
**#4521 root cause (Telegram open fails, file-manager works) — TWO independent axes:**
1.**Cold-start delivery.** On cold launch the ACTION_VIEW intent reaches `handleIntent` before the JS `shared-intent` listener registers; upstream `Plugin.trigger()` drops events with no listener. **#4527** added queue+replay (`emitOrQueue`/`pendingEvents` + `registerListener` override) to fix it. **#4527 is on `dev` but NOT in released v0.11.4** (v0.11.4 has #4407 only) — the reporter's likely cause. Logs show `Queued shared-intent payload (no listener yet)` then `Replaying 1 queued event(s) after registerListener`.
2.**Foreign-private-file read.** File-manager/MediaStore opens point at SHARED storage → Readest reads via real path / MediaProvider FUSE (it holds `MANAGE_EXTERNAL_STORAGE`), so the URI grant is irrelevant. Telegram/Gmail/Drive serve an APP-PRIVATE file via their own FileProvider with a TEMPORARY, non-persistable grant (`takePersistableUriPermission` throws → caught) → readable only in-session via `openInputStream`; the transient book's `content://` filePath then breaks on later reopen once the grant dies.
**Verification gotcha (adb, no Telegram):** an adb MediaStore content-URI VIEW intent
`adb shell am start -a android.intent.action.VIEW -d content://media/external/file/<id> -t application/epub+zip --grant-read-uri-permission -n com.bilingify.readest/.MainActivity`
tests the pipeline (Axis 1) but CANNOT reproduce Axis 2 — shared-storage reads via FUSE bypass the grant, so it always succeeds. To reproduce the real failure use a foreign FileProvider source (Gmail/Drive/Outlook attachment "Open with Readest") or a tiny helper APK with its own FileProvider. Get the MediaStore `_id` via `adb shell content query --uri content://media/external/file --projection _id:_data | grep <name>` (MIUI `--where` chokes on `/storage/...` path tokens). Watch `adb logcat | grep -iE "NativeBridgePlugin|Open with FUSE|Failed to (open|import)|Queued|Replaying"`.
Sideloaded APK installs (Readest's in-app updater path: `installPackage` → `Intent.ACTION_VIEW` with `application/vnd.android.package-archive` → system package installer, NOT Play Store) permit reinstalling an APK whose `versionCode` is **equal** to the currently installed one — it's an in-place reinstall/update as long as the signing certificate matches. Android's `INSTALL_FAILED_VERSION_DOWNGRADE` only triggers for a **strictly-lower** versionCode. (Play Store, by contrast, requires a strictly-incrementing versionCode — that constraint does NOT apply to sideload.)
Consequence for the nightly update channel ([[android-open-with-intent-flow]] uses the same NativeBridge install path): Tauri derives `versionCode = major*1000000 + minor*1000 + patch`, dropping any prerelease suffix, so all nightlies on base `0.11.4` share `versionCode=11004`. That is FINE — they reinstall over each other and over stable `0.11.4`. Because the base only ever increases (0.11.4 → 0.11.5 → ...), nightly versionCode is monotonic non-decreasing, so there is never a downgrade. No need to derive a per-build versionCode from the date stamp. The app's `versionName` carries the full `0.11.4-2026061406` string, which is what the JS `getAppVersion()` updater comparison uses.
A plausible-but-wrong review claim ("same versionCode means Android refuses the install as not-an-upgrade") confuses Play Store rules with sideload behavior. Corrected by the project owner 2026-06-14.
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
- 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]].
#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]].
description: "Biometric (fingerprint/Face ID) startup unlock layered over the PIN app-lock; gotchas for applock-store seeding, mobile-cfg crate, and scoped i18n"
Biometric app-lock (#4645, PR #4650, branch `feat/biometric-app-lock`): biometrics unlock at startup on Android/iOS, app PIN as fallback; desktop/web unchanged. Layered over the existing PIN lock — `pinCodeEnabled` stays the master switch, PIN crypto in `libs/crypto/applock.ts` untouched. All plugin access isolated behind `src/services/biometric.ts` (guarded no-op off mobile; `authenticate` uses `allowDeviceCredential:false` so PIN is the only fallback). New setting `biometricUnlockEnabled` defaults true only for NEW mobile setups; existing PIN users (undefined→off) opt in via a mobile-only toggle.
Non-obvious gotchas (cost real review/rework here):
- **`AppLockScreen` must read startup-snapshot settings from `appLockStore`, NOT `settingsStore`.** `Providers` seeds ONLY the app-lock store via `useAppLockStore.initialize()` (from its own `loadSettings()`), before the gate mounts. `settingsStore.settings` starts `{}` and is seeded later by page-level init — reading the flag from `settingsStore` in the gate RACES and silently no-ops. Fix = thread the value through `initialize()` like `pinHash`/`pinSalt`.
- **`tauri-plugin-biometric` is `#![cfg(mobile)]`** — empty on desktop, so registration must be `#[cfg(any(target_os="ios",target_os="android"))]`-gated (like `haptics`/`sign-in-with-apple`). Desktop `clippy:check` does NOT compile that line, so the Rust side needs a real device build to verify. The dep pin lands in the **workspace-root `Cargo.lock`** (resolved when cargo runs), NOT `src-tauri/Cargo.lock` (which doesn't exist/track here).
- **Scoped i18n without churn:** `public/locales/en` is NOT scanner-managed (key-as-content fallback). Running the full `i18n:extract` reconciles ALL strings and pulls in unrelated drift already on main (e.g. Word Lens keys). For a clean PR, discard the scanner output and add only your new keys to the 33 langs in `i18n-langs.json`. "Face ID"/"Touch ID" are Apple brands — keep verbatim in every locale.
Related: [[ios-instant-dict-double-popup]] (same applock/gate area), [[custom-fonts-reincarnation-4410]] (settings-sync flag patterns).
The library book **context menu** (`BookshelfItem.tsx::bookContextMenuHandler`, native `Menu.new`) only renders where `appService.hasContextMenu` is true — that is **Tauri desktop only** (`nativeAppService.ts`: `!(ios||android)`). It is **false on web AND on iOS/Android**. So a book action added only to the context menu (+ `getBookContextMenuItemIds` in `libraryUtils.ts`) never reaches phone/web users.
The cross-platform home for book-level actions is the **`BookDetailView` action-icon row** (`src/components/metadata/BookDetailView.tsx`), shown in `BookDetailModal`, reachable on every platform (BookItem tap → details, `Bookshelf.tsx::handleShowDetailsBook`). That row is `flex-nowrap` inside a fixed `h-32` column and already holds up to ~5 icons (Edit/Delete/Download/Upload/Export) — adding more risks phone overflow; keep additions to one small icon.
**Rule:** desktop-only fast path → context menu; must reach mobile → BookDetailView (or both). Example: the "Search on Goodreads" feature (#4543) added both — `searchGoodreads` context-menu id + a `FaGoodreads` button in BookDetailView, opening `getGoodreadsSearchUrl` via [[open-external-url-helper]]. In-reader highlighted-text Goodreads search is a built-in [[web-search-provider]] entry instead.
Driving the on-device Readest WebView via CDP to run JS probes/benchmarks **inside the live app** (no rebuild) — used for the NativeFile/RemoteFile I/O study ([[android-nativefile-remotefile-io]]).
**Setup:** app must be running → `adb shell cat /proc/net/unix | grep webview_devtools_remote_<pid>` → `adb forward tcp:9222 localabstract:webview_devtools_remote_<PID>`. Discover targets with a Node `http.get` to `/json/list` (set header `Host: localhost`) — **curl mishandles the WebView's HTTP framing and hangs/returns empty**. Connect `ws://127.0.0.1:9222/devtools/page/<id>`, then `Runtime.enable` + `Runtime.evaluate {expression:'(async()=>{...})()', awaitPromise:true, returnByValue:true}`. Helper scripts kept in `/tmp/cdp/` (eval.mjs, disc.mjs).
**Gotchas that burned time:**
- **Locked device freezes `fetch`.** When the screen is locked the page is `visible:false`; Chromium freezes the network task queue so EVERY `fetch()` (same-origin and asset) hangs forever — but Tauri `invoke()` still resolves. Must have the user **unlock + keep Readest foregrounded**. Set `svc power stayon true` + `settings put system screen_off_timeout 1800000` after unlock (revert `stayon false` when done).
- **`visible:false` also throttles `setTimeout`** (background timer coalescing → ~60 s). Don't rely on setTimeout guards in probes when the page may be hidden; `invoke`-only probes still work hidden.
-`window.__TAURI_INTERNALS__` is ALWAYS injected (independent of `withGlobalTauri`) → use `.convertFileSrc(path)` and `.invoke(cmd,args)` from injected JS. Android asset URL = `http://asset.localhost/<encodeURIComponent(path)>`.
- Real book files live in **internal** storage (`/data/user/0/com.bilingify.readest/...`), not the external `Android/data/.../files` dir (that's `forbidden path` to the fs plugin). `$APPCACHE` = `/data/user/0/com.bilingify.readest/cache` holds import temp copies (in asset scope `$APPCACHE/**/*`). `adb run-as` is denied on the release build.
- fs plugin invokes: `plugin:fs|open{path,options}→rid`, `seek{rid,offset,whence}` (Start=0), `read{rid,len}`→ArrayBuffer whose **last 8 bytes are bigendian nread**, `close{rid}` (**not ACL-allowed** in the installed build). `read_dir`/`stat` on out-of-scope abs paths return `forbidden path`. Tauri v2 `BaseDirectory`: AppData=14, AppLocalData=15, AppCache=16.
- zsh: `$PIPESTATUS[0]` is a bash-ism (empty in zsh; use `$pipestatus[1]`) — don't trust it for exit codes.
How CI/config PRs get delivered in this repo when `dev` has unrelated uncommitted WIP, and the push gotcha.
**Packaging a commit onto a fresh PR branch WITHOUT `pnpm worktree:new`** (the worktree script does a full `pnpm install` + `tauri android init` + icon gen — disproportionate for a YAML/package.json-only PR, and you can't `git checkout` a branch in the dev tree because the user's WIP blocks it):
1. Edit the target files in the dev tree (only files NOT in the user's WIP set — verify with `git status --short -- <files>`), `git add` just those, commit on `dev` (mirrors how the user wanted the pin committed).
2. Re-parent onto `origin/main` (or onto the existing PR-branch tip for a fast-forward add) via a temp index — no checkout, no worktree, dev working tree untouched:
3. Verify `git diff --stat <BASE>..<branch>` shows ONLY the intended files, then push.
This is how PR #4547 (pin `android-emulator-runner` + shard `test_web_app`) was built on top of `origin/main` while `dev` carried 49 files of unrelated dictionary/goodreads WIP.
**Push gotcha (now fixed in `~/.ssh/config`):** `git push` opens the SSH connection BEFORE running the pre-push hook; the husky hook runs the FULL vitest suite (~55s, 5271 tests) + format + lint. The user pushes through a SOCKS proxy (`nc -x 127.0.0.1:8119` → `ssh.github.com:443`), so the idle connection got dropped during the hook → "Broken pipe", ref never transferred (remote stayed at old SHA — always `git ls-remote` to confirm). Fix added: `ServerAliveInterval 15` + `ServerAliveCountMax 60` under `Host github.com`. Also: **`--no-verify` is safe once the hook has already passed** on the same tree — re-running it just re-opens the idle window. See also [[feedback_dont_push_every_change]], [[feedback_use_worktree]].
description: Cover painted via body background-image vanished under an active bg texture (parchment) because textureAwareBackground misclassified it as transparent
Symptom: edit a book's cover in Book Details → Save → return to library, the cover shows the OLD image; a full page refresh fixes it. Title/author DO update.
Root cause: `handleUpdateMetadata` in `src/app/library/page.tsx` mutated the existing `book` object IN PLACE (`book.metadata = …; book.coverImageUrl = …; book.updatedAt = …`) then passed that same reference to `updateBook`. `<BookCover>` (`src/components/BookCover.tsx`) is `React.memo`'d with a custom comparator reading `coverImageUrl`/`metadata.coverImageUrl`/`updatedAt` off the book. Because the previous-render snapshot (`prevProps.book`) is the *same mutated object*, every field compares equal → memo skips → cover never re-renders. Title updates because `BookItem` is NOT memoized. Refresh works because `loadLibraryBooks` (`libraryService.ts`) strips `coverImageUrl` on save and REGENERATES it from `${hash}/cover.png` on load (the file was overwritten by `updateCoverImage`).
KEY INSIGHT: cloning inside `updateBook` would NOT fix it — once the original object is mutated, `prevProps` already reads the new values. The fix must leave the object React holds as `prevProps` untouched.
Fix (PR for fix/txt-open-with-conversion): pure helper `getBookWithUpdatedMetadata(book, metadata)` in `src/utils/book.ts` returns a NEW book object (`{...book, metadata, title, author, primaryLanguage, updatedAt, coverImageUrl}`); `handleUpdateMetadata` uses it instead of mutating. Cover URL is set from `metadata.coverImageBlobUrl || metadata.coverImageUrl` (cached/blob URL is a unique path = the new image; `'_blank'` for remove). Unit test asserts immutability of the input + new reference.
General rule: when a memoized child reads fields off a store object, NEVER mutate that object in place to "update" it — build a new object. Same trap could bite any `React.memo` field comparator in this codebase.
On-device CDP verification (reusable): the cover SET path uses the native Tauri file picker (`selectFiles` → `openDialog`), NOT automatable via CDP; the installed emulator app is the released bundled build (`http://tauri.localhost/...`), not the dev server. So I verified the MECHANISM directly in the live WebView: extracted the zustand library store from the React fiber tree (the library page calls `useLibraryStore()` with NO selector, so its fiber hook `memoizedState` holds the full state incl. `library`/`setLibrary`/`updateBook`), injected an Alice book, then A) mutated it in place + `setLibrary([sameRef])` → rendered `<img src>` stayed stale (bug), B) `setLibrary([{...book,coverImageUrl:NEW}])` → `<img src>` updated immediately (fix). Restore with `Page.reload` (injected book was in-memory only). See [[cdp-android-webview-profiling]], [[android-cdp-e2e-lane]].
# #4410 — disappearing custom fonts when logged into cloud
**Symptom:** custom fonts vanish a few seconds after opening a book (or ~1 min idle) ONLY when logged into cloud sync; logging out fixes it; a brand-new never-deleted font is fine; problem starts after deleting a font / "Clear Custom Fonts" then re-uploading the same file.
**Root cause — CRDT remove-wins.** The replica sync (`src/libs/crdt.ts`, `src/libs/replicaInterpret.ts`) is remove-wins: once a row has a `deleted_at_ts` tombstone, a plain field upsert does NOT revive it. Only a `reincarnation` token whose effective HLC beats the tombstone revives it. `isReplicaRowAlive(row)` = `!deleted_at_ts || (reincarnation && updated_at_ts >= deleted_at_ts)`. In `mergeReplica` the reincarnation candidate's timestamp is the **row's `updated_at_ts`** (fresh on every upsert), NOT the token's mint time — so preserving an old token still revives, as long as the upsert carries it.
Flow that broke: import→delete writes a server tombstone (`publishReplicaDelete`). Re-upload same file → same `contentId` → `addFont` cleared `deletedAt` locally and called `publishFontUpsert` with `reincarnation = undefined` → server tombstone survives → next pull (boot 5s / periodic / book-open / visibility) sees `isReplicaRowAlive===false` → `softDeleteByContentId` → font disappears.
**Fix (PR for #4410):** in `addFont` (`src/store/customFontStore.ts`) and `addTexture` (`src/store/customTextureStore.ts`), when re-adding an existing entry, mint a reincarnation token (`Math.random().toString(36).slice(2)`, matching OPDS) when `!!contentId && !existing.reincarnation && (existing.deletedAt || existing.contentId === new.contentId)`; otherwise preserve `existing.reincarnation`. Covers both re-import-after-local-delete AND the stale-local race (local still live but another device tombstoned the row). Token is inert without a tombstone, so live re-imports are safe.
**Coverage matrix across collection kinds (all share the remove-wins replica):**
- Dictionary — handles BOTH cases (gold standard): `dictionaryService.ts importDictionaries` via `findTombstonedDictionaryMatches` + `shouldMintReincarnationForLiveReimport` (helpers in `dictionaries/dictionaryDedup.ts`), mints `uuidv4()`.
- OPDS — case 1 only: `customOPDSStore.addCatalog` (`existing?.deletedAt && !input.reincarnation`).
- Fonts / Textures — handled NEITHER → this bug. Now fixed to dictionary-parity.
Whole chain carries the token: returned font → `publishFontUpsert` upsert AND `queueReplicaBinaryUpload` → manifest publish (`replicaBinaryUpload.ts` uses `record.reincarnation`).
Note: `saveCustomFonts`/`saveCustomTextures` persist tombstoned (deletedAt) entries too, so the soft-deleted entry is still in the store at re-import time → the `existing.deletedAt` branch fires. (OPDS strips deleted at save; fonts/textures keep them.)
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]].
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
Transitive npm security advisories (Dependabot alerts against `pnpm-lock.yaml`) are fixed by pinning a **minimum patched version** in the `overrides:` block of **`pnpm-workspace.yaml`** at the monorepo root — NOT `package.json`'s `pnpm.overrides`.
**Why:** the repo uses pnpm 9+ (`pnpm@11.x`), which reads `overrides`/`patchedDependencies`/`catalog` from `pnpm-workspace.yaml`. A `pnpm.overrides` block added to root `package.json` is silently ignored — `pnpm install` runs fast and the lockfile doesn't change. There is already a long list of security pins in that `overrides:` block (glob, undici, qs, body-parser, etc.).
**How to apply:**
1.`gh api repos/readest/readest/dependabot/alerts/<N>` → get package + `first_patched_version` + vulnerable range.
3. Add/raise the entry in `pnpm-workspace.yaml``overrides:` in the existing style: `<pkg>: '>=<patched>'` (e.g. `shell-quote: '>=1.8.4'`). **Check for an existing too-low pin** — e.g. `qs: '>=6.14.2'` still allowed the vulnerable 6.15.1; had to raise to `'>=6.15.2'`.
4.`pnpm install --lockfile-only` then `pnpm install`. Verify with `pnpm why -r <pkg>` (should show only the patched version). Stale dirs may linger in `node_modules/.pnpm` but are harmless if the lockfile has zero refs to the old version.
5. Dependabot **alert numbers are not GitHub issue numbers** — don't use `Closes #N`; alerts auto-dismiss when the vulnerable version leaves the default-branch lockfile. Reference the `/security/dependabot/<N>` URLs in the PR body instead.
First done in PR #4523 (shell-quote 1.8.4 / GHSA-w7jw-789q-3m8p, qs 6.15.2 / GHSA-q8mj-m7cp-5q26). Diff stays scoped to just the bumped packages; prefer this over `pnpm update <pkg>` which incidentally refreshes unrelated in-range patches (e.g. react-is). See [[feedback_pr_new_branch]] [[feedback_use_worktree]].
`pnpm deploy` (and `pnpm upload`) crashed for chrox (behind GFW, Privoxy at `http://127.0.0.1:8118`) with an **unhandled `ws` `'error'` event → Node process crash** (ETIMEDOUT to Facebook/Vultr/Twitter IPs).
**Trigger:**`opennextjs-cloudflare deploy`/`upload` ALWAYS runs `populateCache({target:"remote"})` BEFORE the real deploy (no skip flag; only `cacheChunkSize`/`env` knobs). That step calls wrangler's `unstable_startWorker({remote:true})`, which opens a **WebSocket** to a `*.workers.dev` edge host. (`preview` uses `target:"local"` → unaffected.)
**Root cause (two layers):**
1.`*.workers.dev` is **SNI-blocked** by the GFW, not merely DNS-poisoned. Proof: encrypted DoH gives the REAL Cloudflare IPs (104.18.x), but a *direct* TLS connect to that correct IP with SNI=workers.dev is still `Connection reset by peer` before TLS starts. So **DoH/dnscrypt-proxy does NOT help** — the connection must avoid being made directly at all.
2. wrangler's REST calls honor `http_proxy` (undici `ProxyAgent`/`EnvHttpProxyAgent`), but the raw `ws` handshake falls back to `https.globalAgent` and **ignores proxy env**. So it connects directly → SNI reset → crash. The crash fires async (unhandled WS 'error'), so `opennextjs-cloudflare deploy`'s `await` can't catch it.
**Attempt 1 — proxy preload (tried, then REMOVED):** a zero-dep preload that replaced `https.globalAgent` with a `CONNECT`-tunnel agent (CONNECT hides the SNI = defeats the block + does remote DNS; loopback bypassed so the local populate worker on 127.0.0.1 still works). Verified `https.get('https://workers.dev')` → 301 via proxy. This got PAST the WebSocket crash — the local populate worker started and enumerated all 17 cache assets — **BUT the actual R2 writes through the remote binding then timed out** ("Failed to send request to R2 worker: aborted due to timeout", retrying forever). The proxy establishes the connection but can't reliably carry the sustained cache-write traffic. So the preload alone is NOT sufficient. Deleted it.
**Attempt 2 — replicate `wrangler deploy` in the npm script (tried, then reverted):** skip populate by bypassing `opennextjs-cloudflare deploy` and running `CLOUDFLARE_LOAD_DEV_VARS_FROM_DOT_ENV=false OPEN_NEXT_DEPLOY=true wrangler deploy` directly (traced from `runWrangler`: stock deploy's real step is plain `wrangler deploy` vs `wrangler.toml` which has `main=.open-next/worker.js`+all bindings; no generated config/skew mapping; the env flag stops wrangler 4.x auto-loading `.env`/`.dev.vars` into the worker — OpenNext's adapter handles env). Works, but hacky (replicates internals, drift risk).
**Fix that SHIPPED — config flag (cleanest).** populateCache is gated by `if (!config.dangerous?.disableIncrementalCache && incrementalCache)`. So in `open-next.config.ts`: `config.dangerous = { ...config.dangerous, disableIncrementalCache: true }`. This makes the STOCK `opennextjs-cloudflare deploy`/`upload` skip populate (no script hack, no env flag, no drift) — reverted package.json to stock. Caveat: it's the SAME flag the runtime reads, so it ALSO disables the runtime incremental cache — **but readest uses ZERO ISR (no `revalidate`/`unstable_cache`/`'use cache'`/`generateStaticParams`), so runtime caching is a no-op anyway → no real loss.** Re-enable = delete the one line (from a network that can reach the CF edge). `defineCloudflareConfig` returns `OpenNextConfig` (broad type; `dangerous.disableIncrementalCache?: boolean` exists), tsgo+biome clean. `preview` was always fine (local populate).
description: "How to fix transitive npm Dependabot alerts in the readest monorepo (pnpm-workspace overrides, where config lives, tauri-plugins is separate)"
#4574 FR: dictionary lookup should normalize inflected forms before lookup. Dicts that store only base headwords (Oxford Dictionary of English, Cambridge, Longman) miss `ran`/`mice`/`children`/`analyses`/`realised` even though `run`/`mouse`/`child`/`analysis`/`realise` exist.
**Integration point** (single, central): `src/services/dictionaries/lookupCandidates.ts``buildLookupCandidates(word, lang?)` — appends `getLemmaCandidates(lower, lang)` to the tail of `[trimmed, lower, title, upper]`. Lemmas sit AFTER exact/case so exact match always wins. The pre-existing lookup loop in `DictionaryResultsView.tsx` (`useDictionaryResults`, shared by desktop popup + mobile sheet) tries each candidate and breaks on first non-empty hit; wiring was a one-liner passing `langCode` (already in effect scope) as the 2nd arg. Applies to ALL definition providers (mdict/stardict/dict/slob + online builtins).
-`index.ts` — `getLemmaCandidates(word, lang)` + `Record<string, Lemmatizer>` registry (`Lemmatizer = (word)=>string[]`). Lang normalized via `normalizedLangCode` (utils/lang.ts) to primary subtag. **Missing/empty lang defaults to `'en'`** (`normalizedLangCode(lang) || 'en'`); **explicit non-English with no registered lemmatizer → `[]`** (we never force English onto e.g. `fr`/`zh`). Add a language = register one fn, no caller changes.
-`english.ts` — `lemmatizeEnglish(word)`: `IRREGULAR_GROUPS` (base→[forms], flattened to inflected→base at load) for suppletive verbs / irregular plurals / irregular comparatives, + regular suffix rules (plural -s/-es/-ies→y/-ves→f,fe/-ses→sis; past -ed/-d/-ied→y + de-double; -ing + e-restore + de-double + -ying→ie; comparative -er/-est/-ier→y; possessive `'s`; adverb -ly). ASCII-single-token guard `/^[a-z][a-z'’-]*$/` (no-op on phrases/numbers/CJK/accented). Lowercases input; never returns the input itself or single letters.
**Key design insight: over-generate, let the dictionary validate.** The lemmatizer need not be linguistically precise — a bogus stem just misses and the loop moves on. So rules can be liberal. Cost is bounded: lemmas only fire AFTER exact+case all return empty (genuine "not a headword"), and the English rules produce ~2–5 candidates.
**Ordering gotcha**: `-ses→-sis` rule must come BEFORE generic `-es`/`-s` so `analyses`→`analysis` (the issue's expected noun) is tried ahead of `analyse` (the verb). Both are linguistically valid for `analyses`; issue wants the noun.
Tests: `__tests__/services/dictionaries/lemmatize/{english,index}.test.ts` + extended `lookupCandidates.test.ts` (all 8 issue cases asserted). Existing trim test's `spaced` sample swapped to `planet` (non-inflecting) since no-lang path now defaults to English lemmatization. Pure functions, fully deterministic — no live MDX needed. Related: [[dict-lookup-browser-hijack-4559]], [[wordlens-feature]].
#4559 (PR #4568): on VIVO/iQOO (OriginOS) the system-dictionary lookup opened `com.vivo.browser/.BrowserActivity` instead of an installed dictionary. TWO root causes, both in the Android half of `show_lookup_popover` (`tauri-plugin-native-bridge/.../NativeBridgePlugin.kt`):
1.**Package-visibility filtering (primary).** App is `targetSdk 36`, but the native-bridge manifest had NO `<queries>` for `ACTION_PROCESS_TEXT`. Under Android 11+ filtering, `queryIntentActivities(PROCESS_TEXT)` then returns only *auto-visible* apps — web browsers are auto-visible (web-intent exception), arbitrary dictionary apps (Eudic/欧路/GoldenDict) are NOT. So the query returned just the browser. Fix = add `<queries><intent><action PROCESS_TEXT/><data text/plain/></intent></queries>` to the plugin manifest (mirrors the native-tts `TTS_SERVICE` pattern). This alone is likely the whole user-visible fix.
2.**Browser hijack.** Even when visible, an OEM browser registering `ACTION_PROCESS_TEXT` can be the system default and swallow a plain `startActivity`. Fix = filter browsers out of the handler set in a pure `decideLookupDispatch(handlers, browserPackages, remembered)` (new `LookupDispatch.kt`, JUnit-tested): no-browser → implicit (unchanged, keeps native "Always"); browser+1 dict → explicit `setClassName` direct launch; browser+≥2 → `createChooser` + `EXTRA_EXCLUDE_COMPONENTS`; browser-only → `unavailable:true`. Browsers detected via `queryIntentActivities(ACTION_VIEW https + BROWSABLE)` (auto-visible, no `<queries>` needed).
**Remember-the-choice** (the maintainer wanted "smooth once chosen"): `ACTION_CHOOSER` has NO native "Always" button (mutually exclusive with `EXTRA_EXCLUDE_COMPONENTS` — the resolver that *has* Always can't exclude and obeys the browser default). Re-implemented Always: pass an `IntentSender` to `createChooser`; system returns `EXTRA_CHOSEN_COMPONENT` to a manifest `LookupChoiceReceiver` (exported=false; explicit intra-app PendingIntent so non-exported is fine; FLAG_MUTABLE on S+) which persists pkg/class to a plain SharedPreferences (`readest_lookup_dictionary_v1`). Next lookup fast-paths it. Reset UI: `get_lookup_dictionary`/`clear_lookup_dictionary` commands (build.rs COMMANDS + default.toml + autogenerated TOMLs/schema/reference regenerate on `cargo build -p tauri-plugin-native-bridge`) → Android-only conditional reset row in `CustomDictionaries.tsx`, only shown when something is actually remembered (`getRememberedLookupApp` returns null otherwise, so no clutter).
Maintainer DECLINED a settings picker to pre-pick a specific app ("we won't call the app directly"); browser-exclusion respects that (dynamic, not a user-set hardcode). Verified: gradle JUnit (7 cases) + vitest (12). `test:rust`/`clippy` were blocked by UNRELATED stale shared-`target/` cache (deleted sibling worktree `readest-feat-android-rangefile-protocol` path in `fs` plugin permission scan) — not my change; validated Rust via targeted plugin build. Related: [[android-open-with-intent-flow]], [[android-nativefile-remotefile-io]].
description: "#4639 strict is_allowed broke ALL Android downloads to app data dir (covers/dicts/books); fix = app.path() base-dir membership, not glob scope"
Regression from [[security-advisories-web-2026-06]] PR #4639 (commit 4025c4d7b). On Android every `download_file` into the app's own data dir failed: `permission denied: path not in filesystem scope: /data/user/0/com.bilingify.readest/Readest/{Books/<hash>/cover.png, Dictionaries/<id>/*.mdx, ...}`. The error string IS `transfer_file.rs Error::Forbidden` from `ensure_path_allowed`.
**Root cause (non-obvious):**`app.fs_scope().is_allowed(p)` returns **false** for the app's own files on Android. `FsExt::fs_scope()` returns the GLOBAL `state::<Scope>().scope`, but the capability scope patterns that cover the data dir (`$APPDATA/Readest/**/*`, `**/Readest/**/*`) are **command-scoped**, NOT in that global scope. The fs plugin's `resolve_path` (tauri-plugin-fs `commands.rs`) passes because it checks `fs_scope.scope.is_allowed(p) || scope.is_allowed(p)` where the 2nd `scope` is rebuilt from `global_scope.allows()+command_scope.allows()` per-command — that's where those patterns live. This is the SAME gap `dir_scanner::read_dir` works around with `|| contains("Readest")`. So #4639's note "Chose STRICT is_allowed (NOT read_dir's contains-Readest hatch)" was the bug — strict `is_allowed` rejects the app's own dir on Android.
Why fs-plugin writes work but `download_file` didn't: app writes via `baseDir: AppData` + relative path → `webview.path().resolve(rel, AppData)` → canonical form matched by the per-command scope. `download_file`/`upload_file` use raw `tokio::fs` with a JS-supplied ABSOLUTE path, so none of that applies.
**WRONG first attempt (don't repeat):** canonicalizing the symlink (`/data/user/0/<pkg>` → `/data/data/<pkg>`, since `is_allowed` only canonicalizes EXISTING paths and a download target doesn't exist yet) then re-calling `is_allowed`. Verified on-device it STILL fails — the patterns aren't in the global scope at ALL, so no path form matches. The canonicalize-existing-ancestor helper is still useful, just for the prefix check below, not for `is_allowed`.
**Shipped interim fix** (PR #4651, commit 75b469931): owner rejected the base-dir-membership version as over-engineered ("bloody hard coded" dir list) and asked to mirror `dir_scanner` instead. `ensure_path_allowed` = reject relative+`..` (`has_disallowed_components`) → `if app.fs_scope().is_allowed(p) || is_within_app_storage(p) Ok`. `is_within_app_storage(file_path, app_identifier) = file_path.contains("Readest") || file_path.contains(app_identifier)` where `app_identifier = &app.config().identifier` (NOT a hardcoded literal; `config()` is inherent on AppHandle — NO `use tauri::Manager`). The bundle id (`com.bilingify.readest`) is in EVERY Android sandbox path incl. the cache dir, so it closes the OPDS-to-`$APPCACHE` gap that `contains("Readest")` alone misses (`Readest` is the `DATA_SUBDIR`, capital-R; cache dir has only the lowercase bundle id). `..` rejection keeps GHSA-55vr-pvq5-6fmg (`~/.ssh/id_rsa` has neither marker). Substring posture = same as `dir_scanner`. **Follow-up (deferred):** replace the substring fallback with `BaseDirectory`+relative resolved via `app.path()` (the app already has `appService.resolvePath()` → `{baseDir, fp}`; callers currently flatten it to an absolute string) so targets are in-scope by construction.
(Rejected earlier attempt, kept for context: base-dir-membership via `app.path()` dirs + `is_inside_any` canonicalizing both sides for the `/data/user/0`↔`/data/data` symlink. Correct but owner found the dir enumeration ugly.)
**On-device verify recipe (Xiaomi fuxi 2211133C, real release-signed devtools build):**`pnpm dev-android` = `tauri android build -t aarch64 -- --features devtools && adb install -r .../app-universal-release.apk`. Local keystore (`gen/android/keystore.properties` → `/Users/chrox/dev/Android/keys/upload-readest-keystore.jks`, alias `upload`) matches installed signer → `-r` PRESERVES user data (dicts/books). CDP probe: `adb forward tcp:9333 localabstract:webview_devtools_remote_<pid>` (socket name = app pid; stale sockets linger — pick the one matching `adb shell pidof`); fetch `/json/list` via node http (NOT curl — mishandles WebView framing); Node v24 has global `WebSocket`. Raw `invoke('download_file',...)` needs a Channel for `on_progress`: pass `{ ['__TAURI_TO_IPC_KEY__']: () => '__CHANNEL__:'+I.transformCallback(()=>{}) }`. Result: in-scope `/data/user/0/.../Readest/x.bin` → OK; `/data/local/tmp/evil.bin` → still Forbidden. `appData=/data/user/0/com.bilingify.readest` (no `/files`); appCache/temp=`.../cache`.
Issue #4379: an EPUB cover with `data-duokan-page-fullscreen` on `<html>` (Duokan/DangDang convention) shows in paginated mode but is **blank in scrolled mode**; only the first cover image, other images fine in both modes.
**Root cause** — `View.setImageSize()` in `packages/foliate-js/paginator.js` has a `pageFullscreen` branch that pins each img with `position:absolute; inset:0; width:100%; height:100%` and forces ancestors + `<html>` to `height:100%`/`position:relative`. This fills the page in paginated/columnized mode (html has a fixed pixel height). In scrolled mode `scrolled()` sets `html`/`body` height to `auto`, so the `height:100%` chain resolves to **0** and the absolutely-positioned cover collapses out of view (offsetHeight 0).
**Fix** — gate the fullscreen treatment on column mode: `const applyFullscreen = pageFullscreen && this.#column`. Use `applyFullscreen` for the max-height/max-width margin term and the `if` block. Add an `else if (pageFullscreen)` that `removeProperty`s the stale `position/inset/width/height/margin` on the img (and `width/height/margin/padding` on ancestors, `position` on html) so toggling paginated→scrolled doesn't leave the cover collapsed (same iframe/img is reused via `view.render(layout)` on `flow` change). In scrolled mode the cover then flows like a normal full-page image bounded by `max-height = availableHeight`.
**Key facts**
-`this.#column = layout.flow !== 'scrolled'` (set in `render()` before `setImageSize`), so it's reliable inside `setImageSize`.
- Foliate writes these styles as **inline `!important`** → cannot be overridden from `src/utils/style.ts`; the fix must live in the paginator.
- Regression test: `src/__tests__/document/paginator-duokan-cover.browser.test.ts` + fixture `repro-4379.epub` (cover xhtml with the duokan attr + dimensionless `<img>`). Asserts cover `img.offsetHeight > 0` in scrolled mode, paginated sanity, and paginated→scrolled toggle. Browser test (real layout) is required — jsdom can't compute the collapse.
**Design: keep sentence granularity, add word highlight on top.** All clients still report `getGranularities() = ['sentence']` — switching foliate to word marks would regress media-session metadata (one word on lock screen), byMark seek (word steps), `getSpokenSentence`, and per-word synthesis. Instead: `EdgeSpeechTTS.createAudio()` returns `{url, boundaries}` (cached per payload-hash next to the blob URL), `EdgeTTSClient` runs a rAF loop syncing `audio.currentTime` (media time → playbackRate/pause-safe) against boundary ticks, and `TTSController.prepareSpeakWords/dispatchSpeakWord` match words sequentially (`indexOf` with a moving cursor; unmatched word = skip WITHOUT advancing cursor) against the sentence range text, then highlight the sub-range via the existing `#getHighlighter`.
**Edge wire facts** (verified with raw WS probe + live):
-`audio.metadata` frames: `{"Metadata":[{"Type":"WordBoundary","Data":{"Offset":1000000,"Duration":4250000,"text":{"Text":"Dr.","Length":3}}}]}` — one word/frame, ticks = 100 ns (1e7/s), offsets relative to this request's audio stream.
-`Text` is the **verbatim input span** ("Dr.", "23", "$5.50" keep punctuation; trailing sentence punctuation stripped) → sequential indexOf matching is robust. Works for zh too.
- The readaloud endpoint gates on **User-Agent (needs Edg/non-headless), NOT Origin** — a localhost Origin with Edg UA is accepted; default HeadlessChrome UA is rejected (close 1006).
**Pre-existing bug fixed in the same PR:** browser branch did `new WebSocket(url, {headers})` → native WebSocket parses the object as a subprotocol → `SyntaxError` → on web the wss path could NEVER work (always https-proxy fallback, which strips boundaries). Node-only options now.
**Probe gotchas:** Overlayer draws the highlight as a `<path>` inside `<g fill="#808080">` (NOT `<rect>` — rect-only DOM probes miss it); the overlayer svg lives in `FOLIATE-PAGINATOR`'s open shadow root (sibling layer of the iframe, not inside it). TTS auto-advance creates new views — re-query svgs per sample, never cache the list.
**dev-web live-verify recipe:** gstack `browse --proxy http://127.0.0.1:8118` (flag needed on EVERY invocation; this machine's external net needs the local proxy, headless Chromium doesn't inherit it) + `browse useragent '...Edg/143...'` (context-level, doesn't break Next) — do NOT use `browse header Origin:...` (extra headers hit localhost too → Next dev 403s ALL chunks → blank page; headers can't be removed without daemon restart). Import books via synthetic drop: fetch epub from `public/`, `DataTransfer` + `DragEvent('drop')` on `.library-page` (in-memory only — re-import after every reload/restart). Patch `content.overlayer.add/remove` to log the real highlight calls — the ground truth when screenshots race. Related: [[tts-fixes]]
description: "Page-turner \"Refresh Page\" action that deep-refreshes the e-ink panel (clear ghosting) on Android, via generic reflection across BOOX/Tolino/Rockchip"
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`.
-`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.
**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):
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.
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`).
Git commit messages must be **English only**: no CJK characters (no 中文/量词/example glyphs like 第一封信) and no em/en dashes (— –). Use plain ASCII punctuation (comma, colon, parentheses, `...`). The same applies to PR titles for consistency.
**Why:** the user (a maintainer of readest/readest) keeps the project's git history English-only and clean.
**How to apply:** when a fix is about Chinese/CJK text, describe the concept in English in the commit subject/body (e.g. "measure-word prose", "the classifiers for 'letter' and 'book'") instead of pasting the glyphs. Keep the concrete CJK examples and screenshots in the PR *body* / code / tests, where they aid understanding — that is fine. First seen on PR #4660 ([[txt-chapter-measure-word-4658]]), where "量词" in the subject had to be amended to "measure-word".
#4438 (v0.11.4 regression): a stray horizontal line appeared below the footnote/annotation marker because the footnote `<aside epub:type="footnote">` (with the book CSS's `border:3px #333 double`) stopped being hidden.
**Root cause:** PR #4383 (`e8675fb7e`, inline custom @font-face) changed `getStyles` assembly in `src/utils/style.ts` from `${pageLayoutStyles}...` to `${customFontFaces}\n${pageLayoutStyles}...`. The `@namespace epub "..."` declaration lived *inside*`getPageLayoutStyles`. Per the CSS spec a `@namespace` rule is honored ONLY if it precedes every style/`@font-face` rule — a misplaced one is silently ignored. The inlined `@font-face` rules pushed `@namespace` down, invalidating it, so the namespaced selector `aside[epub|type~="footnote"] { display:none }` was dropped and the aside border showed. Only hit users **with custom fonts loaded** (otherwise `customFontFaces` is empty and `@namespace` stayed first).
**Fix:** Hoist `@namespace` to the very front of the assembled stylesheet (`const epubNamespace = '@namespace epub "..."'; return \`${epubNamespace}\n${customFontFaces}\n${pageLayoutStyles}...\``) and remove it from `getPageLayoutStyles`. Custom faces still precede the `--serif`/`--sans-serif` lists, preserving #4383's first-paint intent.
**Gotchas verified the hard way:**
- `epub:type` is a *namespaced* attribute only when the doc is parsed as XHTML/XML (foliate loads EPUB content as XHTML). Playwright `page.setContent` parses as **HTML**, where `epub:type` is a plain attr and `[epub|type~=...]` never matches — repro must use `data:application/xhtml+xml` via `page.goto`.
- The existing test `style-get-styles.test.ts` literally asserted the buggy order (`@font-face` before `@namespace`) on the false premise that an @font-face must precede the font-family rules that use it. It must not — @font-face rules are collected regardless of source position; only same-family redefinition cares about order.
Related: [[css-style-fixes]], [[table-dark-mode-tint-4419]] (both in the bug-prone `style.ts`).
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/`:
**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).
-`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`).
- **#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).
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/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.
- **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).
**#4575** — "after highlighting several main-character names, page turning is very laggy" (Chinese web-novel TXT). Root cause = the **global highlight** feature (highlight-all-occurrences, `note.global`), NOT plain highlights. The `progress` effect in `Annotator.tsx` (`for (const a of annotationIndex.globals) expandAllRenderedSections(view, a)`) re-fans-out EVERY global note across EVERY rendered section on EVERY page turn. Each pass: TreeWalker over the section DOM (`findTextRanges` in `globalAnnotations.ts`) + `view.getCFI(index, range)` per occurrence (~0.2ms each, the dominant cost) + `overlayer.add` which removes+recreates an SVG and calls `getRects` (forces layout). Overlays already exist after pass 1 → pure waste.
**Profiled live** (dev-web + claude-in-chrome, real `<foliate-view>`): 6 names / 226 occurrences across 2 rendered chapters = **~25–45ms synchronous main-thread per page turn** on desktop (×3–5 on mobile = the lag). Leads 姜窈(73×) 驰厉(66×) per 2 chapters.
**Fix (PR branch `fix/global-annot-pageturn-4575`, commit f1404c6b1):** module-level `WeakMap<Document, Map<noteId, signature>>``expandedByDoc` in `globalAnnotations.ts`. `expandGlobalAnnotation` skips when `docMemo.get(note.id) === signature`; records after expanding (even 0 matches). `signature = updatedAt:style:color:text`. `removeGlobalAnnotationOverlays` clears the memo entry. Turns 2..N → ~0ms; one-time cost stays at section-render (`onCreateOverlay`). 5 unit tests in `src/__tests__/utils/global-annotations.test.ts`.
**Correctness invariant:**`doc` & `overlayer` are created/destroyed together per section content-record, and `getContents()` returns STABLE doc/overlayer refs across separate calls (verified). So "same doc ⟺ overlays still present" — a re-rendered section gets a fresh `doc` → memo miss → re-expand; never wrongly skips.
**Secondary complaints in the issue (NOT fixed here):** slow TXT import (`txt.ts` parse, "2MB should be instant"), slow TOC/notes open. Separate concerns.
**Repro recipe — import a GBK TXT into dev-web without the native picker:** copy the .txt into `public/`, then in-browser `fetch('/file.txt')` → `arrayBuffer` → `new File([buf], '原名.txt')` → `DataTransfer` → dispatch a synthetic `drop``DragEvent` (with `Object.defineProperty(ev,'dataTransfer',{value:dt})`) on `.library-page`. Raw bytes preserved → app's encoding detection handles GBK. Books at `/Users/chrox/Documents/books/issues/4575/`. See [[tts-sync-chrome-verification]] for the live-foliate-view profiling pattern.
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.
`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.
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]].
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"
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]].
Bug: a chapter's content jumps straight to its "Reference materials", silently skipping a large middle (deep-dive/wrap-up). Repro book: "System Design Interview Vol.2" (System Design EPUB), Chapter 8 `OEBPS/c554.xhtml`, reported "at page 346".
Root cause: the EPUB's own CSS wraps the whole chapter body in a `<div>` with `display: inline-block` (`.class_s5mz1`). Atomic inline-level boxes (inline-block / inline-flex / inline-grid / inline-table) **cannot fragment across CSS columns**, so in paginated (columnized) mode the 7700px-tall box overflows the page vertically and every column past the first is clipped → "1 page left in chapter" while most of the chapter is unreachable. Direct `goTo({index})` and forward `next()` both still RENDER the section (engine traverses by content), so the symptom only manifests as clipped/unreachable pages + bogus page counts; scrolled mode is unaffected (vertical overflow is normal there).
Diagnosis tell: in column mode `documentElement.scrollHeight >> clientHeight` (e.g. 7768 vs 632); late headings stack vertically at one far-right column-left offset instead of spreading across columns.
Fix: `packages/foliate-js/paginator.js` → `#demoteUnfragmentableBoxes(availableHeight)`, called from `columnize()` after `setImageSize` (column-mode only). Guarded fast-path: returns immediately unless `scrollHeight > clientHeight + 1`. When overflowing, scans `body.querySelectorAll('*')`, and for any element whose computed display is atomic-inline AND `getBoundingClientRect().height > availableHeight`, demotes to the fragmentable equivalent (inline-block→block, inline-flex→flex, inline-grid→grid, inline-table→table) via `setStylesImportant`. Idempotent (demoted elements no longer match), regression-free (short legit inline-blocks like side-by-side figures untouched — they're never page-tall). Mirrors the existing `setImageSize` over-tall-image clamp and the `p { display: block }` rule in `style.ts` ("epubs set insane inline-block for p").
Test: `src/__tests__/document/paginator-inline-block-overflow.browser.test.ts` + fixture `repro-inline-block-overflow.epub` (one chapter wrapped in `.wrap{display:inline-block}`, 50 paras + TAIL_MARKER). Asserts `scrollHeight <= clientHeight+2`, wrap display `block`, tail heading in a later column within page height. Needs real layout → browser test (jsdom has no layout). Dev server (Next/Turbopack) picks up the workspace foliate-js edit on reload. Related: [[paginator-gutter-bleed-asymmetry-4394]].
Three related instant-dictionary bugs fixed together (dev branch, 2026-06-18). Core: the **instant quick action** (Annotator effect `[selection,bookKey]` → `handleQuickAction`) fired per `selectionchange`, and **iOS emits MULTIPLE `selectionchange` for one long-press** (user log showed 3; Android emits 1). Each fire → `handleDictionary` (system path) → `invokeSystemDictionary` → native `show_lookup_popover` which drills to the top-most presented VC and stacks another `UIReferenceLibraryViewController` → 2-3 sheets. Android never hit it because it **defers the action to `touchend`** (coalesces); iOS fired immediately.
**Fix 1 (double/triple sheet)** — `src/app/reader/utils/deferredAction.ts`: added a `fired` latch so `runOrDeferAction`/`flushDeferredAction` run the action **at most once per gesture**; `beginGesture(state)` (clears pending + re-arms) called at gesture start — Android **native**`touchstart` (replaced `cancelDeferredAction`) and a NEW **non-Android DOM `pointerdown`** listener in `Annotator.tsx` (gated `!isAndroidApp`; Android keeps its native path).
**Fix 2 (tap-to-deselect re-opened dict ~1/3)** — after dismissing the sheet iOS leaves the word selected; the reselection is safe (latch still set, no WebView pointerdown from the modal swipe), but tapping outside to deselect IS a pointerdown → `beginGesture` re-armed the latch, then a racy `selectionchange` re-reported the lingering word before collapse → re-fired. Fix = `isLongPressHold(pointerDownTime, now, 300ms)` gate in `handleQuickAction` (gated `!isAndroidApp`): only fire from a long-press hold (iOS selection appears ~500ms after pointerdown; tap-stray fires ~tens of ms). Touch pointerdown time recorded in the non-Android listener; **mouse records 0 → bypasses the gate** (desktop selects on pointerup).
**Fix 3 (Word Lens ignored system dict)** — Annotator effect `wantWordLensDict` branch hardcoded `setShowDictionaryPopup(true)`; changed to call `handleDictionary()` (which checks `isSystemDictionaryEnabled` → `invokeSystemDictionary`, else in-app popup), same as the toolbar/instant paths. See [[wordlens-feature]].
Verified on Xiaomi 13 Pro via CDP+adb (`src/__tests__/android/helpers/*`): real system-dict path on Android = `ACTION_PROCESS_TEXT`→Eudic; instant dict fired once/long-press + re-armed gesture 2; gloss tap → `handleDictionary system=true`+`invokeSystemDictionary os=android`. iOS confirmed by user. Gotchas: `longPressWord` waits for a persistent selection → times out in instant-action mode (the action dismisses the selection); count fires via a `console.info` hook read over CDP `evaluate` instead. `openFixtureBook`'s >200-char gate fails when the fixture's saved progress lands on the sparse feedbooks end-page → connect to the already-open reader instead.
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"
#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]].
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.
readest.koplugin cover handling (in `apps/readest.koplugin/library/`):
- **Local book covers** come from coverbrowser.koplugin's `BookInfoManager` (a hard dependency). `coverprovider.get_local_cover(file_path)` returns `BIM:getBookInfo(file_path, true).cover_bb` (a downscaled thumbnail bb) and kicks off background extraction on a miss.
- **Native-resolution cover** for a file (no open doc needed): `require("apps/filemanager/filemanagerbookinfo"):getCoverImage(nil, file_path)` — opens+closes the doc itself, honors KOReader custom covers, returns a blitbuffer. Same `(nil, file)` call form `calibre.koplugin` uses. Write it to PNG with `bb:writeToFile(path, "png")` (pcall-wrapped internally) then `bb:free()`.
- **Cloud covers** are downloaded to `DataStorage:getSettingsDir()/readest_covers/<hash>.png` (`cloud_covers.covers_dir()`); cloud storage key is `<user_id>/Readest/Books/<hash>/cover.png` (`build_cover_key`), which matches readest's `getCoverFilename` (`<hash>/cover.png`).
**Issue #4374 (fixed):**`syncbooks.uploadBook` only shipped a `cover.png` if one was *already cached* under `readest_covers/<hash>.png` from a prior cloud download — so books that originated locally in KOReader uploaded with no cover and showed blank in readest. Fix = `syncbooks.extractLocalCover(file_path, dst_png)` (extracts via `getCoverImage` → writeToFile), called from `uploadBook` when `has_cover` is false. Only file-upload path is `librarywidget.lua` "Upload to Cloud" → `uploadBook` (FileManager `addToReadest` only stages a local row).
Tests: network/live parts of `syncbooks.lua` aren't unit-tested (manual matrix in `docs/library-design.md`); `extractLocalCover` IS tested by injecting a fake `apps/filemanager/filemanagerbookinfo` via `package.loaded` in `spec/library/syncbooks_spec.lua`. A real KOReader checkout lives at `/Users/chrox/dev/koreader` for verifying KOReader APIs. See [[koplugin-i18n]].
readest.koplugin deletions of highlights/bookmarks never reached the server (#4119 push direction; the pull direction was already handled by `removeDeletedAnnotations`).
**Root cause:**`SyncAnnotations:push` builds its payload from `getAnnotations`, which walks `ui.annotation.annotations` (the *live* list). A deleted note is already removed from that list, so it can never appear in a push — the server never gets a `deleted_at`, and the next pull resurrects it.
**Fix (all in `apps/readest.koplugin/`):**
-`readest_syncannotations.lua`: extracted `buildNoteDescriptor(item, book_hash, meta_hash)` (shared by `getAnnotations` and the new deletion path). Added `recordDeletion(doc_settings, item)` — stamps `deletedAt = os.time()*1000` on the descriptor and persists it under `doc_settings.readest_sync.deleted_notes` (per-book sidecar, survives restart/offline). `push` folds `deleted_notes` into the payload (re-stamping current book/meta hash) and clears them in the success callback only (failed push retries).
-`main.lua``onAnnotationsModified(items)`: detects a removal via `items.index_modified < 0` (additions use positive index_modified; note-text edits / type-changes carry no index_modified — see KOReader `ReaderBookmark:removeItemByIndex`). Records the tombstone when `access_token` is set (independent of `auto_sync`, so a later manual/close push still carries it).
**Key facts:** note id is deterministic — server-created notes keep their stored `item.id` (pulled in), koplugin-created ones derive `generateNoteId(hash, type, pos0, pos1)`; both match the server row, so the tombstone targets it. Server `sync.ts` POST picks the tombstone via `clientDeletedAt > serverDeletedAt` (deletedAt wins even when updatedAt is stale). Missing `text`/`note` in the payload is fine (`sanitizeString(undefined)` returns undefined; plain highlights already push `note=nil`). Tests in `spec/syncannotations_spec.lua` (recordDeletion + push-folds-tombstones, UI-glued push driven by stub client/doc_settings). Related: [[custom-fonts-reincarnation-4410]] (CRDT remove-wins).
**Trigger model (fully automatic, no menu/gesture):**
- **Pull** on book OPEN: `onReaderReady` → `pullBookStats(false)` (via `nextTick`).
- **Push** on book CLOSE: `onCloseDocument` → `pushBookStats(false)` (wrapped in `goOnlineToRun`).
- Both gated on `self.settings.auto_sync and self.settings.access_token`, `interactive=false` (silent on failure).
- NOT per-book: `collectSince` queries the whole `statistics.sqlite3` (`page_stat_data JOIN book`, no book filter). Open/close is just the trigger; it syncs the entire stats delta. Incremental via `stats_push_cursor` (max `start_time`) / `stats_pull_cursor` (max `updated_at_ms`); cursor advances only on full success.
**3 stacked bugs (each hid the next):**
1.`push`/`pull` called `settings:readSetting/saveSetting` on `self.settings`, which is the PLAIN `readest_sync` data table (from `G_reader_settings:readSetting("readest_sync", default)`), NOT a `LuaSettings` object → `attempt to call method 'readSetting' (a nil value)` on every open. Fix: field access + persist via `G_reader_settings:saveSetting("readest_sync", settings)` (mirrors `readest_syncauth.lua`). Field-access pattern is used everywhere else (`self.settings.access_token`, etc.).
2.`pushChanges` requires `books/notes/configs` (`required_params`); stats sent only `statBooks/statPages` → Spore `books is required`. Fix: send empty `books={},notes={},configs={}` alongside. Server (`src/pages/api/sync.ts`) defaults each to `[]` and processes `statBooks`/`statPages` independently (gated only on their own `.length`).
3.`statBooks/statPages` were in the spec's `payload` but not `optional_params` → Spore `statBooks is not expected`. **Spore's expected-param set = `required_params ∪ optional_params`; `payload` only controls body serialization, NOT acceptance** (`common/Spore/Request.lua``validate()`). Fix: add `optional_params:["statBooks","statPages"]` to `readest-sync-api.json`. Must be optional not required (library/config/annotation pushes legitimately omit them).
**Spore client:**`readest_syncclient.lua``_dispatch` runs the RPC in a coroutine with Turbo `AsyncHTTP` (network is non-blocking, yields to event loop); `Format.JSON` encodes the body synchronously first. `SYNC_TIMEOUTS={5,10}` (block/total).
**Large-backlog blocking risk (UNFIXED, potential follow-up):** push sends the WHOLE backlog in one request (no client chunking; only pull is paginated). For ~10k `page_stat_data` rows: `collectSince` builds a 10k-entry Lua table + ~1MB JSON encode SYNCHRONOUSLY on the main thread → ~1-2s UI stall on weak e-ink CPUs at book close (network part is async, doesn't freeze). Worse: 10s timeout + no chunking + all-or-nothing cursor → if it can't finish in time it fails silently, cursor stays 0, and every later close re-collects/re-encodes/re-uploads the same backlog (server upserts are idempotent but wasteful). Fix idea: chunk push (~500-1000/req, matching server `BATCH=500`), advance cursor per successful chunk.
**Testing gap that hid it:**`spec/syncstats_spec.lua` originally tested only `collectSince`/`applyRemote`, never `push`/`pull`. Added tests: cursor advance on success (mock client enforces `required_params`), pull cursor from newest `updated_at_ms`, and a spec-level check parsing `readest-sync-api.json` to assert every stats-push key is an expected param (reproduces bug 3). Busted runs with `cwd=KOPLUGIN_DIR` so `io.open("readest-sync-api.json")` works; `require("json")`→dkjson via spec_helper shim. Debug logging kept (`ReadestStats` prefix). Related: [[koplugin-note-deletion-sync]], [[kosync-cfi-spine-resolution]].
#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]].
#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).
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]].
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.
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]].
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.
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"
# 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]].
End-to-end validating the in-app nightly updater (#4577) on the physical Xiaomi 13 (`fuxi`, model 2211133C, HyperOS V816 / Android 16, arm64). Verified the full chain: stable `0.11.4` on nightly channel → fetch `nightly/latest.json` → detect newer nightly → dialog+changelog → download APK → minisign-verify → Android PackageInstaller → app relaunches as `0.11.4-2026061506` with user data intact.
**Get CDP on a release build:**`pnpm dev-android` = `tauri android build -t aarch64 -- --features devtools` then `adb install -r`. The `devtools` cargo feature enables WebView remote debugging; the **CI nightly has no such flag → no CDP socket**. The local build is release-signed via `src-tauri/gen/android/keystore.properties` (alias `upload`, keystore at `/Users/chrox/dev/Android/keys/upload-readest-keystore.jks`), cert SHA-256 `652d1167…` — SAME as the CI release/nightly cert, so it installs over (and is replaced by) the real nightly. versionCode for `0.11.4[-stamp]` is always `11004` (Tauri ignores the prerelease), and sideload allows equal versionCode. After the updater installs the real nightly, CDP is gone again (no devtools).
**Comparator (`utils/version.ts` `isUpdateNewer`):** on equal X.Y.Z, a nightly outranks the matching stable (`c.isNightly && !cur.isNightly → true`). So a stable build on the nightly channel IS offered the latest nightly — no need to stamp an older version. Two nightlies compare by the 10-digit stamp.
**CDP-over-adb gotcha:**`adb forward tcp:9333 localabstract:webview_devtools_remote_<PID>` (use the LIVE pid — stale sockets for dead pids linger in `/proc/net/unix`). The WebView's `/json` HTTP server breaks BOTH curl and Node `http` (framing → "empty reply"/"socket hang up"); fetch the page list over a RAW TCP socket instead, and build the ws URL yourself as `ws://127.0.0.1:9333/devtools/page/<id>` (the returned `webSocketDebuggerUrl` reflects the request Host header and drops the port). Then `Runtime.evaluate` works. Helper pattern lived in `/tmp/nightly-test/cdp.cjs`. Settings store is NOT on `window`; drive the real UI via evaluated `.click()` (Settings Menu → "Nightly Builds (Unstable)" toggle → "About Readest" → "Check Update").
**MIUI/HyperOS install gates (the hard part; needs adb taps + uiautomator, NOT WebView):** (1) "Readest 正尝试安装应用" → tap 继续. (2) "Couldn't find ICP registration info" (China-region nag) → tap **Install** (left grey), NOT the blue Exit. (3) Enhanced-protection installer shows NO direct install button — the visible "安装" is the AD app's (`installBtn`, `com.aliyun.tongyi` etc. — don't tap it); the real path is top-right **More (⋮) → "单次安装授权"** (one-time auth), then the bottom **OK**. "Security authorization → Authorize unverified apps" uses Face-unlock as the verification method (per-install biometric; single-install-auth covered it here). Use `uiautomator dump` to get exact button bounds — native dialogs are introspectable (unlike the WebView). Pin every adb cmd with `ANDROID_SERIAL` when an emulator is also attached; zsh doesn't word-split `$ADB` vars (use the env var or inline the full path).
description: "Scrolled-mode top inset mask occluded the bg texture; clip-path full-cell trick aligns the mask's texture tiles with the viewer's; CDP-inject + MAE seam verify"
#4479 (MEK catalog `bookserver.mek.oszk.hu`, a PHP backend `teljes.php`): OPDS feeds load on Chrome but on Firefox clicking anything shows loading then silently navigates back. Root cause: the server emits a valid Atom feed followed by **trailing junk after `</feed>`** (a stray PHP warning / extra tag / text). Chrome's `DOMParser` ignores it; **Firefox's strict parser replaces the WHOLE document with a `<parsererror>`** ("junk after document element" / "text data outside of root node", Mozilla namespace `http://www.mozilla.org/newlayout/xml/parsererror.xml`). The code then sees a non-`feed` root → treats response as HTML → finds no OPDS link → `router.back()`.
**jsdom mirrors Firefox exactly** (same strict behavior + same parsererror namespace), so this reproduces in vitest — no need for a real browser. Detect via `doc.documentElement.localName === 'parsererror' || doc.getElementsByTagName('parsererror').length > 0`. Leading whitespace before the root is VALID XML (not the issue); trailing non-whitespace is the killer.
**Fix:**`parseOPDSXML(text)` helper in `src/app/opds/utils/opdsUtils.ts` — parse, and on parser error re-parse the slice from the first element start tag (`/<([A-Za-z_][\w.:-]*)/`) to its last matching `</root>` close tag (drops leading prolog + trailing junk). Returns the original error doc if recovery fails (no regression — falls through to existing HTML branch). Wired into all 3 OPDS XML parse sites: `page.tsx` (reader nav), `validateOPDSURL` (opdsUtils, adding catalog), `feedChecker.ts` (subscriptions/auto-download). feedChecker also had the #4181 detection bug — switched `text.startsWith('<')` → `looksLikeXMLContent(text)` (the MEK feed has ~13 leading newlines + no `<?xml?>` decl), else the parse fix is unreachable there.
This is the same MEK server family as [[empty-start-cfi-sync]]-style "tolerate broken servers" work; related #4181 = leading-whitespace detection (`looksLikeXMLContent`).
NOT fixed (separate, out of scope unless asked): #4479 also reports Android **download** failures (acquisition links point to `mek.oszk.hu/.../*.epub`; works in plain browser, red "download failed" toast in app) — distinct from the XML parse issue.
#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`).
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.
**Root cause:**`isSearchLink` (`src/app/opds/utils/opdsUtils.ts`) only matched `MIME.OPENSEARCH`/`MIME.ATOM`, so `hasSearch` (page.tsx) was false → `<input disabled={!hasSearch}>`. `handleSearch` also only handled those two types.
**Fix:**
- Add `MIME.OPDS2 = 'application/opds+json'` + `templated?: boolean` on `OPDSBaseLink`; `isSearchLink` now also accepts `type === OPDS2 && !!templated`.
- New `expandOPDSSearchTemplate(templateHref, queryTerm)` in opdsUtils expands the RFC 6570 template, placing the term in the primary text var (`query`/`searchTerms`/`q`, else first var). Reuses `foliate-js/uri-template.js` (`replace`, `getVariables`) — do NOT reinvent RFC 6570.
- page.tsx `handleSearch` adds an `OPDS2` branch.
**Gotcha (key):**`resolveURL` mangles `{?query}` template braces (`/opds/search%7B?query}`) — it treats `?` as the query start. ALWAYS `expandOPDSSearchTemplate` FIRST, THEN `resolveURL`. For OPENSEARCH/ATOM the order is reversed (resolve then `.replace('{searchTerms}', ...)`), which is why the new branch can't share the top-level `searchURL`.
**Foliate has `getSearch(link)`** (async, OPDS 2.0 JSON → OPDSSearch via uri-template) but readest's page.tsx never wired it; the JSON path just `JSON.parse`s the feed, preserving raw `templated`/`type`. OPDS 2.0 is JSON-only (XML `getFeed` links never carry `templated`).
description: "Overlayer highlight rects — split range by TEXT NODES, not a block-tag selector; li/blockquote text was silently dropped from SVG when highlight also touched a <p>"
Bug class (3rd occurrence): `Overlayer` in `packages/foliate-js/overlayer.js` splits a range into sub-ranges before `getClientRects()` so fully-contained block elements don't contribute border boxes (over-highlighting blank space — fork commit f087826 #10). The split was `querySelectorAll('p, h1, h2, h3, h4')`; 920676b added headings after the same hole; June 2026 the hole reappeared for `<li>`: a highlight spanning paragraphs + a bullet list drew NO rects over the list (li text fell into no sub-range), while a highlight entirely inside the list worked via the `splitRanges.length === 0 → [range]` fallback.
**Fix:**`#splitRange` walks TEXT NODES (TreeWalker, `FILTER_REJECT` on non-intersecting elements prunes subtrees) plus replaced elements (`img, svg`), clipping first/last text nodes to the range boundaries. Text-node line rects never include block border boxes, cover every block type, and can't double-paint (`li > p` nesting). Shared `#getRects(range)` used by both `add()` and `redraw()`.
**Why:** any hard-coded block-tag selector is whack-a-mole (li, blockquote, dd, td, div-paragraphs…) and adding container tags double-paints nested matched tags.
**How to apply:** when highlight/overlay rects miss some content or over-paint blank space, check `#splitRange` in overlayer.js first. Test at `src/__tests__/document/overlayer-highlight-blocks.test.ts` — jsdom has no `Range.getClientRects`; stub the prototype to record `range.toString()` per call and assert covered text. Dev-web picked up the symlinked foliate-js edit on page reload (no server restart needed for next dev, unlike [[paginator-gutter-bleed-asymmetry-4394]]'s note).
description: "Paginated page background overflowed its column into the outer --_outer-min gutter (asymmetric on mixed spreads); fix = clamp computeBackgroundSegments to the content area, keep the grid"
**Accidental-exit bug:** overlay `handleBackdropClick` closed on tapping the empty area around the centered paragraph (the "too high/low" complaint); `handleContentClick` center-zone tap did nothing; double-tap closes. CRITICAL gotcha: `ParagraphBar` only reshows on `mousemove` — there is NO touch gesture to bring it back once auto-hidden. So you can't just delete the stray-tap exits or touch users get stranded with no exit. Fix = new `paragraph-show-controls` event (bookKey-scoped): backdrop tap + center-zone tap dispatch it instead of exiting; bar listens → `showBar()`. Exit now only via bar ✕, Esc/Backspace, or deliberate double-tap-on-paragraph (kept per user choice).
**Off-center bar bug (same PR):** bar was `absolute bottom-6 left-1/2 -translate-x-1/2` → centers on its positioned ancestor = the gridcell `#gridcell-<key>` (`relative` in `BooksGrid.tsx`). Reader layout is `flex`: `<SideBar/><BooksGrid/>`; a PINNED sidebar uses `position:relative` (`SideBar.tsx`, in-flow width) and shifts the gridcell right. The paragraph centers on the viewport because the overlay is `fixed inset-0` (its blur even covers the pinned sidebar, so the screenshot shows no sidebar). Mismatch → bar drifts right. Fix = `absolute`→`fixed` so the bar anchors to the viewport like the overlay. Safe: no ancestor transforms (gridcell/books-grid/reader-content are plain). Multi-book 2-up paragraph mode already overlaps (overlay is per-book full-viewport), so `fixed` introduces no new regression.
Tests: `src/__tests__/paragraph-mode.test.tsx` (overlay: backdrop+center tap → show-controls not exit; double-tap still exits) and `src/__tests__/paragraph-bar.test.tsx` (root has `fixed` not `absolute`; show-controls reappears bar after auto-hide via fake timers; ignores other bookKey). Related: [[progressbar-focus-ring-4397]], [[paginator-gutter-bleed-asymmetry-4394]].
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]].
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`.
**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).
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.
**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, asserts `renderer.scrollTop` stays 0 (synthetic wheel doesn't chain natively, so any movement is the JS handler = must be 0). Fails `120` against the bug, passes `0` fixed.
Fix lives in the `packages/foliate-js` submodule (separate repo/commit). Relates to [[fixed-layout-paginated-scroll-reset-4683]], [[webtoon-mode-3647]].
CI-built DMG rendered scanned PDFs as blank pages (could still turn pages); text PDFs and EPUBs fine. Local build of the same commit worked. Regression between 0.11.1 (fine) and 0.11.2 (broken).
**Root cause:**`pdfjs-dist` was bumped 5.4.530 → 5.7.284 in #4143 (commit e8df651d5, between the 0.11.1 and 0.11.2 tags). pdfjs 5.7.x moved image decoders — notably **JBIG2** (the codec used by virtually every black-and-white *scanned* PDF) — from pure JS into WebAssembly modules the worker fetches at runtime from `wasmUrl` (`/vendor/pdfjs/`, set in `packages/foliate-js/pdf.js`: `wasmUrl: pdfjsPath('')`). The `copy-pdfjs-wasm` npm script only copied an allow-list `{openjpeg.wasm,qcms_bg.wasm}` and silently dropped `jbig2.wasm`. **`cpx` does not error when a glob matches nothing**, so the missing decoder was invisible: worker loads, pages turn, JBIG2 decode fails → blank.
**Why local masked it:**`pnpm build` / `tauri build` do NOT run `setup-vendors`. Local builds reuse whatever stale `public/vendor/pdfjs/` is already on disk (gitignored — `/public/vendor`). The dev's local copy was the old 5.4.530 worker (pure-JS JBIG2) → worked. CI runs `setup-vendors` fresh (release.yml:192) → ships the new 5.7.284 worker that needs jbig2.wasm → broke.
**Fix:** changed `copy-pdfjs-wasm` to copy the whole `wasm/*` dir instead of an allow-list (mirrors the `{cmaps,standard_fonts}/*` fonts pattern). Robust against future codecs moving to wasm; also ships the `*_nowasm_fallback.js` files for graceful degradation. Regression test: `src/__tests__/document/pdfjs-wasm-assets.test.ts` asserts every `.wasm` the bundled pdf.js references is covered by `copy-pdfjs-wasm`.
**Gotchas for future:**
- Vendor assets in `public/vendor/` are gitignored and only refreshed by `setup-vendors`. Local stale vendor can mask CI breakage — `git status` won't show it.
-`cpx` allow-lists are fragile: any upstream-added required file is dropped silently. Prefer copying whole dirs.
Some files were not shown because too many files have changed in this diff
Show More
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.