Compare commits

...

215 Commits

Author SHA1 Message Date
Huang Xin 5358d85c0b release: version 0.11.16 (#4847) 2026-06-28 20:50:30 +02:00
Huang Xin ea99106677 fix(sync): silence third-party cloud-sync error toasts (#4845)
* fix(sync): never toast third-party cloud-sync errors; log to console only

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Fixes #4830

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Full suite 6416 green; lint + format clean.

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

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

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

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

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

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

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

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

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

Full suite 6416 green; lint + format clean.

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

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

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

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

Full suite 6418 green; lint + format clean.

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Fixes #4780

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

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

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

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

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

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

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

* refactor(sync): extract wire envelope module

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

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

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

* refactor(sync): add FileSyncProvider and LocalStore interfaces

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

* feat(sync): FileSyncEngine orchestration over a provider

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

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

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

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

* feat(sync): WebDAVProvider implementing FileSyncProvider

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

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

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

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

* refactor(reader): drive WebDAV sync through FileSyncEngine

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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


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

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

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

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

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

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

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

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

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

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

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

---------

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

Updated i18n across all 33 locales.

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

Closes #4751

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

* chore(agent): update agent memories

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

---------

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

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

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

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

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

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

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

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

---------

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

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

Fixes #4436

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Migration 016 adds cover_hash / cover_updated_at to books.

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

---------

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

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

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

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

Return the path verbatim when the prefix is empty.

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

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

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

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

---------

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

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

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

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

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

Closes #4721

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

Closes #1616.

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

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

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

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

Fixes #4703

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Also includes local agent memory notes that were staged alongside.

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

---------

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

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

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

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

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

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

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

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

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

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

Also stage the project-memory note for this regression.

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 01:41:16 +02:00
Huang Xin d8953353cf release: version 0.11.10 (#4667) 2026-06-19 16:04:31 +02:00
Huang Xin 7810981417 fix(koplugin): repair reading-stats sync push/pull (#4666)
* 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>
2026-06-19 15:37:32 +02:00
Huang Xin dd53e52453 chore: only show the current position item in TOC and update agent memories (#4665) 2026-06-19 13:17:09 +02:00
Huang Xin b7585ac46d chore(send-to-readest): add Chrome Web Store screenshot generator (#4664)
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>
2026-06-19 12:58:24 +02:00
Huang Xin 6caa376f82 feat(reader): Webtoon Mode seamless continuous scroll for image books (#3647) (#4662)
* feat(reader): make fixed-layout scroll gap configurable (foliate-js bump) (#3647)

* feat(reader): add webtoonMode view setting + scroll-gap helper (#3647)

* feat(reader): Webtoon Mode toggle in the fixed-layout view menu (#3647)

* feat(reader): apply Webtoon Mode gap on fixed-layout book open (#3647)

* fix(reader): clear Webtoon Mode + reset gap when Shift+J leaves scrolled (#3647)

* chore(i18n): translate Webtoon Mode string across locales (#3647)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore(deps): bump foliate-js to merged readest/foliate-js#30 (#3647)

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 12:37:37 +02:00
Huang Xin 590c44f977 fix(send-to-readest): rephrase popup copy and remove em dashes from i18n strings (#4663)
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>
2026-06-19 12:28:25 +02:00
Huang Xin 4cb608be20 chore(send-to-readest): v0.2.1, zip packaging script, store submission doc (#4661)
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>
2026-06-19 10:30:30 +02:00
Huang Xin 1faa931a0e fix(txt): stop detecting measure-word prose as chapters in TXT import (#4658) (#4660)
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>
2026-06-19 10:09:04 +02:00
Huang Xin 86f5502724 fix: bot-review robustness fixes (TTS sync, updater, nightly, a11y) (#4659)
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>
2026-06-19 10:04:34 +02:00
Huang Xin e327d0c992 feat(tts): reuse the speaking session across paragraph & RSVP modes (#4657)
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>
2026-06-19 07:47:29 +02:00
Huang Xin 72233e1c6a feat: sync reading status across devices and with KOReader (#4634) (#4656)
* 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>
2026-06-19 05:00:40 +02:00
Huang Xin e00a1e4f06 fix(settings): tidy Word Lens data pack and level rows on mobile (#4655)
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>
2026-06-19 02:30:24 +02:00
Huang Xin 451f0ccd90 fix(library): count only uploaded, non-deleted books in synced toast (#4654)
* 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>
2026-06-18 19:53:01 +02:00
Huang Xin be5862f08c fix(library): group secondary series sort by series name then index (#4652) (#4653)
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>
2026-06-18 19:09:37 +02:00
Huang Xin 446c2c72de fix(security): unblock app-dir downloads broken by transfer_file fs-scope guard (#4651)
#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>
2026-06-18 18:48:10 +02:00
Huang Xin 6c7c86f346 feat(applock): biometric unlock (fingerprint / Face ID) at startup on mobile (#4650)
* feat(applock): wire up biometric plugin + biometricUnlockEnabled setting

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(applock): add guarded biometric service wrapper

* feat(applock): auto-prompt biometrics on the lock screen with PIN fallback

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(applock): guard concurrent biometric prompts + tighten lock-screen tests

- Add biometricInFlightRef to prevent concurrent authenticateWithBiometrics calls
- Assert PIN input still rendered after biometric failure (test 2)
- Replace flaky waitFor negative assertion with a 50ms flush in test 3

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(applock): mobile biometric toggle + default-on at PIN setup

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore(i18n): add biometric app-lock strings

* fix(applock): seed biometricUnlockEnabled via app-lock store init; close test gaps

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* build(applock): pin tauri-plugin-biometric in Cargo.lock

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 18:39:03 +02:00
Huang Xin af587b1a41 fix(metadata): parse FB2 series from title-info sequence (#4646) (#4649)
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>
2026-06-18 16:12:59 +02:00
Huang Xin be17654fc0 fix(rsvp): render RTL words whole so Arabic shapes correctly (#4630) (#4648)
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>
2026-06-18 16:03:43 +02:00
Huang Xin ff96c6d3f7 feat(annotations): unify highlights and annotations into one record (#3870, #4511) (#4647)
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>
2026-06-18 15:37:46 +02:00
Huang Xin 38a6d3d9ba fix(dict): stop iOS instant system dictionary popping multiple times (#4644)
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>
2026-06-18 14:36:31 +02:00
Huang Xin 6626db967c fix(reader): keep last paragraph's line spacing by making the section skip link a <span> (#4642)
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>
2026-06-18 10:39:44 +02:00
Huang Xin bcd9ed724b fix(reader): paginate inline-block-wrapped chapters instead of clipping them (#4641)
* 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>
2026-06-18 10:15:22 +02:00
Huang Xin 403be32d5a fix(epub): import books whose OPF has an unescaped ampersand (#4640)
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>
2026-06-18 08:22:44 +02:00
Huang Xin 4025c4d7b5 fix(security): scope Tauri download_file/upload_file to fs_scope (#4639)
`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>
2026-06-18 07:48:06 +02:00
Huang Xin 495783d045 fix(security): harden OPDS proxy SSRF, storage key validation, Stripe check (#4638)
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>
2026-06-18 07:45:29 +02:00
dependabot[bot] 2f810a70b3 chore(deps): bump the github-actions group with 3 updates (#4637)
Bumps the github-actions group with 3 updates: [pnpm/action-setup](https://github.com/pnpm/action-setup), [actions/setup-java](https://github.com/actions/setup-java) and [actions/cache](https://github.com/actions/cache).


Updates `pnpm/action-setup` from 6.0.8 to 6.0.9
- [Release notes](https://github.com/pnpm/action-setup/releases)
- [Commits](https://github.com/pnpm/action-setup/compare/0e279bb959325dab635dd2c09392533439d90093...0ebf47130e4866e96fce0953f49152a61190b271)

Updates `actions/setup-java` from 5.2.0 to 5.3.0
- [Release notes](https://github.com/actions/setup-java/releases)
- [Commits](https://github.com/actions/setup-java/compare/be666c2fcd27ec809703dec50e508c2fdc7f6654...ad2b38190b15e4d6bdf0c97fb4fca8412226d287)

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

---
updated-dependencies:
- dependency-name: pnpm/action-setup
  dependency-version: 6.0.9
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions
- dependency-name: actions/setup-java
  dependency-version: 5.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions
- dependency-name: actions/cache
  dependency-version: 5.0.5
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-18 06:40:13 +02:00
Huang Xin 1ea607829c fix(share): load cover under COEP, keep share links out of the clipper, fix in-app import (#4636)
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>
2026-06-18 06:18:28 +02:00
Huang Xin 8bcb9f9b2a feat(wordlens): trim hints to first sense + suppress known derivations (#4635)
* 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>
2026-06-17 19:46:54 +02:00
Huang Xin c2ac207945 refactor(wordlens): rename "Word Wise" to "Word Lens" (#4633)
"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>
2026-06-17 19:09:29 +02:00
Huang Xin 9d0c5dc524 fix(koplugin): sync note deletions to Readest via tombstones (#4632)
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>
2026-06-17 18:05:04 +02:00
Huang Xin 5e5564ef3f fix(search): show context for matches in italicized text (#4594) (#4631)
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>
2026-06-17 18:02:02 +02:00
Huang Xin b31699d052 fix(library): restore back button from series/author folders (#4437) (#4629)
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>
2026-06-17 07:50:14 +02:00
Huang Xin 5861aead4b fix(android): move plugin @Command I/O off the main thread; fix WebView leak & #3297 blank screen (#4628)
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>
2026-06-17 07:13:45 +02:00
Huang Xin fa120081a1 fix(library): never let a routine save shrink library.json (cold-start "Open with" wipe) (#4627)
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>
2026-06-17 07:04:00 +02:00
Huang Xin d5c02e6253 feat(library): add Purge Data and fold detail actions into a More menu (#4615) (#4626)
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>
2026-06-17 06:28:21 +02:00
Huang Xin 5e217544f2 chore(config): add disableIncrementalCache to skip populating remote R2 incremental cache (#4623) 2026-06-17 03:51:17 +02:00
Huang Xin 6514d4aa58 build(web): standalone Docker image + drop Turbopack build cache (#4619)
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>
2026-06-16 21:21:24 +02:00
Huang Xin f6fbbf59f2 chore(deps): bump transitive deps for security advisories (batch) (#4620)
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>
2026-06-16 21:19:36 +02:00
Huang Xin d6e59cedd7 chore(deps): bump esbuild to 0.28.1 and vitest to 4.1.x for security advisories (#4618)
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>
2026-06-16 21:01:17 +02:00
Huang Xin d202d7a61e feat(library): add Clear Pending action to transfer queue (#4617)
* 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>
2026-06-16 20:02:11 +02:00
Huang Xin 480ab5b71e feat(hardcover): automatically sync progress and notes (#4614)
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>
2026-06-16 19:25:01 +02:00
Huang Xin 757ed8066b feat(library): show series and number in list view (#4593) (#4612)
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>
2026-06-16 17:07:27 +02:00
Huang Xin a30a310a17 fix(opds): handle entries with no downloadable format (#4599) (#4611)
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>
2026-06-16 16:45:43 +02:00
Huang Xin f950685f22 chore(agent): update agent memories (#4610) 2026-06-16 15:52:29 +02:00
Huang Xin 35b02c4efc feat(statistics): KOReader-compatible reading stats with cross-device sync (#4606)
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>
2026-06-16 10:42:39 +02:00
Huang Xin 359e406e51 docs(readme): make license badge static and modernize release badge (#4603)
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>
2026-06-16 07:20:03 +02:00
Huang Xin f3c92f80d9 docs(readme): remove the Sponsors / TestMu AI section (#4604)
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>
2026-06-16 07:19:40 +02:00
Huang Xin e145eb835a feat(reader): open image gallery & table zoom on single tap (#4600)
* 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>
2026-06-16 06:58:37 +02:00
loveheaven 675ee78bc9 perf(library): in-place re-import is a no-op on the same file path (#4597)
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.
2026-06-16 06:42:59 +02:00
Huang Xin bdfb595950 docs(readme): point donations to the unified donate.readest.com hub (#4601)
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>
2026-06-16 06:21:34 +02:00
Huang Xin 79496f88d7 feat(settings): move update & telemetry controls into Settings → Behavior (#4592)
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>
2026-06-15 09:52:28 +02:00
Huang Xin 4908245042 feat(reader): Word Wise inline vocabulary hints (#4589)
* 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>
2026-06-15 07:56:00 +02:00
Huang Xin 51fede1a0d fix(rsvp): keep the audio toggle from overlapping transport on mobile (#4585)
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>
2026-06-14 21:18:07 +02:00
Huang Xin b76e3a3718 fix(nightly): publish latest.json via directory rclone copy (#4588)
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>
2026-06-14 20:50:41 +02:00
Huang Xin aab721b219 feat(dictionary): lemmatize inflected words before lookup (#4574) (#4582)
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>
2026-06-14 18:41:39 +02:00
Huang Xin 131f83e15b fix(ci): correct nightly Linux AppImage collect path (#4581)
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>
2026-06-14 18:10:54 +02:00
Huang Xin 0f0b4279a7 perf(reader): memoize global-annotation fan-out per section (#4575) (#4579)
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>
2026-06-14 17:27:52 +02:00
Huang Xin 57501cc520 feat(updater): nightly update channel (Android/Windows/macOS/Linux) (#4577) 2026-06-14 16:33:53 +08:00
Huang Xin bfb85c2f68 feat(reader): sync paragraph mode & speed reader with TTS read-along (#3235) (#4576)
* 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>
2026-06-14 09:46:18 +02:00
Huang Xin cc618b8739 test(tts): add browser e2e for auto-advance across a chapter boundary (#4573)
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>
2026-06-13 20:46:26 +02:00
Huang Xin 5a8f0873fa fix(library): refresh book cover after editing metadata (#4572)
* 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>
2026-06-13 19:56:25 +02:00
Huang Xin 4b0bbc77b0 fix(reader): open TXT files shared via "Open with" (#4571)
* 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>
2026-06-13 18:07:28 +02:00
Huang Xin 67c22c770b feat(reader): Share intent + customizable annotation toolbar (#4014) (#4570)
* 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>
2026-06-13 14:11:59 +02:00
Huang Xin b6937f43f1 chore(agent): stage memories (#4569) 2026-06-13 08:57:21 +02:00
Huang Xin a56cc6c61a feat(tts): word-by-word highlighting for Edge TTS, closes #4017 (#4566)
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>
2026-06-13 08:29:34 +02:00
Huang Xin 763b579c8f fix(android): launch installed dictionary for system lookup, closes #4559 (#4568)
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>
2026-06-13 04:36:31 +02:00
Huang Xin c72afe269a fix(tts): keep voice list stable across region variants of a language, closes #4033 (#4565)
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>
2026-06-13 04:32:27 +02:00
Huang Xin 852d0ae3e9 fix(reader): keep dark-mode page body transparent so the bg texture shows, closes #4446 (#4564)
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>
2026-06-12 19:37:23 +02:00
loveheaven 7f57af8f90 perf(cfi): bucket booknotes per chapter and batch-collapse location matcher (#4561)
* 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>
2026-06-12 18:06:13 +02:00
loveheaven 7cba22ab31 perf(reader): coalesce relocate events and memoize BookCell to stop per-swipe storm (#4562)
* 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>
2026-06-12 17:59:34 +02:00
Huang Xin ee01fcd123 fix(reader): texture the scrolled-mode top inset mask, closes #4486 (#4563)
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>
2026-06-12 17:39:17 +02:00
loveheaven 59d4f0aa33 perf(reader): split progress into its own store to cut React commit storm (#4557)
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.
2026-06-12 17:14:49 +02:00
loveheaven 1c392de0fa perf(reader): throttle library.json writes and cache known dirs to cut IPC (#4556)
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.
2026-06-12 16:32:21 +02:00
loveheaven 1ce79d9abf perf(reader): reduce open-book TBT by batching layout-thrashing reads/writes and deferring annotation page back-fill (#4554)
* 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.
2026-06-12 16:22:35 +02:00
Huang Xin 61d804a54f fix(dict): resolve Android content-URI filenames via native basename (#4553)
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 #4489
Fixes #4472

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 12:01:29 +02:00
Huang Xin 12ac7ae6c0 fix(reader): draw annotation highlights over bullet lists (#4552)
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>
2026-06-12 10:34:34 +02:00
Huang Xin 28767fecd9 docs(readme): add a Documentation section linking to readest.com/docs (#4551)
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>
2026-06-12 09:54:37 +02:00
Huang Xin da6f45f69c ci: single, workspace-aware rust-cache for build_tauri_app (#4550)
* 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>
2026-06-12 09:38:19 +02:00
Huang Xin 5cab1fa94b feat(css): override document layout also apply to hyphenation, closes #4529 (#4546) 2026-06-12 08:54:13 +02:00
Huang Xin 4ff96800d2 ci: pin android-emulator-runner by SHA + shard the slow PR test job (#4547)
* 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>
2026-06-12 08:53:51 +02:00
Huang Xin 9dc41e7adf feat(reader): reference page numbers from EPUB page-list with manual page count fallback (#4549)
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 #672
Closes #4542

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 08:44:18 +02:00
Huang Xin ceddee3793 feat(library): search a book on Goodreads from the library and reader (#4543) (#4548)
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>
2026-06-12 08:30:17 +02:00
Huang Xin cfe2bb9116 fix(reader): Android text selection breaks on the first word of hyphenated paragraphs (#4545) 2026-06-12 12:30:56 +08:00
Huang Xin 390c711070 feat(rsvp): configurable start delay, word stepping, context dictionary lookup, and keyboard shortcut (#4541)
- #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>
2026-06-11 20:52:27 +02:00
Huang Xin d6e981e568 fix(reader): hide footnote aside border again when custom fonts are loaded (#4438) (#4540)
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>
2026-06-11 20:32:39 +02:00
Huang Xin 755bee1ee6 fix(reader): prevent accidental paragraph-mode exit and center its bar (#4474) (#4539)
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>
2026-06-11 20:30:41 +02:00
Huang Xin 64350ca632 fix(reader): keep scrolled-mode scrollbar visible after opening a book (#4470) (#4538)
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>
2026-06-11 19:55:10 +02:00
Huang Xin cf41e7d50d feat(rsvp): apply reader font face/family settings to the RSVP word (#4519) (#4537)
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>
2026-06-11 19:24:46 +02:00
Huang Xin 6dc42222e0 fix(reader): keep double-click-and-drag from turning the page (#4524) (#4536)
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>
2026-06-11 18:39:01 +02:00
justin-jiajia d165e8df2c fix(reader): turn automatically when highlighting across pages (#4487)
* 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>
2026-06-11 18:11:25 +02:00
Huang Xin 1a85f251c0 fix(sync): flush pending Readest cloud push when the reader closes (#4535)
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>
2026-06-11 17:32:14 +02:00
loveheaven 1e26c5d765 fix(nav): bound section-scan concurrency to keep zip.js writers from ERRORED-ing (#4528)
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.
2026-06-11 07:42:39 +02:00
dependabot[bot] 715967dbe7 chore(deps): bump github/codeql-action in the github-actions group (#4533)
Bumps the github-actions group with 1 update: [github/codeql-action](https://github.com/github/codeql-action).


Updates `github/codeql-action` from 4.36.1 to 4.36.2
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/87557b9c84dde89fdd9b10e88954ac2f4248e463...8aad20d150bbac5944a9f9d289da16a4b0d87c1e)

---
updated-dependencies:
- dependency-name: github/codeql-action
  dependency-version: 4.36.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-11 07:38:21 +02:00
Huang Xin 82bd90afc5 feat(reader): random-access file reads on Android via rangefile scheme (#4534)
* 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>
2026-06-11 07:37:46 +02:00
loveheaven 9180767ba4 fix(android): deliver Open-with intents reliably on cold start and re-mount (#4527)
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.
2026-06-10 19:27:56 +02:00
loveheaven 31176e5d47 fix(paginator): bump foliate-js submodule for scrollBounds guard (#4526)
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.
2026-06-10 19:23:07 +02:00
Huang Xin 2ade769956 feat(toc): show current reading page under the active item (#4513) (#4525)
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>
2026-06-10 18:40:59 +02:00
Huang Xin 607e646bc6 chore(deps): bump shell-quote to 1.8.4 and qs to 6.15.2 for security (#4523)
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>
2026-06-10 17:19:25 +02:00
loveheaven 11d796361e perf(import+open): native Rust EPUB/MOBI parser, OPF prefetch, parallel TOC enrichment (#4369)
* 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>
2026-06-10 16:58:25 +02:00
Huang Xin 7e5c74f5ef chore(memory): record OPDS HTML description and JSON search notes (#4516)
Persist pending agent memory notes for the recently merged OPDS fixes:
- OPDS HTML description rendering (#4503 / PR #4510), including the
  sanitizeHtml consolidation into @/utils/sanitize.
- OPDS 2.0 JSON catalog search (#4502 / PR #4509).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 07:17:11 +02:00
Huang Xin 31ebf4b586 feat(opds): make subject and author links clickable in the book detail view (#4515)
* 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>
2026-06-10 07:15:03 +02:00
Wanten 553c2b6398 fix(linux): update tauri submodule for resize cursor fix (#4512)
Update tauri submodule to include input_shape fix that lets edge pointer
events fall through to the GtkWindow for native resize cursor on Linux.
2026-06-10 06:57:55 +02:00
loveheaven 88d8aa285f feat(metadata): show file path for in-place imported books (#4508)
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.
2026-06-10 06:54:53 +02:00
Huang Xin d12e1ad087 fix(opds): enable search for OPDS 2.0 JSON catalogs, closes #4502 (#4509)
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>
2026-06-10 06:53:49 +02:00
Huang Xin 8425d0b91f fix(opds): render HTML in publication descriptions (#4510)
* fix(opds): render HTML in publication descriptions, closes #4503

OPDS publication descriptions showed raw HTML tags (literal `<p>`,
`&quot;`, `&#x27;`) 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 &lt;code&gt;`
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>
2026-06-10 06:52:34 +02:00
Huang Xin 75dc2e4e81 fix(updater): disable in-app updater inside Flatpak sandbox, closes #4440 (#4507)
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>
2026-06-09 17:46:09 +02:00
Wanten ad23fbba9f fix(reader): dismiss annotation popup when selection clears (#4483) 2026-06-09 17:44:10 +02:00
loveheaven c15e850252 fix(reader): shrink hidden page-nav buttons on Android so they don't eat long-press (#4501)
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.
2026-06-09 17:42:32 +02:00
loveheaven 676e14234b fix(reader): correct RTL reading position restore on book reopen (#4505)
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.
2026-06-09 17:39:46 +02:00
Huang Xin 1eaf16ffc2 fix(opds): tolerate junk after document element in feeds (#4479) (#4506)
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>
2026-06-09 17:26:47 +02:00
Huang Xin 4d1205fdf5 fix(reader): stop zoomed image pan from flickering on desktop, closes #4451 (#4465)
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>
2026-06-04 18:29:21 +02:00
Huang Xin b07c9eb631 fix(eink): make Custom Fonts panel readable in e-ink mode (#4454) (#4464)
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>
2026-06-04 18:28:30 +02:00
loveheaven 4eeed74cdd fix(webdav): always sync book covers, not just when syncBooks is on (#4445)
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.
2026-06-04 18:23:53 +02:00
Huang Xin d8fbf5fe08 fix(reader): show KOReader progress-synced as a top-right hint (#4461) (#4463)
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>
2026-06-04 18:03:19 +02:00
dependabot[bot] 93b3c4373a chore(deps): bump the github-actions group with 2 updates (#4450) 2026-06-04 14:46:48 +08:00
Huang Xin 719e9c7542 fix(eink): keep dropdown-toggle label legible under e-ink (#4435) (#4441) 2026-06-03 14:48:10 +08:00
Huang Xin 35eb7f2e14 release: version 0.11.4 (#4431) 2026-06-02 19:15:32 +02:00
991 changed files with 83231 additions and 7073 deletions
+131
View File
@@ -0,0 +1,131 @@
name: Android E2E (CDP)
# On-device end-to-end tests: boots an x86_64 Android emulator (KVM), installs
# a debug APK, and runs the CDP-driven selection lane (pnpm test:android).
# Not PR-blocking: runs nightly, on demand, or when a PR is labeled
# `e2e-android`.
on:
workflow_dispatch:
schedule:
- cron: '30 19 * * *'
pull_request:
types: [labeled, synchronize]
concurrency:
group: android-e2e-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
android-e2e:
if: github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'e2e-android')
runs-on: ubuntu-latest
timeout-minutes: 90
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: initialize git submodules
run: git submodule update --init --recursive
- name: enable KVM for the emulator
run: |
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm
- name: setup pnpm
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6
- name: setup node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
node-version: 24
cache: pnpm
- name: setup Java
uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5
with:
distribution: 'zulu'
java-version: '17'
- name: setup Android SDK
uses: android-actions/setup-android@40fd30fb8d7440372e1316f5d1809ec01dcd3699 # v4
- name: install NDK
run: sdkmanager "ndk;28.2.13676358"
- name: install dependencies
run: pnpm install --frozen-lockfile --prefer-offline
- name: copy pdfjs-dist and simplecc-dist to public directory
run: pnpm --filter @readest/readest-app setup-vendors
- name: install Rust stable
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
with:
targets: x86_64-linux-android
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
with:
workspaces: apps/readest-app/src-tauri
- name: create .env.local file for Next.js
run: |
echo "NEXT_PUBLIC_APP_PLATFORM=tauri" >> .env.local
cp .env.local apps/readest-app/.env.local
- name: build debug APK (x86_64)
env:
NDK_HOME: ${{ env.ANDROID_HOME }}/ndk/28.2.13676358
run: |
cd apps/readest-app
# Only the customized files of gen/android are tracked — regenerate
# the gradle scaffolding, then restore the tracked customizations
# (same flow as the release workflow).
rm -rf src-tauri/gen/android
pnpm tauri android init
pnpm tauri icon ../../data/icons/readest-book.png
git checkout .
# Debug build: signed with the debug keystore, no release secrets
# needed (gradle only loads keystore.properties when it exists).
pnpm tauri android build --debug --target x86_64
APK=$(find src-tauri/gen/android/app/build/outputs/apk -name '*-debug.apk' | head -n 1)
echo "APK=$PWD/$APK" >> "$GITHUB_ENV"
test -n "$APK"
- name: cache AVD snapshot
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
id: avd-cache
with:
path: |
~/.android/avd/*
~/.android/adb*
key: avd-api-34
- name: create AVD snapshot for caching
if: steps.avd-cache.outputs.cache-hit != 'true'
uses: reactivecircus/android-emulator-runner@e89f39f1abbbd05b1113a29cf4db69e7540cae5a # v2
with:
api-level: 34
arch: x86_64
target: google_apis
force-avd-creation: false
emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none
disable-animations: false
script: echo "AVD snapshot created"
- name: run Android e2e lane
uses: reactivecircus/android-emulator-runner@e89f39f1abbbd05b1113a29cf4db69e7540cae5a # v2
with:
api-level: 34
arch: x86_64
target: google_apis
force-avd-creation: false
emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none
disable-animations: true
script: |
adb install -r "$APK"
cd apps/readest-app && pnpm test:android
+3 -3
View File
@@ -61,7 +61,7 @@ jobs:
# your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
# Add any setup steps before running the `github/codeql-action/init` action.
# This includes steps like installing compilers or runtimes (`actions/setup-node`
@@ -71,7 +71,7 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}
@@ -100,6 +100,6 @@ jobs:
exit 1
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
with:
category: '/language:${{matrix.language}}'
+1 -1
View File
@@ -39,7 +39,7 @@ jobs:
platform=${{ matrix.platform }}
echo "PLATFORM_PAIR=${platform//\//-}" >> "$GITHUB_ENV"
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
submodules: recursive
+468
View File
@@ -0,0 +1,468 @@
# Nightly builds. Mirrors the build matrix and build/signing steps of
# release.yml — keep cert/NDK/toolchain bumps, secret names, and the
# truly-portable AppImage + portable-Windows steps in sync between the two.
#
# Differences from release.yml: this workflow (1) stamps a nightly version
# `<base>-<YYYYMMDDHH>` (Asia/Shanghai), (2) publishes to Cloudflare R2 only (no
# GitHub release), and (3) assembles `nightly/latest.json` race-free from
# per-leg manifest fragments so a single failing leg never clobbers the manifest.
name: Nightly Readest
on:
schedule:
- cron: '0 22 * * *' # 22:00 UTC = 06:00 GMT+8
workflow_dispatch:
permissions:
contents: read
# Serialize runs so an older run can't publish nightly/latest.json after a newer
# one (no cancel — let an in-flight build finish rather than drop artifacts).
concurrency:
group: nightly-readest
cancel-in-progress: false
jobs:
compute-version:
runs-on: ubuntu-latest
outputs:
nightly_version: ${{ steps.v.outputs.nightly_version }}
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: main
persist-credentials: false
- id: v
run: |
BASE=$(node -p "require('./apps/readest-app/package.json').version")
STAMP=$(TZ=Asia/Shanghai date +%Y%m%d%H)
echo "nightly_version=${BASE}-${STAMP}" >> "$GITHUB_OUTPUT"
build:
needs: compute-version
strategy:
fail-fast: false
matrix:
config:
- os: ubuntu-latest
release: android
rust_target: aarch64-linux-android,armv7-linux-androideabi,i686-linux-android,x86_64-linux-android
- os: ubuntu-22.04
release: linux
arch: x86_64
rust_target: x86_64-unknown-linux-gnu
- os: ubuntu-22.04-arm
release: linux
arch: aarch64
rust_target: aarch64-unknown-linux-gnu
- os: macos-latest
release: macos
arch: aarch64
rust_target: x86_64-apple-darwin,aarch64-apple-darwin
args: '--target universal-apple-darwin'
- os: windows-latest
release: windows
arch: x86_64
rust_target: x86_64-pc-windows-msvc
args: '--target x86_64-pc-windows-msvc --bundles nsis'
- os: windows-latest
release: windows
arch: aarch64
rust_target: aarch64-pc-windows-msvc
args: '--target aarch64-pc-windows-msvc --bundles nsis'
runs-on: ${{ matrix.config.os }}
timeout-minutes: 60
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: main
persist-credentials: false
- name: initialize git submodules
run: git submodule update --init --recursive
- name: setup pnpm
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6
- name: setup node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
node-version: 24
cache: pnpm
- name: setup Java (for Android build only)
if: matrix.config.release == 'android'
uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5
with:
distribution: 'zulu'
java-version: '17'
- name: setup Android SDK (for Android build only)
if: matrix.config.release == 'android'
uses: android-actions/setup-android@40fd30fb8d7440372e1316f5d1809ec01dcd3699 # v4
- name: install NDK (for Android build only)
if: matrix.config.release == 'android'
run: sdkmanager "ndk;28.2.13676358"
- name: install dependencies
run: pnpm install --frozen-lockfile --prefer-offline
- name: copy pdfjs-dist and simplecc-dist to public directory
run: pnpm --filter @readest/readest-app setup-vendors
- name: install Rust stable
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
with:
targets: ${{ matrix.config.rust_target }}
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
with:
key: nightly-${{ matrix.config.os }}-${{ matrix.config.release }}-${{ matrix.config.arch }}-cargo
- name: install dependencies (ubuntu only)
if: contains(matrix.config.os, 'ubuntu') && matrix.config.release != 'android'
run: |
sudo apt-get update
sudo apt-get install -y pkg-config libfontconfig-dev libgtk-3-dev libwebkit2gtk-4.1 libwebkit2gtk-4.1-dev libjavascriptcoregtk-4.1 libjavascriptcoregtk-4.1-dev gir1.2-javascriptcoregtk-4.1 gir1.2-webkit2-4.1 libappindicator3-dev librsvg2-dev patchelf xdg-utils
- name: create .env.local file for Next.js
run: |
echo "NEXT_PUBLIC_POSTHOG_KEY=${{ secrets.NEXT_PUBLIC_POSTHOG_KEY }}" >> .env.local
echo "NEXT_PUBLIC_POSTHOG_HOST=${{ secrets.NEXT_PUBLIC_POSTHOG_HOST }}" >> .env.local
echo "NEXT_PUBLIC_SUPABASE_URL=${{ secrets.NEXT_PUBLIC_SUPABASE_URL }}" >> .env.local
echo "NEXT_PUBLIC_SUPABASE_ANON_KEY=${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }}" >> .env.local
echo "NEXT_PUBLIC_APP_PLATFORM=tauri" >> .env.local
cp .env.local apps/readest-app/.env.local
- name: install rclone
shell: bash
run: |
if [ "$RUNNER_OS" = "Linux" ]; then
sudo apt-get update && sudo apt-get install -y rclone
elif [ "$RUNNER_OS" = "macOS" ]; then
brew install rclone
else
choco install rclone -y
fi
- name: configure rclone
shell: bash
run: |
mkdir -p ~/.config/rclone
cat > ~/.config/rclone/rclone.conf <<EOF
[r2]
type = s3
provider = Cloudflare
access_key_id = ${{ secrets.RELEASE_R2_ACCESS_KEY_ID }}
secret_access_key = ${{ secrets.RELEASE_R2_SECRET_ACCESS_KEY }}
endpoint = https://${{ secrets.RELEASE_R2_ACCOUNT_ID }}.r2.cloudflarestorage.com
EOF
# ──────────────────────────── ANDROID ────────────────────────────
# `pnpm tauri android init` + `git checkout .` reverts tracked files
# (including package.json), so the nightly version MUST be patched AFTER
# the checkout. Mirrors release.yml's android build/signing steps.
- name: build and sign Android apks
if: matrix.config.release == 'android'
shell: bash
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NDK_HOME: ${{ env.ANDROID_HOME }}/ndk/28.2.13676358
TAURI_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
run: |
version="${{ needs.compute-version.outputs.nightly_version }}"
cd apps/readest-app/
rm -rf src-tauri/gen/android
pnpm tauri android init
pnpm tauri icon ../../data/icons/readest-book.png
git checkout .
# Patch the nightly version AFTER checkout so the stamp survives.
node -e "const f='package.json';const j=require('./'+f);j.version='${version}';require('fs').writeFileSync(f, JSON.stringify(j,null,2)+'\n')"
pushd src-tauri/gen/android
echo "keyAlias=${{ secrets.ANDROID_KEY_ALIAS }}" > keystore.properties
echo "password=${{ secrets.ANDROID_KEY_PASSWORD }}" >> keystore.properties
base64 -d <<< "${{ secrets.ANDROID_KEY_BASE64 }}" > $RUNNER_TEMP/keystore.jks
echo "storeFile=$RUNNER_TEMP/keystore.jks" >> keystore.properties
popd
apk_path=src-tauri/gen/android/app/build/outputs/apk/universal/release
universal_apk=Readest_${version}_universal.apk
arm64_apk=Readest_${version}_arm64.apk
pnpm tauri android build
cp ${apk_path}/app-universal-release.apk $universal_apk
pnpm tauri android build -t aarch64
cp ${apk_path}/app-universal-release.apk $arm64_apk
pnpm tauri signer sign $universal_apk
pnpm tauri signer sign $arm64_apk
# ──────────────────────────── DESKTOP ────────────────────────────
# Linux uses the truly-portable AppImage tauri CLI fork (mirrors
# release.yml). The nightly version is patched BEFORE `tauri build` so the
# bundle filenames carry the stamp.
- name: Override tauri-cli with custom AppImage format (Linux)
if: matrix.config.release == 'linux'
run: cargo install tauri-cli --git https://github.com/tauri-apps/tauri --branch feat/truly-portable-appimage --force
- name: build desktop bundles
if: matrix.config.release != 'android'
shell: bash
env:
TAURI_BUNDLER_NEW_APPIMAGE_FORMAT: 'true'
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
NODE_OPTIONS: '--max-old-space-size=8192'
run: |
version="${{ needs.compute-version.outputs.nightly_version }}"
node -e "const f='apps/readest-app/package.json';const j=require('./'+f);j.version='${version}';require('fs').writeFileSync(f, JSON.stringify(j,null,2)+'\n')"
cd apps/readest-app
# On Linux use the cargo `tauri` CLI (the truly-portable AppImage fork
# installed above); elsewhere the npm @tauri-apps/cli.
if [ "${{ matrix.config.release }}" = "linux" ]; then
cargo tauri build ${{ matrix.config.args }}
else
pnpm tauri build ${{ matrix.config.args }}
fi
# Portable Windows build: rebuild with NEXT_PUBLIC_PORTABLE_APP=true and
# ship the raw exe (mirrors release.yml). Runs after the NSIS build above.
- name: build and sign portable binaries (Windows only)
if: matrix.config.os == 'windows-latest'
shell: bash
env:
TAURI_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
run: |
set -euo pipefail
version="${{ needs.compute-version.outputs.nightly_version }}"
arch="${{ matrix.config.arch }}"
rust_target="${{ matrix.config.rust_target }}"
# The clean NSIS build above produced bundle/nsis/Readest_<ver>_<x64|
# arm64>-setup.exe (+ .sig). The portable rebuild below runs `tauri
# build ... --bundles nsis` AGAIN with NEXT_PUBLIC_PORTABLE_APP=true,
# which OVERWRITES that installer with a portable-flavored one. Stage
# the clean installer + its updater .sig to a safe dir FIRST so the
# collect step can read the untouched copy for the windows-* keys.
if [ "$arch" = "x86_64" ]; then
nsis_name="Readest_${version}_x64-setup.exe"
else
nsis_name="Readest_${version}_arm64-setup.exe"
fi
nsis_src="target/${rust_target}/release/bundle/nsis/${nsis_name}"
mkdir -p nsis-staged
cp "$nsis_src" "nsis-staged/${nsis_name}"
cp "${nsis_src}.sig" "nsis-staged/${nsis_name}.sig"
pushd apps/readest-app/
echo "NEXT_PUBLIC_PORTABLE_APP=true" >> .env.local
pnpm tauri build ${{ matrix.config.args }}
popd
if [ "$arch" = "x86_64" ]; then
bin_file="Readest_${version}_x64-portable.exe"
else
bin_file="Readest_${version}_arm64-portable.exe"
fi
exe_file="target/${{ matrix.config.rust_target }}/release/readest.exe"
# Browsers on Windows refuse to download zips containing exe files, so
# ship the exe directly (matches release.yml).
cp "$exe_file" "$bin_file"
pushd apps/readest-app/
pnpm tauri signer sign "../../$bin_file"
popd
# ───────────────── COLLECT ARTIFACTS + BUILD FRAGMENT ─────────────────
# Each leg copies its updater artifacts (+ .sig) into ./nightly-out and
# emits a per-leg manifest fragment keyed by the EXACT Tauri platform keys
# the client expects (see src/helpers/updater.ts::getNightlyPlatformKey and
# src/components/UpdaterWindow.tsx::TAURI_UPDATER_KEYS). The fragment
# `signature` is the .sig file CONTENTS; `url` is the download.readest.com
# URL of the uploaded artifact under nightly/<version>/.
- name: collect artifacts + build manifest fragment
shell: bash
run: |
set -euo pipefail
version="${{ needs.compute-version.outputs.nightly_version }}"
release="${{ matrix.config.release }}"
arch="${{ matrix.config.arch }}"
rust_target="${{ matrix.config.rust_target }}"
base_url="https://download.readest.com/nightly/${version}"
out="$PWD/nightly-out"
frag_dir="$out/frag"
mkdir -p "$out" "$frag_dir"
# Cargo WORKSPACE: bundles land in the REPO-ROOT target/ dir, not
# apps/readest-app/src-tauri/target/ (matches release.yml line 371).
bundle="target"
# Stage one artifact + its .sig into $out, then append a
# platforms[<key>] = {signature, url} entry to the fragment JSON.
frag="$frag_dir/${release}-${arch:-all}.json"
echo '{"platforms":{}}' > "$frag"
add_entry() {
local src="$1"; local fname="$2"; local key="$3"
if [ ! -f "$src" ] || [ ! -f "${src}.sig" ]; then
echo "::error::missing artifact or signature for $key: $src"
exit 1
fi
cp "$src" "$out/$fname"
cp "${src}.sig" "$out/${fname}.sig"
local sig; sig=$(cat "${src}.sig")
local url="${base_url}/${fname}"
jq --arg k "$key" --arg sig "$sig" --arg url "$url" \
'.platforms[$k] = {signature: $sig, url: $url}' "$frag" > "$frag.tmp" && mv "$frag.tmp" "$frag"
echo "fragment += $key -> $fname"
}
case "$release" in
android)
# Signed in the android step; basenames already version-stamped.
add_entry "apps/readest-app/Readest_${version}_universal.apk" "Readest_${version}_universal.apk" "android-universal"
add_entry "apps/readest-app/Readest_${version}_arm64.apk" "Readest_${version}_arm64.apk" "android-arm64"
;;
macos)
# Universal updater bundle: bundle/macos/Readest.app.tar.gz (no
# version/arch on disk). Upload under the stable convention name
# Readest_universal.app.tar.gz; both darwin keys point at it.
src="${bundle}/universal-apple-darwin/release/bundle/macos/Readest.app.tar.gz"
add_entry "$src" "Readest_universal.app.tar.gz" "darwin-aarch64"
# Reuse the already-staged copy for the x86_64 key (same artifact).
x86_sig=$(cat "$out/Readest_universal.app.tar.gz.sig")
jq --arg sig "$x86_sig" --arg url "${base_url}/Readest_universal.app.tar.gz" \
'.platforms["darwin-x86_64"] = {signature: $sig, url: $url}' "$frag" > "$frag.tmp" && mv "$frag.tmp" "$frag"
echo "fragment += darwin-x86_64 -> Readest_universal.app.tar.gz"
;;
windows)
if [ "$arch" = "x86_64" ]; then
nsis_name="Readest_${version}_x64-setup.exe"
portable_name="Readest_${version}_x64-portable.exe"
nsis_key="windows-x86_64"; portable_key="windows-x86_64-portable"
else
nsis_name="Readest_${version}_arm64-setup.exe"
portable_name="Readest_${version}_arm64-portable.exe"
nsis_key="windows-aarch64"; portable_key="windows-aarch64-portable"
fi
# Read the CLEAN NSIS installer staged before the portable rebuild
# (the rebuild overwrites bundle/nsis/...-setup.exe). See the
# "build and sign portable binaries" step.
add_entry "nsis-staged/${nsis_name}" "$nsis_name" "$nsis_key"
# Portable exe was copied + signed into the repo root above.
add_entry "$portable_name" "$portable_name" "$portable_key"
;;
linux)
# Truly-portable AppImage: bundle/appimage/Readest_<ver>_<amd64|aarch64>.AppImage
if [ "$arch" = "x86_64" ]; then
appimage_name="Readest_${version}_amd64.AppImage"
key="linux-x86_64-appimage"
else
appimage_name="Readest_${version}_aarch64.AppImage"
key="linux-aarch64-appimage"
fi
# The Linux leg builds with `cargo tauri build` WITHOUT `--target`
# (no matrix args), so cargo emits bundles under target/release/
# (host-target default) — NOT target/<triple>/release/ like the
# macOS/Windows legs, which DO pass `--target`. So there is no
# ${rust_target} subdir here.
add_entry "${bundle}/release/bundle/appimage/${appimage_name}" "$appimage_name" "$key"
;;
*)
echo "::error::unknown release leg: $release"; exit 1
;;
esac
- name: upload artifacts + fragment to R2
shell: bash
run: |
set -euo pipefail
version="${{ needs.compute-version.outputs.nightly_version }}"
base="r2:readest-releases/nightly/${version}"
out="$PWD/nightly-out"
# Artifacts (exclude the local frag/ scratch dir).
rclone copy "$out" "$base/" --exclude "frag/**"
# Per-leg manifest fragment.
rclone copy "$out/frag" "$base/manifest-fragments/"
assemble-manifest:
needs: [compute-version, build]
if: ${{ always() && needs.build.result != 'cancelled' }}
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: install rclone + jq
run: sudo apt-get update && sudo apt-get install -y rclone jq
- name: configure rclone
run: |
mkdir -p ~/.config/rclone
cat > ~/.config/rclone/rclone.conf <<EOF
[r2]
type = s3
provider = Cloudflare
access_key_id = ${{ secrets.RELEASE_R2_ACCESS_KEY_ID }}
secret_access_key = ${{ secrets.RELEASE_R2_SECRET_ACCESS_KEY }}
endpoint = https://${{ secrets.RELEASE_R2_ACCOUNT_ID }}.r2.cloudflarestorage.com
EOF
- name: assemble + atomically promote latest.json
run: |
set -euo pipefail
version="${{ needs.compute-version.outputs.nightly_version }}"
base="r2:readest-releases/nightly"
# Pull only the fragments that SUCCEEDED legs uploaded.
rclone copy "$base/${version}/manifest-fragments" ./frag || true
if [ -z "$(ls -A ./frag 2>/dev/null)" ]; then
echo "::error::no manifest fragments found — all build legs failed; manifest NOT promoted"
exit 1
fi
# Merge every fragment's .platforms into one manifest.
jq -s \
--arg version "$version" \
'{version: $version, pub_date: (now | todateiso8601), notes: "Nightly build", platforms: (map(.platforms) | add)}' \
./frag/*.json > latest.json
echo "Assembled latest.json:"
jq '{version, platforms: (.platforms | keys)}' latest.json
# Promote the manifest with a directory `rclone copy`, exactly like the
# stable release flow writes releases/latest.json (upload-to-r2.yml).
# A single-file `rclone copyto`/`moveto` first issues a CreateBucket
# probe (PUT /<bucket>) that the object-scoped R2 token can't satisfy
# (403 AccessDenied); a directory copy PUTs the object directly. R2
# PutObject is atomic, so readers never observe a half-written manifest.
mkdir -p promote && cp latest.json promote/latest.json
rclone copy promote "$base/"
- name: prune old nightly folders (keep newest 7)
run: |
set -euo pipefail
base="r2:readest-releases/nightly"
# Version dirs are <base>-<YYYYMMDDHH>. Lexicographic order is NOT
# chronological across a base-version bump (e.g. 0.2.0-* sorts after
# 0.11.0-*), so sort by the numeric stamp tail (everything after the
# last '-') to keep the newest 7 by build time.
mapfile -t dirs < <(rclone lsf "$base/" --dirs-only | sed 's:/$::' \
| sed -E 's/^(.*)-([0-9]{10})$/\2 \1-\2/' | sort -n -k1,1 | cut -d' ' -f2-)
count=${#dirs[@]}
if [ "$count" -gt 7 ]; then
for d in "${dirs[@]:0:$((count-7))}"; do
echo "pruning $d"
rclone purge "$base/$d"
done
fi
- name: notify on failure
if: failure()
run: echo "::error::Nightly assemble failed — manifest not promoted."
+124 -49
View File
@@ -14,7 +14,7 @@ jobs:
SCCACHE_GHA_ENABLED: 'true'
RUSTC_WRAPPER: sccache
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
submodules: 'true'
- name: setup sccache
@@ -26,7 +26,7 @@ jobs:
override: true
components: rustfmt, clippy
- name: Cache apt packages
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with:
path: /var/cache/apt/archives
key: apt-rust-lint-${{ runner.os }}
@@ -40,16 +40,19 @@ jobs:
- name: Clippy Check
working-directory: apps/readest-app/src-tauri
run: cargo clippy -p Readest --no-deps -- -D warnings
- name: Unit tests
working-directory: apps/readest-app/src-tauri
run: cargo test -p Readest --lib
build_web_app:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
submodules: 'true'
- name: setup pnpm
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6
- name: setup node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
@@ -57,30 +60,18 @@ jobs:
node-version: 24
cache: pnpm
# Turbopack persists its build cache here (turbopackFileSystemCacheForBuild).
# The key is deterministic (no commit SHA) so every PR restores the same
# entry — in practice the one `main` last saved, which is the only cache
# sibling PRs can all see.
- name: cache Turbopack build cache
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
with:
path: apps/readest-app/.next/cache
key: turbo-build-web-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
turbo-build-web-${{ runner.os }}-
- name: install Dependencies
working-directory: apps/readest-app
run: |
pnpm install --frozen-lockfile --prefer-offline && pnpm setup-vendors
- name: install LuaJIT (for koplugin lint)
run: sudo apt-get update && sudo apt-get install -y luajit
- name: run format check
run: |
pnpm format:check || (pnpm format && git diff && exit 1)
# pnpm lint here is web-only (tsgo + biome). The koplugin syntax check
# (lint:lua) runs in the test_extensions job, which installs LuaJIT only
# when the koplugin sources changed.
- name: run lint
working-directory: apps/readest-app
run: |
@@ -93,7 +84,7 @@ jobs:
- name: cache playwright browsers
id: playwright-cache
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
@@ -120,15 +111,23 @@ jobs:
path: apps/readest-app/playwright-report/
retention-days: 7
# The jsdom unit suite is the slowest part of the PR checks, so it is split
# across two parallel shards (vitest --shard). The browser tests need
# Playwright and run only on shard 1; koplugin + browser-extension tests
# moved to the test_extensions job.
test_web_app:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
shard: [1, 2]
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
submodules: 'true'
- name: setup pnpm
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6
- name: setup node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
@@ -141,16 +140,91 @@ jobs:
run: |
pnpm install --frozen-lockfile --prefer-offline && pnpm setup-vendors
# Playwright is only needed by the browser tests, which run on shard 1.
- name: cache playwright browsers
if: matrix.shard == 1
id: playwright-cache
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
- name: install playwright browsers
if: matrix.shard == 1
working-directory: apps/readest-app
run: |
if [ "${{ steps.playwright-cache.outputs.cache-hit }}" = 'true' ]; then
npx playwright install-deps chromium
else
npx playwright install --with-deps chromium
fi
- name: run web unit tests (shard ${{ matrix.shard }}/2)
working-directory: apps/readest-app
run: pnpm test:pr:web:unit --shard=${{ matrix.shard }}/2
- name: run web browser tests
if: matrix.shard == 1
working-directory: apps/readest-app
run: pnpm test:browser
# Browser-extension tests + build always run. The koplugin lint + Lua tests
# (and the ~45s LuaJIT/busted install they need) only run when the koplugin
# sources changed, so most PRs skip that cost entirely.
test_extensions:
runs-on: ubuntu-latest
# pull-requests: read lets dorny/paths-filter list a PR's changed files
# via the REST API (the top-level grant is contents: read only).
permissions:
contents: read
pull-requests: read
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
submodules: 'true'
- name: detect koplugin changes
id: changes
uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4
with:
filters: |
koplugin:
- 'apps/readest.koplugin/**'
- name: setup pnpm
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6
- name: setup node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
node-version: 24
cache: pnpm
- name: install Dependencies
working-directory: apps/readest-app
run: |
pnpm install --frozen-lockfile --prefer-offline && pnpm setup-vendors
- name: run extension tests
working-directory: apps/readest-app
run: pnpm test:extension
- name: build browser extension
working-directory: apps/readest-app
run: pnpm build-browser-ext
- name: cache apt packages
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
if: steps.changes.outputs.koplugin == 'true'
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with:
path: /var/cache/apt/archives
key: apt-test-web-${{ runner.os }}
key: apt-test-koplugin-${{ runner.os }}
- name: install LuaJIT + busted (for koplugin tests)
- name: install LuaJIT + busted (for koplugin lint + tests)
if: steps.changes.outputs.koplugin == 'true'
run: |
sudo apt-get update
# luajit — pnpm test:lua
# luajit — pnpm lint:lua + pnpm test:lua
# luarocks/libsqlite3-dev — required to build lsqlite3complete
sudo apt-get install -y luajit luarocks libsqlite3-dev
# Install busted + the SQLite binding the LibraryStore specs use,
@@ -160,27 +234,13 @@ jobs:
sudo luarocks --lua-version=5.1 install busted
sudo luarocks --lua-version=5.1 install lsqlite3complete
- name: cache playwright browsers
id: playwright-cache
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
- name: install playwright browsers
- name: lint koplugin
if: steps.changes.outputs.koplugin == 'true'
working-directory: apps/readest-app
run: |
if [ "${{ steps.playwright-cache.outputs.cache-hit }}" = 'true' ]; then
npx playwright install-deps chromium
else
npx playwright install --with-deps chromium
fi
- name: run web tests
working-directory: apps/readest-app
run: pnpm test:pr:web
run: pnpm lint:lua
- name: run koplugin tests
if: steps.changes.outputs.koplugin == 'true'
working-directory: apps/readest-app
run: pnpm test:lua
@@ -190,12 +250,12 @@ jobs:
SCCACHE_GHA_ENABLED: 'true'
RUSTC_WRAPPER: sccache
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
submodules: 'true'
- name: setup pnpm
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6
- name: setup node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
@@ -206,7 +266,7 @@ jobs:
# The tauri tests run `next dev`, whose Turbopack cache lives in
# `.next/dev/cache` (a different path from the `next build` cache).
- name: cache Turbopack dev cache
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with:
path: apps/readest-app/.next/dev/cache
key: turbo-dev-tauri-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
@@ -225,14 +285,29 @@ jobs:
uses: actions-rust-lang/setup-rust-toolchain@46268bd060767258de96ed93c1251119784f2ab6 # v1
with:
toolchain: stable
# Disable this action's built-in rust-cache so the explicit
# Swatinem/rust-cache below is the single cache (it's the one
# configured with cache-workspace-crates for the vendored tauri fork).
cache: false
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
with:
key: tauri-cargo
# cache-workspace-crates caches the path/workspace crates too: the
# vendored tauri fork (packages/tauri, packages/tauri-plugins, wired
# via [patch.crates-io]) and the local src-tauri/plugins/*. These are
# workspace members that rust-cache prunes by default, so the whole
# tauri stack — plus every crates.io plugin that depends on the
# patched `tauri` — rebuilt on every run. They change only
# sporadically (pinned submodules), so caching them is a big win.
# The key is bumped (-ws) so the old workspace-crate-less cache is
# invalidated and the next run repopulates it with the workspace
# crates included.
key: tauri-cargo-ws
cache-all-crates: 'true'
cache-workspace-crates: 'true'
- name: Cache apt packages
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with:
path: /var/cache/apt/archives
key: apt-tauri-${{ runner.os }}
+5 -5
View File
@@ -19,7 +19,7 @@ jobs:
release_version: ${{ steps.get-release-notes.outputs.release_version }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: setup node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
- name: get version
@@ -89,7 +89,7 @@ jobs:
contents: write
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: create KOReader plugin zip
env:
@@ -156,13 +156,13 @@ jobs:
runs-on: ${{ matrix.config.os }}
timeout-minutes: 60
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: initialize git submodules
run: git submodule update --init --recursive
- name: setup pnpm
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6
- name: setup node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
@@ -172,7 +172,7 @@ jobs:
- name: setup Java (for Android build only)
if: matrix.config.release == 'android'
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5
uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5
with:
distribution: 'zulu'
java-version: '17'
+2 -2
View File
@@ -34,7 +34,7 @@ jobs:
steps:
- name: "Checkout code"
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
@@ -73,6 +73,6 @@ jobs:
# Upload the results to GitHub's code scanning dashboard (optional).
# Commenting out will disable upload of results to your repo's Code Scanning dashboard
- name: "Upload to code-scanning"
uses: github/codeql-action/upload-sarif@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
uses: github/codeql-action/upload-sarif@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
with:
sarif_file: results.sarif
+1 -1
View File
@@ -11,7 +11,7 @@ jobs:
permissions:
contents: read
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
submodules: 'true'
- uses: amondnet/vercel-action@de09aeac2ace6599ec9b11ef87558759a496bac4 # v42
Generated
+193 -4
View File
@@ -12,26 +12,35 @@ checksum = "fe438c63458706e03479442743baae6c88256498e6431708f6dfc520a26515d3"
name = "Readest"
version = "0.2.2"
dependencies = [
"base64 0.22.1",
"block",
"cocoa",
"discord-rich-presence",
"futures",
"futures-util",
"image",
"libc",
"log",
"md-5",
"minisign-verify",
"mobi",
"objc",
"objc-foundation",
"objc2",
"objc2-authentication-services",
"objc2-foundation",
"objc_id",
"percent-encoding",
"quick-xml 0.36.2",
"rand 0.8.6",
"read-progress-stream",
"reqwest 0.12.28",
"semver",
"serde",
"serde_json",
"tauri",
"tauri-build 2.6.2",
"tauri-plugin-biometric",
"tauri-plugin-cli",
"tauri-plugin-clipboard-manager",
"tauri-plugin-deep-link",
@@ -62,6 +71,8 @@ dependencies = [
"tokio",
"tokio-util",
"walkdir",
"winreg 0.52.0",
"zip 2.4.2",
]
[[package]]
@@ -1133,6 +1144,12 @@ dependencies = [
"objc",
]
[[package]]
name = "color_quant"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
[[package]]
name = "colorchoice"
version = "1.0.5"
@@ -1948,7 +1965,7 @@ dependencies = [
"rustc_version",
"toml 1.1.2+spec-1.1.0",
"vswhom",
"winreg",
"winreg 0.55.0",
]
[[package]]
@@ -1957,6 +1974,70 @@ version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7"
[[package]]
name = "encoding"
version = "0.2.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b0d943856b990d12d3b55b359144ff341533e516d94098b1d3fc1ac666d36ec"
dependencies = [
"encoding-index-japanese",
"encoding-index-korean",
"encoding-index-simpchinese",
"encoding-index-singlebyte",
"encoding-index-tradchinese",
]
[[package]]
name = "encoding-index-japanese"
version = "1.20141219.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04e8b2ff42e9a05335dbf8b5c6f7567e5591d0d916ccef4e0b1710d32a0d0c91"
dependencies = [
"encoding_index_tests",
]
[[package]]
name = "encoding-index-korean"
version = "1.20141219.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4dc33fb8e6bcba213fe2f14275f0963fd16f0a02c878e3095ecfdf5bee529d81"
dependencies = [
"encoding_index_tests",
]
[[package]]
name = "encoding-index-simpchinese"
version = "1.20141219.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d87a7194909b9118fc707194baa434a4e3b0fb6a5a757c73c3adb07aa25031f7"
dependencies = [
"encoding_index_tests",
]
[[package]]
name = "encoding-index-singlebyte"
version = "1.20141219.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3351d5acffb224af9ca265f435b859c7c01537c0849754d3db3fdf2bfe2ae84a"
dependencies = [
"encoding_index_tests",
]
[[package]]
name = "encoding-index-tradchinese"
version = "1.20141219.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fd0e20d5688ce3cab59eb3ef3a2083a5c77bf496cb798dc6fcdb75f323890c18"
dependencies = [
"encoding_index_tests",
]
[[package]]
name = "encoding_index_tests"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a246d82be1c9d791c5dfde9a2bd045fc3cbba3fa2b11ad558f27d01712f00569"
[[package]]
name = "encoding_rs"
version = "0.8.35"
@@ -2640,6 +2721,16 @@ dependencies = [
"polyval",
]
[[package]]
name = "gif"
version = "0.14.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159"
dependencies = [
"color_quant",
"weezl",
]
[[package]]
name = "gio"
version = "0.18.4"
@@ -3293,10 +3384,14 @@ checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"
dependencies = [
"bytemuck",
"byteorder-lite",
"color_quant",
"gif",
"moxcms",
"num-traits",
"png 0.18.1",
"tiff",
"zune-core",
"zune-jpeg",
]
[[package]]
@@ -3955,6 +4050,16 @@ version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
[[package]]
name = "md-5"
version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf"
dependencies = [
"cfg-if",
"digest",
]
[[package]]
name = "measure_time"
version = "0.9.0"
@@ -4056,6 +4161,17 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "mobi"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f3f8e34216126be00a189105bda27462e1743d59cd4e15d6b99ff4af051b1b4"
dependencies = [
"encoding",
"indexmap 1.9.3",
"thiserror 1.0.69",
]
[[package]]
name = "moxcms"
version = "0.8.1"
@@ -5279,7 +5395,7 @@ checksum = "092791278e026273c1b65bbdcfbba3a300f2994c896bd01ab01da613c29c46f1"
dependencies = [
"base64 0.22.1",
"indexmap 2.14.0",
"quick-xml",
"quick-xml 0.39.4",
"serde",
"time",
]
@@ -5562,6 +5678,15 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
[[package]]
name = "quick-xml"
version = "0.36.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f7649a7b4df05aed9ea7ec6f628c67c9953a43869b8bc50929569b2999d443fe"
dependencies = [
"memchr",
]
[[package]]
name = "quick-xml"
version = "0.39.4"
@@ -7571,6 +7696,21 @@ dependencies = [
"walkdir",
]
[[package]]
name = "tauri-plugin-biometric"
version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "306175b744890b5e4aeb8add6aae7debec1b1504c5087bd0310ed7c9c6feae38"
dependencies = [
"log",
"serde",
"serde_json",
"serde_repr",
"tauri",
"tauri-plugin",
"thiserror 2.0.18",
]
[[package]]
name = "tauri-plugin-cli"
version = "2.4.1"
@@ -7755,6 +7895,7 @@ dependencies = [
"keyring-core",
"schemars 0.8.22",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"thiserror 2.0.18",
@@ -7968,7 +8109,7 @@ dependencies = [
"tokio",
"url",
"windows-sys 0.60.2",
"zip",
"zip 4.6.1",
]
[[package]]
@@ -9435,7 +9576,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c324a910fd86ebdc364a3e61ec1f11737d3b1d6c273c0239ee8ff4bc0d24b4a"
dependencies = [
"proc-macro2",
"quick-xml",
"quick-xml 0.39.4",
"quote",
]
@@ -9960,6 +10101,15 @@ dependencies = [
"windows-targets 0.42.2",
]
[[package]]
name = "windows-sys"
version = "0.48.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
dependencies = [
"windows-targets 0.48.5",
]
[[package]]
name = "windows-sys"
version = "0.52.0"
@@ -10281,6 +10431,16 @@ dependencies = [
"memchr",
]
[[package]]
name = "winreg"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a277a57398d4bfa075df44f501a17cfdf8542d224f0d36095a2adc7aee4ef0a5"
dependencies = [
"cfg-if",
"windows-sys 0.48.0",
]
[[package]]
name = "winreg"
version = "0.55.0"
@@ -10724,6 +10884,23 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "zip"
version = "2.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50"
dependencies = [
"arbitrary",
"crc32fast",
"crossbeam-utils",
"displaydoc",
"flate2",
"indexmap 2.14.0",
"memchr",
"thiserror 2.0.18",
"zopfli",
]
[[package]]
name = "zip"
version = "4.6.1"
@@ -10742,6 +10919,18 @@ version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
[[package]]
name = "zopfli"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249"
dependencies = [
"bumpalo",
"crc32fast",
"log",
"simd-adler32",
]
[[package]]
name = "zstd"
version = "0.13.3"
+19 -7
View File
@@ -45,15 +45,27 @@ COPY --from=dependencies /app/apps/readest-app/public/vendor /app/apps/readest-a
COPY --from=dependencies /app/packages/foliate-js/node_modules /app/packages/foliate-js/node_modules
COPY . .
WORKDIR /app/apps/readest-app
# Opt into the self-contained `.next/standalone` tree for this image only;
# next.config.mjs gates `output: 'standalone'` on BUILD_STANDALONE so other
# web builds keep their default output.
ENV BUILD_STANDALONE=true
RUN pnpm build-web
# Production runtime ships only the standalone server, its traced node_modules,
# and the static/public assets — no pnpm, no source tree, no dev dependencies,
# no build cache. `output: 'standalone'` (next.config.mjs) emits the self-contained
# tree under .next/standalone, so the entrypoint is a plain `node server.js`.
FROM docker.io/library/node:24-slim@sha256:24dc26ef1e3c3690f27ebc4136c9c186c3133b25563ae4d7f0692e4d1fe5db0e AS production-stage
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
RUN corepack prepare pnpm@11.1.1 --activate
ENV NODE_ENV=production
ENV PORT=3000
ENV HOSTNAME=0.0.0.0
WORKDIR /app
COPY --from=build /app /app
WORKDIR /app/apps/readest-app
ENTRYPOINT ["pnpm", "start-web", "-H", "0.0.0.0"]
# Monorepo-rooted standalone tree: server.js + hoisted, traced node_modules.
COPY --from=build --chown=node:node /app/apps/readest-app/.next/standalone ./
# Static and public assets are not part of the standalone trace; copy them next
# to the server so their default relative paths resolve.
COPY --from=build --chown=node:node /app/apps/readest-app/.next/static ./apps/readest-app/.next/static
COPY --from=build --chown=node:node /app/apps/readest-app/public ./apps/readest-app/public
USER node
EXPOSE 3000
ENTRYPOINT ["node", "apps/readest-app/server.js"]
+11 -11
View File
@@ -29,6 +29,7 @@
<a href="#planned-features">Planned Features</a> •
<a href="#screenshots">Screenshots</a> •
<a href="#downloads">Downloads</a> •
<a href="#documentation">Documentation</a> •
<a href="#getting-started">Getting Started</a> •
<a href="#troubleshooting">Troubleshooting</a> •
<a href="#support">Support</a> •
@@ -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">
<img src="https://raw.githubusercontent.com/readest/readest/refs/heads/main/data/sponsors/testmu-ai-logo.png" style="vertical-align: middle;" width="250" />
</a>
</p>
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
[badge-website]: https://img.shields.io/badge/website-readest.com-orange
[badge-web-app]: https://img.shields.io/badge/read%20online-web.readest.com-orange
[badge-license]: https://img.shields.io/github/license/readest/readest?color=teal
[badge-release]: https://img.shields.io/github/release/readest/readest?color=green
[badge-license]: https://img.shields.io/badge/license-AGPL--3.0-teal
[badge-release]: https://img.shields.io/github/v/release/readest/readest?color=green
[badge-platforms]: https://img.shields.io/badge/platforms-macOS%2C%20Windows%2C%20Linux%2C%20Android%2C%20iOS%2C%20Web%2C%20PWA-green
[badge-last-commit]: https://img.shields.io/github/last-commit/readest/readest?color=blue
[badge-commit-activity]: https://img.shields.io/github/commit-activity/m/readest/readest?color=blue
@@ -350,6 +349,7 @@ We would also like to thank the [Web Chinese Fonts Plan](https://chinese-font.ne
[link-website]: https://readest.com?utm_source=github&utm_medium=referral&utm_campaign=readme
[link-flathub]: https://flathub.org/en/apps/com.bilingify.readest
[link-web-readest]: https://web.readest.com
[link-docs]: https://readest.com/docs
[link-gh-releases]: https://github.com/readest/readest/releases
[link-gh-commits]: https://github.com/readest/readest/commits/main
[link-gh-pulse]: https://github.com/readest/readest/pulse
+142 -78
View File
@@ -1,84 +1,148 @@
# Readest Project Memory
## Key Reference Documents
- [Bug Fixing Patterns](bug-patterns.md) - Common bug categories, root causes, and fix strategies
- [CSS & Style Fixes](css-style-fixes.md) - EPUB CSS override patterns and the style.ts pipeline
- [TTS Fixes](tts-fixes.md) - Text-to-Speech architecture and bug patterns
- [Layout & UI Fixes](layout-ui-fixes.md) - Safe insets, z-index, platform-specific UI issues
- [Platform Compat Fixes](platform-compat-fixes.md) - Android, iOS, Linux, macOS platform-specific bugs
- [Annotator & Reader Fixes](annotator-reader-fixes.md) - Highlight, selection, accessibility bugs
## Paginator Scroll Knowledge
- [Issue #4112 scroll-anchoring](issue-4112-scroll-anchoring.md) — RESOLVED (PR #4349). Scroll-anchoring suppressed at scrollTop 0 when prepending a section in scrolled mode; fix patterns (prepend compensation, eager backward preload, no-blank nav) + test & dev-server gotchas
- [Reading ruler line/column-aware](reading-ruler-line-aware.md) — ruler snaps to real lines; multi-column band spans one column; Range.getClientRects() returns tall block boxes that must be dropped; iframe frame-offset mapping; synthetic-key throttling
- [TOC expand + auto-scroll](toc-expand-and-autoscroll.md) — #4059 collapse-by-default policy in `tocTree.ts`; pinned-sidebar mounts before progress → dynamic expansion breaks scroll-to-current via (1) spurious onScroll clearing pending and (2) Virtuoso scrollToIndex landing short after row growth (re-assert on rAF)
- [BooknoteView auto-scroll (#4352)](booknote-view-autoscroll-4352.md) — virtualizing the annotation/bookmark list dropped auto-scroll-to-nearest; two paths (reload: OverlayScrollbars resets scrollTop → re-apply in `initialized` via ref; tab-switch: synchronous scrollToIndex on fresh-mounted list wedges Virtuoso → use `initialTopMostItemIndex` + skip-gate). Mirrors TOCView. Includes dev-server/Fast-Refresh/screenshot-vs-DOM verification gotchas
- [Swipe page-turn bg flash](paginator-swipe-bg-flash.md) — white↔black flash on swipe+animation only; `#background` was static screen-space and didn't track content during drag/snap; fix = sliding per-view full-bleed segments (`computeBackgroundSegments`) rebuilt on scroll + per-rAF synced to the view transform during snap
- [Duokan fullscreen cover hidden in scroll mode](duokan-fullscreen-cover-scroll.md) — #4379 `data-duokan-page-fullscreen` cover pinned `position:absolute height:100%` collapses against auto-height scroll container; gate fullscreen on `this.#column` + reset stale absolute props on toggle (`setImageSize` in paginator.js)
- [Paginated texture occlusion](paginated-texture-occlusion-4399.md) — #4399 host `.foliate-viewer::before` texture absent in paginated (shown in scrolled); opaque `#background` container (`= fallbackBg`) from the swipe-flash fix occludes it; shared `textureAwareBackground` helper + `hasTexture ? '' : fallbackBg` container
- [Background overflows column (#4394, PR #4429)](paginator-gutter-bleed-asymmetry-4394.md) — paginated page bg stretched into the outer `--_outer-min` gutter → mixed cover/title 2-up spread shifted off-centre (~250px at 1920px). KEEP the grid (`--_outer-min` keeps margins symmetric); fix = clamp `computeBackgroundSegments` to `[containerStart,containerEnd]` (Math.max/Math.min) so bg stays in its column. 2 wrong tries first (bleed-gating, "page shouldn't be yellow"); foliate submodule needs dev-server RESTART to pick up edits
## Key Reference Documents (aggregators)
- [Bug Patterns](bug-patterns.md) · [CSS & Style](css-style-fixes.md) — EPUB CSS + style.ts · [TTS](tts-fixes.md)
- [Layout & UI](layout-ui-fixes.md) — insets/z-index · [Platform Compat](platform-compat-fixes.md) — Android/iOS/Linux/macOS · [Annotator & Reader](annotator-reader-fixes.md)
## Safety & Security
- [In-place delete wiped originals](in-place-delete-wiped-originals.md) — never `fs.removeFile` `external` source;
- [Backup zip Windows paths (#4703)](backup-windows-zip-paths-4703.md) `\` entry names broke restore; normalize
- [download_file scope Android (#4639)](download-file-scope-android-regression.md) — strict `is_allowed` broke
- [Security advisories 2026-06](security-advisories-web-2026-06.md) — 4 GHSA #4638 (OPDS SSRF, storage key, Stripe
## Paginator & Scroll
- [Reading ruler line-aware](reading-ruler-line-aware.md) — snaps to real lines; iframe frame-offset map
- [TOC expand + auto-scroll](toc-expand-and-autoscroll.md) — #4059 collapse-default breaks scroll-to-current
- [BooknoteView auto-scroll (#4352)](booknote-view-autoscroll-4352.md) — reload via `initialized` ref;
- [TOC current-position row](toc-current-position-row.md) — synthetic row; insert AFTER to keep flatItems index
- [Swipe page-turn bg flash](paginator-swipe-bg-flash.md) — static `#background`; `computeBackgroundSegments` per-rAF
- [Paginated texture occlusion (#4399)](paginated-texture-occlusion-4399.md) — opaque bg occludes texture; `hasTexture
- [Background overflows column (#4394)](paginator-gutter-bleed-asymmetry-4394.md) — bg bled into gutter; clamp to
- [Inline-block column overflow](inline-block-column-overflow.md) — body can't fragment; `#demoteUnfragmentableBoxes`
- [FXL fit-width scroll reset (#4683)](fixed-layout-paginated-scroll-reset-4683.md) — scrollTop not reset;
- [PDF spread 1px seam (#4587)](pdf-spread-canvas-seam-4587.md) — fractional dpr truncates; pin `canvas.style=viewport`
- [PDF scrolled wheel double (#4727)](pdf-scroll-mode-wheel-double-4727.md) — drop manual `scrollBy` over native
- [Scrolled header title center (#4436)](scrolled-header-title-center-4436.md) — view covering `renderedStart + size/2`
- [Duokan fullscreen cover scroll](duokan-fullscreen-cover-scroll.md) — #4379 cover collapses; gate on `this.#column`
- [TOC table heading clip (#4439)](toc-table-heading-clip-4439.md) — #4400 scroll-wrapper `overflow:auto` clips
- [Page-turn bg-replace reflow (#4785)](pageturn-bg-replace-reflow-4785.md) — `#replaceBackground` rebuilt context per
## Critical Files (Most Bug-Prone)
- `src/utils/style.ts` - Central EPUB CSS transformation hub (14+ bug fixes)
- `packages/foliate-js/paginator.js` - Page layout, image sizing, backgrounds
- `src/services/tts/TTSController.ts` - TTS state machine, section tracking
- `src/hooks/useSafeAreaInsets.ts` - Safe area inset management
- `src/app/reader/components/FoliateViewer.tsx` - Reader view orchestration
- `src/app/reader/components/annotator/Annotator.tsx` - Annotation lifecycle
- `src/utils/style.ts` EPUB CSS hub · `packages/foliate-js/paginator.js` layout/image/bg · `src/services/tts/TTSController.ts` TTS state machine
- `src/hooks/useSafeAreaInsets.ts` insets · `src/app/reader/components/FoliateViewer.tsx` view orchestration · `.../annotator/Annotator.tsx` annotation lifecycle
## Sync Notes
- [KOSync CFI spine resolution](kosync-cfi-spine-resolution.md) — convert via the CFI's own spine (`getXPointerFromCFI`/`getCFIFromXPointer`), never `new XCFI(primaryDoc, primaryIndex)`; primaryIndex lags during scroll → spine-mismatch throw
- [Empty-start CFI sync bug](empty-start-cfi-sync.md) — `epubcfi(/6/24!/4,,/20/1:58)` (empty-start range) from the cfi-inert skip-link transitional window; jumps to wrong section end; `isMalformedLocationCfi` → discard the synced value in `useProgressSync` (NOT the local open path); foliate fix doesn't repair already-synced values
- [Custom fonts disappear on cloud sync (#4410)](custom-fonts-reincarnation-4410.md) — CRDT remove-wins: re-import-after-delete needs a `reincarnation` token or the pull re-applies the tombstone; `addFont`/`addTexture` minted none; fix mirrors dictionary (both cases) + OPDS token style; coverage matrix per kind
## Testing
- [Tauri Rust↔JS parser parity tests](tauri-parser-parity-tests.md) — #4369 native Rust EPUB/MOBI parser; how to cross-check vs foliate-js in the `.tauri.test.ts` WebView suite (CWD disk path for Rust, Vite URL for JS, normalizer-based compare, cover presence-only, desc whitespace-collapse); the `dcterms:modified``published` divergence fix
## Build & Vendoring
- [pdfjs vendor wasm decoders](pdfjs-vendor-wasm-decoders.md) — scanned PDFs blank in CI build only (0.11.2 regression); pdfjs 5.7.x moved JBIG2 to `jbig2.wasm`, `copy-pdfjs-wasm` allow-list dropped it; `cpx` no-errors on empty glob; local stale `public/vendor` (gitignored, not refreshed by `tauri build`) masked it; fix = copy `wasm/*`
- [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
- [Custom fonts vanish on sync (#4410)](custom-fonts-reincarnation-4410.md) — CRDT remove-wins; re-import
- koplugin: [note deletion](koplugin-note-deletion-sync.md) `recordDeletion` tombstone; [stats #4666](koplugin-stats-sync.md) statistics.sqlite3 delta LuaSettings; [bulk download #4751](koplugin-bulk-download-4751.md)
- [Statusless books re-pin top (#4677)](sync-statusless-book-rebump-4677.md) — `(a??null)!==(b??null)`
- [Pull cursor via synced_at (#4678)](sync-synced-at-cursor-4678.md) — books `synced_at` + BEFORE trigger
- [WebDAV metadata sync (#4756)](webdav-metadata-sync-4756.md) — PR #4776; LWW on `book.updatedAt` +
- [File-sync refactor (#4784)](webdav-filesync-refactor-plan.md) — `FileSyncEngine`/`FileSyncProvider`/`merge.ts`;
- [Third-party library auto-sync (#4835)](third-party-library-autosync-4835.md) — `useLibraryFileSync` parity w/
- [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
- [Android CDP e2e lane](android-cdp-e2e-lane.md) — `pnpm test:android` adb+CDP; CI KVM emulator
- [CDP Android WebView profiling](cdp-android-webview-profiling.md) — adb+CDP JS probes; locked-device freezes fetch
- [Tauri Rust↔JS parser parity](tauri-parser-parity-tests.md) — #4369 cross-check; `dcterms:modified``published`
- [TTS browser e2e harness](tts-browser-e2e-harness.md) — seed `settings.globalViewSettings` or getMergedRules crashes
- [TTS paragraph+RSVP sync (#3235)](tts-sync-paragraph-rsvp-3235.md) — TTS-is-clock; overlay CLONE via CSS Custom
- [fastlane App Store](fastlane-apple-appstore-submission.md) — keep `APPLE_API_KEY_PATH` out of macOS build env
- [Turbopack cache OOM (#4619)](turbopack-build-cache-oom-docker-standalone.md) — partial cache freeze; gate on
- [Deps override workflow](deps-security-overrides-workflow.md) — overrides in `pnpm-workspace.yaml`; tauri-plugins
- [pdfjs vendor wasm](pdfjs-vendor-wasm-decoders.md) — pdfjs 5.7 moved JBIG2 to `jbig2.wasm`; copy `wasm/*`
- [CI/PR delivery + push keepalive](ci-pr-delivery-and-push.md) — temp-index plumbing; SSH `ServerAliveInterval`
## Platform Compat
- [Window-state sanitizer (#4398)](window-state-sanitize-4398.md) — Windows launch crash (WebView2 0x80070057) from invalid `.window-state.json` (`-32000` minimized sentinel / `0×0`); our plugin already has upstream #253 fix so bad files are stale; defense-in-depth `window-state-sanitizer` plugin registered BEFORE window-state (plugin init = registration order); coord threshold `-16000` (~halfway to the -32000 sentinel; real desktops sit a few thousand px off origin) keeps multi-monitor negatives
## 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
- [Cloudflare Workers WebSocket](cloudflare-workers-websocket.md) — use fetch() Upgrade pattern (not `ws` npm); CF delivers binary frames as Blob (must serialize async decodes)
- [Share-a-Book Feature (in progress)](share-feature.md) — locked decisions for the /s/{token} share-link feature; plan at ~/.claude/plans/ok-we-will-learn-cosmic-acorn.md
- [readest.koplugin i18n](koplugin-i18n.md) — gettext loader at `apps/readest.koplugin/i18n.lua`, `.po` catalog at `locales/<i18next-code>/translation.po`, extract/apply scripts in `scripts/`
- [koplugin cover upload](koplugin-cover-upload.md) — #4374 uploadBook only shipped cached cloud covers; local-origin books uploaded blank. Fix = `extractLocalCover` via `FileManagerBookInfo:getCoverImage(nil, file)``writeToFile(path,"png")`. KOReader checkout at `/Users/chrox/dev/koreader`
## Patterns
- [Virtuoso + OverlayScrollbars](virtuoso_overlayscrollbars.md) — useOverlayScrollbars hook integration for overlay scrollbars on mobile webviews
- [Design system → DESIGN.md](feedback_design_system_doc.md) — codify recurring UI/UX rules in `apps/readest-app/DESIGN.md`; never `pl/pr/ml/mr/text-left/text-right` (RTL); §5 boxed list anatomy has uniform `min-h-14` rows and chromeless controls
## Reader UI Fixes
- [Android image callout freeze](android-image-callout-freeze.md) — long-press `<img>` fires WebView native callout that collides with app touch handlers → whole-app freeze; `-webkit-touch-callout: none` doesn't inherit so put `.no-context-menu` on an ancestor of the image (`.no-context-menu img` rule in globals.css); seen on book covers (#4345) + image preview/zoom (#4420, `ImageViewer.tsx`)
- [ProgressBar focus-ring line (#4397)](progressbar-focus-ring-4397.md) — decorative `.progressinfo` footer was `tabIndex={-1}` → Android long-press focused it → stray content-width focus-ring line at the bottom every page; fix = drop tabIndex (role='presentation' must not be focusable); ffmpeg-the-video debugging + live-browser `:focus-visible` confirmation
- [Table dark-mode tint regression (#4419)](table-dark-mode-tint-4419.md) — `blockquote, table *` color-mix tint in `getColorStyles` must stay gated on `overrideColor` (gate added #2377, removed #4055, re-broke → #4419); safe now that #4392 light-bg rewriters handle #4028 zebra legibility; SAME rule paints vertical-TOC `.space`/▉ spacer cells (▉ U+2589 = blank glyph, contours=0) → "spacing changes" symptom; both fixed by the gate
- [Android hyphen selection (#1553)](android-hyphen-selection-bounds-1553.md) — Blink start handle on last hyphen;
- [NativeFile vs RemoteFile I/O](android-nativefile-remotefile-io.md) — NativeFile slow; asset Range broken;
- [Window-state sanitizer (#4398)](window-state-sanitize-4398.md) — invalid json crashes WebView2; sanitize before
- [Android Open-with intent (#4521)](android-open-with-intent-flow.md) — VIEW gated by `autoImportBooksOnOpen`
- [Dict lookup browser hijack (#4559)](dict-lookup-browser-hijack-4559.md) — missing `<queries>` sdk36; filter in
- [Large-PDF OOM range flood (#3470)](pdf-oom-range-flood-3470.md) — un-awaited ranges OOM; MAX_CONCURRENT_RANGES=6
- [Android themed icon (#4733)](android-themed-icon-4733.md) — no monochrome → force-commit; tint=SRC_IN
## Reader Features & UI
- [Mobile reading widgets (#1602/PR#4842)](mobile-reading-widgets.md) — home-screen widget iOS+Android; gotchas: iOS
- [PDF scrolled-mode lag (#4795/#4031)](pdf-scroll-lag-preload-4795.md) — 415ms/page render vs 50% margin; widen to
- [Scrolled-PDF pinch-zoom (#4817)](scrolled-pdf-pinch-zoom-4817.md) — live pinch + no-shift (gap×`--scroll-zoom` +
- [Search modes #4560 + spoiler-bound bug](search-modes-4560-and-spoiler-bound-bug.md) — regex + nearby-words;
- [OPDS groups carousel (#4750)](opds-groups-carousel-4750.md) — >=2 groups → virtuoso carousel; `scrollToIndex`
- [WebDAV browser sort + search (#4724)](webdav-browse-sort-search-4724.md) — PR #4786;
- [Image zoom trackpad flicker (#4742)](image-zoom-trackpad-flicker-4742.md) — macOS pinch=`ctrl+wheel`;
- [Instant Highlight ate tap/swipe](instant-highlight-tap-paginate.md) — preventDefault killed tap-paginate;
- [Keyboard selection adjust (#4728)](keyboard-selection-adjust-4728.md) — Shift+←/→ char, Ctrl/Alt+Shift word;
- [Double-click word select](iframe-double-click-word-select.md) — orphaned `iframe-double-click`
- [Cross-page selection auto-turn (#4741)](cross-page-selection-autoturn-4741.md) — `useAutoPageTurn` dwell;
- [Annotator onLoad listener leak (#4735)](annotator-onload-listener-leak-paragraph-mode.md) — `useRendererInputListener
- [Paragraph mode toggle/resume (#4717)](paragraph-mode-toggle-resume-4717.md) — snapshot live Set; resume from fresh
- [Paragraph-mode accidental exit (#4474)](paragraph-mode-accidental-exit-4474.md) — `paragraph-show-controls`; bar
- [#4584 tap-death](issue-4584-tap-death-investigation.md) — UNFIXED; `isPopuped` RED HERRING; likely WebView-148
- [Dblclick-drag turns page (#4524)](dblclick-drag-pageturn-4524.md) — `isMouseDown` gates `postSingleClick`
- [Tap to open image/table (#4600)](tap-to-open-image-table-4600.md) — `iframe-open-media` + `detectMediaTarget`
- [PDF/CBZ Contrast view-menu](pdf-cbz-contrast-view-menu.md) — per-book `contrast`; ONE `filter:` (invert+contrast);
- [iOS instant-dict double popup](ios-instant-dict-double-popup.md) — once-per-gesture latch; `isLongPressHold` 300ms
- Dict: [popup font size #4443](dict-popup-font-size-4443.md) `--dict-font-scale` MDict `::part(dict-content)`; [lemmatization #4574](dict-lemmatization-4574.md)
- Word Lens: [inline gloss](wordlens-feature.md) CFI-safe `<ruby cfi-skip>…<rt cfi-inert>` TTS/search isolation; [en-en](wordlens-en-en.md)
- [Stripe highest-active plan (#4694)](stripe-plan-highest-active-4694.md) — `plans.plan` = MAX over active subs
- [Save image to gallery (#4680)](save-image-to-gallery-android.md) — MediaStore; sharekit 0-byte self-copy
- [Webtoon Mode (#3647)](webtoon-mode-3647.md) — no-gap scrolled images; FXL fit-width; `--scroll-page-gap`
- [Biometric app-lock (#4645)](biometric-app-lock-4645.md) — flag from `appLockStore`; plugin `cfg(mobile)`
- [Reference Pages (#4542)](reference-pages-672-4542.md) — 'reference' progressStyle; `referencePageCount`
- [E-ink screen refresh page-turner (#4687)](eink-screen-refresh-pageturner-4687.md) — bindable 'refresh' action;
- [Share intent + toolbar (#4014)](annotation-share-toolbar-4014.md) — Share gated mobile+macOS;
- [Instant highlight delete orphan (#4773)](instant-highlight-delete-orphan-4773.md) — stale memoized index + in-place
- [Empty highlight leak on annotate cancel (#4791)](empty-highlight-leak-on-annotate-cancel-4791.md) — Annotate
- [Customize Toolbar global (#4760)](customize-toolbar-global-serializeconfig.md) — `serializeConfig` ref-compare →
- [Customize Toolbar e-ink black bar (#4839)](customize-toolbar-eink-black-bar-4839.md) — preview Zone copied
- Native TTS: [iOS #4676](native-ios-tts-4676.md) AVSpeechSynthesizer pause==stop rate `pow^(1/2.5)`; [offline halt #4613](native-tts-offline-autoadvance-4613.md)
- Edge TTS: [word highlight #4017](edge-tts-word-highlighting-4017.md) `audio.metadata` WordBoundary by rAF gate on UA; [drift](tts-word-highlight-singletextnode-drift.md)
- [TTS highlight granularity setting](tts-highlight-granularity-setting.md) — Word/Sentence; gate `prepareSpeakWords`
- [TTS start-from-selection](tts-start-from-selection.md) — use last mark at/before sel; cloneRange+deselect
- [Reuse TTS session on mode entry](tts-reuse-session-mode-entry.md) — `redispatchPosition()` + `tts-sync-request`
- RSVP: [control-bar overlap REVERT](rsvp-control-bar-overlap-revert.md) #4585 fixed/#4589 stale-reverted; [font face/family #4519](rsvp-font-settings-4519.md) `getBaseFontFamily` overlay top doc; [RTL word #4630](rsvp-rtl-word-display-4630.md)
- [Overlay z-index scale](zindex-overlay-scale.md) — RSVP 100 / Settings 110 / ModalPortal 120 / toast 130 / app-lock
- [Global annotation page-turn lag (#4575)](global-annotation-pageturn-perf-4575.md) — `global` re-fanned every turn;
- [Overlayer splitRange text nodes](overlayer-splitrange-textnodes.md) — `'p,h1-h4'` dropped `li`; walk text nodes +
- [Android image callout freeze](android-image-callout-freeze.md) — `.no-context-menu` on ANCESTOR
- [Table dark-mode tint (#4419)](table-dark-mode-tint-4419.md) — `blockquote, table *` tint gated on `overrideColor`
- [Footnote aside border line (#4438)](footnote-aside-namespace-order-4438.md) — @font-face before @namespace; hoist
- Proofread: [enhancements #4700](proofread-enhancements-4700.md) regex UI/Ctrl+P reuse/`wholeWord` no-op; [per-book CRDT #4781](proofread-per-book-crdt-sync.md)
- [Russian hanging-preposition NBSP (#4769)](russian-hanging-prepositions-nbsp-4769.md) — generic `nbsp` +
- OPDS: [Firefox strict-XML #4479](opds-firefox-strict-xml-4479.md) junk after `</feed>` slice to last close; [JSON search #4502](opds2-json-search-4502.md) expand `{?query}` BEFORE resolveURL; [HTML desc #4503](opds-html-description-4503.md) decode-once+sanitize; [self-link meta #4749](opds-self-link-metadata-4749.md) `rel:self` deref; [popular dedup #4782](opds-popular-catalog-dedup-4782.md)
- [D-pad Navigation](dpad-navigation.md) — Android TV remote / arrow-key nav
- [koplugin cover upload (#4374)](koplugin-cover-upload.md) — `extractLocalCover` via `getCoverImage`
## Library Fixes
- [Tauri menu append race (#4389)](tauri-menu-append-race-4389.md) — un-awaited `Menu.append()` (async IPC) in `BookshelfItem.tsx` context-menu items shuffle order every open (native only, invisible in jsdom); fix = single `await Menu.new({ items })` of ordered `MenuItemOptions`; order/inclusion extracted to pure `getBookContextMenuItemIds` for unit testing
- [TXT author recognition (#4390)](txt-author-recognition-4390.md) — 【】-titled Chinese web-novels show author missing/garbage; they're TXT→EPUB (title==full filename is the tell, check `txt.ts` not foliate-js); `extractTxtFilenameMetadata` only handled 《》 + greedy header capture grabbed metadata blobs; fix = `parseLabeledAuthor` for any filename + `isPlausibleAuthorName` guard
## Architecture Notes
- foliate-js is a git submodule at `packages/foliate-js/`
- Multiview paginator: loads adjacent sections in background, multiple View/Overlayer instances per book
- Style overrides: `getLayoutStyles()` (always), `getColorStyles()` (when overriding color)
- `transformStylesheet()` does regex-based EPUB CSS rewriting at load time
- TTS uses independent section tracking (`#ttsSectionIndex`) decoupled from view
- Safe area insets flow: Native plugin -> useSafeAreaInsets hook -> component styles
- Dropdown menus use `DropdownContext` (not blur-based) for screen reader compat
- [Foliate touch-listener capture phase](foliate-touch-listener-capture-phase.md) — to suppress reader gestures from the app, use `{capture:true}`; the paginator registers bubble-phase doc listeners first (during `view.open()`)
- [iframe cross-realm instanceof](iframe-cross-realm-instanceof.md) — app-bundle code (style.ts, iframeEventHandlers.ts) runs in top realm; `iframeEl instanceof Element` is ALWAYS false → guards silently drop all iframe elements (passes jsdom, dead in app). Duck-type `'closest' in target` instead. Bit PR #4391's touch routing + applyTableStyle dedupe
## Workflow
- [Test file filter](feedback_test_file_filter.md) — use `pnpm test <path>` without `--` to run a single file
- [Always rebase before PR](feedback_pr_rebase.md) — rebase onto origin/main before creating PRs
- [New branch per PR](feedback_pr_new_branch.md) — always create a fresh branch from main for each new PR/issue
- [Upgrade gstack locally](feedback_gstack_upgrade.md) — always upgrade from the project's .claude/skills/gstack, not global
- [No lookbehind regex](feedback_no_lookbehind_regex.md) — never use `(?<=)` or `(?<!)` in JS/TS; build check rejects them
- [Use worktree](feedback_use_worktree.md) — never `git worktree add` directly; always `pnpm worktree:new` before PR review, issue fix, or feature work
- [en/translation.json holds ONLY plural variants + proper nouns](feedback_en_plurals_manual.md) — non-plural strings stay out (defaultValue: key is the en source); plural strings (`_('...', { count })`) need hand-added `_one`/`_other` entries or the singular renders as "1 days"
- [Never push on every change](feedback_dont_push_every_change.md) — hold pushes during active bug iteration; commit locally only until user confirms or work hits a clean done-state
- [No test seams in production code](feedback_no_test_seams_in_prod.md) — production must never import or call `__reset*ForTests`; cross-module test resets belong in the test file's beforeEach/afterEach
- [Book action platform surfaces](book-actions-platform-surfaces.md) — context menu Tauri-desktop-only; cross-platform
- [Tauri menu append race (#4389)](tauri-menu-append-race-4389.md) — single `await Menu.new({ items })`
- TXT: [author recognition #4390](txt-author-recognition-4390.md) `parseLabeledAuthor`+`isPlausibleAuthorName`; [chapter measure-word FP #4658](txt-chapter-measure-word-4658.md)
- [Cover stale (in-place mutation)](cover-stale-inplace-mutation-memo.md) — pure `getBookWithUpdatedMetadata`
- [Series/author back no-op (#4437)](series-folder-back-noop-4437.md) — Next 16.2 empty-search no-op; `handleBack`
- [Library/reader separate texture (#4743)](library-reader-separate-texture-4743.md) — `libraryBackground*`
- [List view series overflow (#4796)](list-view-series-overflow-4796.md) — fixed `h-28` clipped series+description
- [Recently-read shelf (#3797)](recent-read-shelf-3797.md) — PR #4829; reuse `BookItem` + shared `useOpenBook`; flex
## Architecture & Patterns
- foliate-js submodule at `packages/foliate-js/`; multiview paginator preloads adjacent sections
- [Markdown .md support (#774)](markdown-md-support-774.md) — in-memory foliate book (no EPUB) in `src/utils/md.ts`;
- Style: `getLayoutStyles()` always, `getColorStyles()` when overriding color; `transformStylesheet()` rewrites EPUB CSS at load
- TTS independent section tracking (`#ttsSectionIndex`); safe insets: native plugin → useSafeAreaInsets → styles; Dropdowns `DropdownContext`
- 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
- [iframe cross-realm instanceof](iframe-cross-realm-instanceof.md) — `instanceof Element` false; duck-type `'closest'
- [Virtuoso + OverlayScrollbars](virtuoso_overlayscrollbars.md) — useOverlayScrollbars for mobile webviews
- [Design system → DESIGN.md](feedback_design_system_doc.md) — never `pl/pr/ml/mr/text-left/right` (RTL)
## Workflow & Feedback
- [Commit messages English-only](feedback-commit-message-english-only.md) — English only (no CJK, no em/en dashes)
- [Test file filter](feedback_test_file_filter.md) — `pnpm test <path>` without `--` runs one file
- [Rebase before PR](feedback_pr_rebase.md) — rebase onto origin/main before PRs
- [New branch per PR](feedback_pr_new_branch.md) — fresh branch from main per PR/issue
- [Use worktree](feedback_use_worktree.md) — never `git worktree add`; always `pnpm worktree:new`
- [Never push every change](feedback_dont_push_every_change.md) — commit locally until user confirms
- [No test seams in prod](feedback_no_test_seams_in_prod.md) — prod never imports `__reset*ForTests`
- [No lookbehind regex](feedback_no_lookbehind_regex.md) — never `(?<=)`/`(?<!)`; build check rejects
- [en plurals manual](feedback_en_plurals_manual.md) — only plural variants + proper nouns; `_one`/`_other`
- [i18n:extract prunes keys](i18n-extract-prunes-keys.md) — `removeUnusedKeys` deletes valid keys; revert churn, add
- [Dependabot transitive fixes](dependabot-pnpm-overrides.md) — pin in `pnpm-workspace.yaml` `overrides:`; alert#≠issue#
- [Upgrade gstack locally](feedback_gstack_upgrade.md) — upgrade from project `.claude/skills/gstack`
@@ -0,0 +1,21 @@
---
name: android-cdp-e2e-lane
description: "pnpm test:android — CDP+adb e2e lane driving the installed app on a device/emulator; harness design, gotchas, CI workflow"
metadata:
node_type: memory
type: project
originSessionId: 16f94822-04b0-4be3-a47e-8a2e3cab290a
---
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).
@@ -0,0 +1,30 @@
---
name: android-hyphen-selection-bounds-1553
description: "#1553 Android selection breaks on first word of hyphenated paragraphs — Blink generated-hyphen bounds bug, full RCA + app-side repair/suppress fix"
metadata:
node_type: memory
type: project
originSessionId: 16f94822-04b0-4be3-a47e-8a2e3cab290a
---
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, "&shy;" 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).
@@ -0,0 +1,27 @@
---
name: android-nativefile-remotefile-io
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"
metadata:
node_type: memory
type: project
originSessionId: 8057ac9c-2e3e-446d-86aa-29baddfbfe66
---
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.
**Side-observation:** installed build's ACL rejects `plugin:fs|close` (allows open/seek/read) → possible `NativeFile` handle-leak / `close()` throw path; verify `fs:default` grants. See [[cdp-android-webview-profiling]].
@@ -0,0 +1,26 @@
---
name: android-open-with-intent-flow
description: "Android \"Open with/Send to Readest\" intent pipeline + why Telegram/cloud-app opens differ from file-manager opens (issue"
metadata:
node_type: memory
type: project
originSessionId: 73ee84b7-27a0-4232-981c-de8235a15f07
---
Android "Open with Readest" / "Send to Readest" file-intent pipeline and the #4521 ("open book from Telegram") diagnosis. Confirmed fixed by #4527.
**Pipeline (ACTION_VIEW = "Open with", ACTION_SEND = "Share"):**
- `NativeBridgePlugin.kt::handleIntent` is the real handler (NOT `MainActivity.kt` — its ACTION_SEND branch is legacy/redundant). → `emitSharedIntent("VIEW"|"SEND", uris)` → JS `useAppUrlIngress` `shared-intent` plugin listener → `app-incoming-url` event → `useOpenWithBooks`.
- VIEW 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"`.
Critical files: `src-tauri/plugins/tauri-plugin-native-bridge/android/src/main/java/NativeBridgePlugin.kt`, `src/hooks/useAppUrlIngress.ts`, `src/hooks/useOpenWithBooks.ts`, `src/services/nativeAppService.ts::openFile`, `src/services/bookContent.ts::resolveBookContentSource`.
@@ -0,0 +1,14 @@
---
name: android-sideload-same-versioncode
description: Android sideloaded APK reinstall allows EQUAL versionCode; only strictly-lower is blocked
metadata:
node_type: memory
type: reference
originSessionId: a58a4eba-7a3c-4560-9b52-e3713c6ad211
---
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.
@@ -0,0 +1,52 @@
---
name: android-themed-icon-4733
description: "Android Material-You themed (monochrome) launcher icon — restoring it (#4733), the gen/android force-commit pipeline, and emulator verification"
metadata:
node_type: memory
type: project
originSessionId: 6bc82dac-a705-4ef2-ab28-c13b43f48a46
---
Issue #4733 = add Android themed (Material You / monochrome) launcher icon. It had
existed (#2122/#2153 added `ic_launcher_monochrome.png`) but PR #2353 ("fixed
launcher icon size") rewrote the committed adaptive icon to inset the foreground
22% and **silently dropped the `<monochrome>` layer**, so themed icons stopped
working. Fix = re-add `<monochrome><inset android:drawable="@mipmap/ic_launcher_monochrome" android:inset="22%"/></monochrome>`
to `ic_launcher.xml` + ship the monochrome mipmaps.
**Android icon pipeline (non-obvious).** `src-tauri/gen` is gitignored, BUT specific
customized res files are **force-added** (tracked): `mipmap-anydpi-v26/ic_launcher.xml`,
`drawable/ic_launcher_background.xml`, `values/themes.xml`, `splash_icon.png`. CI
(release/nightly/android-e2e) does `rm -rf gen/android``tauri android init`
`tauri icon ../../data/icons/readest-book.png`**`git checkout .`** (restores the
tracked customizations) → build. So **the committed gen files are the build's source
of truth.** `tauri icon` (CLI 2.10.1) writes gen mipmaps + a DEFAULT `ic_launcher.xml`
(foreground+background only) and does NOT emit a monochrome layer — so the monochrome
PNGs (or a vector drawable) MUST be force-committed into `gen/.../res/` to survive
`git checkout .`. `git add -f apps/.../gen/.../mipmap-*/ic_launcher_monochrome.png`.
`src-tauri/icons/android/*` is the historical master but is NOT what the build reads.
**Themed tint = SRC_IN (alpha only).** The launcher replaces the monochrome layer's
RGB with the wallpaper tint, keeping only alpha → any fully-opaque artwork flattens
to a solid blob (the original desaturated-logo monochrome lost all detail). Convey
character via negative space. For Readest we kept the existing artwork and carved a
**narrow vertical center-gap (spine)** via an alpha-multiply mask (ImageMagick:
`magick src -alpha extract a.png; magick -size WxH xc:white -fill black -draw "roundrectangle ..." g.png; magick a.png g.png -compose multiply -composite na.png; magick src na.png -alpha off -compose CopyOpacity -composite out.png`),
gap ≈ centered, width ~4% of content, from ~3%→84% of content height (pages stay
joined at the binding). Preview-as-themed = tint `-colorize`, inset to central 56%
(=22% inset), composite over dark bg, circular mask.
**Emulator verify (Pixel_9_Pro AVD, Google Play image, NexusLauncher).** Themed icons
toggle: Wallpaper & style (`am start -n com.google.android.apps.wallpaper/com.android.customization.picker.CustomizationPickerActivity`)
→ "Home screen" tab → "Themed icons" switch. Only the **home screen/dock** is themed;
the **app drawer keeps full color** (expected, not a bug). `uiautomator dump` returns
"null root node" on the wallpaper picker (SurfaceView) → navigate by screenshot
coords. Build for emulator = `pnpm tauri android build --debug --target aarch64 --apk`
(NDK_HOME must be set). Gradle-standalone (`./gradlew :app:assembleUniversalDebug`)
fails: the `rustBuild*` task shells `pnpm tauri ...` which panics at
`tauri-cli/src/mobile/mod.rs:403` unless driven by `tauri android build`. Confirm the
APK packaged it: `aapt2 dump xmltree --file res/mipmap-anydpi-v26/ic_launcher.xml app.apk`
should show an `E: monochrome` node. Regression guard:
`src/__tests__/android/themed-icon.test.ts` (asserts `<monochrome>` in the XML + a
tracked monochrome mipmap per density). Related: [[dict-lookup-browser-hijack-4559]]
(Android resource/manifest gotchas), [[android-cdp-e2e-lane]].
@@ -0,0 +1,64 @@
---
name: annotation-share-toolbar-4014
description: "Share intent in the selection toolbar + drag-and-drop toolbar customizer (#4014)"
metadata:
node_type: memory
type: project
originSessionId: 507a0166-cb55-4f33-b633-3230c0c514ff
---
#4014 (PR #4570) — added a native "Share" tool to the in-reader text-selection toolbar
plus a drag-and-drop customizer (show/hide + reorder tools). Branch
`feat/annotation-share-toolbar-4014`; spec + plan in
`docs/superpowers/{specs,plans}/2026-06-13-annotation-share-toolbar*`.
Key facts / gotchas:
- **`src/utils/share.ts` is dual-purpose** — it already held share-LINK helpers
(`buildShareUrl`/`parseShareDeepLink` for the `/s/{token}` feature). Text-share was
added there: `shareSelectedText(text, position?, appService?)` and
`canShareText(appService)`.
- **Native share is gated to mobile + macOS only** (`isMobileApp || isMacOSApp`).
Windows/Linux desktop are excluded because `@choochmeque/tauri-plugin-sharekit-api`'s
share UI can FREEZE the app on Windows (issue #4343) — `nativeAppService.saveFile`
gates `shareFile` the same way. Ladder: native → `navigator.share` → clipboard.
`canShareText` = that OR web `navigator.share`; used to gate Share's visibility in
toolbar + customizer + the quick-action dropdown.
- **Toolbar order is a view setting**: `AnnotatorConfig.annotationToolbarItems`
(`src/types/book.ts`), default in `DEFAULT_ANNOTATOR_CONFIG` = the original 8 tools,
**Share hidden by default** (starts in the "Available" tray). No migration needed:
the `{...getDefaultViewSettings(ctx), ...saved}` merge in `settingsService.ts` +
`getToolbarToolTypes(undefined,...)` fallback both yield the default.
- **Pure helpers** in `src/utils/annotationToolbar.ts` (unit-tested) own all
order/visibility logic: `getToolbarToolTypes`/`getAvailableToolTypes` (canShare-gated,
dedup, drop-unknown), `add/remove/reorderToolbar`. `ALL_ANNOTATION_TOOL_TYPES` is
asserted to match the `annotationToolButtons` registry order by a test.
- **Customizer** = `src/components/settings/AnnotationToolbarCustomizer.tsx`, a sub-page
off `ControlPanel` (Behavior panel) via `NavigationRow`. Two `@dnd-kit` zones; chips are
tap-to-toggle AND drag. Design evolved heavily during live browser testing (see gotchas):
- **WYSIWYG**: "In toolbar" renders a faithful preview of the real selection popup —
`selection-popup bg-gray-600 text-white`, icon-only 32×32 buttons (mirrors
`AnnotationToolButton`), `w-fit max-w-full` (content-width, start-aligned). "Available"
tools are labeled icon+text chips. Zone content uses `px-4` to align with `SubPageHeader`.
- **dnd-kit multiple-containers pattern** (NOT the simple single-list one): single
`{toolbar, available}` state; `onDragOver` live-reparents across zones; custom
`collisionDetection` = `pointerWithin``rectIntersection` fallback, snapping a zone-id
hit to the closest inner chip (plain `closestCorners`/`closestCenter` CANNOT drop into an
empty zone). `rectSortingStrategy` (NOT `horizontalListSortingStrategy`, which breaks
wrapped layouts).
- **NO `DragOverlay`** — the settings modal is a CSS-`transform` container, so a
`position:fixed` overlay is offset from the cursor. In-place `useSortable` transform
(relative translate) tracks correctly.
- **`itemsRef` stale-closure fix**: dnd-kit calls `onDragEnd` with the handler captured at
drag START, so the closed-over `items` is stale → a cross-zone drag would bounce back on
release. Read live state from `itemsRef.current` in `handleDragEnd`/tap handlers.
- **Add all** (rebuilds in canonical `ALL_ANNOTATION_TOOL_TYPES` order, NOT prior order) /
**Clear all** header buttons.
- Cross-platform guard: when editing on a `!canShare` device, `persist` re-appends a
`share` that was synced-in but hidden, so it isn't dropped for share-capable devices.
- **Empty toolbar suppresses the popup**: when `getToolbarToolTypes` yields [] (user cleared
all), `Annotator.tsx` does NOT render the `AnnotationPopup` on a plain selection (gated on
`toolButtons.length > 0 || highlightOptionsVisible || annotationNotes.length > 0`) — no
empty bar, but highlight-edit/notes popups still work. (Earlier tried fallback-to-default;
user wanted full suppression instead.)
- Adding a tool to the union (`AnnotationToolType`) is compile-checked: the
`createAnnotationToolButtons` generic in `AnnotationTools.tsx` requires every member.
@@ -0,0 +1,25 @@
---
name: annotator-onload-listener-leak-paragraph-mode
description: "Paragraph mode degrades over chapters on Android (#4735) — Annotator onLoad leaked renderer-scroll + native-touch listeners on long-lived objects; per-view fix + the reusable per-section-leak pattern"
metadata:
node_type: memory
type: project
originSessionId: 980f8d79-9360-4402-bd49-8dd389200c1e
---
PR #4735 (`fix/annotator-input-listener-leak`). Reported: reading a 3000-chapter web novel in **paragraph reading mode** on Android (Z Fold 7), paragraph transitions get sluggish after a few chapters and keep degrading until app restart. Classic per-section-transition resource leak.
**Root cause — Annotator `onLoad` attaches listeners to objects that OUTLIVE the section.** `Annotator.tsx`'s `onLoad` (wired via `useFoliateEvents(view, { onLoad })` to the foliate `load` event, which fires once per section document load) did:
- `view.renderer.addEventListener('scroll', handleScroll)` — never removed
- `view.renderer.addEventListener('scroll', () => repositionPopups())` — anonymous, unremovable
- Android: `eventDispatcher.on('native-touch', handleNativeTouch)` — never `off`'d
`view.renderer` is created ONCE per book (`createElement('foliate-view')` + `view.open()` in `FoliateViewer.tsx`), lives the whole session; the global `eventDispatcher` too. So every `load` permanently adds listeners. **foliate fires `load` for PRELOADED neighbour sections too** (`paginator.js#loadAdjacentSection``dispatchEvent(new CustomEvent('load',…))`, `#preloadNext` loads up to 8), so the accrual is several-per-chapter, not one. The doc-scoped `detail.doc.addEventListener(...)` listeners do NOT leak (the section iframe is destroyed by foliate's `#destroyView`, taking them with it). Only the renderer-/dispatcher-scoped ones leak.
**Why paragraph mode + Android specifically.** Every paragraph advance calls `renderer.goTo({index, anchor})` (`focusCurrentParagraph`), which scrolls the renderer container → `paginator.js:~1161` `this.#container.addEventListener('scroll', () => { if(!#isAnimating) dispatchEvent(new Event('scroll')) })` → runs ALL accumulated scroll listeners. Normal paginated reading scrolls only on occasional page turns, so the same leak is far less felt. Cost is REAL on Android: `handleScroll` (useTextSelector.ts, the `#873` selection-pin workaround) early-returns unless `osPlatform==='android'`, then calls `getViewSettings`; `native-touch` is Android-only. Restart recreates the view/renderer → cleared (the reporter's workaround).
**Fix = `useRendererInputListeners(view, {...})` hook** (`src/app/reader/hooks/`): registers the renderer `scroll` + (Android) `native-touch` listeners ONCE per view in an effect keyed `[view, enableNativeTouch]`, with cleanup; handlers routed through refs so re-renders never re-subscribe. The native-touch handler now resolves the CURRENT primary section's doc/index at fire time (`view.renderer.getContents().find(c=>c.index===primaryIndex)`) instead of capturing a load's doc/index (a load may be an off-screen preload — and the old code fan-fired EVERY loaded section's handler per touch, calling handleTouchEnd/handlePointerUp N times; new code fires once = strictly more correct). Dropped the redundant `scroll→repositionPopups` (a dedicated effect already repositions popups on scroll). `listenToNativeTouchEvents()` just sets one global `window.onNativeTouch` that re-dispatches `native-touch` via eventDispatcher — idempotent, fine to call once/view.
**Reusable pattern (the lesson).** Listeners attached inside a per-section / per-event handler (`onLoad`, `load`, relocate, create-overlay) to an object that outlives that event (`view.renderer`, global `eventDispatcher`, `window`, `document`) LEAK one set per event. Audit `addEventListener`/`eventDispatcher.on` inside `onLoad`-style handlers: if the target isn't the per-section `detail.doc` (which dies with the iframe), it must move to a per-view `useEffect` with cleanup. `eventDispatcher` (`utils/event.ts`) stores async listeners in a per-event `Set` keyed by callback reference; fresh closures each call never dedupe → unbounded.
**Verify gotchas.** Hook unit-tested with `renderHook` + a `MockRenderer extends EventTarget` tracking scroll listeners + a mocked `eventDispatcher` Set; assert size stays 1 across 20 re-renders, latest-handler routing, unmount→0, Android gate. NOTE: creating the mock `view` INSIDE the renderHook callback churns view identity → the `[view]`-keyed effect re-runs each render (still no leak — cleanup keeps Set at 1 — but `listenToNativeTouchEvents` call-count grows); hoist `view` outside to mirror the real stable `getView(bookKey)`. Needs on-device Android verification (Android-gated paths). Related: [[paragraph-mode-toggle-resume-4717]], [[tts-sync-paragraph-rsvp-3235]], [[android-nativefile-remotefile-io]].
@@ -0,0 +1,16 @@
---
name: backup-windows-zip-paths-4703
description: Backup zip exported on Windows failed to restore anywhere — backslash separators in zip entry names
metadata:
node_type: memory
type: project
originSessionId: dd015419-996e-466b-8039-f2d98312d9d6
---
#4703: Backup `.zip` exported on Windows wouldn't restore on any platform (Web/Android/Windows) — books restored with metadata but missing files/covers.
**Root cause:** `appService.readDirectory` returns paths with the host separator. On Windows `nativeAppService.readDir``getRelativePath` strips the base prefix but leaves backslashes, so `file.path` is `hash\cover.png`. `addBackupEntriesToZip` used `file.path` verbatim as the zip entry name. Restore (`restoreFromBackupZip`) matches a book's files by `e.filename.startsWith(`${hash}/`)` (forward slash) → backslash names never match → all files silently skipped. (The "garbled Unicode" reported in zip viewers was just the `\` rendered oddly.)
**Fix (export side only):** normalize the zip entry name to forward slashes — `file.path.replace(/\\/g, '/')` — in `addBackupEntriesToZip` (`src/services/backupService.ts`). Keep `file.path` (host separators) for `readFile`. Test: `backup-windows-paths.test.ts` drives the now-exported `addBackupEntriesToZip` with a capturing ZipWriter stub + Windows-style backslash listing (no zip.js workers needed; the forced `useWebWorkers`/`useCompressionStream` config makes a real round-trip impractical under jsdom).
**General lesson:** `readDirectory`/`readDir` paths carry host separators; normalize to `/` at any cross-platform boundary (zip entries, sync keys, anything serialized for another device). Already-broken Windows backups still need re-export — restore was left unchanged (minimal fix; matches the issue's expected behavior). See [[platform-compat-fixes]].
@@ -0,0 +1,17 @@
---
name: biometric-app-lock-4645
description: "Biometric (fingerprint/Face ID) startup unlock layered over the PIN app-lock; gotchas for applock-store seeding, mobile-cfg crate, and scoped i18n"
metadata:
node_type: memory
type: project
originSessionId: 7d0f633b-0e69-405e-a4b4-5a1b19723d86
---
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).
@@ -0,0 +1,14 @@
---
name: book-actions-platform-surfaces
description: Where to add a library book action so it reaches every platform (context menu is desktop-only)
metadata:
node_type: memory
type: project
originSessionId: 4efb7b40-cce1-4742-9730-7e93e643d196
---
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.
@@ -0,0 +1,20 @@
---
name: cdp-android-webview-profiling
description: "How to drive the Android WebView via CDP (adb) to run JS probes/benchmarks inside the live Readest app, and the gotchas that waste time"
metadata:
node_type: memory
type: reference
originSessionId: 8057ac9c-2e3e-446d-86aa-29baddfbfe66
---
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.
@@ -0,0 +1,26 @@
---
name: ci-pr-delivery-and-push
description: Delivering small PRs from a dirty dev tree without a worktree; the slow pre-push hook + proxy SSH-drop and its keepalive fix
metadata:
node_type: memory
type: project
originSessionId: c1097233-8b53-422a-98ec-3f0146f32f6b
---
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:
```
export GIT_INDEX_FILE=$(mktemp); git read-tree <BASE>
git update-index --cacheinfo 100644,$(git rev-parse HEAD:<path>),<path> # per changed file
TREE=$(git write-tree); unset GIT_INDEX_FILE
NEW=$(git log --format=%B -n1 HEAD | git commit-tree $TREE -p <BASE>)
git update-ref refs/heads/<branch> $NEW
```
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]].
@@ -0,0 +1,47 @@
---
name: cover-bg-image-texture-suppression
description: Cover painted via body background-image vanished under an active bg texture (parchment) because textureAwareBackground misclassified it as transparent
metadata:
node_type: memory
type: project
originSessionId: 9d32520c-53be-4871-9104-d93617736e30
---
EPUB cover pages that paint the cover via a `<body>` CSS `background-image`
(EPUB sets `background-color` transparent + `background-size:100% 100%`, no
`<img>` — e.g. Sigil/duokan样书《商梯》) showed the **background texture instead
of the cover** on the first page. Reported "Xiaomi only" but it's
texture-only, not Android-only.
Root cause (verified on-device via adb+CDP, Xiaomi 13 WV147): foliate
`packages/foliate-js/paginator.js` `textureAwareBackground(resolved, hasTexture)`.
foliate captures the body bg into `view.docBackground` via
`getComputedStyle(body).background` (the SHORTHAND), which always serializes the
transparent background-*color* first: `rgba(0, 0, 0, 0) url("blob:…") no-repeat
fixed 50% 50% / 100% 100% …`. The old `isTransparent` regex
`/^\s*(transparent|rgba\(0,\s*0,\s*0,\s*0\))/` matched that prefix → under an
active texture (`--bg-texture-id` != none) it returned `''` → no bg segment in
the host `#background` → texture (`.foliate-viewer::before`) showed through. With
no texture it worked (returns the cover bg unchanged), which is why desktop/
default looked fine.
Fix: a bg that carries an image is NOT transparent. Add `hasImage =
/\burl\(/i.test(resolved)` and gate `isTransparent` on `!hasImage`. A full-page
cover should occlude the texture; plain `none` transparent pages still drop so
the texture shows through. Helps scrolled (line ~1464) and paginated (~1482)
callers alike. Test: `paginator-background-segments.test.ts` (added the
url()-keeps case; kept the existing `none`-drops case).
NOT the bug (ruled out on-device): Rust `parse_epub_metadata` cover EXTRACTION
(library thumbnail was correct), shorthand serialization (WV147 emits the url
fine), the cover blob URL (loads 1200x1800 fine), `background-attachment:fixed`
(Android falls back to scroll but the segment sets `background-attachment:
initial` anyway). Related: [[paginated-texture-occlusion-4399]],
[[dark-mode-texture-body-bg-4446]], [[paginator-swipe-bg-flash]].
CDP verify recipe: pid changes per app restart — re-derive socket from
`/proc/net/unix` (`webview_devtools_remote_<pid>`), `adb forward tcp:9333
localabstract:…`; curl mishandles WV HTTP framing → raw-socket fetch `/json`;
pure-python WS client (omit Origin for M111+); paint a 50%-width test segment
with the cover blob bg into `#background` + `Page.captureScreenshot` to see
cover-vs-texture side by side.
@@ -0,0 +1,20 @@
---
name: cover-stale-inplace-mutation-memo
description: Library cover (or any memoized child) not updating until refresh — in-place object mutation defeats React.memo
metadata:
node_type: memory
type: project
originSessionId: ec78c172-79e7-448c-8671-780dcc115613
---
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]].
@@ -0,0 +1,57 @@
---
name: cross-page-selection-autoturn-4741
description: Cross-page selection/highlight in paginated mode via extracted useAutoPageTurn; all four selection gestures drive the corner-dwell turn
metadata:
node_type: memory
type: project
originSessionId: 33b70e98-fb55-467a-b03f-e4065491bc7e
---
#4741: in paginated (non-scrolling) mode, extend a selection/highlight past the
page edge by turning the page mid-gesture. Branch `feat/cross-page-highlight-autoturn`.
**Extracted `src/app/reader/hooks/useAutoPageTurn.ts`** from `useTextSelector`
the corner-dwell auto page-turn (#1354), now **decoupled from the DOM selection**
so selection-less gestures can drive it. API: `notePoint`/`noteAutoTurnPoint`
(window-coord engagement point), `cancel`, `isAutoTurning`, `onAfterTurn(cb)`
(Set of subs), `cornerAtPoint`, `readingAreaRect`. Liveness at dwell fire-time is
an injected predicate, not `doc.getSelection()`: `noteCorner(corner, isInCorner)`.
`useTextSelector` keeps the dual-signal native liveness (`pointerCornerNow ||
caretCornerNow`); point-only callers use `noteAutoTurnPoint` (last-point liveness).
Pure exports `getReadingAreaRect`, `turnForFocusBeyondPage`, `keyboardTurnDirection`.
**Key trap:** the old `armDwell` required a valid DOM selection to turn. Instant
Highlight (`user-select:none` + CFI overlay) and AnnotationRangeEditor (CFI
overlay) have **no** DOM selection, so the machine refused to turn for them. The
decoupling is what makes them work at all.
**Four gestures, all feeding the one machine** (`useTextSelector` re-exposes
`noteAutoTurnPoint`/`cancelAutoTurn`/`onAutoTurn` to the editors via `Annotator`):
1. Instant Highlight drag — `handlePointerMove`/`handleNativeTouchMove` feed the
finger corner. `useInstantAnnotation` now **DOM-anchors the start** (`startPosRef`
= `{node,offset}` at pointer-down; `buildRangeFromAnchor` builds anchor->end each
move) so it survives the scroll; relaxed the pointer-up `distance<10` cancel with
`&& !previewAnnotationRef.current`. See [[instant-highlight-tap-paginate]].
2. `SelectionRangeEditor` handle drag — already DOM-anchored the fixed end; just
feed `noteAutoTurnPoint(point)` + cancel + re-emit.
3. `AnnotationRangeEditor` handle drag — `useAnnotationEditor` changed from
`handleAnnotationRangeChange(startPt,endPt)` (`buildRangeFromPoints` resolved BOTH
ends from window coords -> lost previous page) to `applyAnnotationRange(range,...)`;
component anchors the non-dragged end (`fixedAnchorRef`) + builds via
`rangeFromAnchorToPoint` like SelectionRangeEditor.
4. `Shift+Arrow` keyboard adjust (#4728) — `useBookShortcuts.adjustTextSelection`,
after `extendSelectionFromContents`, **immediate turn-on-cross** (no dwell):
`keyboardTurnDirection(contents, getReadingAreaRect(...))` -> `view.next()/prev()`
when the extended focus leaves the page. Desktop-only; gated `!scrolled`.
**After-turn re-emit:** active gesture subscribes `onAfterTurn` to rebuild its range
from the held point onto the new page immediately (instant: `reapplyInstantAnnotation`;
editors: `subscribeAutoTurnReemit` -> `updateFromDraggedPoint(lastPoint)`). Native
selection does NOT subscribe (browser extends its own). The Android #873 scroll-pin
(`selectionPosition`) is re-anchored after every turn via `onAfterTurn` in useTextSelector.
`focusCaretWindowPos` promoted `useTextSelector` -> `src/utils/sel.ts` (keyboard reuse).
Scope: within-section column turns only (a Range can't span two iframe docs).
Tests: `useAutoPageTurn.test.ts` (21), `useTextSelector-instantTurn.test.ts`,
`useInstantAnnotation.test.ts`, `useAnnotationEditor.test.ts`; existing autoTurn/
instantHold suites stay green (regression net for the extraction).
@@ -0,0 +1,14 @@
---
name: customize-toolbar-eink-black-bar-4839
description: Customize Toolbar preview rendered as a solid black bar in e-ink; preview surfaces copying bg-gray-600 need eink-bordered
metadata:
type: project
---
#4839: the Customize Toolbar sub-page (`AnnotationToolbarCustomizer.tsx`) toolbar **preview** Zone copied the live popup's `selection-popup bg-gray-600 text-white` but rendered as an unreadable solid black bar under `[data-eink='true']`.
**Why:** the real reader popup earns its e-ink chrome from `.popup-container` (globals.css `[data-eink] .popup-container``bg base-100` + 1px `base-content` border). The preview Zone is a plain `<div>` with NO `popup-container`, so the dark `bg-gray-600` survived in e-ink; the base-content (inverted via `[data-eink] button`) chip icons then sat black-on-black.
**How to apply:** any e-ink "preview" surface that mimics the live popup must scope the dark fill to non-e-ink (`not-eink:bg-gray-600 not-eink:text-white`) and add `eink-bordered` so e-ink renders it as `bg-base-100` + 1px `base-content` border (don't just rely on `eink-bordered`'s `!important` to override the gray — drop the gray in e-ink outright). Also fix copied white hint text (`text-white/70``not-eink:text-white/70 eink:text-base-content`) since the surface turns base-100. Chip icons need no change — they are `<button>`s, already inverted to base-content by the global `[data-eink] button` rule. Guard: render test asserts `.selection-popup` element carries `eink-bordered`. Verify rendered colors via `getComputedStyle` under `[data-eink]` (set `data-theme='default-light'` first or theme vars are unresolved → transparent); note daisyUI returns **oklch** not rgb — e-ink correct = bg `oklch(1 0 0)`, border/icon `oklch(0.2 0 0)`. PR #4841.
Same feature as [[customize-toolbar-global-serializeconfig]]; e-ink conventions in [[feedback_design_system_doc]].
@@ -0,0 +1,48 @@
---
name: customize-toolbar-global-serializeconfig
description: Customize Toolbar applied per-book not global; root cause = serializeConfig compared viewSettings by reference (!==) so array values were always stored as stale per-book overrides
metadata:
node_type: memory
type: project
originSessionId: c6601464-9463-4ac3-99c0-e7527e4051b5
---
Customize Toolbar (annotation bar, #4014, shipped v0.11.12) changes only applied
to the book where edited, not globally. Fixed in PR #4760 (MERGED, squashed onto
main as 7da5f8321).
**Root cause:** `serializeConfig` (`src/utils/serializer.ts`) decides which per-book
viewSettings to persist as overrides via `globalViewSettings[key] !== value` — a
*reference* compare. It deep-clones the config first (`JSON.parse(JSON.stringify)`),
so any **array/object** viewSettings value (`annotationToolbarItems`, and latently
`paragraphMode`, `proofreadRules`, `ttsHighlightOptions`, `noteExportConfig`) is a
fresh reference ≠ global → stored as a per-book override on **every** save (progress
autosave serializes with settings each relocate). On reopen the merge
`{ ...globalViewSettings, ...perBookOverrides }` lets the stale override shadow
global → a global toolbar change never reaches already-saved books.
**Fix (final — minimal, general, no special-casing):** compare viewSettings values
by content, not reference. Added `isSameViewSettingValue(a,b) = a===b ||
JSON.stringify(a)===JSON.stringify(b)`, used in the viewSettings reduce ONLY
(searchConfig left on `!==` — it holds functions / large `results`). The field
stays `annotationToolbarItems` in `AnnotatorConfig` (normal per-book viewSettings,
honors the isGlobal "Apply to This Book" toggle). PR diff is just serializer.ts +
serializer.test.ts.
**Iteration history (user steered):** (1) a `GLOBAL_ONLY_VIEW_SETTINGS` exception
forcing global save + strip/ignore per-book — rejected "don't make it an exception";
(2) move field to `SystemSettings.globalReadSettings` — rejected "too much";
(3) rename `annotationToolbarItems``annotationToolbar` for a clean slate — rejected,
keep the original name (it's synced in globalViewSettings). Landing point: keep the
name, fix only the serializer reference-compare bug.
**Known limitation (no rename clean-slate):** existing books may carry a per-book
`annotationToolbarItems` override from the buggy v0.11.12 build. The value compare
stops new ones and drops an existing one on next save when it matches global, but
does NOT retroactively clear an override whose content differs from current global —
those books keep the stale toolbar until re-saved while equal to global. A follow-up
one-time migration (clear persisted per-book toolbar overrides) would close this if
needed.
Tests: `src/__tests__/utils/serializer.test.ts` — array setting equal to global is
not persisted; differing array still persisted.
@@ -0,0 +1,73 @@
---
name: dark-mode-texture-body-bg-4446
description: "#4446 dark-mode bg texture occluded by body.theme-dark opaque bg !important (style.ts getDarkModeLightBackgroundOverrides); verified on Xiaomi via CDP; multiview = patch ALL section iframes"
metadata:
node_type: memory
type: project
originSessionId: 61f864c0-488e-466c-89e9-86df66b57d42
---
RESOLVED — PR #4564 MERGED 2026-06-12 (`fix/dark-mode-texture-4446`, built on origin/main
via temp-index plumbing from the dirty dev tree; branch + local mods cleaned after merge).
User-verified on the Xiaomi via `pnpm dev-android` (full aarch64 build + `adb install -r`,
~7 min).
#4446 remaining case (after [[paginated-texture-occlusion-4399]] fixed light mode): in
**dark mode** the bg texture is absent in paginated entirely, and absent in the scrolled
text area while header/footer still show it.
**Root cause (verified live on Xiaomi 2211133C via CDP, no rebuild).**
`getDarkModeLightBackgroundOverrides` (style.ts ~194) emits
`body.theme-dark { background-color: ${bg} !important; }`, applied when
`isDarkMode && !overrideColor` (style.ts ~320). That paints every section iframe's body
opaque dark (`rgb(34,34,34)`), which occludes the host `.foliate-viewer::before` texture.
Downstream it also poisons foliate: `resolveBackground(view.docBackground)` resolves the
opaque body color, so `textureAwareBackground` keeps the paginated `#background` segment
and the scrolled `view.element` inline bg opaque too. The #4399 fix is intact (container
is transparent); this is a second, dark-mode-only occluder one layer deeper.
- Paginated: segment + iframe body span the full viewer → no texture anywhere.
- Scrolled: iframes cover only the text column → header/footer strips keep texture
(grid-cell + the #4486 notch overlay, see [[notch-mask-texture-4486]]).
**Proof:** injecting `body.theme-dark{background-color:transparent !important}` into ALL
section iframes + clearing the segment/view inline bgs reveals the leaves texture in both
modes instantly.
**Regression source (git-proven):** commit `176b950c9` = PR #4392 (2026-06-01, shipped
v0.11.4) added the rule. NOT foliate-js — the foliate swipe-flash regression (#4399,
167757a→142bf11) broke LIGHT-mode textures in the same release window, which is why it
looked like one. The transformStylesheet light-bg rewriter (also #4392) was EXONERATED
for the repro book: Alice's stylesheets have zero body/html background rules (verified by
on-device stylesheet enumeration). **No #4392 revert needed** — its callout attribute
selectors + rewriter fix real legibility bugs (#4028; #4419/#4426 build on them).
**Fix (applied):** make the rule `background-color: transparent !important`
UNCONDITIONALLY, not gated on hasBackgroundTexture, because foliate captures
`docBackground` once per section load (paginator.js `load` listener; `setStyles` re-runs
`#replaceBackground` but never re-captures), so a texture-gated body bg would go stale on
live texture toggling. Visuals without texture are identical: the dark fill comes from the
paginator container `fallbackBg` / reader grid cell. Book-forced light page bgs stay
neutralized (theme-dark fill shows through); page-level rewriter output is cascade-beaten
by our `!important` body rule and later-in-head `html` rule; only book `!important`
page rules survive — consistent with the #4399 "book-forced opaque page wins" policy.
Test: `style-get-styles.test.ts` "#4446" cases. E2E device-verified: with the fixed CSS
present at load, capture is transparent and the paginated segments array comes out EMPTY.
**Verification gotchas (cost ~30 min):**
- **Multiview!** The renderer shadow root holds MULTIPLE section iframes (adjacent preload).
Patching `sr.querySelector('iframe')` hits a section possibly 18k px off-viewport →
"fix didn't work". Patch every `sr.querySelectorAll('iframe')`.
- `elementsFromPoint` reports the iframe ELEMENT's computed bg (transparent) — the
occluding paint is its content document's body, invisible to the top-doc/shadow stack walk.
- Switching `renderer.setAttribute('flow', ...)` can reload section docs and silently wipe
styles injected into them (but not always — re-check after every flow switch).
- Pseudo-element paint test: patch the `#background-texture` style text with
`background-color: red` — if red doesn't show, the pseudo is occluded, not broken.
- **Stale preload views**: after patching styles via `renderer.setStyles`, views loaded
PRE-patch keep their opaque `docBackground` and `#clearViewsExcept` keeps `|iindex|≤2`
across navigation — an opaque segment can come from a kept old view, not the fresh one.
Jump ≥3 sections to guarantee fresh captures.
- **Capture-time instrumentation**: the paginator dispatches `load` synchronously right
BEFORE `docBackground = getBackground(doc)` — an event listener on the renderer sees
exactly what the capture will see (class list, computed bgs, active rules).
@@ -0,0 +1,47 @@
---
name: dblclick-drag-pageturn-4524
description: Web double-click-and-drag selection turned the page; deferred single-click fired mid-drag while button held
metadata:
node_type: memory
type: project
originSessionId: 5fe20151-9768-4e7c-9cee-2aa25da5318c
---
#4524: on Readest Web, **double-click a word then drag** to extend the native
selection also **turned the page** (a plain double-click did not). The user
expects browser-native double-click-drag word-by-word selection without a page
turn.
**Root cause** (`src/app/reader/utils/iframeEventHandlers.ts` `handleClick`):
the first click of a potential double-click schedules a deferred
`postSingleClick()` after `DOUBLE_CLICK_INTERVAL_THRESHOLD_MS` (250ms).
- Plain double-click: the 2nd `click` fires fast, updates `lastClickTime`, posts
`iframe-double-click`; when the 1st click's timer fires, the
`Date.now() - lastClickTime >= 250` check is now false → single-click
suppressed → no page turn.
- Double-click **+ drag**: the user holds the button down on the 2nd click and
drags, so the 2nd `mouseup`/`click` is delayed past 250ms. At first-click+250ms
`lastClickTime` is still the 1st click → check passes → `iframe-single-click`
posted **while the button is still held**`usePagination.handlePageFlip`
turns the page.
**Fix**: module-level `isMouseDown` flag (set in `handleMousedown`, cleared in
`handleMouseup`); the deferred `postSingleClick()` returns early when
`isMouseDown` is true (a drag is in progress). Cannot affect a normal single
click — `isMouseDown` is false by the time its deferred timer fires; only a
held button (drag) suppresses it.
**Verification gotcha**: reproduced live by dispatching synthetic
mousedown/mouseup/click to the reading iframe doc (found via deep shadow-DOM
walk; the foliate iframe sits in nested shadow roots, `document.querySelectorAll('iframe')`
returns 0). Watch `iframe-single-click` on `window` 'message' + the
`.progress-info-label` "N / M" page text. NOTE: back-to-back synthetic gestures
share the module's real `setTimeout` deferrals and `lastClickTime`, so a
follow-up "normal single click" repro can spuriously show no single-click — the
vitest unit test (fake timers) is the authoritative regression check, not
chained browser repros. Iframe listeners are attached once
(`detail.doc.isEventListenersAdded`), so a full page reload is required to pick
up edits — Fast Refresh won't re-bind them.
Test: `src/__tests__/reader/utils/iframeEventHandlers.test.ts`. Related:
[[foliate-touch-listener-capture-phase]], [[progressbar-focus-ring-4397]].
@@ -0,0 +1,21 @@
---
name: dependabot-pnpm-overrides
description: How to fix transitive-dependency Dependabot/CVE alerts in the readest monorepo (pnpm overrides location + style)
metadata:
node_type: memory
type: project
originSessionId: cdc9c728-a2c7-4a9e-b87a-44046560a4fa
---
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.
2. Confirm parent ranges allow the patch (`cat node_modules/.pnpm/<parent>@*/.../package.json | grep '"<pkg>"'`).
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]].
@@ -0,0 +1,24 @@
---
name: deploy-workers-dev-sni-proxy
description: "pnpm deploy crashes in China — workers.dev SNI-blocked, wrangler ws WebSocket bypasses http_proxy; fix = NODE_OPTIONS preload"
metadata:
node_type: memory
type: project
originSessionId: 65342d98-7939-41ed-9e10-2efc466946b1
---
`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).
Related: [[turbopack-build-cache-oom-docker-standalone]], [[r2-rclone-createbucket-403]].
@@ -0,0 +1,45 @@
---
name: deps-security-overrides-workflow
description: "How to fix transitive npm Dependabot alerts in the readest monorepo (pnpm-workspace overrides, where config lives, tauri-plugins is separate)"
metadata:
node_type: memory
type: reference
originSessionId: c61e7dd2-4033-4bd1-8f32-22056e4ef322
---
Fixing transitive npm Dependabot security alerts (manifest `pnpm-lock.yaml`).
**Where pnpm config lives (non-obvious):** the MAIN monorepo's `overrides`,
`patchedDependencies`, `onlyBuiltDependencies`, `allowBuilds` are in
**`pnpm-workspace.yaml`** (newer pnpm style) — NOT the root `package.json`
(root `package.json` has no `pnpm` section). The root `pnpm-lock.yaml` is what
Dependabot scans; alerts report manifest `pnpm-lock.yaml` = this root lockfile.
**`packages/tauri-plugins` is a SEPARATE project**, not part of the main pnpm
workspace. It's a git submodule (`tauri-plugins-workspace`) with its OWN
`pnpm-lock.yaml` and its own `package.json` `pnpm.overrides` +
`minimumReleaseAge: 4320`. The `minimumReleaseAge` (3-day age gate) applies ONLY
there — the main monorepo has NO age gate, so `^X` specs resolve to the very
latest matching version. Dependabot does not scan the tauri-plugins lockfile.
`pnpm-workspace.yaml` `packages:` = `apps/*`, send-email worker, extensions,
`packages/foliate-js` (NOT tauri-plugins).
**Recipe for a transitive advisory:**
1. Add `pkg: '>=X.Y.Z'` to the `overrides:` block in `pnpm-workspace.yaml`
(forces all transitive instances up). For risky 0.x packages, BOUND it like
the existing `vite: '>=7.3.2 <8'` (e.g. `esbuild: '>=0.28.1 <0.29'`).
2. For packages that are also DIRECT deps, bump the spec in
`apps/readest-app/package.json` too (e.g. the vitest family:
`vitest`, `@vitest/browser-playwright`, `@vitest/browser-webdriverio`,
`@vitest/coverage-v8` — move in lockstep).
3. `pnpm install`, then `grep -oE "pkg@[0-9.]+" pnpm-lock.yaml | sort -u` to
confirm no vulnerable versions remain.
4. Verify: `pnpm test` + `pnpm lint` + `pnpm build-web` (the last exercises
esbuild in the OpenNext/Cloudflare bundle path).
**Override applicability:** an override forces a transitive version regardless
of the parent's declared range ONLY when the package is a regular dep (no peer
warning). esbuild is a regular dep of vite; vite 7.3.x pins esbuild `^0.27.0`
but esbuild 0.28.x is API-compatible for vite's usage (0.28 changelog = install
integrity + minifier/codegen fixes). Verified via PR #4618 (alerts #238/#239
esbuild→0.28.1, #240 @vitest/browser→4.1.9).
@@ -0,0 +1,45 @@
---
name: dict-import-contenturi-filename-4489
description: "Android dict import \"incomplete bundle\" — ext-less content URIs; classify used getFilename (string) not basename (content resolver)"
metadata:
node_type: memory
type: project
originSessionId: 92f1011c-89b9-4ae1-b128-f24cfb4462a8
---
#4489 / #4472: importing a StarDict bundle (`.ifo`+`.idx`+`.dict.dz`) on **some** Android
devices fails with "Skipped incomplete bundles"; works on Xiaomi/Boox and web.
**Root cause — two filename resolvers diverged.** Tauri's Android `path.file_name`
(used by `basename`) special-cases `content://`/`file://` URIs and calls the native
`getFileNameFromUri` plugin → **content resolver DISPLAY_NAME** (real filename WITH ext).
See `tauri-2.11.2/src/path/android.rs`. Our `getFilename()` (`src/utils/path.ts`) is pure
JS string-parse of the URI. On devices whose SAF URI is an **opaque ext-less document id**
(e.g. `content://com.android.providers.downloads.documents/document/msf%3A20`), getFilename
`msf%3A20` (no ext) while basename → `21cen.dict.dz`. Working devices return
`primary%3ADictionaries%3A21cen.dict.dz` (ext in the URI) so getFilename happens to work.
The OLD `selectFileTauri` extension filter ALREADY used `basename` (so files passed the
filter and got imported), but **threw the resolved name away** and returned the raw URI.
Then `dictionaryService.classify()` re-derived the name with `getFilename()` → no ext →
every file orphaned → "incomplete bundle". The bug is the divergence, not the picker.
**Fix (PR for #4489):**
- `SelectedFile.name?: string` added (`useFileSelector.ts`).
- `resolveTauriFileName(path, appService)`: `basename` for `content://` / iOS `file://`,
else `getFilename` — resolved ONCE in `selectFileTauri`, reused by the ext filter AND
stored on `SelectedFile.name`. Removed `processTauriFiles`.
- `classify()` (`dictionaryService.ts`): `source.file?.name ?? source.name ?? getFilename(path)`.
- Test: `groupBundlesByStem.test.ts` — ext-less content URIs + `name` form a complete bundle.
- Also fixes #4472 issue 3 (uploaded dict files renamed to the SAF dir path).
**Emulator repro (real granted URI, no rebuild needed):** drive SAF picker → "Downloads"
location → returns `content://...downloads.documents/document/msf%3A<id>` (ext-less).
basename resolves the real name ONLY for granted URIs (synthetic URIs → "path does not have
a basename"). Verified via CDP `invoke('plugin:path|basename',{path,ext:null})`. See
[[cdp-android-webview-profiling]] for the CDP harness (`src/__tests__/android/helpers/`).
**Also fixed same turn:** e-ink "black spot" on the Settings→Dictionaries `+` badges
(Import Dictionary / Add Web Search) — add `eink-inverted` to the round badge span, mirroring
the font import button #4454 (`globals.css` `[data-eink] .eink-inverted` → base-content bg +
base-100 icon).
@@ -0,0 +1,22 @@
---
name: dict-lemmatization-4574
description: "Dictionary lookup lemmatizes inflected words (ran→run, mice→mouse) before lookup; pluggable per-language registry, English impl, candidate-chain integration"
metadata:
node_type: memory
type: project
originSessionId: d4206b72-47da-4c3d-adab-04df32c1137a
---
#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).
**New module** `src/services/dictionaries/lemmatize/`:
- `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 ~25 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]].
@@ -0,0 +1,17 @@
---
name: dict-lookup-browser-hijack-4559
description: Android system-dictionary lookup landing in the OEM browser instead of Eudic/欧路 — package-visibility + PROCESS_TEXT browser hijack
metadata:
node_type: memory
type: project
originSessionId: 5e397668-b766-439c-873c-00ccb1da715a
---
#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]].
@@ -0,0 +1,45 @@
---
name: dict-popup-font-size-4443
description: Adjustable dictionary popup font size via ::part() + em-rebasing; the only cross-shadow font hook for MDict
metadata:
node_type: memory
type: project
originSessionId: b105ba93-61b7-4d28-a269-1201a7be89bd
---
#4443 — adjustable dictionary popup font size (independent of the reading view).
SHIPPED: merged to main via PR #4734.
**The lever** = `DictionarySettings.fontScale` (number, default 1), set in
Settings → Language → Dictionaries (`SettingsSelect`, 85175%). Stored in the
dictionary settings; SYNCED by adding `dictionarySettings.fontScale` to
`SETTINGS_WHITELIST` (whole-field LWW, like providerOrder). `setFontScale` in
`customDictionaryStore` + default-merge in `loadCustomDictionaries`
(`?? DEFAULT_DICTIONARY_SETTINGS.fontScale`).
**Plumbing**: `useDictionaryResults` returns `fontScale`; `DictionaryResultsBody`
puts `data-dict-content` + inline `--dict-font-scale` on each per-tab container
(the `setContainerRef` div). All CSS lives in `globals.css`.
**Two non-obvious CSS facts that drove the design:**
1. **MDict renders into a Shadow DOM** (`shadowHost.attachShadow`, the only
provider that does) → its body is unreachable by ordinary popup CSS.
`::part(dict-content)` is the ONLY hook. So `mdictProvider` sets
`body.setAttribute('part','dict-content')` AND adds a stable host class
`dict-shadow-host` (the `::part()` rule needs a host selector subject).
`--dict-font-scale` inherits across the shadow boundary, so the outer rule
`…::part(dict-content){font-size: calc(var(--dict-font-scale,1) * 0.875rem)}`
resolves it. The dict's own shadow CSS never targets our wrapper `<div>`, so
no cascade fight — em-based dict content scales from it, px-based stays fixed
(expected for a font-size lever).
2. **Light-DOM providers size text with Tailwind `text-*` = root-relative `rem`**,
which a container `font-size` can't move. Fix = re-base the utilities to `em`
WITHIN `[data-dict-content]` only: `[data-dict-content] .text-sm{font-size:.875em}`
etc. Higher specificity than the bare utility + declared after `@tailwind
utilities` → wins, no `!important`. Container itself = `calc(scale * 1em)`.
**Verify**: the CSS contract (em-rebasing + `::part` + var inheritance) needs a
real browser — jsdom has no layout. Covered by
`dict-popup-font-size.browser.test.ts` (scale 1 → 18/14/14px, scale 1.5 →
27/21/21px, incl. the shadow body). Provider/store/whitelist sides have jsdom
unit tests. See [[css-style-fixes]].
@@ -0,0 +1,22 @@
---
name: download-file-scope-android-regression
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"
metadata:
node_type: memory
type: project
originSessionId: e78227be-6260-405a-88fb-48ffe4b20615
---
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`.
@@ -0,0 +1,23 @@
---
name: edge-tts-word-highlighting-4017
description: "Edge TTS word-by-word highlighting (#4017, PR"
metadata:
node_type: memory
type: project
originSessionId: afd9b381-c17d-4988-b287-07263d8bea0b
---
# Edge TTS word-by-word highlighting (#4017, PR #4566)
**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]]
@@ -0,0 +1,30 @@
---
name: eink-screen-refresh-pageturner-4687
description: "Page-turner \"Refresh Page\" action that deep-refreshes the e-ink panel (clear ghosting) on Android, via generic reflection across BOOX/Tolino/Rockchip"
metadata:
node_type: memory
type: project
originSessionId: 742b1517-392b-4735-8355-32b57fbfa400
---
Issue #4687 — added a bindable **"Refresh Page"** page-turner action that triggers a deep e-ink full refresh (GC16) to clear ghosting. Shipped as **PR #4822 (MERGED)** (`feat/eink-screen-refresh-pageturner` → main, 55 files +470/-41), built in an isolated worktree off origin/main (worktree + branch since removed). Rebase note: origin/main's Drive-sync PR #4821 added `secure_item` native-bridge commands at the exact anchors I used (end of COMMANDS / handler list / structs / impls), so all 7 plugin files (build.rs, default.toml, commands.rs, desktop.rs, lib.rs, mobile.rs, models.rs) conflicted on apply — resolved "keep both" by re-adding `refresh_eink_screen` after the secure_item code; locales re-derived via script on main's current files; autogenerated permission files regenerated via `cargo check -p tauri-plugin-native-bridge`.
**Frontend** (reuses the existing hardware page-turner binding machinery — see [[keyboard-selection-adjust-4728]] / `src/utils/keybinding.ts`):
- `keybinding.ts`: `'refresh'` added to `PageTurnAction` + `PAGE_TURN_ACTIONS` (so `resolvePageTurn` matches it). `matchesBinding` now accepts `undefined`.
- `types/settings.ts`: `HardwarePageTurnerSettings.bindings.refresh?: KeyBinding | null` — OPTIONAL (older persisted settings lack it; never migrate, optional-chaining handles absence). Default `refresh: null` in `constants.ts`.
- `PageTurnerSettings.tsx`: refresh slot rendered ONLY when `appService?.isAndroidApp && viewSettings.isEink` (the user-facing Eink-mode view setting, not just hardware detection).
- `usePagination.ts` `handleHardwarePageTurn`: branch `if (action === 'refresh') { if (appService?.isAndroidApp) refreshEinkScreen().catch(()=>{}); return true; }` BEFORE the page/section side/mode logic. Also added `bindings.refresh?.source === 'native'` to `hasNativeBinding` + the effect dep array so a media key bound to refresh still acquires page-turner key interception.
- `bridge.ts`: `refreshEinkScreen()``invoke('plugin:native-bridge|refresh_eink_screen')`.
**Native generic refresh** (`EinkRefreshController.kt`, new) — the answer to "compatible with most e-ink devices, generic interface not brand SDK". Android has NO public e-ink API; each vendor patches `android.view.View`. Probe via reflection, stop at first success (patterns from KOReader android-luajit-launcher EPD controllers):
1. **Onyx BOOX (Qualcomm)**: `View.refreshScreen(0,0,w,h, 34)` instance method. `34 = FULL(32)+GC16(2)`.
2. **Tolino/Nook (NTX/Freescale)**: `View.postInvalidateDelayed(0L,0,0,w,h, 34)`.
3. **Rockchip (Boyue clones)**: `View.requestEpdMode(View$EINK_MODE.EPD_FULL, true)`.
Deliberately do NOT bundle the Onyx SDK (`com.onyx.android.sdk.*` classes aren't on-device unless bundled — reflection would always fail) and do NOT call Onyx `setWaveformAndScheme`/None (KOReader does, but it owns the update loop; Readest leaves system auto-update in place, so switching to manual mode could FREEZE later updates). Run on UI thread against `activity.window.decorView`; `success:false` (no controller) is a soft no-op, not an error. iOS Swift stub resolves `{success:false}`.
**Plugin wiring** added across `models.rs`/`commands.rs`/`mobile.rs`/`desktop.rs`/`lib.rs` + `build.rs` COMMANDS + `permissions/default.toml` `allow-refresh-eink-screen` (build regenerates `reference.md`/`schema.json`/`commands/refresh_eink_screen.toml`). App uses `native-bridge:default` so no capability edit needed.
**Verified on real hardware**: ONYX BOOX Leaf5 (`ro.product.manufacturer=ONYX`). `pnpm dev-android` build+install; via adb+CDP invoked `plugin:native-bridge|refresh_eink_screen` directly in the WebView → `{success:true}`, logcat `EinkRefresh: onyx full refresh requested` (the Onyx/Qualcomm `View.refreshScreen` path, decor view), and the user visually confirmed 5/5 full GC16 screen flashes in the reader. So the onyx path works on modern BOOX without SDK bundling or `setWaveformAndScheme` priming. (CDP socket is pid-bound `webview_devtools_remote_<pid>`; re-forward when the WebView process recycles — see [[cdp-android-webview-profiling]].)
**i18n**: ran into [[i18n-extract-prunes-keys]] (scanner `removeUnusedKeys:true` deleted ~314 dynamic keys / huge churn). REVERTED the scanner output and added the single `"Refresh Page"` key MANUALLY to all 33 non-en locales (en is key-as-content, needs no entry), aligning each translation with the locale's existing `"Reload Page"`/`"Next Page"` terminology. `check:translations` green.
@@ -0,0 +1,46 @@
---
name: empty-highlight-leak-on-annotate-cancel-4791
description: Annotate eagerly creates a highlight placeholder; cancelling the note must tear it down
metadata:
node_type: memory
type: project
originSessionId: 1c75c865-8e1b-4641-ac20-81692d3ff20b
---
#4791 — clicking **Annotate** on a selection eagerly creates a highlight (`note:''`)
as the note anchor (`handleAnnotate``handleHighlight(true)` in `Annotator.tsx`),
so the selection stays visible while the NoteEditor is open. Cancelling the note
(Cancel button, overlay, Escape, switching books, closing the notebook) left that
empty highlight leaked into config → showed as a stale card in the left-sidebar
Booknotes list + a phantom yellow highlight.
**Fix:**
- `handleHighlight` now returns the created `BookNote` only when it pushes a NEW
record (returns `null` when it restyles/toggles an EXISTING one — that record
predates the flow and must survive a cancel).
- `handleAnnotate` stores `created?.id` via `setNotebookNewHighlightId` (new
`notebookStore` field). This tracked id is what distinguishes a removable
placeholder from a pre-existing highlight; do NOT identify it by cfi (a fresh
selection can collide with an existing highlight's cfi).
- `removeEmptyAnnotationPlaceholder(booknotes, id, now)` in `annotatorUtil.ts`
tombstones (`deletedAt`) the live annotation with that id ONLY if it still has
no note text, and returns it so the caller tears the overlay down with
`removeBookNoteOverlays` across ALL views (`getViewsById`, symmetric with how
`handleHighlight` drew it).
- Cleanup is **presentation-driven**, not threaded through every cancel path:
`Notebook.tsx` runs `handleCancelNewAnnotation` from an effect whenever the
creation editor stops being presented (`!(isNotebookVisible && notebookNewAnnotation)`)
— catches Cancel/Escape/overlay/close/swipe/navigate — plus a second effect's
cleanup on `sideBarBookKey` change / unmount for book-switch (pinned) and
reader-close.
- Save survives the guard (placeholder gains note text) and also clears the
tracked id. `handleCancelNewAnnotation` has stable identity (empty deps) so the
effects don't re-fire mid-edit; it reads settings fresh via
`useSettingsStore.getState().settings` (stale-closure guard, see [[webdav-connect-nullified-4780]]).
**Why id-set-LAST in handleAnnotate matters:** `setNotebookNewHighlightId` is
called after `setNotebookVisible(true)` + `setNotebookNewAnnotation`, so no
intermediate render has (editing=false AND a fresh placeholder id) — prevents the
presentation effect from deleting the placeholder it just created.
Related: [[instant-highlight-delete-orphan-4773]], [[customize-toolbar-global-serializeconfig]].
@@ -0,0 +1,18 @@
---
name: fastlane-apple-appstore-submission
description: "fastlane lanes for iOS/macOS App Store + TestFlight submission, and two gotchas (Tauri notarization trigger, fastlane cwd)"
metadata:
node_type: memory
type: project
originSessionId: 6604c57a-dee4-4a6e-8624-540162f41a80
---
Readest's Apple App Store + TestFlight submission via fastlane (root `fastlane/Fastfile`, alongside the existing Android `upload_to_play_store` lanes). Builds are unchanged (`pnpm run release-ios-appstore` / `release-macos-universial-appstore``tauri build` + `xcrun altool --upload-app`); fastlane only does the post-upload App Store version + review submission and TestFlight distribution on the already-uploaded build.
Lanes (per-platform, each does App Store review submit AND TestFlight, sharing a `submit_apple_build` helper): `release_ios`, `release_macos`. App Store via `upload_to_app_store(skip_binary_upload: true, ipa:/pkg:, platform: "ios"/"osx", submit_for_review: true, automatic_release: true, force: true, skip_screenshots: true, skip_metadata: false, release_notes:{"en-US"=>...}, promotional_text:{"en-US"=>...})`; TestFlight via `upload_to_testflight(distribute_only: true, app_platform: "ios"/"osx", distribute_external: true, groups:["Beta Testers"])`. App Store submit runs FIRST (it waits for build processing, which the TestFlight distribute then needs). `release_notes_text` parses `apps/readest-app/release-notes.json` (latest version by `Gem::Version`, drops notes matching `/\b(?:Android|Windows|Linux)\b/i`, prefixes each ` `). Auth: `app_store_connect_api_key`. Commands: `pnpm run submit-appstore-ios` / `submit-appstore-macos`.
GOTCHA 1 (Tauri notarization): `tauri build` auto-notarizes the macOS App Store bundle whenever the FULL App Store Connect API key trio (`APPLE_API_KEY` + `APPLE_API_ISSUER` + `APPLE_API_KEY_PATH`) is in the build env. Notarization REJECTS App Store builds ("not signed with a valid Developer ID certificate" / "no secure timestamp") because they use an Apple Distribution cert — App Store apps are NOT notarized. So `APPLE_API_KEY_PATH` must stay OUT of `.env.apple-appstore.local` (the macOS build env). `asc_api_key` instead DERIVES the `.p8` path from the key id: `repo_path("apps/readest-app/private_keys/AuthKey_#{key_id}.p8")` (the keys are named `AuthKey_<KEYID>.p8`, same convention altool uses; honors an explicit `APPLE_API_KEY_PATH` when set, e.g. the iOS build env which DOES need it and iOS doesn't notarize).
GOTCHA 2 (fastlane cwd): fastlane changes cwd to the `./fastlane` folder when EXECUTING a lane (`__dir__` is just "."), so raw `File.read("./apps/...")` breaks with "No such file". `fastlane lanes` only PARSES (doesn't run lane bodies) so it won't catch this — verify path-dependent lanes by actually RUNNING one. Fix = `repo_path(rel) = File.expand_path(rel, File.expand_path("..", __dir__))`, route every path (release-notes.json, .p8, ipa, pkg) through it.
GOTCHA 3 (dotenv shadowing): bare `dotenv` on PATH is the Ruby gem (`-f` syntax); package.json scripts use the npm `dotenv-cli` (`-e` syntax) resolved from `apps/readest-app/node_modules/.bin`. The submit scripts run `dotenv -e .env.apple-appstore.local -- bash -c 'cd ../.. && fastlane release_*'` — the `cd ../..` is required because fastlane does NOT search upward for the `fastlane/` dir (pnpm runs scripts from `apps/readest-app`).
@@ -0,0 +1,14 @@
---
name: feedback-commit-message-english-only
description: "Commit messages (and PR titles) must be English-only — no CJK characters, no em/en dashes"
metadata:
node_type: memory
type: feedback
originSessionId: c0199d69-f314-45ee-bf7c-867b908641cc
---
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".
@@ -0,0 +1,40 @@
---
name: fixed-layout-paginated-scroll-reset-4683
description: "Fit-width tall fixed-layout page opens scrolled-to-end on WebKit page turn (#4683); Blink unaffected; fix = explicit scrollTop=0 on page-turn render"
metadata:
node_type: memory
type: project
originSessionId: 780a4235-5498-42c8-8286-7021c6fcf1ed
---
#4683: in paginated fixed-layout (PDF / fixed-layout EPUB) **fit-width** mode, when a
page is scaled taller than the viewport (`isOverflowY` true, host gets a vertical
scrollbar), turning to the next page opened the new page **scrolled to the bottom**
instead of the top. Root cause: `FixedLayout` host (`:host{overflow:auto;align-items:center}`
in `packages/foliate-js/fixed-layout.js`) scrolls vertically; `#render`'s `transform`
re-centered `container.scrollLeft` on every render but **never reset `container.scrollTop`**.
On a page turn the freshly-shown page inherited the previous page's offset (≈ bottom, since
the reader scrolled down to finish, and same-size pages share maxScrollTop).
**Engine-specific — WebKit only.** WebKit (Linux WebKitGTK, iOS, macOS WKWebView)
*preserves* a scroll container's offset when `#showSpread` swaps the flow content
(old frame → `position:absolute;visibility:hidden`, new frame appended). **Blink**
(Android WebView, Chrome, WebView2) *resets* scrollTop to 0 on that swap, so the bug
never manifests there. Reporter was on Ubuntu/WebKitGTK `WebView 605.1.15`.
**Fix:** new exported pure helper `computePaginatedScroll({elementWidth,containerWidth,scrollTop,pageTurn})`
`{scrollLeft:(elementWidth-containerWidth)/2, scrollTop: pageTurn?0:scrollTop}`.
Thread a `pageTurn` flag into `#render(side, pageTurn=false)`; set `true` ONLY at the
3 navigation entry points (`#showSpread`, `#goLeft`, `#goRight`). Plain re-renders
(ResizeObserver, zoom/scale-factor attr, pageColors, goToSpread same-index re-render)
keep `pageTurn=false` so resize/pinch-zoom of a tall page does NOT jar to the top.
Test: `src/__tests__/document/fixed-layout-paginated-scroll.test.ts` (pure-helper pattern,
like [[booknote-view-autoscroll-4352]] sibling fixed-layout helper tests — the custom
element can't be instantiated in jsdom: no ResizeObserver + getBoundingClientRect=0).
**Verification recipe (the bug is NOT Android-reproducible):** CDP on Xiaomi showed
`view.next()` already yields scrollTop 0 on Blink → can't distinguish fix on Android.
Proved on REAL WebKit instead: auto-running HTML mirroring host CSS + `#showSpread` swap,
opened via `open -a Safari file://…`, screenshot. Safari `AppleWebKit/605.1.15` (== reporter)
showed scrollTop 420/440 (bug) without reset, 0 with reset. readest fixed-layout page turn
goes through `view.next()`/`view.prev()` (`usePagination.ts`), the same path.
@@ -0,0 +1,20 @@
---
name: footnote-aside-namespace-order-4438
description: Footnote aside border line regression — @font-face inlined before @namespace invalidated the namespaced footnote-hiding selector
metadata:
node_type: memory
type: project
originSessionId: 788943e6-fede-4c8f-828c-695ca873f178
---
#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`).
@@ -0,0 +1,82 @@
---
name: gdrive-provider-multipr-status
description: "Google Drive file-sync provider — phased multi-PR build status, what shipped in PR1 and what each later PR adds"
metadata:
node_type: memory
type: project
originSessionId: 50e2c2b8-ca61-4c33-acae-cd5d2c9aa93f
---
Adding **Google Drive as a second `FileSyncProvider`** for the merged file-sync engine (the WebDAV refactor, PR #4784). Approved plan: `/Users/chrox/.claude/plans/floating-chasing-feather.md`. Research + reuse map: [[gdrive-sync-provider-research]]. Author of the reference (`ratatabananana-bit/Readest-google-drive-mod-patcher`, AGPL-3.0) granted explicit reuse permission; adapted files carry attribution headers.
**Shipped across multiple PRs (decided at the autoplan gate; no BYO client, official iOS-type client only).**
**PR1 — DONE (built, all gates green, committed locally, NOT pushed).** Branch `feat/gdrive-sync-core` (worktree `/Users/chrox/dev/readest-feat-gdrive-sync-core`), commit `1a0065818`. 25 files / ~2.6k lines, ~81 new unit tests, full suite 6377 passing + lint + format clean. Contents under `src/services/sync/providers/gdrive/`:
- `GoogleDriveProvider.ts` — Drive v3 over `FileSyncProvider`; id-addressed resolution + per-instance id cache; create-then-name upload; real `ensureDir`; `files.list` pagination; Retry-After 429/5xx backoff; per-path folder-creation locks + deterministic dup-collapse (smallest id); stale-id eviction; `mapDriveError` (403 split rate-limit→NETWORK vs permission→AUTH_FAILED). Factory `createGoogleDriveProvider(auth, fetchFn, {sleep?})`; streaming omitted.
- `auth/``pkce`, `parseRedirect` (target + CSRF, takes `expectedRedirectUri`), `reverseDnsRedirect`, `tokenStore` (no client secret), `oauthFlow` (DI).
- `PersistedDriveAuth.ts` — single-flight refresh + re-check, carries old refresh_token, one save; `accountLabel` via `about.get`.
- `driveTokenStore.ts``TokenPersistence` + `KeychainTokenPersistence` over keyed secure-KV; `createDriveTokenPersistence()` returns null off-Tauri (NO ephemeral fallback for refresh token).
- `driveRest.ts` — pure builders + pagination + `aboutUrl`.
- `buildGoogleDriveProvider.ts` (env client id + keychain), `file/providerRegistry.ts` (`createFileSyncProvider`/`getEnabledFileSyncBackends`).
- Shared `file/providerSemanticContract.ts` test helper run for BOTH WebDAV + Drive.
- `utils/bridge.ts` — TS wrappers `set/get/clearSecureItem` (`plugin:native-bridge|*_secure_item`).
**DEVIATION from plan:** the native keyed secure-KV implementation (Rust desktop/mobile + Kotlin + Swift + permissions) was DEFERRED out of PR1 — nothing in PR1 calls it (no UI/sync wiring), and 4 languages of un-runnable native code don't belong in a "CI-testable, no-platform" PR. The TS contract exists + is mock-tested. Native impl lands with **PR3 (desktop OAuth)**, which first exercises it and can live-verify.
**PR2 — DONE (foundation only; committed `9ba097ea2`, UNPUSHED, on same `feat/gdrive-sync-core` branch).** Full suite 6403 passing + lint + format clean.
- `GoogleDriveSettings` type (mirrors WebDAVSettings minus URL/creds/rootPath, +`accountLabel`) in `types/settings.ts` + `SystemSettings.googleDrive`; `DEFAULT_GOOGLE_DRIVE_SETTINGS` in `constants.ts`.
- `googleDrive.deviceId`/`lastSyncedAt` added to `BACKUP_SETTINGS_BLACKLIST` (backupService.ts) + backup-settings test.
- `webdavSyncStore``store/fileSyncStore.ts`: per-backend progress keyed by kind + GLOBAL library-sync mutex (`beginSync(kind,label)` returns false if another holds lock). Migrated `WebDAVForm` + `IntegrationsPanel`; WebDAV behavior unchanged. `fileSyncStore.test.ts`.
- **DEFERRED to PR3 (deliberate):** `useWebDAVSync``useFileSync` hook generalization + `WebDAVForm``FileSyncForm` extraction + visible Drive Integrations row/connect UI. Rationale: until Drive connects (needs OAuth), the multi-provider hook paths can't run and `FileSyncForm` would be a single-use abstraction (violates YAGNI); also the autoplan gates these on a live WebDAV Sync-now check. Do them WITH PR3.
**PR3 — IN PROGRESS (3 commits, all gates green: full suite 6411 passing + rust fmt/clippy/test + lint/format). UNPUSHED on `feat/gdrive-sync-core`.**
- `ff1ffe717` native keyed secure-KV: `set/get/clear_secure_item` across Rust desktop (keyring keyed by item key) + mobile forward + models/commands/lib/build/default.toml + Kotlin (EncryptedSharedPreferences `readest_secure_items_v1`) + Swift (Keychain, service `com.bilingify.readest.secure-items`). Rust compiles+clippy+fmt clean; permission files regenerated (passphrase preserved).
- `602f41406` desktop OAuth machinery: `auth/oauthDesktop.ts` (`runDesktopDeepLinkOAuth`, DI, 3 tests) + `src-tauri/src/spawn_fresh_browser.rs` (registry default-browser cold-spawn on Windows / no-op macOS+Linux; winreg Windows-only dep; pure-helper tests; registered `#[cfg(desktop)]`) + `connectGoogleDrive.ts` (`connectGoogleDrive`/`disconnectGoogleDrive`, fail-loud token save, 4 tests). `DRIVE_FILE_SCOPE='https://www.googleapis.com/auth/drive.file'`.
- `5efbe6b2f` ingress filter: `isGoogleOAuthRedirectUrl` (scheme-prefix match) + filter in `useAppUrlIngress` dispatch so the reverse-DNS redirect never reaches book-import consumers (OAuth runner catches via own listeners). Tested.
**Official client id PROVISIONED:** `209390247301-ctpmep68ppfa56r1b8tr35e4qi4p60kq.apps.googleusercontent.com` (iOS type, no secret, `drive.file`). Baked as default in `getGoogleClientId` (env `NEXT_PUBLIC_GOOGLE_CLIENT_ID` overrides); reverse-DNS scheme `com.googleusercontent.apps.209390247301-ctpmep68ppfa56r1b8tr35e4qi4p60kq` registered in `tauri.conf.json` desktop+mobile deep-link. Commit `7a2ac3671`.
**Drive UI DONE (commit `c657c34f0`):** `FileSyncForm` (shared sync controls extracted from WebDAVForm, parameterized by kind, builds provider via registry; WebDAVForm refactored to use it, behavior unchanged) + `GoogleDriveForm` (OAuth Connect/account/Disconnect + FileSyncForm) + `googleDriveConnect.ts` (assembles env client id + keychain + desktop runner) + IntegrationsPanel "Google Drive" row gated on `appService.isDesktopApp`. Full suite 6412 green.
**Cloud Sync redesign DONE (commit `1a31a8cbd`):** new "Third-party Cloud Sync" Integrations section with a unified "Cloud Sync" sub-page (`CloudSyncForm`) — WebDAV + Google Drive MUTUALLY EXCLUSIVE via `withActiveCloudProvider` (enabling one disables the other). Radio picker (AIPanel pattern) + shared `FileSyncForm`. WebDAVForm/GoogleDriveForm refactored to embeddable panels; Drive has a "configured-but-inactive" state (`accountLabel` present, `enabled=false`) with frictionless "Use Google Drive" re-activate (no re-OAuth); explicit Disconnect clears the keychain token. Temp concurrency probe removed (upload was already concurrency-4, confirmed).
**Reader auto-sync DONE (commit `f5e07e50b`):** `useWebDAVSync``useFileSync` — the reader auto-syncs the single ACTIVE provider per-book while reading (pull-on-open, debounced push, cover/file). Async engine build (Drive keychain probe) held in state, pull-on-open waits for it; engine keyed on connection-relevant settings (not lastSyncedAt) to avoid re-probing keychain; deviceId/lastSyncedAt write the active provider slice; events renamed `*-file-sync`. WebDAV reader behavior unchanged.
**Drive feature is functionally complete on desktop:** connect, manual Sync now, auto-sync while reading, exclusive provider switching. Live-verified: connected + synced a 675-book library.
**DESKTOP PR OPENED: readest/readest#4821** (`feat/gdrive-sync-core`, rebased onto origin/main, all gates green incl. rust). Covers provider + OAuth + native KV + redesign (exclusive Third-party Cloud Sync section, inline radio switch) + reader auto-sync + **premium gating** (any paid plan via `isCloudSyncInPlan`; free sees upgrade CTA; reader auto-sync off for free). Rebase needed `git -c protocol.file.allow=always submodule update --init packages/foliate-js` (foliate-js drift, index wanted `6f1a190`).
**PR #4821 review fix (pushed `5769682c5`):** CodeQL flagged `escapeDriveLiteral` (driveRest.ts) for not escaping backslashes — fixed (escape `\``\\` FIRST, then `'``\'`). Was the only review comment.
**Both branches REBASED onto origin/main `324bb8a36` (was `7e78f80e1`). UNPUSHED, both gates green (lint+format+full suite: mobile 6483, resumable 6486). foliate-js submodule drift on rebase: origin/main now wants `0fa407c4c` (not in local submodule clone whose origin is the main checkout's modules dir); fix `git -C packages/foliate-js fetch https://github.com/readest/foliate-js.git 0fa407c4c... && git -C packages/foliate-js checkout 0fa407c4c...` (the `submodule update --init` shortcut FAILS here — local origin lacks the commit; must fetch from GitHub URL). Current commits: mobile `6728c94f0`(Android)+`8b3dd1cd5`(iOS); resumable `f7a1e5117`.**
**Branch `feat/gdrive-mobile-oauth` (Android+iOS OAuth) — no longer stacked, off main. PR not opened yet.**
**Android OAuth (PR4) DONE (commit `eb8e22081`, was `5583c9b38` pre-rebase).** `auth/oauthAndroid.ts` (`runAndroidOAuth` via existing `authWithCustomTab`, DI, 2 tests) + platform dispatch in `googleDriveConnect` (`osType()==='android'`→Custom Tab, else desktop) + Drive row shown on Android. NATIVE (device-verify pending, no Android toolchain in CI): `NativeBridgePlugin.kt` `handleIntent` resolves `com.googleusercontent.apps.<id>:/oauthredirect` via the same `pendingInvoke` as the Supabase callback; matching BROWSABLE intent-filter added to `gen/android/.../AndroidManifest.xml`.
**iOS OAuth (PR5) DONE (commit `1230fb291`).** `auth/oauthIos.ts` (`runIosOAuth` via `authWithSafari({authUrl, callbackScheme})`; callbackScheme = `deriveReverseDnsRedirectScheme(clientId)` = bare `com.googleusercontent.apps.<id>` — ASWebAuthenticationSession matches on SCHEME not path; DI, 2 tests) + `AuthRequest.callbackScheme?` (nativeAuth.ts; Supabase keeps native `readest` default) + `resolveOAuthRunner` `os==='ios'`→runIos + Drive row on iOS (`isDesktopApp||isAndroidApp||isIOSApp`). `createDriveTokenPersistence` already works on iOS (Keychain via secure-KV). NATIVE (device-verify pending, no iOS toolchain in CI): Swift `auth_with_safari` uses `args.callbackScheme ?? "readest"` (`SafariAuthRequestArgs.callbackScheme: String?`); `Info-ios.plist` CFBundleURLTypes gains the reverse-DNS scheme. macOS Drive uses the desktop deep-link runner (NOT authWithSafari), so no macOS native change. Full suite 6477 green + lint + format + plutil OK.
**Drive streaming upload/download DONE — own branch `feat/gdrive-resumable-upload` off origin/main (commit `0c9cc1a22`, UNPUSHED).** `uploadStream`+`downloadStream` on GoogleDriveProvider so book files stream from/to disk instead of buffering the whole file in the JS heap (buffered marshal of a large book across the WebView↔Rust bridge crashes the renderer on mobile — this unlocks Drive book sync on Android/iOS and flattens heap on desktop too). `driveRest.resumableCreateUrl`/`resumableUpdateUrl`; `uploadStream` opens a Drive resumable session (POST new `{name,parents}` / PATCH existing `{name}`, metadata in initiation so NO reparent follow-up), reads `Location` session URI, PUTs bytes via `tauriUpload`; `downloadStream` GETs `alt=media` to disk via `tauriDownload` + bearer. Attached **Tauri-only** (`isTauriAppPlatform()`); web keeps buffered fallback. Both swallow→`false` per provider contract (engine retries once). REUSES `@tauri-apps/plugin-upload` already shipped for WebDAV — NO new native code. Single-shot streaming PUT (not chunked mid-stream resume) — sufficient for the heap/OOM fix; chunked-resume-on-failure is a further enhancement. Full suite 6484 green + lint + format. **NOTE: changes desktop Drive book sync from buffered → streaming (previously live-verified buffered); device-verify the streaming path on desktop + mobile.**
**ALL PRs MERGED to main/dev (dev @ `c6f2a83d9`).** Worktree `feat/gdrive-*` branches no longer needed; work continues in the MAIN repo `/Users/chrox/dev/readest` on `dev` (tracks `origin/main`; there is NO `origin/dev`).
- **#4821** desktop Drive cloud sync + premium Third-party Cloud Sync section.
- **#4824** Drive resumable streaming upload/download.
- **#4823** mobile OAuth (Android Custom Tab + iOS ASWebAuthenticationSession).
- **#4827** Android sync fix: retry THROWN transport errors in `withBackoff` (was 429/5xx only); `mapDriveError` classifies transport throws (incl. Tauri plugin's plain `error sending request` Error) as NETWORK. Root cause: Android pooled keep-alive connection to googleapis.com goes stale mid-sync → every files.list after the first batch threw; sync recovered on its own after ~3-4 min (reqwest evicting dead conns). The retry forces a fresh connection so recovery is fast + kills the error spam.
**CODE COMPLETE + MERGED.** REMAINING (human/ops-only): (1) on-device re-verify with #4827 in the build — Android sync should no longer stall ~3-4 min / spam `failed to inspect hash dir`; iOS OAuth sign-in; desktop streaming book-sync re-check; (2) Google consent screen → Production (testing caps 100 users). NOTE: Android build auto-generates a deep-link intent-filter for the gdrive reverse-DNS scheme in `gen/android/.../AndroidManifest.xml` (duplicates the manual `gdrive-oauth` filter) — benign build drift, don't commit.
**Google Drive on WEB via FULL-PAGE REDIRECT OAuth — DONE on branch `feat/gdrive-web-oauth` (was `feat/gdrive-web-gis`; local/unpushed; suite 6516 green).**
- **GIS popup ABANDONED:** `src/middleware.ts:55` sets `Cross-Origin-Opener-Policy: same-origin` on every web doc (Turso WASM/SharedArrayBuffer needs `crossOriginIsolated`). COOP same-origin SEVERS a cross-origin popup's opener handle → GIS's `popup.closed` poll reads true instantly → `popup_closed` fires while the popup is still open (diagnosed live). Can't relax COOP (breaks Turso); can't scope it (connect happens over Turso routes). So no popup OAuth on web.
- **Web flow:** full-page redirect (no `window.opener`, works under COOP). `auth/webRedirectFlow.ts` (implicit `response_type=token` — secretless Web client can't code-exchange; CSRF state+returnPath in sessionStorage; parse token from callback fragment) + `auth/webTokenStore.ts` (sessionStorage access token, no refresh token) + `WebDriveAuth.ts` (reads stored token, expired→AUTH_FAILED, `accountLabel` via about.get) + `app/gdrive-callback/page.tsx` (validates state, stores token, `withActiveCloudProvider(settings,'gdrive')`+label via `appService.load/saveSettings`, routes back). `buildGoogleDriveProvider` web branch: `new WebDriveAuth(globalThis.fetch)` (Drive REST CORS-ok; streaming Tauri-only→web buffered). `googleDriveConnect` web: Connect=`beginWebDriveRedirect` (navigates away, never resolves), Disconnect=`clearWebDriveToken`.
- **Official Web client id BAKED** `209390247301-585tc3dohg4c02588uvah5d32hg6dneq` (`getGoogleWebClientId`, env `NEXT_PUBLIC_GOOGLE_WEB_CLIENT_ID` overrides). **NO auto-refresh** (secretless browser client → no refresh token; Google blocks hidden-iframe silent renewal) → user reconnects per session; true auto-refresh needs a server-side token broker (Worker holds secret+refresh token) — deferred ("A for now").
- **OPS REMAINING:** add `https://web.readest.com/gdrive-callback` + `http://localhost:3000/gdrive-callback` to the Web client's **Authorized redirect URIs** (JS origins already set). Then live-verify `pnpm dev-web`.
**PR3 REMAINING:**
- **LIVE VERIFICATION (needs the user — real Google sign-in):** `pnpm tauri dev` → add own Google account as a Test user in the consent screen (Testing mode caps + gates) → Settings → Integrations → Google Drive → Connect → browser → grant → "Connected as <email>" → add book / Sync now → confirm `Readest/books/<hash>/{config.json,cover.png}` in Drive. Windows cold-browser fallback.
- **Reader-hook auto-sync (deferred):** generalize `useWebDAVSync``useFileSync` (per-provider state maps, async Drive provider build in the hook) so Drive auto-syncs per-book while reading like WebDAV. Manual Sync-now already works without it; do after live-verifying the base.
- Consent screen → Production before GA (testing caps 100 users).
- PR4 Android OAuth (Custom Tab + manifest scheme), PR5 iOS OAuth (authWithSafari scheme param + Info-ios.plist). Later: Drive resumable upload for `syncBooks` on mobile.
- Ops/launch blocker: create Google Cloud project (iOS client, `drive.file`) + consent screen to production (testing caps 100 users).
- PR4 Android OAuth, PR5 iOS OAuth. Later: Drive resumable upload to unlock `syncBooks` on mobile.
- Ops/launch blocker: create the Google Cloud project (iOS client, `drive.file`) + set consent screen to production (testing caps 100 users).
@@ -0,0 +1,42 @@
---
name: gdrive-sync-provider-research
description: Research on the ratatabananana-bit Google Drive mod for building a Drive FileSyncProvider; OAuth approach + reuse map
metadata:
node_type: memory
type: reference
originSessionId: 50e2c2b8-ca61-4c33-acae-cd5d2c9aa93f
---
NEXT TASK (research done, not yet built): add **Google Drive as a `FileSyncProvider`** for the merged file-sync engine ([[webdav-filesync-refactor-plan]] / PR #4784). Researched reference: `github.com/ratatabananana-bit/Readest-google-drive-mod-patcher` (AGPL-3.0, same as Readest → can adapt WITH attribution). Reference patch saved at `~/.../scratchpad/gdrive-ref/` (extracted modules under `extracted/`).
**The repo is a PATCHER**, not a fork: the whole impl is one squashed diff `tooling/mod/mod.patch` (13k lines) against Readest v0.11.12. Design/plan docs live in a SIBLING repo `readest-gdrive-sync-mod` (referenced in MOD.md, likely private — not in the patcher).
**Their architecture = REPLACE Readest's native cloud sync with Drive** (library/progress/notes/stats). Two layers:
- `src/services/cloudprovider/` — REUSABLE: a backend-agnostic provider seam + OAuth. `CloudProvider.ts` (their interface), `GoogleDriveProvider.ts` (Drive v3 REST impl), `FakeCloudProvider.ts`, `buildDriveProvider.ts` (assembly), `googleAuth/*` (the OAuth layer).
- `src/services/drivesync/` — SKIP for us: their integration with the native-sync data model (driveMerge, statsMerge, DriveSyncClient, DriveBlobStore, jsonl, layout). We REPLACE this with our `FileSyncEngine`.
**KEY: their `CloudProvider` is ~1:1 with our `FileSyncProvider`.** Map: getText↔readText, getBinary↔readBinary, putText/putBinary↔writeText/writeBinary, list↔list, stat↔head, deleteFile↔deleteDir. Their `CloudEntry` even carries `md5` (Drive checksum) — stronger than our size-only HEAD short-circuit. Extra on theirs: `isAuthenticated()`/`accountLabel()` (auth state) + `putBinary` `onProgress`. Missing on theirs: `ensureDir` (Drive auto-creates folders on write).
**Recommended fit for US = Drive as a parallel `FileSyncProvider`** (like WebDAV), NOT replacing native sync. Reuses the whole engine (incremental/concurrency/merge). Build = (1) `createGoogleDriveProvider(settings): FileSyncProvider` adapting their `GoogleDriveProvider` (rename methods, map CloudEntry→FileEntry, head from stat, deleteDir from delete-folder-by-id, ensureDir = no-op since write auto-creates, rootPath='/'), (2) reuse `googleAuth/*` OAuth nearly as-is, (3) token persistence (the ONE big gap — see below), (4) settings UI + provider registry.
**Drive specifics (vs WebDAV path-addressing):**
- **Drive is ID-addressed, not path-addressed.** Resolve a logical path (`Readest/books/<hash>/config.json`) segment-by-segment via `files.list` (name+parent queries), cache folder/file ids in a `Map<path,id>`. `driveRest.ts` = pure query/URL builders; `GoogleDriveProvider` owns resolver+cache.
- **`drive.file` scope** = app sees only files it created → Drive root is a safe private namespace (no appdata hidden folder; a visible "Readest" folder). Non-sensitive scope = no Google verification needed (unverified-app warning shows once).
- **Upload = create-then-name:** `uploadType=media` carries no metadata, so POST bytes to root → PATCH name + reparent (addParents=folder, removeParents=root). Overwrite = media PATCH on the existing id (preserves id/links).
- Endpoints: metadata `drive/v3/files`, media `upload/drive/v3/files?uploadType=media`. Folder MIME `application/vnd.google-apps.folder`.
**OAuth (the hard part — every gotcha you flagged is CONFIRMED + implemented):**
- **One iOS-type Google client** (Bundle ID only, NO secret, NO SHA-1, App Check OFF) for BOTH Windows + Android. Redirect = reverse-DNS `com.googleusercontent.apps.<id>:/oauthredirect` (SINGLE slash) + PKCE. Client id derives the scheme (`reverseDnsRedirect.ts`). Client id is committed (not a secret). App Check must stay OFF (Android can't produce iOS attestation → would break everyone).
- Loopback dead for iOS clients (Google blocked 2022); embedded WebView blocked (`disallowed_useragent`). Reverse-DNS is the only no-SHA native redirect Google accepts.
- `oauthFlow.ts` — provider-agnostic orchestration, platform mechanics injected (DI, headless-testable). Arms `awaitRedirect` BEFORE `openUrl` (race fix). PKCE + `state` CSRF via `parseRedirect.ts`.
- **Android** (`oauthAndroid.ts`): Chrome Custom Tab via Readest's EXISTING native bridge `authWithCustomTab` (same as Supabase login) — NOT external browser (keeps Tauri Activity foregrounded so in-flight auth survives memory pressure; redirect resolves via a native Kotlin field that survives WebView reload). Register the client scheme as a BROWSABLE intent-filter (patcher injects into `tauri.conf deep-link.mobile`). MUST filter the OAuth redirect out of Readest's deep-link ingress (`useAppUrlIngress` via `matchesReverseDnsRedirect`) or it triggers a /library reload that kills the flow. `tauri android init` wipes the manifest → restore MANAGE_EXTERNAL_STORAGE etc.
- **Windows/desktop** (`oauthDesktopDeepLink.ts` + `spawn_fresh_browser.rs`): system browser + self-registered scheme (`deep_link().register_all()`, no installer/admin). Capture via `single-instance` (url=args[1]) + `onOpenUrl`. THE WINDOWS SUBTLETY: a browser process snapshots protocol associations at launch, so a browser already running before scheme-registration silently drops the redirect. Fix: open default browser first; if no redirect in `DEFAULT_FALLBACK_DELAY_MS=25_000`, re-open in a freshly-spawned COLD browser (`spawn_fresh_browser` Rust cmd: resolve default browser from registry UserChoice → if Chromium-family spawn with `--user-data-dir=<isolated>` → else fall back to Edge). Hard deadline `CONNECT_DEADLINE_MS=15min` rejects an abandoned sign-in. Whichever browser returns first wins.
- `tokenStore.ts` = PKCE token exchange + `refreshAccessToken` (Google omits refresh_token on refresh → keep the old one). `pkce.ts` = PKCE pair + `buildAuthUrl`.
**GAPS / NOT in the reference (we'd build):**
1. **Token persistence is a stubbed interface** (`TokenPersistence` load/save/clear) — they explicitly left the secret store (Tauri secure storage / Android Keystore) as a later task. WE implement it.
2. **No resumable/streaming upload** — simple `uploadType=media` buffers the whole file in JS heap (same OOM risk our WebDAV `uploadStream` avoids). For large book files we'd add Drive resumable upload (`uploadType=resumable`); configs/covers are fine buffered. Our engine's streaming is optional (falls back to buffered).
3. **accountLabel is a placeholder** ('Google Drive'); real email needs a userinfo call.
4. **iOS/macOS** not covered (Windows + Android only).
**License call:** AGPL→AGPL is compatible. **The author (ratatabananana-bit) granted EXPLICIT permission** (2026-06): "feel free to do whatever you want with the code (it's the AGPL fork - Drive sync + the recently-read shelf)." So we can copy-adapt freely; keep attribution/credit. The OAuth platform glue is the high-value, hard-to-reproduce part → adapt with credit. Note the author also mentions a "recently-read shelf" feature in the same fork (separate, potential bonus).
@@ -0,0 +1,19 @@
---
name: global-annotation-pageturn-perf-4575
metadata:
node_type: memory
type: project
originSessionId: 4640857e-9c37-4ec6-890a-8aa20ec3a3f3
---
**#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 = **~2545ms synchronous main-thread per page turn** on desktop (×35 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.
@@ -0,0 +1,26 @@
---
name: grimmory-native-sync
description: Grimmory (Booklore fork) sync API surface + CORS analysis for adding native grimmory sync to Readest
metadata:
node_type: memory
type: project
originSessionId: ef2f9371-2968-4f81-abe4-a9349547542b
---
Goal: add **native grimmory sync** to Readest (vs the current OPDS+KOReader-compat detour, which causes 3-way KOReader↔Kobo↔grimmory desync — see discussion grimmory-tools/discussions/1417). Grimmory repo at `/Users/chrox/dev/grimmory` (Java/Spring backend, package `org.booklore`).
**Native API (use this, not KOSync):** JWT bearer. `POST /api/v1/auth/login` `{username,password}``{accessToken,refreshToken,expires}` (2h/30d); `POST /api/v1/auth/refresh`. Progress: `POST /api/v1/books/progress` `{bookId, fileProgress: BookFileProgress{bookFileId, progressPercent 0-100, positionData (CFI for EPUB), positionHref, ttsPositionCfi}, dateFinished}`; `GET /api/v1/books` to list — **the Book DTO does NOT expose the file hash** (no native hash lookup endpoint; match by metadata, see below). Annotations `/api/v1/annotations/**`, bookmarks `/api/v1/bookmarks/**`, download `/api/v1/books/{id}/download` (Range OK), cover `/api/v1/media/{id}/cover`. KOReader-compat path exists at `/api/koreader/**` (X-Auth-User + X-Auth-Key=md5(pw)) but is the thing we're replacing.
**CORS (`SecurityConfig.java`): per-filter-chain only — NO global CorsFilter/addCorsMappings.** Policy (`:340-368`): origins default `*` (env `ALLOWED_ORIGINS`, uses `setAllowedOriginPatterns` so `*`+credentials valid); methods all; **allowed-headers is a FIXED whitelist** = `Authorization, Cache-Control, Content-Type, Range, If-None-Match, If-Modified-Since` (NOT `*`, and **excludes X-Auth-User/X-Auth-Key**); allowCredentials true. Chains WITH `.cors()`: jwtApi (order10: `/api/**` minus whitelist → books/progress/annotations/bookmarks/reading-sessions/koreader-users), bookDownload(8), epub/audiobook/custom-font/ws(5-9). Chains WITHOUT `.cors()`: opds(1), komga(2), **koreader(3)**, kobo(3), **media/cover(4)**, **catch-all static(11)**. CRITICAL GAP: `/api/v1/auth/login` + `/auth/refresh` are whitelisted OUT of order10's matcher (`:265-289`) so they hit order11 catch-all = **no CORS** → cross-origin browser login fails (invisible to grimmory's own SPA, served same-origin from `classpath:/static/`).
**What it means for Readest:** Tauri desktop/mobile = CORS irrelevant (`@tauri-apps/plugin-http` is native, all endpoints work incl. login). Readest **web build** = JWT data endpoints work cross-origin once token obtained, but **login + koreader need a server-side proxy** (same pattern as existing `/api/kosync`, `/api/opds/proxy`) or same-origin reverse proxy.
**Readest extension points (template = KOSync):** new `src/services/grimmory/GrimmoryClient.ts` (connect/getProgress/updateProgress mirroring `KOSyncClient.ts`), `src/app/reader/hooks/useGrimmorySync.ts` (mirror `useKOSync.ts`), `GrimmorySettings` in `src/types/settings.ts`, `GrimmoryForm.tsx` wired into `IntegrationsPanel.tsx`. Progress mapping: Readest `BookProgress.location` (CFI) ↔ grimmory `BookFileProgress.positionData`; grimmory has `EpubCfiService` for CFI↔XPointer. Related: [[kosync-cfi-spine-resolution]], [[kosync-connect-false-positive-4692]].
**STATUS: NOT shipped.** A full vertical slice was built on 2026-06-23 (GrimmoryClient + useGrimmorySync hook + `/api/grimmory` proxy + GrimmoryForm/IntegrationsPanel + settings/types; metadata-match identity cached in BookConfig; native `/api/v1/books/progress`; tests+lint green) then **REVERTED at the maintainer's request ("not ready yet")**. Working tree fully restored (all grimmory files deleted, the 7 edited shared files reverted; lint + test green). Re-attempt later — the design below + the two findings below are the distilled learnings. Reverted because the native-progress identity story was judged immature; the more robust paths (OPDS acquisition capture, or mirroring the official koplugin) hadn't been built yet.
**FINDING A — how the OFFICIAL koplugin (`github.com/grimmory-tools/grimmory.koplugin`) maps identifiers (it does NOT use `/api/v1/books/progress`).** Local SQLite `book(book_path, partial_md5, grimmory_id)` stores BOTH ids per file. TWO paths: (1) native `grimmory_id` (= book.id) for sessions/downloads/shelves, resolved by **ISBN13/ISBN10/ISBN/ASIN only** (`doc_metadata.lua isBook` — NOT title/author), persisted via `repository.upsertBook(path, book.id)`; sessions → `POST /api/v1/reading-sessions` keyed by grimmory_id. (2) reading **PROGRESS via the KOReader-compat endpoint** `GET/PUT /api/koreader/syncs/progress[/{partialMD5}]`, keyed by KOReader's own `util.partialMD5(book_path)` (NOT grimmory_id), with creds auto-provisioned from native `GET/PUT /api/v1/koreader-users/me` (`getKoreaderCredentials``md5(secret)` → X-Auth-User/X-Auth-Key). ⇒ The proven progress path reuses our existing KOSync XPointer/partial-MD5 machinery against `/api/koreader/...`, not the native progress API. Caveat: backend `FileFingerprint.generateHash` samples i=-1 at `1024L<<-2` → Java overflow to offset **0**, vs KOReader LuaJIT `bit.lshift(1024,-2)` → offset **256**; first block MAY differ ⇒ partial-MD5 progress-by-hash could silently mismatch — VERIFY (hash one real downloaded file both ways) before relying.
**FINDING B — OPDS acquisition-time capture (the chosen "best match", not yet built).** Grimmory OPDS fingerprints (`OpdsFeedService.java`): every `<id>` is `urn:booklore:*` (root `urn:booklore:root`, books `urn:booklore:book:{bookId}`); feed `<title>Booklore Catalog`; self/start link `/api/v1/opds`. The book acquisition link encodes BOTH ids: `<link href="/api/v1/opds/{bookId}/download?fileId={fileId}" rel="http://opds-spec.org/acquisition">`. So at OPDS download (`src/app/opds/page.tsx` ~line 505 has `url`; already persists sourceUrl via `upsertOPDSSourceMapping`) parse `bookId` (path) + `fileId` (query); corroborate via `urn:booklore:` entry id OR same-origin with configured grimmory serverUrl; write the ids into config. Authoritative, no metadata guessing — best identity strategy for grimmory-sourced books.
Identity options ranked (native path): (1) OPDS acquisition capture [authoritative]; (2) cached ids; (3) ISBN/ASIN exact; (4) gated title+author (require format + fileSizeKb match, abstain on ambiguity — wrong match corrupts another book's progress). `fileSizeKb` IS exposed on BookFile (size corroborator); hash is NOT.
@@ -0,0 +1,21 @@
---
name: hardcover-progress-edition-id-4792
description: Hardcover progress sync parse-failed — edition_id falls back to book_id; invalid edition rejected by Hasura Action
metadata:
node_type: memory
type: project
originSessionId: 6273b46d-b22d-4d48-9295-7420b251a197
---
Issue #4792 (v0.11.12) — FIXED in PR #4794 (branch `fix/hardcover-progress-edition-id`). "Hardcover sync fails completely despite successful API key auth." Auth (`GetUserId`) works; progress push fails with:
`GraphQL Errors: [{"message":"parsing Hasura.GraphQL.Execute.Action.Types.ActionWebhookErrorResponse failed, key \"message\" not found","extensions":{"code":"parse-failed"}}]`
**Root cause (verified live in Chrome, account chrox, book "Crime and Punishment"):** `HardcoverClient.pushProgress``MUTATION_UPDATE_READ` (`update_user_book_read`) sent `edition_id: 713309`, which is the **book_id**, not a real edition id. `update_user_book_read`/`insert_user_book_read` are Hardcover **Hasura Actions**; an invalid edition makes the Action handler throw and return a non-conforming error body, which Hasura surfaces as the generic `parse-failed` (`ActionWebhookErrorResponse` missing `message`). HTTP status is 200 — the error is GraphQL-level only.
**Why edition_id == book_id:** title-search path in `fetchBookContext` (`HardcoverClient.ts`). `QUERY_SEARCH_BOOK` (`per_page:1`, returns raw `results`) does **not** select `featured_edition_id` — confirmed the hit `document` has no such key. So `searchBookByTitle` does `editionId = featured_edition_id ?? bookId` → always `bookId`. Then `QUERY_GET_BOOK_USER_DATA` only resolves a real edition via `selectedEdition` (the user_book's / read's `edition`); here both were `null` (user added the book with no specific edition), so `editionId` stays `bookId`. Broad impact: any no-ISBN (title-matched) book whose Hardcover library entry has no edition selected sends `edition_id = book_id`.
**Fix shipped (PR #4794):** `BookContext.editionId` is now `number | null`; `searchBookByTitle` drops the `?? bookId` fallback (null when no `featured_edition_id`); `$edition_id` made nullable (`Int`) in `MUTATION_INSERT_READ`/`MUTATION_UPDATE_READ`/`MUTATION_INSERT_JOURNAL`; `insert_user_book` omits `edition_id` when null. Verified live: book id → `parse-failed`; real edition id → `error:null`; `edition_id:null``error:null` and is a no-op (does NOT clear an existing edition).
**NOT a recent Readest regression:** the buggy `editionId = featured_edition_id ?? bookId` fallback + `edition_id: context.editionId` in the read mutations exist unchanged since the original feature #3724 (2026-04-03). It surfaces now because auto-sync (#4614, 2026-06-16, shipped v0.11.10/v0.11.12) made progress-push run automatically on every page turn (debounced) and via the BookMenu "Hardcover Sync → Push Progress". Possibly compounded by Hardcover tightening server-side edition validation. Secondary: title search also mis-matches (e.g. matched a Harold Bloom study guide, not Dostoevsky's novel) — separate match-quality concern.
Files: `src/services/hardcover/HardcoverClient.ts` (`fetchBookContext` ~306-426, `searchBookByTitle` ~286-289, `pushProgress` ~499-536), `src/services/hardcover/hardcover-graphql.ts` (`QUERY_SEARCH_BOOK`, `MUTATION_UPDATE_READ`/`MUTATION_INSERT_READ` ~131-155). Proxy: `src/app/api/hardcover/graphql/route.ts` forwards client `authorization` header.
@@ -0,0 +1,36 @@
---
name: i18n-extract-prunes-keys
description: "pnpm i18n:extract (removeUnusedKeys) deletes valid keys not statically in the branch; don't commit that churn"
metadata:
node_type: memory
type: feedback
originSessionId: afe50e44-d394-4301-bd81-1368df66f90b
---
`pnpm run i18n:extract` (i18next-scanner, `i18next-scanner.config.cjs` has
`removeUnusedKeys: true`) can DELETE ~30+ valid-looking keys from every non-`en`
locale on a feature branch — keys whose source usage isn't statically present in
the current branch (e.g. `"Sync History"`, `"downloaded {{n}} book(s)"`,
`"Match Whole Words"`). The extract diff then shows huge churn (~1000 +/- lines)
unrelated to your change.
**Why:** the committed locales can be ahead of the branch's source (strings from
features not yet on this base, or built dynamically/in non-scanned modules), and
`removeUnusedKeys` strips anything the scanner can't find. `en/translation.json`
is a tiny key-as-content file (~70 lines, only plural/proper-noun overrides), so
new keys never land there anyway — it stays out of the diff.
**How to apply:** for a feature that adds a few strings, do NOT commit the
scanner's deletions into an unrelated PR.
1. Run `pnpm run i18n:extract` (optional — only confirms which keys are new).
2. `git checkout -- apps/readest-app/public/locales` to drop ALL the churn.
3. Add ONLY your new keys manually to each locale in `i18n-langs.json` with real
translations. The files are exactly `JSON.stringify(obj, null, 2) + "\n"`, so
a Node script that `JSON.parse`s, appends new keys (insertion order preserved),
and rewrites that way yields a zero-extra-diff result. Skip `en` (key-as-content).
Match each locale's existing terminology (grep the file for a related key, e.g.
`"Export Annotations"` / `"Annotations"`, before translating). Verify with
`grep -rn '"<Your Key>"' apps/readest-app/public/locales | wc -l` == number of locales.
Related: [[feedback_en_plurals_manual]].
@@ -0,0 +1,51 @@
---
name: iframe-double-click-word-select
description: Double-click / touch double-tap on a word selects it and fires the instant action or annotation toolbar
metadata:
node_type: memory
type: project
originSessionId: bac4ae5d-047f-4b4f-8a04-b239beb4d7d7
---
Double-tap (touch) / double-click (mouse) on a word now selects that word — like
a long-press — then runs the configured instant quick action, or raises the
annotation toolbar if none is set. Verified live on Xiaomi 12 (Android).
**The gap:** `iframe-double-click` was posted by `handleClick`
(`src/app/reader/utils/iframeEventHandlers.ts`, gated on `!doubleClickDisabled`)
but had **no consumer** — a touch double-tap did nothing (Android has no native
double-tap word-select; desktop double-click already selects natively via the
`handlePointerUp` path).
**Impl (3 files):**
- `src/utils/sel.ts`: `getWordRangeAt(node, offset)` expands a caret to the
word-like segment via `Intl.Segmenter` (CJK + Latin), `[start,end]` inclusive
so a boundary caret still selects the adjacent word; `getWordRangeFromPoint(doc,x,y)`
resolves the caret (`caretPositionFromPoint`/`caretRangeFromPoint`) then delegates.
- `useTextSelector.ts`: `handleDoubleClick(doc, index, x, y)` selects the word and
routes through the existing `makeSelection` (guarded so the programmatic
`selectionchange` echo is ignored). **Guard `if (isValidSelection(sel)) return`**
— on desktop the browser already selected the word natively (flows through
`handlePointerUp`), so synthesize ONLY when nothing is selected (touch double-tap).
No `isUpToPopup` latch: a double-tap is two taps both consumed by double-click
detection, so no trailing single-click follows that would dismiss the popup.
- `Annotator.tsx`: window `message` listener for `iframe-double-click` resolves the
visible section doc/index like `handleNativeTouch` (`renderer.getContents()` +
`primaryIndex`), then sets **`pointerDownTimeRef.current = 0`** before calling
`handleDoubleClick` so the deliberate double-tap bypasses `handleQuickAction`'s
`quickActionMinHoldMs` (300ms) long-press gate (mouse already uses 0). Coords:
`clientX/clientY` from the iframe click are already section-doc-relative, exactly
what caretFromPoint wants — no window↔frame mapping (unlike `rangeFromAnchorToPoint`).
The branch decision (instant action vs toolbar) reuses the existing Annotator
`selection` effect: `enableAnnotationQuickActions && annotationQuickAction &&
isTextSelected.current ? handleQuickAction() : handleShowAnnotPopup()`. Default
config has `annotationQuickAction: null` → toolbar.
**Tests:** unit `sel.test.ts` (getWordRangeAt/FromPoint), `useTextSelector-doubleClick.test.ts`
(selection routing + desktop guard); e2e `double-click.android.test.ts` + `doubleTap`
helper in `helpers/adb.ts` (two `input tap` in one shell, < 250ms apart). Live CDP
verify: toolbar branch (`.popup-container.selection-popup`) and instant-action
branch (set quick action to Dictionary via header dropdown → `.popup-container.select-text`,
toolbar absent). See [[dblclick-drag-pageturn-4524]], [[instant-highlight-tap-paginate]],
[[tap-to-open-image-table-4600]].
@@ -0,0 +1,16 @@
---
name: image-zoom-trackpad-flicker-4742
description: "Trackpad pinch-zoom flickered the image viewer; macOS pinch = ctrl+wheel stream, disable CSS transition during continuous gestures"
metadata:
node_type: memory
type: project
originSessionId: affbfa14-0152-4d69-8fce-f7e0b9ee97a3
---
ImageViewer (`src/app/reader/components/ImageViewer.tsx`) flickered when zooming an open image with a MacBook trackpad pinch (#4742, PR #4748).
**Root cause:** on macOS a trackpad pinch-to-zoom is delivered to the WebView as a rapid stream of `wheel` events with `ctrlKey: true` (NOT touch events), so it flows through `handleWheel`. The zoomed `<img>` kept its `transition: transform 0.05s ease-out` whenever `isDragging` was false. Pinch wheel events fire faster than 50ms apart, so each event restarted the in-flight transition from its interpolated mid-point — the transform constantly lagged and caught up = visible flicker. Same root cause as the #4451 pan flicker, which only fixed the pan path and (via `isDragging` set in `onTouchStart`) the touch-pinch path; the wheel-zoom path was the only continuous gesture left with the transition on. That's why touch pinch on iPhone was smooth but trackpad pinch flickered.
**Fix:** added an `isWheelZooming` state set on each `handleWheel` event and cleared on a 200ms debounce (wheel has no explicit gesture-end). Transition is `isDragging || isWheelZooming ? 'none' : 'transform 0.05s ease-out'`. Discrete zoom (buttons, double-click, keyboard) keeps the smoothing.
**General pattern:** never run a CSS `transition` on a transform that's being updated by a high-frequency continuous input stream (drag, touch pinch, trackpad/`ctrl+wheel` pinch) — the interrupted-transition restart flickers. Gate the transition off for the duration of the gesture. Maintainer couldn't repro on macOS 15.6.1 (WebKit) while reporter hit it on macOS 26.5.1 / WebKit 605.1.15; the fix is version-independent. Related: [[instant-highlight-tap-paginate]].
@@ -0,0 +1,18 @@
---
name: in-place-delete-wiped-originals
description: "Deleting a \"Read books in place\" book from Readest used to permanently delete the user's original source file; fixed (PR #4696) to never touch external sources"
metadata:
node_type: memory
type: project
originSessionId: 432bbb95-47b4-4d9c-825b-528168e2cfb7
---
User report (v0.11.12 Windows): imported a folder via "Import From Directory" with **Read books in place**, later deleted the books in-app, and Readest **permanently deleted the original local files** (not even sent to Recycle Bin). Files were unrecoverable; cloud sync hadn't uploaded them yet ("Book File Not Uploaded").
**Root cause:** `deleteBook` in `src/services/cloudService.ts`. For `local`/`both`/`purge`, it called `resolveBookContentSource` (`src/services/bookContent.ts`) and, when `source.kind === 'external'` (i.e. `book.filePath` set, base `'None'` — the user's own file from an in-place or transient import), unconditionally `fs.removeFile(source.path, source.base)`. `book.filePath` is set in `bookService.ts importBook` whenever `transient || inPlace`.
**The trap:** this was NOT an accidental bug — it was **deliberately coded AND tested**. `cloud-service.test.ts` had a whole `in-place (book.filePath set)` describe block asserting the source file IS removed, with a comment rationalizing it as "symmetric with deleting Books/<hash>/<title>.epub for a normal book." Don't assume tested == intended; the maintainer reversed the decision.
**Fix (PR #4696):** never `removeFile` an `external` source. Only `managed` sources (our Books/<hash>/ copy) and app-generated sidecars (cover.png, and the whole Books/<hash>/ dir on `purge`) are Readest's to delete. Removed the `external` branch entirely; flipped the in-place tests to assert the source is preserved (cover sidecar still removed on `both`, sidecar dir still wiped on `purge`). Also fixed the misleading JSDoc in `ImportFromFolderDialog.tsx` (`readInPlace`) that documented the destructive behavior as intended.
Out of scope but noted in the support thread: deletion flow lacks a warning/disclaimer, and delete doesn't use the OS Recycle Bin. See [[bug-patterns]].
@@ -0,0 +1,18 @@
---
name: inline-block-column-overflow
description: Foliate paginator fix — atomic-inline (inline-block) boxes too tall to fragment clip content in paginated mode
metadata:
node_type: memory
type: project
originSessionId: f0c35f7b-d4ff-4275-9f13-7019b0e167d9
---
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]].
@@ -0,0 +1,50 @@
---
name: instant-highlight-delete-orphan-4773
description: Deleting a just-made highlight leaves the overlay drawn (gone only after reopen); a stale memoized annotationIndex re-draws it
metadata:
node_type: memory
type: project
originSessionId: 3a58d242-3867-414c-869a-95a23714b361
---
#4773 (Android, instant highlight): highlight a word, delete it "within a very
short time" → the mark stays painted on the page, vanishing only after reopening
the book. Booknote IS soft-deleted (`deletedAt` set, gone on reopen) but the
**overlay was re-drawn after removal** → orphan.
**Root cause — stale memoized index re-draws a deleted annotation.**
`Annotator.tsx` re-applies per-location annotations on every relocate via the
memoized `annotationIndex` (`useMemo(buildAnnotationIndex(config.booknotes), [config.booknotes])`)
`selectLocationAnnotations(index, location)``view.addAnnotation(a)`.
`buildAnnotationIndex` filters `deletedAt` at BUILD time, but
`selectLocationAnnotations` trusted that and did NOT re-check. The delete
(`handleHighlight(false)`) stamps `existing.deletedAt = Date.now()` **in place**
on the same booknote object that's still sitting in the index bucket, and
removes the overlay (`addAnnotation(existing, true)`). If the re-apply effect
scheduled from the popup-open render flushes AFTER the delete (the "very short
time" window — passive effects deferred on Android WebView under rapid taps),
`selectLocationAnnotations` returns the now-deleted object from the pre-deletion
snapshot and `addAnnotation` re-draws it → overlay orphaned. Annotator does NOT
re-render on booknote changes (subscribes only to the stable `getConfig` fn), so
the memo stays stale until some other state change recomputes it.
NOT instant-specific in the data layer — instant highlight (`useInstantAnnotation`)
just makes it easy to hit (no popup friction, fast gesture). Delete + re-apply
(where the fix lives) is shared with normal highlights. `onCreateOverlay` reads
`getConfig` FRESH so it's safe; FoliateViewer onLoad re-draw only fires on
section load (not a quick delete).
**Fix:** re-check `deletedAt` at the READ site, not just at index build:
- `selectLocationAnnotations` (annotationIndex.ts): `if (item.deletedAt) continue;`
before classifying — covers both the annotations and notes lists.
- The sibling `annotationIndex.globals` loop in the Annotator re-apply effect:
`if (annotation.deletedAt) continue;` before `expandAllRenderedSections` (same
stale-snapshot hazard for global highlights).
Test: `src/__tests__/utils/annotation-index.test.ts` — build index with a styled
note, then `highlight.deletedAt = 123` in place, assert `selectLocationAnnotations`
returns `{ annotations: [], notes: [] }` (red before fix). Verified on Xiaomi 13
Pro (fuxi, WebView) via the CDP lane: real create→delete→immediate-relocate over
4 iterations left overlay count 6→7→6 each time (no orphan); overlay-count metric
proven non-blind by a stray-overlay sanity probe. See [[android-cdp-e2e-lane]].
Related: [[instant-highlight-tap-paginate]], [[global-annotation-pageturn-perf-4575]].
@@ -0,0 +1,43 @@
---
name: instant-highlight-tap-paginate
description: Instant Highlight quick action swallowed tap/swipe-to-paginate on Android; fixed with a 300ms still-hold gate
metadata:
node_type: memory
type: project
originSessionId: d92c120f-6272-4366-92b8-e2d8f32dfd52
---
After the 2026-06-19 update, Android users reported tap-to-paginate failing in
paginated mode: tapping TEXT didn't turn the page, only tapping the empty side
MARGINS worked. Trigger = **Instant Highlight** quick action enabled (3rd toolbar
icon / highlighter; setting = `enableAnnotationQuickActions && annotationQuickAction === 'highlight'`).
**Root cause:** `useTextSelector.handlePointerDown` called `ev.preventDefault()` +
`startInstantAnnotating()` on EVERY pointer-down over selectable text. The
`preventDefault` suppressed the native click that drives tap-to-paginate (iframe
`handleClick``iframe-single-click` → usePagination). Margins worked only because
`handleInstantAnnotationPointerDown``isSelectableContent` returns false there.
The synthetic-mousedown fallback in `handlePointerUp` is dead on Android because the
native-touch `touchend` calls `handlePointerUp(doc, index)` with NO `ev` (Annotator.tsx
`handleNativeTouch`), and `if (isInstantAnnotating.current && ev)` skips.
**Fix (PR/commit on `dev`):** gate instant-highlight engagement behind a still hold
for touch/pen — `INSTANT_HOLD_MS = 300`, `INSTANT_HOLD_MOVE_PX = 10` in useTextSelector.ts.
- `armInstantHold` (touch/pen) records the press, starts a 300ms timer, does NOT
preventDefault. A tap releases first (`handlePointerUp`/`handlePointerCancel`
`cancelInstantHold`) → native click → paginate. A swipe moves first
(`maybeCancelInstantHoldOnMove`, called in BOTH `handlePointerMove` and
`handleNativeTouchMove`, compares window-coord `pointerPos` vs `instantHoldStartWindow`)
→ native swipe → paginate. Only a still hold fires the timer → `startInstantAnnotating`.
- Mouse path unchanged (immediate `preventDefault` + start) — click vs. press-drag is
already unambiguous; matches the existing "mouse shouldn't be time-gated" stance.
- Refactor: `startInstantAnnotating(target, startPoint)` / `stopInstantAnnotating()` no
longer take `ev`; the down `target` is stored in `instantAnnotationTarget` so the exact
element gets `user-select` restored (pointerup target may differ after the finger moves).
Two parallel instant-highlight mechanisms share the same enable flag: (1) the
`useInstantAnnotation` live drag-to-highlight (this fix), and (2) the
quick-action-on-selection deferred path (`beginGesture`/`deferredQuickActionRef`/
`pointerDownTimeRef` in Annotator.tsx) which ALREADY long-press-gates touch on
iOS/desktop but not Android. Test: `useTextSelector-instantHold.test.ts`. See
[[keyboard-selection-adjust-4728]] for the adjacent `isPointerDown`/`handleSelectionchange` logic.
@@ -0,0 +1,18 @@
---
name: ios-instant-dict-double-popup
description: iOS instant system-dictionary fired 2-3× per long-press + tap-to-deselect re-opened it + Word Lens ignored system dict; deferredAction once-per-gesture latch + long-press-hold gate
metadata:
node_type: memory
type: project
originSessionId: 1c09f918-0b1d-4f75-b1c6-8cef5eb73d60
---
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.
@@ -0,0 +1,48 @@
---
name: issue-4584-tap-death-investigation
description: "#4584 single-taps-dead-after-picture-zoom: isPopuped self-heals (red herring), likely WebView-148-specific, plus Android emulator/CDP gesture-verification gotchas"
metadata:
node_type: memory
type: project
originSessionId: a41b6cab-c0f3-4740-a4c0-61a10b68fc09
---
#4584 (Android): after using picture zoom (long-press image → ImageViewer → close),
single taps stop registering until app restart while long-press keeps working. OP on
Android 16 / WebView 148. **NOT root-caused / NOT fixed.** PR #4600 only adds single-tap
as a second way into the viewer (see [[tap-to-open-image-table-4600]]); it does not fix
the tap-death.
**Red herring — don't "fix" `isPopuped`.** An adversarial analysis pointed at
`useTextSelector.ts` `handleSingleClick`'s `isPopuped` branch consuming a tap without
resetting the flag (unlike its `isTextSelected`/`isUpToPopup` siblings). But it
**self-heals**: the branch calls `handleDismissPopup()``showingPopup` false →
`handleShowPopup(false)``isPopuped` false after 500ms. And the uncancelled 500ms
timer in `handleShowPopup` always converges `isPopuped` to the FINAL `showingPopup`
(timers are same-delay FIFO), so it can't strand `isPopuped=true` with `showingPopup=false`.
Net effect is at most a one-tap glitch, NOT permanent. Resetting it in the branch is
harmless but does NOT explain #4584.
Likely **WebView-148-specific**: the OP reproduces; the maintainer's Xiaomi and the
Pixel_9_Pro emulator (WebView **133**) do NOT. A stronger unverified permanent-death
candidate is `isInstantAnnotating`/`scrollLocked` getting stuck when the ImageViewer
overlay swallows the `pointerup` (needs instant-annotation enabled).
**Android emulator gesture-verification gotchas (this machine):**
- Host-GPU `Pixel_9_Pro` AVD CRASHES on rapid pinch (Vulkan `bad_function_call`); a
re-launch can hang at Vulkan init. `-gpu swiftshader_indirect` boots reliably but is
too slow for the WebView reader → repeated "Input dispatching timed out for
FocusEvent" ANRs under automation. **That ANR is an EMULATOR ARTIFACT, not app code**
(JS stays responsive; CPU runs native arm64 on Apple Silicon, only the GPU is
swiftshader; CPU profile of page-turns is light; full-screen backdrop-blur made zero
frame-time difference).
- CDP `Input.dispatchTouchEvent` CANNOT trigger the WebView's native text-SELECTION
gesture, so the annotation popup never appears (blocks reproducing selection-driven
bugs). It also leaves `screenX=0`, so `handlePageFlip` treats every synthetic tap as
left/`prev()` (no-op at page 1 → navigate mid-book to test page-turns via relocate).
- Native `adb shell input tap` does NOT reach the WebView's click pipeline.
- Long-press via CDP only works if you HOLD the touch on one open connection and poll
(a touchStart→sleep→touchEnd across separate `node` invocations releases too early).
- Drive the live on-device WebView: `adb forward tcp:9222 localabstract:webview_devtools_remote_<pid>`
+ a `ws` CDP client; the debug APK exposes the socket. VIEW-intent a pushed EPUB via its
MediaStore `content://.../<id>` to import it.
@@ -0,0 +1,22 @@
---
name: keyboard-selection-adjust-4728
description: "After a reader text selection, keystrokes land in the PARENT (container focus), not the iframe — fix Shift/Ctrl/Alt+Arrow selection refine in useBookShortcuts"
metadata:
node_type: memory
type: project
originSessionId: 9ebaeccc-0436-4c7b-a81e-1a4aa3de64dd
---
#4728: standard desktop selection shortcuts — `Shift+←/→` refine selection by character, `Ctrl/Alt(Option)+Shift+←/→` by word — implemented in the **parent** shortcut system, not the iframe.
**Critical gotcha (cost a full redesign):** after a text selection, `Annotator.handleShowAnnotPopup` calls `containerRef.current?.focus()` on desktop, so `document.activeElement` is a **parent-document DIV**, not the book iframe. Real OS keystrokes therefore go to the parent `window``useShortcuts` (native keydown path) → page-turn shortcuts (`shift+ArrowRight`=`onGoNext`/`onGoForward`). A fix inside the iframe `handleKeydown` is **bypassed** for real keystrokes — it only fires if focus is in the iframe (e.g. quick-actions config). JS-dispatched `KeyboardEvent`s into the iframe doc DO hit the iframe handler, so they falsely "pass" — only a **real OS key** (`computer.key`) reveals the parent-focus path. Always verify with a real keystroke, not a synthetic dispatch.
**Fix shape:**
- `utils/sel.ts`: pure `getKeyboardSelectionAdjustment(KeyModifiers)``{direction:'left'|'right', granularity:'character'|'word'}|null` (Shift=char, Ctrl||Alt=word, metaKey→null so native Cmd+Shift line-select survives; 'left'/'right' visual dir for RTL). `extendSelectionFromContents(contents, ev, extend)` walks `view.renderer.getContents()` (`{doc}[]`), finds the non-collapsed `doc.defaultView.getSelection()`, and (if `extend`) `sel.modify('extend', dir, gran)`; returns whether a selection was found.
- `helpers/shortcuts.ts`: new `onAdjustTextSelection` (section 'Selection') with keys `shift+Arrow{Left,Right}` + `ctrl/alt+shift+Arrow{...}`.
- `useBookShortcuts.ts`: `adjustTextSelection` wired **first** in the `useShortcuts` actions map so it intercepts before `onGoNext/Prev/...`. Native keydown (parent focus) → extend ourselves; forwarded iframe-keydown MessageEvent (iframe already extended natively) → `extend:false`, just report presence to suppress nav. Returns true ⇒ `processKeyEvent` stops ⇒ no page turn.
- `useTextSelector.handleSelectionchange`: desktop normally defers to pointerup; relaxed the gate to `!isAndroid && !isTouchInput && isPointerDown.current` (new `isPointerDown` ref set in pointerdown, cleared in pointerup/cancel) so a keyboard-driven `selectionchange` (no pointer drag) refreshes the popup/range. This realm-agnostic gate refreshes for BOTH the parent-modify and native-iframe-modify paths.
**Selection.modify test artifact:** in browser-lane tests build the starting selection with `setBaseAndExtent` (or collapse+extend), NOT `addRange``addRange` leaves the selection directionless so backward `modify('extend','left'/'backward')` silently no-ops; `setBaseAndExtent` establishes anchor/focus like a real mouse drag.
Verified live on Chrome with real keystrokes (Alice EPUB, scrolled mode): `Shift+→` "Queen"→"Queen." no turn; `Opt+Shift+→` "two"→"two miles" (word); popup follows; no selection ⇒ `Shift+→` still scrolls (nav preserved). Paginated auto-scroll-to-follow when extending past the page edge is NOT wired (foliate's `isKeyboardSelecting` scrollToAnchor only fires for iframe-focus keydowns; parent-focus has none) — minor known limitation. See [[layout-ui-fixes]].
@@ -0,0 +1,19 @@
---
name: koplugin-bulk-download-4751
description: "koplugin Library \"Download all books\" bulk download — entry point, candidate query, and the sync/async coroutine bridge"
metadata:
node_type: memory
type: project
originSessionId: b474b24d-cfa5-4f32-b6f2-d6a35f27cadd
---
Issue #4751: bulk "download all" for the readest.koplugin Library view (parity with Readest web/desktop "download all"). Branch `feat/koplugin-bulk-download-4751`, PR #4765 (base main).
- Entry point: view-menu Actions section in `library/libraryviewmenu.lua` → calls `require("library.librarywidget").downloadAll()` (no args; reads `M._opts`/`M._store` like `M.refresh()`).
- Candidate set: new `LibraryStore:listCloudOnlyBooks()` = `cloud_present=1 AND local_present=0 AND deleted_at IS NULL AND uploaded_at IS NOT NULL` (phantom records with no uploaded file are excluded, same as `listBooks`). Whole library, ignores active search/group. Test-first in `librarystore_spec.lua`.
- Orchestration `M.downloadAll()`: sequential reuse of `syncbooks.downloadBook`, inside `Trapper:wrap`. Progress + cancel via `Trapper:info("Downloading %1 of %2…")` — it yields to UIManager, so a tap queued during the previous (blocking) download is processed at the book boundary and raises Trapper's Abort/Continue confirm (returns false → cancel). Skip per-book failures, count them, show a summary toast. Only `Trapper:clear()` when NOT cancelled (abort path already closed the widget).
- **Sync/async cb bridge** (the non-obvious bit): `downloadBook`'s callback fires exactly once but may be synchronous (token fresh) OR async (after token refresh). In the cb, resume the coroutine only `if coroutine.status(co) == "suspended"`; capture result + a `finished` flag, and only `coroutine.yield()` `if not finished`. This avoids "resume non-suspended coroutine" errors in the sync case and correctly awaits in the async case. Reusable for any callback-style KOReader API awaited inside a Trapper coroutine.
- i18n: 6 new `_()` strings, `T(_("… %1 …"), ...)` interpolation (`local T = require("ffi/util").template`). Ran `node scripts/extract-i18n.js`; translated all 33 locales via [[i18n-koplugin]] flow. Verify: placeholders `%1/%2/%3` preserved (no `%s/%d`), `…` U+2026 kept.
- Note: the per-book long-press sheet already had a "Download All" (cover+file for ONE book) — left as-is; distinct from the new bulk "Download all books".
Gates: `pnpm lint:lua` + `pnpm test:lua` (see [[verify-format-check-gate]] / verification.md). No JS/TS/Rust changes.
@@ -0,0 +1,18 @@
---
name: koplugin-note-deletion-sync
description: "koplugin deleting a note didn't sync to Readest — push only walked live annotations; fixed with persisted tombstones"
metadata:
node_type: memory
type: project
originSessionId: f3f6e25e-1bde-4e08-9fc9-c8184d1ef457
---
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).
@@ -0,0 +1,27 @@
---
name: koplugin-stats-sync
description: KOReader readest.koplugin reading-stats sync (push on close / pull on open); 3-bug chain fixed in PR
metadata:
node_type: memory
type: project
originSessionId: 9b3eaf27-8688-4293-ab42-f635af2e1905
---
`readest.koplugin` reading-statistics sync (KOReader `statistics.sqlite3` page events) — fixed in PR #4666 (`readest_syncstats.lua`, `main.lua`, `readest-sync-api.json`).
**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]].
@@ -0,0 +1,20 @@
---
name: kosync-connect-false-positive-4692
description: "KOSync connect() accepted any 2xx (even an HTML web-UI page) as login → misconfigured Server URL silently \"connects\" but never syncs"
metadata:
node_type: memory
type: reference
originSessionId: 43e853c2-58ea-42f0-97ed-66aa3f65e4d1
---
#4692 (PR #4711): KOReader Sync to a self-hosted Grimmory/Booklore server failed on Android (worked on iOS). Root cause was a **misconfigured Server URL** that resolved to the host's static web UI instead of the sync endpoint, made undebuggable by a Readest gap.
**Server-side tell (the smoking gun):** Android `PUT /syncs/progress` was handled by Spring's `ResourceHttpRequestHandler``HttpRequestMethodNotSupportedException: Request method 'PUT' is not supported`. That handler is Booklore's SPA/static fallback — so the request reached the server but **missed the koreader controller** and hit the catch-all static handler. GET requests (auth/pull) silently get the HTML index with 200; only PUT errors (static handler rejects non-GET/HEAD).
**Readest gap:** `KOSyncClient.connect()` treated any 2xx from `/users/auth` (or `/users/create`) as success. An HTML web-UI page returns 200 → false-positive "connected"; then pulls show 0% and pushes fail with no error surfaced. Matches the classic "no errors reported, still 0%" report.
**Fix:** validate the auth/registration response is an actual koreader JSON object (real server → `{"authorized":"OK"}`; HTML fails `response.json()`), else return "Not a KOReader Sync server. Check the Server URL." (`isKoSyncJsonResponse` helper in `KOSyncClient.ts`). Catches misconfig at setup, when actionable.
**Still silent (intentional follow-up, not done):** per-sync push/pull failures. `getProgress`/`updateProgress` collapse "request failed" and "no remote data" into the same `null`/`false`; naive toasting would fire on every transient auto-push (5s). Needs noise-aware design before surfacing.
KOSync settings are **per-device** (no Readest account → not synced across devices), so iOS vs Android URLs are entered independently — the #1 suspect when one platform syncs and the other doesn't. Related: [[kosync-cfi-spine-resolution]], [[empty-start-cfi-sync]].
@@ -0,0 +1,18 @@
---
name: library-reader-separate-texture-4743
description: "Separate library vs reader background texture (#4743); shared-style-element + two gotchas"
metadata:
node_type: memory
type: project
originSessionId: dfdb7b38-1869-4fb4-b869-c32301c80128
---
#4743: library and reader shared one background texture; split so each is set independently.
**Architecture**: ONE global `<style id="background-texture">` paints `body::before` (covers library) plus reader containers (`.foliate-viewer/.sidebar-container/.notebook-container ::before`). Library and reader are separate routes (only one mounted), so the split = store two values + have each page apply its own on activation, not separate style elements. New device-local `SystemSettings.libraryBackground{TextureId,Opacity,Size}` (NOT in settings sync whitelist — texture *selection* is per-device like reader's `backgroundTextureId`; only image binaries sync via `texture` replica kind). `getLibraryViewSettings(settings)` in `helpers/settings.ts` resolves each field with `?? globalViewSettings.<field>` so the bookshelf inherits the reader texture until decoupled (no migration). `ColorPanel` is context-aware via `isLibraryContext = !bookKey`: library context writes `libraryBackground*` via `saveSysSettings`, reader context unchanged via `saveViewSettings`. Applied at boot (`Providers`) + on every library mount (`library/page.tsx` effect).
**Gotcha 1 — `useBackgroundTexture` early-returned on `'none'` WITHOUT unmounting.** Since library+reader share the one style element, switching a page to None must actively clear a texture the OTHER page mounted. Fixed: always delegate to `applyTexture(envConfig, textureId || 'none')` (it unmounts on 'none'); only set CSS vars / addTexture for a real texture. Also fixes the symmetric reader case (opening a 'none' book after a textured one).
**Gotcha 2 — `useSettingsStore` initializes `settings: {} as SystemSettings`.** So `settings.globalViewSettings` is `undefined` on the first renders before `appService.loadSettings()` runs. Any NEW effect/deps that deep-derefs `settings.globalViewSettings.<x>` crashes the library with "Cannot read properties of undefined (reading 'backgroundTextureId')". Caught only in a hard reload (HMR kept old store state, so first nav didn't repro). Fix = optional-chain in effect deps + make the resolver tolerate missing globalViewSettings (fallback to 'none'). Relates to [[cover-stale-inplace-mutation-memo]].
Verified end-to-end in dev-web: library moon texture, reader stays none, round-trip persists, None clears live. Related: [[wordlens-feature]] i18n (recent feature commits ship `_()` strings WITHOUT running `i18n:extract`; translations are batched separately — don't commit locale churn in a feature PR).
@@ -0,0 +1,18 @@
---
name: list-view-series-overflow-4796
description: "Library list view series + description text overlapped/clipped under fixed h-28, worsened by Android system font scaling"
metadata:
node_type: memory
type: project
originSessionId: 8645710b-673d-422a-ad8a-e3f385057f49
---
PR #4799 (branch `fix/list-series-overflow-4796`). Reported on Pixel 10 Pro / Android 16: in library **list view**, a book that belongs to a series shows its series line and description preview overlapping and cut off.
**Root cause:** `BookItem.tsx` list-mode container used a fixed `h-28` (112px) with `overflow-hidden`. The right column stacks title + authors + (optional) series + description + a progress/actions row (`useResponsiveSize(15)` → ~19px on phones). Without a series it fits 112px; the optional series line (added in #4593/#4612) pushes the total over 112px, so the lines collide and clip. **Android applies the system accessibility font-size scale to WebView CSS text**, inflating line heights — that's what made it bad enough to report (matched the issue screenshot at ~130% scale).
**Fix:** `h-28``min-h-28` (one class). Row grows to fit; non-series rows keep 112px. List is `Virtuoso` with measured (not fixed) heights, so variable row heights are fine.
**Verification:** jsdom can't measure layout, so reproduced the exact flex markup in a real browser at normal + 130% font scale (before = overlap, after = clean). Lint + full `pnpm test` (6324 pass) + `format:check` pass.
Lesson: fixed-height list/card rows are fragile against optional metadata lines AND user font scaling. Prefer `min-h-*` when the row can virtualize. Related: [[cover-stale-inplace-mutation-memo]].
@@ -0,0 +1,40 @@
---
name: markdown-md-support-774
description: Markdown (.md) reading via in-memory foliate book (no EPUB); split-at-H1; foliate book-object contract gotchas
metadata:
node_type: memory
type: project
originSessionId: a82e979b-0edb-4964-91fd-3677ecfe5679
---
Issue #774: render standalone `.md` files at runtime (NO EPUB conversion). **MERGED as
PR #4816** (branch `feat/markdown-support`). Built test-first via `/autoplan` (CEO+Eng
dual-voice review). Suite green (6365), lint + format clean; live-verified in web app
(import via drop, split TOC, cross-section nav, GFM rendering, pagination).
**Where:** `src/utils/md.ts` `makeMarkdownBook(file)` builds an in-memory foliate book
modeled on `packages/foliate-js/fb2.js`. Routed in `src/libs/document.ts` `open()` via
`isMd()` **before `isTxt()`** (a `.md` served as `text/plain` would otherwise hit TXT→EPUB).
`'md'` added to `SUPPORTED_BOOK_EXTS` (constants.ts). `sanitize.ts` `sanitizeHtml` gained
`'class'` (code `language-*` theming) + `del`/`ins` tags. Pipeline: strip YAML frontmatter →
`marked`(gfm) → `sanitizeHtml` → split at `<h1>` (preamble = pre-first-H1 content) → nested
heading-outline TOC.
**Non-obvious foliate book-object contract (cost us the CRITICAL review finding):**
- `section.id` and `splitTOCHref()` output MUST be the SAME type. readest nav
(`services/nav/index.ts:133`) does `new Map(sections.map(s=>[s.id,s]))` then `.get(sectionId)`
where sectionId = `splitTOCHref(href)[0]`. `SectionItem.id` is typed `string`, so use
STRING ids + `splitTOCHref => href.split('#')`. fb2.js uses numbers consistently (works
only because it's untyped JS); do NOT copy fb2's `Number(x)`.
- Fragment CFIs / TOC sub-anchors require `section.loadText` (nav skips sections without it,
index.ts:153). Provide it.
- `SectionItem.cfi` is non-optional → set `cfi: ''` (foliate falls back to `CFI.fake.fromIndex`).
- `createDocument()` parses `application/xhtml+xml`; marked's HTML5 void tags (`<br><hr><img>`)
are parse errors there → serialize sections with `XMLSerializer` (not innerHTML). `load()`
and `createDocument()` must derive from the SAME string (CFI round-trip).
- `resolveHref` returns `null` for unresolved anchors (never index 0). jsdom lacks
`URL.createObjectURL``load()` is lazy + tests stub it.
**Deferred follow-ups (open issues):** relative image resolution (web File-objects have no
sibling access — needs the bundle model); Markdown folder/zip "package" model; footnotes/math/
Mermaid/wikilinks; syntax-highlight token colors. Plan: `.claude/plans/2026-06-26-markdown-md-support-774.md`.
@@ -0,0 +1,20 @@
---
name: mobile-reading-widgets
description: "Home-screen reading widgets (#1602, PR"
metadata:
node_type: memory
type: reference
originSessionId: 7f7f8218-4656-4863-972e-ea6204c130fa
---
Mobile home-screen reading widgets (issue #1602, merged PR #4842). Code lives in the **native-bridge plugin**: `src-tauri/plugins/tauri-plugin-native-bridge/{android,ios}/` (Android `ReadingWidgetProvider.kt` + `res/`; iOS writer `ReadingWidgetWriter.swift`) and the iOS WidgetKit extension at `src-tauri/gen/apple/ReadestWidget/`. App publishes a snapshot + downsized cover thumbnails via the `update_reading_widget` command to iOS App Group `group.com.bilingify.readest` / Android `SharedPreferences`. Widget hook: `src/hooks/useReadingWidget.ts`; payload builder `src/services/widget/readingWidget.ts`; tap opens `readest://book/{hash}` via `useOpenBookLink.ts`.
Durable, non-obvious gotchas (each cost a debugging round):
- **iOS widget missing from gallery = stale `.xcodeproj`.** `gen/apple/project.yml` defines the `ReadestWidget` target, but **Tauri's iOS build does NOT re-run xcodegen**, so a newly-added target is silently omitted from the build. Fix: `cd src-tauri/gen/apple && xcodegen generate`. Also: iOS builds from the **MAIN repo** `/Users/chrox/dev/readest` (complete gen/apple), NOT the `pnpm worktree:new` worktree (its gen/apple is incomplete — missing `Sources/`, `Assets.xcassets`, `Externals`, `LaunchScreen.storyboard` — so xcodegen fails there).
- **Android RemoteViews allow only @RemoteView widgets.** Plain `<View>` (and `<Space>`) is NOT allowed → launcher inflate fails → "Can't load widget". Use an empty `FrameLayout` for spacers. Covers: badge + progress bar are **baked into the bitmap** (Canvas in `writeThumbnail`) because RemoteViews can't clip/overlay reliably; shown via `fitCenter`. Responsive sizing by grid cells: `n = (minWidthDp + 30) / 70` (Android cell formula); one book per column, cap 3.
- **Background TTS progress freeze.** `book.progress` (libraryStore) AND `readerProgressStore` are both written by the same `setProgress`, inside `commitRelocate`**`requestAnimationFrame`**, which Android pauses for a backgrounded WebView → both freeze during background TTS. No store-only fix (page-based progress needs rendering). Fix: in `FoliateViewer.progressRelocateHandler`, commit synchronously when `document.visibilityState === 'hidden'` (relocate still fires; only the rAF commit was deferred). Confirmed working on device.
- **iOS TTS controls deferred** — interactive widget buttons need iOS 17 App Intents; widget min target is iOS 15 (15/16 widgets can only deep-link, no buttons). Android uses `MediaButtonReceiver.buildMediaButtonPendingIntent` (any version). Follow-up only.
- **`.superpowers/` is NOT gitignored** in this repo → a subagent's `git add` can sweep SDD scratch (`*-report.md`) into a commit; check `git ls-files '.superpowers/*'` before squashing/pushing.
Related: [[android-nativefile-remotefile-io]] · [[tts-fixes]] · build/worktree [[feedback_use_worktree]]
@@ -0,0 +1,16 @@
---
name: multiwindow-settings-clobber-4580
description: Pagination/global settings revert with multiple desktop windows; cross-window broadcast fix
metadata:
node_type: memory
type: project
originSessionId: 4df1808d-e106-4316-9206-b4e606b4b9bf
---
Issue #4580 (fix: PR #4803, branch `fix/multiwindow-settings-revert-4580`): on desktop (Tauri) global view settings (Click/Swipe to Paginate, Show Page Navigation Buttons) "revert to default" — only when multiple windows are open (OP ran `1 + n_opened_books` windows).
**Root cause:** each Tauri window keeps its own in-memory `useSettingsStore.settings`, loaded once at window open. Global settings persist to ONE shared `settings.json`, and every window writes the WHOLE object via the store's `saveSettings`. A window opened before the user customized a global setting holds the default (e.g. `disableClick=false`); when it later saves (notably `handleCloseBooks` on reader-window close in `ReaderContent.tsx`, but ANY settings write) it clobbers the user's value back to default. Explains "reverts to *default*, only with multiple windows". Note: `replicaCursorStore` avoids this by load-modify-saving from disk each time.
**Fix:** cross-window broadcast. `src/utils/settingsSync.ts` (`broadcastGlobalSettings` emits `global-settings-window-sync` with `sourceLabel` + the two global blobs; `subscribeSettingsSync` ignores self; `mergeSyncedGlobalSettings` adopts `globalViewSettings`/`globalReadSettings` and preserves all device/window-local fields). Store `saveSettings` calls `broadcastGlobalSettings` after persisting. `useSettingsSync` (mounted in `Providers.tsx`, the shared root for both library + reader windows) adopts broadcasts via `setSettings`. No-op off Tauri.
Only the two global objects are synced (minimal scope) — covers the reported bug + sibling read settings; top-level scalars left window-local. No save/broadcast loop: receive calls `setSettings` only; the replica publisher subscriber pushes to network (no disk write) and pagination fields aren't in `SETTINGS_WHITELIST` anyway. Live cross-window view update of already-open books is intentionally NOT done (bug is persistence, not live propagation). Related: [[webdav-connect-nullified-4780]] (stale settings closure), [[window-state-sanitize-4398]].
@@ -0,0 +1,91 @@
---
name: native-ios-tts-4676
description: Native local iOS TTS (AVSpeechSynthesizer) mirroring the Android native TTS plugin;
metadata:
node_type: memory
type: project
originSessionId: ec6b5ad5-f187-4615-83b4-33b1a9e77ba7
---
# Native local iOS TTS (#4676)
STATUS: MERGED (PR #4697, into main 2026-06-21). Device-verified by maintainer:
system-voice playback, voice selection, rate/pitch, auto-advance, pause/resume,
stop/disable teardown, and lock-screen controls + metadata for both system and
Edge TTS. Final design = iOS lock screen via `navigator.mediaSession` (NOT the
native plugin); the Swift media-session methods are dead on iOS (Android-only).
Diagnostic logging was stripped before merge.
Goal: give iOS the same on-device TTS Android has (private, offline). The shared
TypeScript `NativeTTSClient` (`src/services/tts/NativeTTSClient.ts`) and the Rust
command/mobile layer (`src-tauri/plugins/tauri-plugin-native-tts/src/{commands,mobile,models}.rs`)
were already platform-agnostic — only the Swift plugin (a `ping()` stub) and two
gates were missing. See [[tts-fixes]].
## What was changed
- **`ios/Sources/NativeTTSPlugin.swift`** — full impl mirroring `android/.../NativeTTSPlugin.kt`.
Commands: init, speak, stop, pause, resume, set_rate, set_pitch, set_voice,
get_all_voices, set_media_session_active, update_media_session_state,
update_media_session_metadata, checkPermissions/requestPermissions.
- **`TTSController.ts:91`** gate: `isAndroidApp``isAndroidApp || isIOSApp` (creates `ttsNativeClient`).
- **`mediaSession.ts` `getMediaSession()`** reorder: check native platforms FIRST
(`(android||ios) && isTauriAppPlatform()``TauriMediaSession`), THEN
`'mediaSession' in navigator`. iOS WKWebView (and Android WebView) expose
`navigator.mediaSession`, but the web session can't drive lock-screen controls
for AVSpeech/TextToSpeech — so it must lose to the native plugin.
- Tests: `tts-controller.test.ts` iOS gate + new `__tests__/libs/mediaSession.test.ts`.
## Non-obvious gotchas
- **`init` is a Swift reserved word.** Tauri iOS dispatch = `perform(Selector("\(command):"))`
(`mobile/ios-api/.../Tauri.swift`), so the "init" command needs selector `init:`.
Solution: `@objc(init:) public func initialize(_ invoke: Invoke)`. Verified it
compiles + `responds(to: Selector("init:"))==true` via swiftc. `perform` doesn't
apply ARC init-family retain rules (those are compile-time, direct-send only).
If it ever misbehaves on-device, fallback = rename the command for iOS in `mobile.rs`.
- **Pause == stop (mirror Android).** JS `NativeTTSClient.pause()` returns `false`,
so `TTSController.pause()` (line 472) does stop + re-speak on resume. The Swift
delegate must emit `end` ONLY on `didFinish`, **never on `didCancel`** (cancel
comes from stop/pause; an `end` there would auto-advance the reader).
- **AVAudioSession is owned by native-bridge.** `useTTSControl` calls
`invokeUseBackgroundAudio({enabled})` (plugin:native-bridge|use_background_audio →
`.playback`) on iOS TTS start/stop. AVSpeechSynthesizer uses the app session
(`usesApplicationAudioSession` defaults true), so the native-tts plugin does NOT
touch the audio session. Background + silent-switch playback comes for free
(Info.plist already declares `UIBackgroundModes: [audio]`).
- **MPRemoteCommandCenter.shared() is app-global and shared** with native-bridge's
`MediaKeyHandler` (hardware media-key page-turns on next/previousTrack). The
native-tts plugin stores its `addTarget` tokens and removes ONLY those on
deactivate. Lock-screen next/previous + the media-key page-turn both fire if
both are active — on-device test point.
- **Rate curve.** JS sends `pow(userRate, 2.5)` (tuned for Android setSpeechRate,
1.0=normal). Swift `avRate()` inverts (`^(1/2.5)`) and rescales onto
AVSpeechUtterance (0…1, `AVSpeechUtteranceDefaultSpeechRate`≈0.5 = normal). Top
speeds saturate at max (AV limitation).
- Voice id = `AVSpeechSynthesisVoice.identifier` (round-trips through set_voice →
`AVSpeechSynthesisVoice(identifier:)`). All iOS voices group under "System TTS"
in the JS `getVoices` (no `_`-prefixed engine id); enhanced/premium quality
appended to the display name to disambiguate same-named variants.
- Permissions already granted: `native-tts:default` (no platform restriction in
`capabilities/default.json`) covers every command.
## Media session on iOS — REVERTED the native reroute (round 3)
- On-device trace confirmed parallel teardown WORKS (`stop: wasSpeaking=true stopSpeaking returned true``set_media_session_active active=false``deactivateRemoteCommands: removed 5 targets, cleared nowPlayingInfo`). The remaining media-session problems were caused by the `getMediaSession()` reroute itself:
- Edge TTS lock screen lost cover + current sentence (REGRESSION): Edge plays via a WebView `<audio>` element → its lock-screen card is driven by `navigator.mediaSession.metadata` (set in `useTTSControl`). Routing iOS to `TauriMediaSession`/`MPNowPlayingInfoCenter` bypassed that.
- System TTS got NO controls: `AVSpeechSynthesizer` is not a WebView media element, so the app never becomes "Now Playing" and the plugin's `MPRemoteCommandCenter` targets never surface. (Edge gets controls because its `<audio>` element makes the app now-playing.)
- FIX: `getMediaSession()` reverted so iOS uses `navigator.mediaSession` (Android still first→`TauriMediaSession` foreground service). iOS system TTS now rides the same WebView path as Edge — the silent keep-alive `unblockAudio` `<audio>` element + `navigator.mediaSession` metadata/action-handlers. OPEN/UNVERIFIED: whether the SILENT keep-alive element registers as Now Playing on iOS (if not, system TTS still shows no card — would need a non-silent keep-alive or a real native now-playing implementation). The iOS Swift media-session methods (set_media_session_active etc.) are now DEAD on iOS (only Android Kotlin uses them via TauriMediaSession); left in place, harmless.
- Heavy Swift diagnostic logging (per-voice dump + per-command enter/resolve + delegate) still present; trim once confirmed.
## Follow-up iOS fixes (same PR)
- **Duplicate voice names**: Eloquence + legacy "novelty" voices (Rocko, Shelley, Grandma, Grandpa, Eddy, Reed, Flo, Sandy…) ship in many regions of one language, all quality=default. JS `getVoices` groups by primary language (`isSameLang`→normalized subtag), so e.g. en-US "Rocko" + en-GB "Rocko" collide in one "System TTS" list. Fix (in Swift `get_all_voices`): count `(primaryLanguage, displayName)`; for collisions append `regionDescription` (localized region, e.g. "Rocko (United Kingdom)"). Unique names stay clean. 192 system voices on a loaded device.
- **First word clipped "sometimes"**: each sentence is a separate `AVSpeechUtterance` spoken after a gap → audio route goes cold between sentences → first phonemes clipped. Same family as the startup `!act` (cannotActivate) `AVAudioSession` error from native-bridge `use_background_audio`. Fix: `utterance.preUtteranceDelay = 0.1` warms the route with silence first.
- **Stop "never tears down" / TTS icon stays blue (native only, Edge fine)**: the icon's blue state is driven by `viewState.ttsEnabled` (footer toggle) AND `isPlaying`/`showIndicator` (floating gradient `TTSIcon`). `handleStop` (useTTSControl) did all of `setIsPlaying`/`showIndicator` THEN `await ttsController.shutdown()` THEN `setTTSEnabled(bookKey,false)` as the LAST line — with NO try/catch. So if native `shutdown()` hangs OR throws, `setTTSEnabled(false)` never runs → footer icon stays blue forever; Edge never hits the stalling native path. ROOT FIX = reset ALL UI/session state (incl. `setTTSEnabled(false)`, null the ref) UP FRONT, then run shutdown/deinit best-effort in try/catch. Couldn't statically prove the exact native hang (every await in shutdown→stop is bounded/resolvable; native stop resolves since set_voice/set_rate use the same `resolve()` and playback works), so ALSO: bounded native stop invoke in `NativeTTSClient.stop()` (1500ms `Promise.race`) + Swift `os.Logger` lifecycle traces (speak/stop/pause/didStart/didFinish/didCancel) to pinpoint on-device — tapping stop should log `stop: requested``stop: resolved` + `didCancel`. Guard tests in `useTTSControl.test.tsx` assert `setTTSEnabled(false)` runs even when `shutdown()` rejects/never-resolves. NOTE: web bundle must be rebuilt for these JS fixes (not just the Swift plugin).
- **Lock-screen media session keeps running after disable (native only) — round 2**: the icon fix moved `setTTSEnabled` early, but `deinitMediaSession()` + `invokeUseBackgroundAudio({enabled:false})` were STILL after `await ttsController.shutdown()`. Native `shutdown()` stalls → those never run → lock-screen Now Playing lingers (Edge unaffected: never hits the stalling native path). The Swift media-session teardown is correct (Edge proves `set_media_session_active(false)``deactivateRemoteCommands` clears `MPNowPlayingInfoCenter.nowPlayingInfo`) — it just wasn't being CALLED. FIX = run shutdown + `invokeUseBackgroundAudio(false)` + `deinitMediaSession()` via `Promise.all` (best-effort, parallel) so media/audio teardown never waits on the controller shutdown. Added `set_media_session_active` os_log + guard test (deinit called even when shutdown never resolves). Still UNCONFIRMED why native `shutdown()` itself stalls (all JS awaits bounded; native stop invoke should resolve) — Swift lifecycle logs will reveal on-device.
## Verification done / pending
- Done (host): `pnpm lint`, `pnpm test` (only pre-existing unrelated
`fixed-layout-paginated-scroll.test.ts` fails — untracked, no impl), swiftc
`-typecheck` of the plugin vs iOS SDK with Tauri stubs (0 errors; Sendable
warning is a standalone-swiftc strict-concurrency artifact, project is Swift 5).
- Pending (on-device, user): build iOS, confirm init/speak/voices/rate/pitch,
auto-advance, pause-resume, lock-screen play/pause/next/prev + now-playing,
background playback, and the MediaKeyHandler interaction.
@@ -0,0 +1,18 @@
---
name: native-tts-offline-autoadvance-4613
description: "Android/iOS System TTS stops at chapter end (or random intervals) offline — controller only auto-advances on 'end', native terminal 'error' dead-ends + wedges state"
metadata:
node_type: memory
type: project
originSessionId: 5ae3d6fc-9082-4ba2-b7d4-e02dd277ee8f
---
# Native System TTS offline auto-advance halt (#4613, #4408)
**Symptom:** With Android System TTS (or iOS) **offline**, read-aloud stops — #4613 "at the end of the chapter, won't go to next chapter" (Samsung S25, Chinese voices); #4408 "random intervals" (GrapheneOS, Supertonic engine). Then the play/headphone controls feel **wedged**; #4408 also flashes the "Please log in to use advanced TTS features" toast on manual restart (separate client-selection path — controller briefly tries Edge).
**Root cause (`TTSController.#speak`):** auto-advance fires ONLY on `lastCode === 'end'`. The native client surfaces an offline engine failure as a terminal **`'error'`** code (Android `UtteranceProgressListener.onError`). Usually a **specific unsynthesizable utterance** (an unsupported CHARACTER — chrox's insight, fits online/offline asymmetry: engines network-fall-back for hard chars when online), hit on the new chapter's first utterance. On `'error'`: no `forward()` → playback dead-ends; `this.state` stays `'playing'` → controls wedge (restart re-errors on the same chunk). Edge/Web throw instead (caught by `error()` → state 'stopped'), so only **native** hits this. Engine-specific: Google local voices emit `onDone` fine, so it doesn't reproduce on every device.
**Fix (PR #4716, `#speak` only):** gate `canSkipOnError = this.ttsClient === this.ttsNativeClient`. On terminal `'error'` (native, playing, !aborted, !oneTime): **SKIP the chunk and `forward()`** — same as `'end'` — because re-speaking deterministically-bad text just fails again (do NOT retry; first attempt was retry-the-same-chunk which is futile for an unspeakable char). Bound `#consecutiveSpeakErrors` (reset on `'end'`); when it exceeds `TTS_NATIVE_SPEAK_MAX_CONSECUTIVE_ERRORS=5``await this.stop()` (graceful: wholly-unusable engine stops instead of silently racing to book end; leaves 'playing' so controls recover). Edge/Web byte-for-byte unchanged. Tests (`tts-controller.test.ts` "native TTS offline error recovery (#4613, #4408)"): skip-advances-past-bad-chunk (forward spied) + cap-stops-gracefully (key off `state.attempts` NOT `state` — controller starts 'stopped' and `forward()` transiently re-enters 'stopped', so `waitFor(state==='stopped')` false-matches).
**On-device verification reality (Xiaomi 13 fuxi, Android 16, WebView 147, Google TTS):** CANNOT reproduce the fault — offline auto-advance works, even offline+screen-off (foreground-audio service keeps the WebView UNthrottled; Google local engine emits onDone offline). Matches maintainer's non-repro. Needs the reporter's engine (Samsung/Supertonic/Chinese-network voice). Force the engine-error path on this device by setting a `*-network` voice offline. See [[cdp-android-webview-profiling]] for the CDP recipe; gotcha: `window.__TAURI_INTERNALS__.invoke`/`runCallback` get RE-INJECTED on Next.js client nav (wrappers revert) — `console.log` wrapping persists, so trace via the `[TTS] speak` / `[TTS] Initialized TTS for section N` logs instead. Related: [[tts-fixes]], [[tts-browser-e2e-harness]].
@@ -0,0 +1,18 @@
---
name: nightly-updater-android-e2e
description: "How to E2E-test the nightly self-updater on a real Android (Xiaomi/HyperOS) device — devtools build, CDP-over-adb, MIUI install gates"
metadata:
node_type: memory
type: reference
originSessionId: b0c01e3c-9485-45fe-8ae3-eb5f2762f8fa
---
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).
@@ -0,0 +1,40 @@
---
name: notch-mask-texture-4486
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"
metadata:
node_type: memory
type: project
originSessionId: 47d2276d-0e04-455c-99b5-4fd0a651b579
---
#4486 (PR #4563): in scrolled mode the `notch-area` in `SectionInfo.tsx` masks the top
safe-area inset with opaque `bg-base-100` at z-10 (hides content scrolling under the
status bar) — and painted over the texture (`.foliate-viewer::before` lives at the z-0
paint layer, see [[paginated-texture-occlusion-4399]]). Flat untextured strip across the
unsafe header area.
**Fix pattern (paint-box matching).** A texture `::before` only tile-aligns with the
viewer's when `background-size: cover/contain` resolves against the SAME element box.
So: make the mask span the grid cell (`inset-0`) and clip the visible+hit area to the
strip with `clip-path: inset(0 0 calc(100% - topInsetPx) 0)`; add `.notch-masked::before`
to the selector group in `styles/textures.ts`, gated by a conditional class only in
scrolled-horizontal mode (paginated/vertical notch is transparent — texturing it would
double-texture over the viewer's). `mix-blend-mode: multiply` blends against the mask's
own opaque bg inside its z-10 stacking context → identical color math to the viewer area.
clip-path clips hit-testing too, so the click target stays the strip (verified with
`elementsFromPoint`: notch present in stack inside strip, absent mid-screen).
**Still texture-unaware** (same flaw, not yet reported): the vertical scrolled-mode side
masks in `BooksGrid.tsx` (`bg-base-100 absolute left-0/right-0 h-full` when
vertical+scrolled). Same trick applies if reported.
**Verification technique (no rebuild).** Drive the installed app on the device via
adb+CDP ([[cdp-android-webview-profiling]]) and inject the exact CSS artifacts the fix
produces (patch the `#background-texture` style text + classList/style edits — React
won't wipe manual DOM edits unless its computed className/style prop string changes
between renders). Quantify the seam: `magick compare -metric MAE` between adjacent
1px rows across the boundary — buggy hard seam was MAE 11913 (16-bit scale), fixed
seam 230 ≈ ordinary texture-row variation. Gotchas: adb taps at status-bar y-coords are
consumed by SystemUI (never reach the app) — use elementFromPoint or in-page click
counters for hit-testing; a mid-screen tap toggles the header/footer chrome which then
sits ABOVE the notch and pollutes hit stacks (toggle it off before probing).
@@ -0,0 +1,18 @@
---
name: opds-firefox-strict-xml-4479
description: OPDS feeds fail on Firefox but work on Chrome — strict DOMParser parsererror on junk after root; parseOPDSXML recovery
metadata:
node_type: memory
type: project
originSessionId: 9539d003-7df3-4643-99ca-bdee69be1b3f
---
#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.
@@ -0,0 +1,23 @@
---
name: opds-groups-carousel-4750
description: OPDS feed groups (>=2) render as horizontal virtualized carousels with lazy cover loading
metadata:
node_type: memory
type: project
originSessionId: 3073b2b0-8219-42cc-8e3f-547715b86b01
---
#4750 (PR #4755, merged): when an OPDS `feed.groups.length >= 2`, `FeedView` renders each group's publications/navigation as a horizontal carousel (`src/app/opds/components/GroupCarousel.tsx`) instead of the grid; single-group feeds keep the grid. Matches Thorium.
`GroupCarousel` wraps a horizontal `react-virtuoso` `Virtuoso` (`horizontalDirection`), so only in-view items mount → covers load lazily as you scroll (verified via network: ~12 covers/group fetched regardless of group size; far-right items fetch only after scrolling to them).
Gotchas (cost real debugging):
- `VirtuosoHandle.scrollBy({left})` is a **no-op** in horizontal mode (the handle maps to the vertical axis). Page the arrows by **index** via `scrollToIndex({index, align, behavior})`, tracking the visible range from `rangeChanged`.
- Virtuoso sizes the horizontal track **lazily**, so a pixel `scrollBy` on the scroller element clamps to the currently-rendered width — another reason to scroll by index.
- Arrow visibility comes from `atTopStateChange`/`atBottomStateChange` (top=left, bottom=right). Row height is measured from the first `[data-carousel-item]`; arrows are vertically centered on the cover by measuring the first `<figure>` (cards have title/author below, so centering on the whole row looks low).
- Scrollbar hidden via a scoped `.no-scrollbar` util in `globals.css`; arrows use `eink-bordered`.
- Tests must mock `react-virtuoso` (jsdom has no layout) like the TOCView/BooknoteView tests — render all items via `itemContent`.
`PublicationCard` (shared by carousel + grids) got rounded covers (`overflow-hidden rounded`, matching the library bookshelf) and dropped the inline acquisition/price badge — that badge still renders on the detail page (`PublicationView`).
Related: [[virtuoso_overlayscrollbars]].
@@ -0,0 +1,46 @@
---
name: opds-html-description-4503
description: "OPDS publication descriptions showed raw HTML tags; double-escaped type=\"text\" summaries + unsanitized innerHTML; fix decodes-if-fully-escaped then sanitizes"
metadata:
node_type: memory
type: project
originSessionId: 73fd2a21-ea89-4cdb-bbbd-23256a6ae5a2
---
Issue #4503 (FR, but a real bug): OPDS publication detail descriptions rendered
raw HTML — literal `<p>`, `</p>`, `&quot;`, `&#x27;` text — while Thorium renders
them. Reporter's feed served the same Gutenberg book (51726) via an aggregator.
**Root cause** (confirmed empirically by running foliate `getPublication` in jsdom):
the aggregator serves the description as an Atom `type="text"` `<summary>` whose
HTML was escaped *twice*. foliate's `getContent` (`packages/foliate-js/opds.js`)
only un-escapes for `type="html"`/`"xhtml"`; for `text` it returns `textContent`
verbatim, so the value stays `"&lt;p&gt;...&amp;quot;Wall&amp;quot;..."`.
`PublicationView` then dumped `content.value` straight into
`dangerouslySetInnerHTML` (also **unsanitized — an XSS sink** for arbitrary
remote feeds). The browser decodes one entity level → shows the still-escaped
`<p>`/`&quot;` as literal text. Single-escaped `type="text"` renders fine (value
already has real tags); only *double*-escaped breaks. type=html/xhtml render fine.
**Fix:** new helper `src/app/opds/utils/opdsContent.ts` `getOPDSDescriptionHtml()`:
decode one extra entity level **only when the value is entirely escaped markup**
(`/&lt;\/?[a-z]/i` present AND no real `/<[a-z]/i` tag — so mixed content like
`<p>see &lt;code&gt;</p>` is left literal), then `sanitizeHtml()` (the shared
DOMPurify sanitizer in `@/utils/sanitize` — generic, reused; note its
ALLOWED_TAGS has no `div`, so xhtml's `<div xmlns>` wrapper is unwrapped,
harmless). Wired into `PublicationView` via `useMemo`. Decode-then-sanitize
order matters: scripts hidden behind double-escaping are still stripped.
(PR #4510 also moved `sanitizeHtml`/`sanitizeForParsing` out of
`services/send/conversion/` into `@/utils/sanitize`, alongside `sanitizeString`.)
Exported `OPDSContent` from `types/opds.ts` for the helper's param.
Scope: only the detail view renders description HTML. `PublicationCard` shows no
summary; `NavigationCard` renders `SYMBOL.SUMMARY` as React-escaped plain text
(getSummary only returns `type==='text'` values) — both correct, untouched.
Verifying which render path produces "raw tags": React `{value}` text-escapes
(shows `&copy;` literally); `dangerouslySetInnerHTML` decodes one level (shows
literal `<p>` only if value is `&lt;p&gt;`). The screenshot showed literal `<p>`
AND `&quot;` → double-escaped innerHTML path, not the dead `description` path
(foliate's `getPublication` never sets `metadata.description`). Related:
[[opds-firefox-strict-xml-4479]].
@@ -0,0 +1,27 @@
---
name: opds-popular-catalog-dedup-4782
description: "Added popular OPDS catalog still showed in Popular section (looked like a duplicate); filter it out, not just hide its Add button"
metadata:
node_type: memory
type: project
originSessionId: fd07b2a4-290b-4f10-a01d-190281571221
---
Issue #4782: adding a generic "Popular Catalog" (e.g. Project Gutenberg) to My
Catalogs left it ALSO rendering in the Popular Catalogs section → looked like a
duplicate.
Root cause in `src/app/opds/components/CatalogManager.tsx`: on add, only the
**Add button** was hidden (`{!isAdded && ...}`) — the whole card kept rendering
with its Browse button, so the entry visibly appeared in both sections.
Fix: filter added/disabled entries out of the Popular list entirely. New pure
helper `getUnaddedPopularCatalogs(popular, added)` in
`src/app/opds/utils/opdsUtils.ts` dedups by **normalized URL** (trim +
lowercase), mirroring the store's `findByUrl`. Component computes
`popularCatalogs = isOnlineCatalogsAccessible ? getUnaddedPopularCatalogs(POPULAR_CATALOGS, catalogs) : []`;
the section already auto-hides on `popularCatalogs.length === 0`, so once all
popular entries are added the whole section disappears. Tested in
`src/__tests__/app/opds/opds-utils.test.ts`.
Related: [[opds-self-link-metadata-4749]], [[opds-groups-carousel-4750]].
@@ -0,0 +1,19 @@
---
name: opds-self-link-metadata-4749
description: OPDS 2.0 summary publications need self-link dereference for full metadata; JSON description is HTML
metadata:
node_type: memory
type: project
originSessionId: 0e1e6ec0-38c1-45a2-aab6-52b78a5ad38a
---
Readest issue #4749 (pglaf/Gutenberg test feed `https://opds-test.pglaf.org/opds/`). Two related OPDS bugs, both fixed together.
**1. Summary publications need a `self`-link dereference.** OPDS 2.0 feeds may list a publication with only minimal metadata + a `rel:"self"` link of type `application/opds-publication+json` (no acquisition links, no description) — the server sends the full record only when the client follows that link on click. Thorium does this; Readest did not.
- New `src/app/opds/utils/opdsPublication.ts`: `getPublicationDetailHref(pub)` finds the `rel:"self"` link whose type is `application/opds-publication+json` or Atom `application/atom+xml;type=entry`; `parsePublicationDocument(text, docURL)` parses JSON or Atom-entry XML (reuses foliate `getPublication`) and **absolutizes** links/images hrefs against `docURL` so downloads/cover resolve regardless of the feed's `baseURL`.
- `page.tsx`: renamed derived `publication``basePublication`; an effect fetches the detail doc (via `fetchWithAuth` + proxy refs) when `selectedPublication` is set AND a detail link exists (skip directly-loaded entry docs — already full); merges as `{ metadata: resolved.metadata, links/images: resolved.* || base.* }` keyed by `source===basePublication` so a stale fetch can't bleed into the next selection. Summary renders immediately, upgrades in place.
**2. JSON `description` is HTML.** OPDS 2.0 keeps the summary in plain `metadata.description` (no typed `<content>`), and pglaf fills it with `<p>...</p>`. `PublicationView` rendered `<p>{description}</p>` → literal tags. Fix: `getOPDSDescriptionHtml(content ?? description)` so the (sanitized) markup renders. See [[bug-patterns]] and prior [[OPDS HTML description (#4503)]] decode-once+sanitize.
**Why:** less data per feed page + faster load; client dereferences on demand.
**How to apply:** when an OPDS publication looks under-populated, check for a `rel:"self"` publication-type link before assuming the feed is the whole record. Related OPDS notes: opds-firefox-strict-xml-4479, opds2-json-search-4502, opds-html-description-4503.
@@ -0,0 +1,23 @@
---
name: opds2-json-search-4502
description: "OPDS 2.0 JSON catalog search bar greyed out; isSearchLink didn't recognize templated application/opds+json links"
metadata:
node_type: memory
type: project
originSessionId: 9eb835a8-ce7a-4f80-ae3d-94e330935585
---
#4502 — OPDS 2.0 JSON catalogs (e.g. `type: "application/opds+json"`, `templated: true`, href `/opds/search{?query}`) showed a **greyed-out** navbar search input that rejected queries.
**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`).
Related: [[opds-firefox-strict-xml-4479]].
@@ -0,0 +1,16 @@
---
name: overlayer-splitrange-textnodes
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>"
metadata:
node_type: memory
type: project
originSessionId: 58d4b1a1-8823-4474-9966-c96727692e3f
---
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).
@@ -0,0 +1,54 @@
---
name: pageturn-bg-replace-reflow-4785
description: "Page-turn frame drops at chapter boundaries (#4785) — per-frame"
metadata:
node_type: memory
type: project
originSessionId: e42ea03e-cda7-4e59-b398-1a28f589b37e
---
Issue #4785: swipe page-turn animation drops frames, worst crossing .xhtml
section boundaries, "first open" (Android/Xiaomi). Repro book is a Taiwan light
novel with custom fonts + `.bg`/`background-attachment:fixed` front-matter.
Root cause (in `packages/foliate-js/paginator.js`, a submodule):
`#replaceBackground()` rebuilt its whole paint context **every frame** of both
swipe phases — `getComputedStyle(<html>)` + `this.size` + one
`getBoundingClientRect()` **per rendered view** + a per-view background-reset
write loop + full `#background` DOM rebuild. Those forced reads scale with the
number of loaded views, which **peaks at a chapter boundary** because adjacent
sections are preloaded there — hence "worst at boundaries". Two callers ran it
per-frame: the snap `syncBackground` rAF loop (`#scrollTo`) and the drag-phase
container `scroll` listener (`#onTouchMove``scrollBy`→scroll event).
Everything `#replaceBackground` reads is **invariant for one gesture** (theme/
texture, bg+container geometry, each view size+bg) — only scroll offset changes.
Fix:
- Split into `#readBackgroundStyle` / `#computePaginatedBgContext` (the reads) +
`#paintPaginatedBackground(ctx, atPosition)` (writes only; calls the unchanged
pure `computeBackgroundSegments`).
- `#bgAnimContext` field snapshots context once: set in `#onTouchStart` (drag)
and at the start of the animated branch in `#scrollTo` (snap); cleared in
`#onTouchEnd` and both animation `.then()`s. `#replaceBackground` uses
`this.#bgAnimContext ?? this.#computePaginatedBgContext()`.
- Also deferred the heavy mid-drag forward preload: added `&& !this.#touchScrolled`
to the scroll-listener `#loadAdjacentSection` gate (columnize/expand on the main
thread janked the drag). The scroll that settles the gesture re-fires the gate
with the finger up, so the buffer still tops up.
Tests: `src/__tests__/document/paginator-background-anim-perf.browser.test.ts`
(real Chromium) drives `next()` (snap) and a synthetic touch drag, spying on the
primary iframe `<html>` getComputedStyle. Pre-fix: 39 reads (snap) / 7 (drag);
post-fix ≤3 / ≤1. Existing `paginator-background-segments.test.ts` (pure
`computeBackgroundSegments`) stays green — visual output unchanged.
Scrolled-mode branch kept inline in `#replaceBackground` (never the per-frame hot
path). Behavior preserved: scrolled set every view bg so the old reset loop was
redundant; `containerSize = containerRect[sideProp]` == old `this.size`.
NOT the cause (ruled out): `computeBookNav`/`nav.json` is awaited before the view
renders, so first/second-open in-memory state is identical — it can't explain
reading-time swipe jank. See [[booknote-view-autoscroll-4352]] neighbors in
Paginator & Scroll. Related: [[paginator-swipe-bg-flash]],
[[global-annotation-pageturn-perf-4575]], [[paginated-texture-occlusion-4399]].
@@ -0,0 +1,16 @@
---
name: paragraph-mode-accidental-exit-4474
description: Paragraph mode exited on stray taps + control bar off-center with pinned sidebar; fix = reveal-bar event + absolute→fixed
metadata:
node_type: memory
type: project
originSessionId: 3f480cd2-ca07-4eef-ac23-e60e497f882b
---
#4474 (FR "prevent accidental exit from paragraph mode"). Paragraph/focus mode = full-screen `ParagraphOverlay` (one centered paragraph) + auto-hiding `ParagraphBar` (prev/next/exit pill). Files: `src/app/reader/components/paragraph/{ParagraphOverlay,ParagraphBar,ParagraphControl}.tsx`, hook `useParagraphMode.ts`. Overlay↔bar talk ONLY via `eventDispatcher` (`paragraph-focus`/`-next`/`-prev`/`-mode-disabled`/`-section-changing`/`toggle-paragraph-mode`).
**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]].
@@ -0,0 +1,20 @@
---
name: paragraph-mode-toggle-resume-4717
description: "Paragraph mode (#4717) Shift+P double-toggle, dialog key handling, and chapter-start rewind — root causes + reusable gotchas (eventDispatcher re-entrancy, foliate lastLocation vs store progress)"
metadata:
node_type: memory
type: project
originSessionId: a19a021a-0e84-4bf6-bde4-02b115b6306f
---
PR #4725 fixed three Shift+P paragraph-mode bugs (#4717). Follow-on to #4723 (Alt+P proofread + initial overlay attempt). Three reusable, non-obvious findings:
**1. `eventDispatcher.dispatch` live-Set re-entrancy (the widest-reach gotcha).** `dispatch()` iterated the live `asyncListeners` Set while `await`ing each listener. A listener that triggers a React state change which re-runs an effect that re-subscribes a handler for the SAME event gets that new handler invoked in the same dispatch loop → the event double-fires. Symptom here: one Shift+P toggled paragraph mode twice (exit→re-enter "flash"); `useParagraphMode`'s `toggle-paragraph-mode` subscription effect has `paragraphConfig.enabled` in deps, so the exit's awaited `dispatch('paragraph-mode-disabled')` re-subscribed mid-loop. Fix = snapshot before iterating: `for (const l of [...listeners])` in `dispatch` (`dispatchSync` already did). **Applies to ANY event whose handler re-subscribes.**
**2. Overlay key handling = dialog/alert pattern, NOT a global window listener.** The maintainer (chrox) explicitly rejected wiring `onEscape` into useShortcuts. Correct pattern: the overlay's container is `role=dialog tabIndex=-1`, gets `containerRef.current.focus({preventScroll:true})` on open, and handles Escape / toggle shortcut / nav in its OWN `onKeyDown` (stopPropagation so the global handler can't double-fire). Add `outline-none` to the programmatically-focused non-tab-stop container or it draws a focus ring around the whole viewport. The old capture-phase `window.addEventListener('keydown',...,true)` + `stopImmediatePropagation()` swallowed global shortcuts and only fired when focus was on the parent doc (not the foliate iframe).
**3. Paragraph-mode resume rewound to chapter start.** Two causes: (a) entering/exiting scrolled the underlying view to the focused paragraph's START via `renderer.goTo`/`scrollToAnchor`; when the page-top paragraph began on the previous page this rewinds a page, and repeated enter/exit accumulates. Fix = `focusCurrentParagraph(align=false)` on resume/first-mount, drop the exit `scrollToAnchor`; navigation keeps `align=true`. (b) resume preferred the rAF-debounced `readerStore` progress and a stored last-paragraph CFI (`view.getCFI(docIndex, paragraphBlockRange)`) that comes out MALFORMED (e.g. `epubcfi(/6/18!,/4/110,/4)`) and `resolveCFI`s to a non-null EMPTY range, shadowing the correct candidate in the `??` chain and sending `findByRangeAsync` to `first()` (the title). Fix = resume from `view.lastLocation.cfi` FIRST.
**foliate `view.lastLocation` vs readerStore progress:** `view.lastLocation = {cfi, range, ...}` is set SYNCHRONOUSLY by foliate on every relocate; the readerStore progress is rAF-debounced (FoliateViewer `commitRelocate`) and lags/desyncs. For any resume/current-position logic prefer `view.lastLocation.cfi` (CFI is document-instance-independent, survives the section iframe being recreated on toggle — a stored `Range` does not). `view.lastLocation` is NOT in the FoliateView TS type by default (had to add it).
**Verification/harness gotchas (claude-in-chrome on the reader):** synthetic keystrokes (`computer key shift+p`) BUFFER/DROP chaotically — single presses vanish then fire in delayed bursts, creating inconsistent multi-toggle states; `ArrowRight` repeat worked, single Shift+P often didn't. Menu clicks (View menu → Paragraph Mode) are reliable for toggling. The content iframe lives in the foliate-view shadow DOM (`iframeCount:0` at top level); focus on `FOLIATE-VIEW` → keydown arrives as an `iframe-keydown` postMessage, focus on parent → real window keydown. Probe live state via `document.querySelector('foliate-view').lastLocation`. Tool returns containing "overlay=" strings sometimes hit a `[BLOCKED: Cookie/query string data]` filter — return JSON objects instead. Related: [[tts-sync-chrome-verification]], [[tts-browser-e2e-harness]].
@@ -0,0 +1,18 @@
---
name: pdf-cbz-contrast-view-menu
description: "Contrast option in View menu for fixed-layout (PDF/CBZ) docs; per-book, CSS filter"
metadata:
node_type: memory
type: project
originSessionId: 94f785c8-9015-4140-b64d-c6177e033189
---
Added a **Contrast** stepper to the reader **View menu** (`ViewMenu.tsx`) for fixed-layout / image docs (PDF/CBZ/FXL-EPUB). Models the existing `invertImgColorInDark` / `zoomLevel` pattern. Increase/decrease/reset (+ / / ◐%), gated inside the `rendition?.layout === 'pre-paginated'` block, placed right under the Zoom Level control.
**Key wiring (mirror this for any future fixed-layout image adjustment — brightness, saturation):**
- Type: `contrast: number` in `BookStyle` (`types/book.ts`, fixed-layout section). Default `contrast: 100` in `DEFAULT_BOOK_STYLE` (`constants.ts`); also `MIN_CONTRAST=50`/`MAX_CONTRAST=300`/`CONTRAST_STEP=10`.
- Filter applied in `applyFixedlayoutStyles()` (`utils/style.ts`) on the `img, canvas` rule. **GOTCHA:** CSS `filter` is a single property — a second `filter:` line overrides the first. Build ONE declaration: collect `invert(100%)` (dark+invert) and `contrast(${c}%)` (c!==100) into an array, join with spaces. invert/contrast commute so order is irrelevant. Contrast applies in light mode too (independent of dark/invert).
- **Local to current document:** `saveViewSettings(envConfig, bookKey, 'contrast', value, /*skipGlobal*/ true, /*applyStyles*/ true)`. `skipGlobal=true` forces the per-book branch (`applyViewSettings(bookKey)`) regardless of `isGlobal`, so it never touches `globalViewSettings`.
- **Re-apply on change:** add `viewSettings?.contrast` to the dependency array of the `FoliateViewer.tsx` effect (~L829) that calls `applyFixedlayoutStyles` on every rendered doc. New pages pick it up via the on-load `applyFixedlayoutStyles(detail.doc, viewSettings)` call (~L321). Re-render is driven by `setViewSettings` updating `bookDataStore` config → parent `BooksGrid` re-renders FoliateViewer.
Test: `src/__tests__/utils/fixed-layout-styles.test.ts` (new) asserts the combined `filter: invert(100%) contrast(150%)` and the no-filter-at-100% cases. The settings dialog `ColorPanel.tsx` was intentionally NOT touched — request was View menu only. Related: [[tap-to-open-image-table-4600]], css/style hub `src/utils/style.ts`.
@@ -0,0 +1,47 @@
---
name: pdf-oom-range-flood-3470
description: "Android/iOS large-PDF import/open OOM (#3470) = unthrottled pdf.js range-request flood, not whole-file load; fix = concurrency cap in foliate makePDF"
metadata:
node_type: memory
type: project
originSessionId: 1f5ecad5-076c-4170-939a-c80438c37f64
---
# Large-PDF OOM on Android/iOS (#3470)
**Symptom:** importing/opening a 50 MB+ PDF crashes (no message) with
`java.lang.OutOfMemoryError ... target footprint 536870912` (512 MB Java heap)
at `RustWebViewClient.handleRequest``shouldInterceptRequest`. Same file is
fine in the official pdf.js viewer on Android Chrome. Repro file: `100个句子记完7000个雅思单词.pdf` (67 MB, 970 pages).
**Root cause (NOT whole-file load):** opening the PDF makes pdf.js fire ~759
small **64 KB** range reads to parse scattered xref/object streams. foliate-js
`makePDF` fulfilled every `requestDataRange` with an **un-awaited**
`file.slice(begin,end).arrayBuffer()` → all dispatched at once (measured
**maxInFlight 753**). On Android each read is a `fetch()` to the `rangefile`
scheme → `shouldInterceptRequest` allocates a Rust `Vec<u8>` + a Java `byte[]`
per request; ~750 simultaneous intercepted requests exhaust the 512 MB Java
heap. The official pdf.js viewer survives because the **browser caps ~6
connections/host**; the custom `rangefile` (and iOS native-file) scheme has no
such cap. Explains "50 MB+" (bigger PDF → more scattered objects → bigger
flood) and "crashes on some devices only" (heap/WebView threshold).
**Fix (RESOLVED — foliate-js#31 squash `e098bc3` + readest#4670, both merged):** `packages/foliate-js/pdf.js` `makePDF` — queue + pump bounding
range reads to `MAX_CONCURRENT_RANGES = 6` (mimics the browser's per-host
limit). One spot covers Android `RemoteFile`, iOS `NativeFile`, web `File`.
Throttling is **free** on speed (6 parallel fetches saturate throughput). foliate-js
is a **git submodule** → commit + push to readest fork, then bump pointer.
Test: `src/__tests__/foliate-pdf-range-concurrency.test.ts``vi.mock('@pdfjs/pdf.min.mjs')` installs a fake `globalThis.pdfjsLib` whose `getDocument` fires a 200-call flood; asserts `maxInFlight ≤ 6` and all served. Fails (200) before, passes after.
## On-device CDP verification recipe (no rebuild)
Release Readest 0.11.10 ships a debuggable WebView (socket
`webview_devtools_remote_<pid>`), so CDP attaches without `run-as`.
- `adb forward tcp:9222 localabstract:webview_devtools_remote_$PID`; page WS from `curl :9222/json`.
- Push file where asset scope allows: `/sdcard/Readest/Books/` matches scope glob `**/Readest/**/*`; app has MANAGE_EXTERNAL_STORAGE → readable. Canonical path `/storage/emulated/0/Readest/Books/x.pdf`.
- rangefile URL: `http://rangefile.localhost/?path=<encodeURIComponent(abs)>&start=&end=` (end **inclusive**, omit=EOF, 8 MB cap, returns 200 + `X-Total-Size`).
- Faithfully replicate `makePDF`: `await import('http://tauri.localhost/vendor/pdfjs/pdf.min.mjs')` (sets `globalThis.pdfjsLib`, same vendored 5.7.284), a file-like `{size, slice(b,e)→{arrayBuffer:()=>fetchRangePart(b,e-1)}}`, `new pdfjsLib.PDFDataRangeTransport(size,[])`, instrument `requestDataRange`, `getDocument({range,wasmUrl:'/vendor/pdfjs/',cMapUrl,standardFontDataUrl,isEvalSupported:false})` then `getPage(1)/getViewport/getMetadata`.
- Java heap via `adb shell dumpsys meminfo com.bilingify.readest` (Dalvik Heap line).
**Verified on Xiaomi 13 (fuxi) / Android 16 / WebView 147 / 8 GB:** this device does NOT OOM (newer WebView; Dalvik only +9 MB) but the flood reproduces: **753 → 6** concurrent, open time **1446 → 1479 ms** (no penalty), 970 pages/title/viewport identical. Gotcha: package installs (`installPackageLI` in logcat) kill the app mid-session → re-discover the devtools socket PID. The makePDF flood alone did NOT crash this device — can't get a live OOM here; rely on the user's WebView-145 log + the bounded-concurrency proof.
Related: [[android-nativefile-remotefile-io]] (rangefile vs asset-protocol Range bug), [[webtoon-mode-3647]] (foliate-js submodule fork-push).
@@ -0,0 +1,27 @@
---
name: pdf-scroll-lag-preload-4795
description: "PDF scrolled-mode rendering lag on Android (#4795/#4031) — fix via widened preload margin + bounded prioritized load scheduler in fixed-layout.js"
metadata:
node_type: memory
type: project
originSessionId: 902324ba-94ee-4c88-804e-ea9f796681f9
---
PDF **scrolled mode** showed blank pages while scrolling on Android (#4795, resurfacing #4031). Reproduced + fixed + CDP-verified on Xiaomi 13 (fuxi).
**Root cause (measured via CDP on-device):** per-page render (`pdf.js` `onZoom``render()`: canvas raster + text layer + annotation layer) ≈ **415 ms** uncached / ~65 ms cached, but the scrolled-mode IntersectionObserver used `rootMargin: '50% 0px'` (~0.5 page of lead). Loads also had **unbounded concurrency** (observer fired `#loadScrollPage` for every intersecting page) and **no viewport prioritization**, so the slow render never finished before the page scrolled into view, and a fling spawned dozens of competing renders. Per-page canvas ≈ **7 MB** at dpr 3 (screen-res, NOT the ~50 MB I first feared) → memory headroom existed; the #3470 OOM was byte-range *parsing* flood (`MAX_CONCURRENT_RANGES`, orthogonal).
**Fix** (`packages/foliate-js/`):
- `fixed-layout.js`: widened observer to `rootMargin: '200% 0px'` (~2 viewports lead); observer now only **flags `page.visible`** and calls new `#scheduleScrollPages()`.
- New pure exported `planScrollModePages({pages, currentIndex, maxLoaded, maxConcurrent, loadingCount})``{load, evict}`: loads **visible+idle pages nearest currentIndex first, bounded by `maxConcurrent - loadingCount`**; evicts **farthest non-visible loaded** beyond `maxLoaded`; **never evicts a visible page** (distance = `|index - currentIndex|`). Unit-tested in `src/__tests__/document/fixed-layout-scroll-scheduler.test.ts`.
- `#scrollMaxLoaded 8→12` (live-canvas cap = memory ceiling), `#scrollMaxConcurrent=3`, `#scrollLoadingCount` tracked in `#loadScrollPage` (inc on start, dec in `finally`, then reschedule so a freed slot pulls the next nearest page). Removed `#evictScrollPages` (scheduler handles it).
- **Terminal `error` state**: a load that throws or returns no src sets `state='error'` (not `'idle'`) so the post-completion reschedule can't retry a persistently failing page in a tight async loop (regression I introduced with reschedule-on-completion).
- `pdf.js`: `MAX_CACHED_PAGES 8→16` (page objects + render blobs are cheap, not the canvas) so back-scroll within the wider window doesn't re-parse.
**Verified (CDP + screenrecord, identical 9-swipe reading-pace test, fresh region):** baseline = mostly blank frames, settled forward lead **+2** (span [-9,+2]); fix = **every frame fully rendered**, forward lead **+4** (span [-7,+4]). Extreme 8-fling (240 pages/2s) still blanks mid-fling (inherent) but settles to rendered content and **no crash**.
**Best-practice cache strategy for scrolled PDF on mobile** (asked during this work): two bounded tiers — live-canvas cap = the hard memory ceiling (sized to window+lead), decoded-page cache a bit larger (cheap); distance/viewport-aware LRU never evicting visible; bound+prioritize loads; release bitmaps eagerly (`canvas.width=0`); the biggest unused lever for low-end devices is **capping effective DPR** (canvas mem ∝ DPR²) — not applied here since 12×7 MB≈84 MB is fine on the Xiaomi.
**CDP on release builds:** the installed Play/release 0.11.12 has **no `webview_devtools_remote_<pid>` socket** — WebView debugging is gated behind the `devtools` Cargo feature (`src-tauri/Cargo.toml`); must build+install `pnpm dev-android` (release + `--features devtools`, same keystore so it updates over the store build, library preserved). CDP `webSocketDebuggerUrl` comes back as `ws://localhost/devtools/...` **with no port** (echoes Host header) → rewrite to `ws://127.0.0.1:9222<path>`; `ws` npm pkg is CJS so import default + destructure. See [[cdp-android-webview-profiling]], [[pdf-oom-range-flood-3470]].
**WIP caveat:** during this work the foliate-js submodule had unrelated uncommitted `paginator.js` WIP (background-anim perf, #4785) — exclude it from any #4795 commit.
@@ -0,0 +1,26 @@
---
name: pdf-scroll-mode-wheel-double-4727
description: Fixed-layout/PDF scrolled mode scrolls 2x (instant lurch) when wheeling over the page vs smooth over the margin
metadata:
node_type: memory
type: project
originSessionId: 063f5588-52bf-4042-92f9-babcf492e378
---
# PDF scrolled-mode wheel double-scroll (#4727)
**Symptom:** In fixed-layout/PDF **scrolled** mode, a mouse-wheel notch scrolls ~2× as far and feels instant when the pointer is **over the page** (the iframe), but a single smooth scroll when over the **page margin**. Reproduces on BOTH web and tauri (reporter saw it only in the WebView2 app, but maintainer reproduced on web too). Paginated mode unaffected.
**Root cause:** `fixed-layout.js` scroll mode (`#loadScrollPage`) attaches a `{ passive: true }` wheel listener to each page iframe's doc that called `this.scrollBy({ top: e.deltaY, behavior: 'instant' })`. The iframe is `scrolling="no"` + `overflow:hidden`, so the browser **already chains** the wheel to the host scroller natively (smooth). The manual `scrollBy` **stacks on top of** that native scroll → 2× distance, the instant jump = the `behavior:'instant'` part, the glide = the native chain. Margin-hover hits the host directly → only the single native scroll → no doubling.
The iframe is interactive (`pointer-events:auto`) only during a 150ms idle window after scrolling settles (`#handleScrollEvent` disables it during active scroll, re-enables 150ms after). A notched wheel slower than ~6/sec puts EVERY notch in that idle window → every notch lands on the iframe → every notch doubles (explains the steady "twice as fast", not just the first tick).
**Fix:** Delete the manual `this.scrollBy(...)`; keep `this.#setScrollIframeInteraction(false)` so the iframe stops intercepting and the rest of the gesture also scrolls the host natively. Native scroll-chaining is the single smooth scroll that matches the margin. The old "forward wheel to host" code wrongly assumed the tick was lost without it.
**Why not preventDefault+manual:** would make page-hover an *instant* scroll, not matching the smooth native margin scroll the user wants. Letting native handle it is the only way to match the margin feel.
**Reproduction / test technique (jsdom can't — needs real layout + real wheel):**
- Standalone Playwright proof: scroll container + `scrolling="no"` srcdoc iframe + the buggy handler, `page.mouse.wheel(0,120)` over the iframe → scrollTop 240 vs 120 over margin; remove `scrollBy` → 120 == 120. (real `mouse.wheel` triggers native chaining; synthetic dispatch does NOT.)
- Committed regression test `src/__tests__/document/fixed-layout-scroll-wheel.browser.test.ts` (browser lane, `pnpm test:browser`): mounts the REAL `<foliate-fxl>` in scrolled mode (minimal fake book: `rendition.viewport`, sections whose `load()` returns `{ src:'srcdoc', data: tallHtml }``src` must be truthy or `#createScrollFrame` returns blank; `data` → srcdoc keeps iframe same-origin so contentDocument is reachable), dispatches a **synthetic** `WheelEvent` on the page iframe doc, 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]].
@@ -0,0 +1,52 @@
---
name: pdf-spread-canvas-seam-4587
description: PDF two-page spread shows a 1px white bar at the spine on fractional devicePixelRatio (Windows 150%); canvas bitmap truncation
metadata:
node_type: memory
type: project
originSessionId: 9c176878-7bcd-4411-8c55-5ebce094a73b
---
#4587 — PDF two-page spread shows a one-pixel white bar in the MIDDLE (at the
spine) on "certain zoom levels". Repro condition = fractional devicePixelRatio
(Windows display scale 150% → dpr 1.5); at 100% (dpr 1) no bar. Fixed in
`packages/foliate-js/pdf.js` `render()`.
**Root cause:** `render()` sized the page canvas only via its bitmap
(`canvas.width = viewport.width`). `viewport.width = pageWidthCss * dpr` is
fractional, and a canvas bitmap width must be an integer, so it truncates (FP
error often drops a whole pixel: 522*1.5=783 → viewport 782.9999 → bitmap 782).
The iframe content is displayed scaled by `1/dpr` (the `documentElement`
`transform: scale(1/devicePixelRatio)`), so the truncated bitmap renders up to
~1 device px NARROWER than the page box. The left page's canvas stops short of
the spine → exposes the reader background as a thin seam (white in light
themes; in the dark demo it reads as a dark line). Right page's canvas starts
exactly at the spine, so the gap is the LEFT page's shortfall only. The element
flex boxes are always exactly adjacent (left.elR === right.elL === spine) — NOT
the source; the seam is canvas-vs-box, not box-vs-box.
**Fix:** pin an explicit CSS size to the un-truncated viewport dims so the
bitmap scales to fill the box exactly:
`canvas.style.width = `${viewport.width}px``; same for height. Display =
viewport.width/dpr = exact page box → left canvas reaches the spine. General:
fixes every page-canvas edge shortfall (single page + right page outer edge
too), all dpr/modes. Idiomatic pdf.js HiDPI pattern (bitmap=device px, CSS=
logical size) that the foliate wrapper had omitted.
**Why dpr=2 can't repro (and dpr=1.5 readily does):** equal-width spread pages
split content/2 exactly. At dpr 2, pageW*2 stays integer for even content
widths → clean. At dpr 1.5, pageW*1.5 = content*0.75 is fractional unless
content divisible by 4 → seam of 0, 0.5, or 1.0 device px depending on width.
**CDP dpr=1.5 repro recipe (no device needed):** launch a throwaway desktop
Chrome `--force-device-scale-factor=1.5 --remote-debugging-port=9444
--user-data-dir=/tmp/x`; dev-web seeds demo EPUBs in a fresh profile but no PDF
— import a sample PDF (`apps/readest-app/src/__tests__/fixtures/data/sample-alice.pdf`,
69pp US-Letter) via CDP `Page.setInterceptFileChooserDialog`+`fileChooserOpened`
`DOM.setFileInputFiles` (the readest "Import Books" button opens a MENU; click
"From Local File" to trigger the chooser). `Browser.setWindowBounds` to sweep
ODD inner widths (1283/1284…) to hit fractional pageW. Measure left-page canvas
abs-right vs spine; capture a thin vertical clip at the spine to see the line.
Dev server picks up foliate-js edits on reload (HMR recompiled it; no restart
needed here, contra some older paginator notes). See [[issue-4112-scroll-anchoring]]
neighbors for other paginator/foliate fixes.
@@ -0,0 +1,24 @@
---
name: proofread-enhancements-4700
description: "Proofread/replacement-rule feature — sync, regex UI, Opt/Alt+P shortcut, i18n (issue"
metadata:
node_type: memory
type: project
originSessionId: 41894f93-c46e-457b-be84-847ccf6243d7
---
Issue #4700 (FR: Proofread enhancements) — SHIPPED, merged to main via PR #4708. The proofread (校对/替换规则) find-replace feature lives in: data model `ProofreadRule` in `src/types/book.ts`; store `src/store/proofreadStore.ts`; engine `src/services/transformers/proofread.ts`; selection popup `src/app/reader/components/annotator/ProofreadPopup.tsx`; manager dialog `src/app/reader/components/ProofreadRules.tsx` (mounted in `Reader.tsx`); sidebar entry `BookMenu.tsx`.
What shipped (all test-first, full suite green):
1. **Sync** — added `'globalViewSettings.proofreadRules'` to `SETTINGS_WHITELIST` in `src/services/sync/adapters/settings.ts` (whole-field LWW). ⚠️ CORRECTION (the original "KEY INSIGHT" here was WRONG): book/selection-scope rules were PUSHED (serializeConfig keeps the viewSettings delta) but **silently DROPPED on pull**`useProgressSync.applyRemoteProgress` only consumed `location`/`xpointer` and discarded the rest of the synced config (the "Currently, only reading progress is synced" comment). So per-book/selection rules did NOT actually propagate across devices until the fix below. Library/global rules sync independently via the settings replica. See [[proofread-per-book-crdt-sync]].
2. **Regex** — the transformer ALREADY fully supported `isRegex`; only UI was missing. Added a Regex toggle to the selection popup AND a full "Add Rule" form (pattern/replacement/scope Book|Library/Regex/Case-sensitive) to the manager dialog, validated via `validateReplacementRulePattern`. Popup skips the whole-word validation when regex is on.
3. **i18n** — the whole-word warning in ProofreadPopup was a hardcoded English string (root cause of issue's point #2: Chinese user couldn't read it, thought symbols couldn't be replaced). Wrapped in `_()` + 8 new keys translated across all 33 locales via `pnpm i18n:extract`.
4. **Shortcut** — reused the existing `onProofreadSelection` (`ctrl+p`/`cmd+p`). `handleProofread` in `Annotator.tsx` now opens the rules manager (`setProofreadRulesVisibility(true)`) when there's no active selection, and opens the create-from-selection popup when there is. No new shortcut entry, no first-level toolbar button (maintainer said skip). NOTE: first attempt used a dedicated `opt+p`/`alt+p` action — reverted because macOS Option+P is a dead-key (emits `'π'`, not `'p'`; `useShortcuts` matches on `event.key`). Ctrl+P avoids that entirely. The Annotator selection shortcuts have no unit-test harness (same as onTranslate/onDictionary), so this wiring isn't unit-tested; `setProofreadRulesVisibility` itself is covered by ProofreadRules.test.tsx.
Later additions (same PR): modernized the manager dialog to the design-system primitives (SectionTitle, `card eink-bordered border-base-200`, `input input-bordered`, `btn-contrast` CTA disabled-until-pattern); scrollbar-to-edge via `contentClassName='!px-0'` on Dialog (the body's default `px-6 sm:px-[10%]` was insetting the inner scroll container); **drag-to-reorder** rules per category via @dnd-kit (mirrors `CustomDictionaries.tsx` — sensors, `dragModifiers`, `SortableContext`, drag-handle-only listeners). Reorder persistence = new `proofreadStore.reorderRules(envConfig, bookKey, orderedIds)` that rewrites only the `order` field (index-based) across BOTH stores (book config + global settings) in one call; the manager now sorts both displayed lists by `order` (stable, so default-1000 rules keep insertion order). NOTE: transformer re-buckets by scope (selection→book→library) so cross-scope drag order in the merged "Book Specific Rules" list is cosmetic — only within-scope order affects application; reordering a library rule there changes its GLOBAL order (affects all books).
Gotchas / caveats:
- **`wholeWord` field is a near no-op in the transformer**: `normalizePattern` always wraps ASCII patterns in `\b…\b` regardless of `rule.wholeWord`; `isValidMatch` never reads it. It only gates the popup's pre-create validation (`isWholeWord` on the literal selection). So ASCII substring replacement (e.g. "cat" inside "category") is impossible today — pre-existing, out of #4700 scope.
- **macOS Option+letter dead-key**: `useShortcuts` matches on `event.key`, so any `opt+<letter>` shortcut won't fire on macOS (Option+letter emits a special glyph, not the letter). Avoid `opt+<letter>` bindings; prefer ctrl/cmd. A robust fix would need code-based matching in `useShortcuts` (deferred).
- Test isolation: spying `useProofreadStore.getState().addRule` across multiple tests leaks call counts — add `vi.restoreAllMocks()` in afterEach.
- Run single test files with `npx dotenv -e .env -e .env.test.local -- vitest run <file>` (bare `npx vitest` crashes on supabase `atob`).
@@ -0,0 +1,62 @@
---
name: proofread-per-book-crdt-sync
description: "Per-book/selection proofread rules now CRDT-merge by id on config pull (was dropped); tombstone-on-delete"
metadata:
node_type: memory
type: project
---
MERGED via PR #4781 (2026-06-25, squash commit 79ae8a48).
Per-book + selection-scope proofread rules now actually sync across devices via an
item-level CRDT merge (keyed by rule `id`), mirroring how booknotes merge. Before
this, `useProgressSync.applyRemoteProgress` pulled the full synced book config but
only applied `location`/`xpointer`, dropping `viewSettings.proofreadRules` (and
everything else). Library/global-scope rules sync separately via the settings
replica (`adapters/settings.ts` whitelist, whole-field LWW) — see [[proofread-enhancements-4700]].
**Design (per maintainer):** no new DB table — the rules keep riding the existing
book-config blob; the pull side just stops discarding them and merges by id instead.
What changed:
- `ProofreadRule` (`types/book.ts`) gained `updatedAt?: number` (LWW key) and
`deletedAt?: number | null` (tombstone). No `createdAt` (the existing `order` covers ordering).
- New pure `mergeProofreadRules(local, remote)` in `src/utils/proofread.ts` — by id,
LWW on updatedAt/deletedAt, identical semantics to `mergeNotes` in WebDAVSync.ts.
- `proofreadStore.ts`: stamps `updatedAt` on add/update/toggle/reorder; **`removeBookRule`
now TOMBSTONES (sets deletedAt) instead of splicing** so the per-id merge can't
resurrect a deleted rule from the peer's live copy. `removeGlobalRule` STAYS a
hard-splice — library deletion already propagates via the settings replica's
whole-field LWW (shrinking the array wins), so a tombstone there would just leave
dead entries. Getters (`getBookRules`/`getGlobalRules`/`getMergedRules`) and the
book-scope dedup filter out `deletedAt`.
- `transformers/proofread.ts`: render filter gained `!r.deletedAt`.
- `ProofreadRules.tsx` `useReplacementRules`: filters `deletedAt` so tombstoned rules
don't show in the manager list.
- `useProgressSync.applyRemoteProgress`: merges `syncedConfig.viewSettings?.proofreadRules`
(filtered to scope !== 'library') into the open book's rules, `setViewSettings` +
`saveConfig`, and `recreateViewer` ONLY when the merged array actually differs (guards
a reflow on no-op pulls).
Convergence gotcha (why the push re-uploads the union): `bookDataStore.saveConfig` only
merges `{updatedAt}` into the in-memory config — it does NOT write the passed viewSettings
into `booksData`. The thing that syncs merged viewSettings into `booksData.config` (so the
next `pushConfig``getConfig` serializes the union) is `readerStore.setViewSettings`, but
only when the viewState `isPrimary`. So call order must be setViewSettings → saveConfig
(same as `proofreadStore.updateBookViewSettings`).
**Stable id (`ensureRuleId` in utils/proofread.ts):** the merge keys on `id`, so id-less
rules (legacy / hand-edited / foreign peer) would ALL collide on the Map's `undefined`
slot — distinct rules clobber each other (silent loss, NOT duplication). `ensureRuleId`
backfills a missing id with a content hash `ph-${md5(scope|isRegex|pattern)}` (selection
scope also folds in sectionHref+cfi since it's per-instance), applied on both sides inside
`mergeProofreadRules`. `createProofreadRule` now seeds book/library ids the same way
(`id = scope==='selection' ? uniqueId() : ''` then `ensureRuleId`) so the SAME rule made
independently on two devices dedupes on sync instead of duplicating; selection rules keep
`uniqueId` (per-instance). Identity excludes replacement/case/wholeWord to match the
in-store dedup (pattern+isRegex). Ids are assigned ONCE and frozen — edits never re-key
(updates omit `id`). Limitation: rules already created with the old random `uniqueId` keep
those ids, so pre-existing identical rules across devices are NOT retroactively merged.
WebDAV does NOT carry proofread rules: its wire envelope strips viewSettings (`buildRemotePayload`),
so this only fixes the native cloud sync path. WebDAV would need un-stripping + the same merge.
@@ -0,0 +1,14 @@
---
name: r2-rclone-createbucket-403
description: rclone copyto/moveto 403s on object-scoped R2 tokens (hidden CreateBucket probe); use a directory rclone copy
metadata:
node_type: memory
type: project
originSessionId: b0c01e3c-9485-45fe-8ae3-eb5f2762f8fa
---
Single-file `rclone copyto`/`moveto` to Cloudflare R2 first issues a **CreateBucket** probe — `PUT /<bucket>` with no object key — to "ensure the bucket exists." An **Object Read & Write** R2 token (least-privilege; can't create buckets) returns `403 AccessDenied` (a real S3 `<Error><Code>AccessDenied</Code></Error>`, with an *empty* `request id` because R2 short-circuits it). A **directory** copy `rclone copy DIR dst/` does NOT probe — it `PutObject`s the key directly → 200.
**Why it bit us:** The nightly channel (#4577) `assemble-manifest` job promoted `nightly/latest.json` via `copyto` + server-side `moveto` → 403, so the manifest was never published (build legs uploaded fine). The build legs and the stable release flow (`upload-to-r2.yml` writing `releases/latest.json`) were unaffected because they use `rclone copy DIR/`. The token had Admin earlier (build legs passed), then was narrowed to Object-R&W mid-investigation, which is when even the build-leg style would have started needing the directory form. Fixed in PR #4588.
**How to apply:** For any R2 write in CI use the directory form — `mkdir -p d && cp f d/ && rclone copy d r2:bucket/prefix/` (mirrors `upload-to-r2.yml`) — OR add `no_check_bucket = true` to the `[r2]` rclone.conf (equiv. `--s3-no-check-bucket`). R2 `PutObject` is atomic, so a `.tmp`+`moveto` "atomic promote" adds nothing over a direct write. Diagnose live with a push-triggered throwaway probe workflow (`on: push` to a feature branch — `workflow_dispatch` won't dispatch a workflow that isn't on the default branch); `rclone -vv --dump bodies` reveals `PUT /<bucket>` (CreateBucket) vs `PUT /<bucket>/<key>`.
@@ -0,0 +1,16 @@
---
name: recent-read-shelf-3797
description: "Recently-read carousel at library top (#3797 / PR"
metadata:
node_type: memory
type: project
originSessionId: d5f79cf1-9e58-4ae4-9f8a-46a8e8ca625f
---
Opt-in "Recently read" strip in the library Virtuoso header (PR #4829, issue #3797). `selectRecentShelfBooks(books, count)` in `libraryUtils.ts` (filter `!deletedAt && progress != null`, sort by `updatedAt` desc, slice 12). Setting `libraryRecentShelfEnabled` (default false) + View-menu toggle. Rendered via the Virtuoso `Header` through `BookshelfListContext` (stable identity → no grid re-render churn); list `<Virtuoso>` needs explicit `context={listContext}`.
**Reuse, don't reimplement:** each slide renders the real `BookItem` (identical cover/title/progress/badges). The open path was extracted to `src/app/library/hooks/useOpenBook.ts` (in-place stale-record probe + `makeBookAvailable` on-demand download for cloud-only synced books + navigate) and is shared by `BookshelfItem` AND the recent shelf. Do NOT open via the select-mode `navigateToReader` path — it skips the download, so a recently-read book that synced (progress + `updatedAt`) without its blob fails to open on a second device.
**Alignment gotcha (cost several iterations):** a horizontal flex strip with `basis-1/N` does NOT match a CSS-grid column when the grid has a row gap — CSS Grid subtracts the gap from each track, flex `basis` does not (covers come out too wide at 2/3 cols where `BOOKSHELF_GRID_CLASSES` uses `gap-x-4`; matches at `sm+` where `gap-x-0`). Fix: size each slide with the grid's own formula `flexBasis: calc((100% - (var(--rs-cols) - 1) * var(--rs-gap)) / var(--rs-cols))`, with `--rs-cols` (responsive `3/4/6/8/12` ladder when auto, else `libraryColumns`) and `--rs-gap` (`1rem` base / `0px` sm+, mirroring `gap-x-4 sm:gap-x-0`) set on the row. Also `min-w-0` on each flex item, else image covers expand to intrinsic width. Verified 0.00-0.02px edge diff vs a real CSS grid at N=2/3/4/5 (standalone HTML repro + getBoundingClientRect).
Arrows: plain scroll div + `scrollBy`, shown on overflow (`scrollLeft`/`scrollWidth`, `ResizeObserver`), centered on `.bookitem-main` via measure; `start-2`/`end-2` + `rtl:rotate-180`. Swipe never opens (useLongPress moveThreshold). i18n: `i18n:extract` churns every locale (see [[i18n-extract-prunes-keys]]) — added the 2 keys manually; bo/si/ta/bn best-effort.
@@ -0,0 +1,17 @@
---
name: reference-pages-672-4542
description: "Reference Pages progress style (#672 +"
metadata:
node_type: memory
type: project
originSessionId: 2a51b785-c6db-4bab-86f1-4a9b086a0e48
---
PR #4549 (2026-06-12) merged #4542 (manual page count) into #672 (page-list/page-map): `progressStyle: 'reference'` shows physical-book pages in `ProgressBar` + `DesktopFooterBar`.
- **foliate-js already did the hard part**: `book.pageList` (EPUB3 nav page-list at `epub.js` parseNav; EPUB2 NCX `pageList` — only parsed when there's no navigable nav-doc TOC) and `view.js #pageProgress` emits `detail.pageItem` on every relocate. Readest never consumed it before. Adobe `page-map.xml` is NOT parsed, but books that ship it (Count Zero) also carry the NCX pageList, so it works anyway. Gap: EPUB3 nav-with-TOC-but-no-page-list never falls back to NCX pageList.
- **Total = highest numeric label**, not last entry (a trailing roman-numeral index page like "XII" after p553 would corrupt it — reported in #672 comments). All-roman page lists fall back to entry count. Logic in `getReferencePageInfo` (`utils/progress.ts`).
- **Per-book-only viewSettings field**: `referencePageCount` saved with `saveViewSettings(..., skipGlobal=true)` so a physical page count never leaks into `globalViewSettings` even when the user runs global settings. The merge `{...global, ...perBook}` keeps it.
- **Verification EPUBs** (issue #672 comments, in /tmp/issue672 while it lasts): Caleb's Crossing = EPUB3 nav page-list (419 pp); Count Zero = EPUB2 NCX pageList + page-map.xml (346 pp; `Text/c2.html` starts at `name="22"` — good exact-match oracle).
- **Web-import injection trick for dev-web e2e**: stage the epub in `public/`, then dispatch a synthetic `DragEvent('drop')` with a real `DataTransfer` (files added via `dt.items.add(new File(...))`) on `.library-page``useDragDropImport` takes it from there. The chrome-MCP `javascript_tool` has NO top-level await ("await only valid in async functions") and collects returned promises — use an async IIFE writing to `window.__result` and poll.
- **Locale-file rebase conflicts** (every feature PR appends keys to all 33 translation.json tails): don't hand-merge — `git checkout --ours -- public/locales`, re-run `pnpm i18n:extract`, re-run the translation script, `git add`, `git rebase --continue`.
@@ -0,0 +1,18 @@
---
name: rsvp-control-bar-overlap-revert
description: RSVP mobile control bar overlap was a REGRESSION — PR
metadata:
node_type: memory
type: project
originSessionId: cc658a96-fce5-4922-b924-361173c57e2a
---
RSVP (Speed Reading) overlay control bar: on narrow phones (Xiaomi 13 = 360px CSS width) the audio (TTS 🔊) toggle + settings ⚙ gear overlapped the right end of the centered transport row, hiding the "skip forward 15" label.
**This was a regression, not a new bug.** PR #4585 (`51fede1a0`, merged 2026-06-14 19:18Z) already fixed it: replaced the `absolute end-0` cluster with a single in-flow `flex items-center justify-between md:justify-center` row — audio toggle far-left, settings far-right, flanking the centered play button; secondary buttons tightened to `h-8 w-8 shrink-0 md:h-9 md:w-9`, skip buttons `px-1.5 md:px-2`. At 360px the 9 controls pack ~340px into a 336px row with zero gaps but **no overlap** (verified on-device).
**Reverted by PR #4589** (`490824504`, `feat/word-wise`, Word Wise inline vocab). Branch created 19:13Z — 5 min BEFORE #4585 merged — and merged ~10.5h later WITHOUT rebasing. The squash carried the stale pre-#4585 `RSVPOverlay.tsx`, so its diff is an exact mirror revert: #4585 = +53/51 src & +29 test; #4589 = +51/53 src & 29 test. #4589 added ZERO Word Wise code to RSVPOverlay — those hunks were purely the revert. It also deleted #4585's guard test (`audioBtn.closest('.absolute')).toBeNull()`), so CI couldn't catch the reintroduced overlap.
**Re-fix (this session):** restored the #4585 block byte-for-byte + re-added a guard test (`audio/settings share the play button's parent`, parent `className` has no `absolute`) in `rsvp-overlay-context.test.tsx`. Pattern to watch: a stale feature branch squash-merged after an intervening fix silently reverts it — see [[security-advisories-web-2026-06]] (shared-target worktree build-cache pollution) for the same stale-branch family.
**On-device verify recipe:** app running → `adb forward tcp:9222 localabstract:webview_devtools_remote_<pid>` → enter RSVP by dispatching synthetic `KeyboardEvent('keydown',{key:'v',shiftKey:true})` on `window` (Shift+V shortcut, listener on window; blur activeElement first) → click "From Current Page" → CDP-mutate the live DOM to preview a layout fix before rebuilding (apply exact source classes + reparent, then `getBoundingClientRect` overlap check + `adb exec-out screencap`). See [[cdp-android-webview-profiling]].
@@ -0,0 +1,37 @@
---
name: rsvp-font-settings-4519
description: "RSVP word uses the reader's font face/family via getBaseFontFamily; overlay renders in the top document where fonts are mounted"
metadata:
node_type: memory
type: project
originSessionId: 18ca3271-7e08-4b4f-bf33-a135370d5844
---
#4519 — RSVP (speed-reading) word display now mirrors the reader's font face/family
settings instead of a hardcoded `font-mono`.
Key facts (non-obvious):
- The RSVP overlay (`RSVPOverlay.tsx`, rendered via `createPortal` to
`document.body` from `RSVPControl.tsx`) lives in the **top document**, NOT an
iframe. So it cannot read the iframe's `--serif`/`--sans-serif` CSS variables
that `getFontStyles` sets. Instead apply a resolved `font-family` string.
- `getBaseFontFamily(viewSettings)` in `src/utils/style.ts` returns the resolved
body font chain (serif or sans-serif per `defaultFont`, including the chosen
typeface, `defaultCJKFont`, and any custom font selected as serif/sans). It
reuses the shared `buildFontFamilyLists` helper extracted from `getFontStyles`.
- This works because fonts are already mounted in the **top document**:
`FoliateViewer.tsx` calls `mountCustomFont(document, font)` for user-imported
fonts, and `Reader.tsx` calls `mountAdditionalFonts(document)` for the basic
Google fonts.
- KNOWN GAP: `Reader.tsx` calls `mountAdditionalFonts(document)` WITHOUT the
book-language CJK flag, so the built-in CJK *web* fonts (LXGW WenKai, Noto
Serif JP, etc.) only mount in the top document when `isCJKEnv()` is true. A
non-CJK-env user reading a CJK book may see a system CJK fallback in the RSVP
word rather than the exact web font. Latin fonts and user-imported custom
fonts are unaffected (always mounted). The iframe loads CJK fonts per-doc via
`mountAdditionalFonts(detail.doc, isCJKLang(...))` regardless of env.
- RSVP keeps its own font *size* control (localStorage `readest_rsvp_fontsize`);
only face/family were wired up. Font weight is intentionally left as the RSVP
design (word `font-medium`, ORP char `font-bold`).
Related: [[iframe-cross-realm-instanceof]] (top-realm vs iframe-realm distinction).
@@ -0,0 +1,16 @@
---
name: rsvp-rtl-word-display-4630
description: RSVP ORP focus-letter split breaks Arabic/RTL shaping; render RTL words whole with dir=rtl
metadata:
node_type: memory
type: project
originSessionId: 0561d60a-5d21-4b58-8bc9-1295a9f768ce
---
#4630: In RSVP the word window showed Arabic with letters separated, LTR, wrong order (e.g. علم → ل م ع), sometimes a tofu box. Root cause = the ORP focus-letter layout in `RSVPOverlay.tsx` slices each word into `wordBefore`/`orpChar`/`wordAfter` by character index and lays them out in absolutely-positioned LTR spans. Slicing by index breaks Arabic letter shaping (letters stop connecting → isolated/notdef forms) and the before→after LTR layout reverses visual order. The context panel renders each word as ONE unsplit `<span>`, which is why the reporter saw it render correctly there.
Fix: detect RTL and render the word whole, reusing the existing CJK "Highlight Word" `.rsvp-word-whole` branch, with `dir='rtl'` for correct base direction.
- New `isRTLText(text)` in `src/services/rsvp/utils.ts``RTL_PATTERN = /[֐-ࣿיִ-﷿ﹰ-]/` (Hebrew/Arabic/Syriac/Thaana/NKo/Samaritan/Mandaic + presentation forms). Mind the literal-char Edit pitfall: bidi reordering made exact-string Edit fail; wrote the regex with `\u` escapes via perl on the line number instead.
- Overlay branch: `isRTLWord || (isCJKWord && highlightWholeWord)` → whole-word span; `dir={isRTLWord ? 'rtl' : undefined}`. No new toggle — RTL always renders whole (ORP anchoring is meaningless for unsplittable shaped scripts).
General lesson: any complex-shaping/bidi script can't survive per-character span splitting; the same trap applies to other features that slice words by index for highlighting. Related: [[rsvp-font-settings-4519]]. jsdom tests assert DOM structure (whole span + dir) only; glyph shaping is a real-browser concern but is guaranteed correct because the sibling context span already shapes correctly.
@@ -0,0 +1,24 @@
---
name: russian-hanging-prepositions-nbsp-4769
description: "Russian hanging-preposition NBSP transformer; generic per-language, lang-gated, no toggle"
metadata:
node_type: memory
type: project
originSessionId: 423131fb-8192-4055-b617-3f79d412e258
---
Issue #4769: Russian typography forbids short function words (prepositions/conjunctions/particles) hanging at the end of a line ("hanging preposition"). Fix = a content transformer that inserts U+00A0 after such words so they stick to the next word. Source file is never modified.
**Where:** `src/services/transformers/nbsp.ts` (export `nbspTransformer`, name `'nbsp'`), registered in `transformers/index.ts`, added to the FoliateViewer pipeline AFTER `simplecc`, before `proofread` — must run after `whitespace` (which strips NBSP when `overrideLayout`) or the glue is undone. (Originally named `russianNbsp` / `russianNbspTransformer`; renamed generic so it's the home for NBSP across languages.)
**Generic by language:** internally a `NBSP_LANGUAGES: Record<langCode, {script, shortWords}>` registry; gate = `NBSP_LANGUAGES[normalizedLangCode(ctx.primaryLanguage)]` (so `ru-RU` -> `ru`; returns content unchanged if no entry). Only `ru` configured today; adding another language = one registry entry (its Unicode script name + a 3+ letter function-word list).
**Gating decision (user, via AskUserQuestion):** language gate ONLY, no settings toggle (deliberately skipped the issue's requested toggle to keep scope in `services/transformers`). Belarusian/Ukrainian/Bulgarian (also Cyrillic) are NOT included — `ru` only.
**Algorithm:** regex on the raw HTML string (NOT a DOM round-trip — avoids restructuring XML decl/doctype for every section, unlike `proofread`/`sanitizer` which parse+serialize). `TEXT_OR_SKIP = /<(style|script)\b[^>]*>[\s\S]*?<\/\1>|>([^<]+)</gi` skips style/script blocks and only rewrites text between tags, leaving tags/attrs/entities byte-for-byte intact.
- Glue regex (built per language from `config.script` + `config.shortWords`): `(^|[^\p{L}])(<3+ letter words>|\p{Script=<script>}{1,2}) (?=[\p{Script=<script>}\p{N}])` -> replace `$1$2` + NBSP. 1-2 letter words of the script glue generically; 3+ letter function words need the explicit list (content nouns excluded so we never glue after them).
- No look-behind ([[feedback_no_lookbehind_regex]]): capture+re-emit the boundary char instead. Because the boundary is consumed, consecutive short words ("и в доме") need a loop-until-stable (`do/while result!==prev`); NBSP is in `[^\p{L}]` so a just-inserted NBSP counts as the next boundary.
**Known limitation (accepted):** postfix particles же/бы/ли glue FORWARD (to next word) not backward (to preceding word) — still prevents end-of-line hang, which is the issue's actual concern. Prepositions before digits glue too ("в 2025", "около 5").
**Authoring gotcha:** typing literal NBSP (U+00A0) into tool inputs near Cyrillic silently produced many stray NBSP bytes in source. Always write NBSP as the ` ` escape in JS source; normalize files with a Python `chr(0xA0)->chr(0x20)` pass then restore the one intended escape. Verify with `python3 -c "...read().count(chr(0xA0))"`, not shell `grep $' '` (matches regular spaces). Same applies to test assertions: define `const NBSP = ' '` and build expectations via template literals.
@@ -0,0 +1,23 @@
---
name: save-image-to-gallery-android
description: Image-viewer Save button → Android MediaStore (not share); sharekit 0-byte self-copy bug; tsgo misses abstract conformance
metadata:
node_type: memory
type: project
originSessionId: d72184f1-0e4c-412b-9dc9-fb384e189427
---
PR #4680 — image gallery "Save Image" button (`ImageViewer.tsx` + `ZoomControls.tsx`).
**Routing (the button reflects the actual action):** `canShare = !isAndroidApp && canShareText(appService)`.
- Android → `appService.saveImageToGallery(filename, bytes, mimeType)` = new native-bridge command `save_image_to_gallery` (Kotlin `MediaStore.Images` insert into `Pictures/Readest`, scoped-storage = NO permission on API 29+; pre-29 best-effort). Writes a Temp `shared/<name>` staging file, passes its path, removes it after.
- iOS/macOS / web-with-`navigator.share``saveFile({share:true})`.
- desktop / web-no-share → saveDialog / download.
**WHY Android does NOT use the share sheet to "save to file":** Android `ACTION_SEND` only lists apps that *consume* content; NO file manager registers for it. Verified on device: `adb shell cmd package query-activities -a android.intent.action.SEND -t image/png` → 34 apps (Bluetooth/Gmail/WPS/Telegram/Xiaomi-Drive…), zero file managers. "Save to a folder" is `ACTION_CREATE_DOCUMENT` (system `com.google.android.documentsui`), which never appears in a share sheet. So on MIUI the share flow genuinely can't save-to-file.
**sharekit 0-byte self-copy bug (separate fix commit on #4680):** `@choochmeque/tauri-plugin-sharekit` (rust `tauri-plugin-sharekit 0.3`) `shareFile` copies src → `File(activity.cacheDir, sourceFile.name)` BEFORE `ACTION_SEND`. Tauri `Temp` dir IS `activity.cacheDir` = `/data/user/0/<pkg>/cache` (verified `invoke('plugin:path|resolve_directory',{directory:12})`). Writing the shared file to the Temp ROOT makes that a copy onto itself → `FileOutputStream` truncates the source to 0 before `copyTo` reads it → **0 KB shared file**. Fix = write to a Temp `shared/` SUBDIR in `nativeAppService.saveFile`. Also fixed the same latent 0-byte bug in annotation/markdown export.
**tsgo gap (bit me):** `pnpm lint` (tsgo) does NOT flag abstract-class interface conformance — adding a method to the `AppService` interface compiled clean under tsgo but the production Next `tsc` failed (`BaseAppService` missing abstract member). When extending `AppService`: add `abstract` decl in `BaseAppService` (appService.ts) + impls in native/web/**node**AppService + the 2 test stub classes (`app-service.test.ts`, `import-metahash.test.ts`). Run real `npx tsc --noEmit -p tsconfig.json` to catch.
**On-device verify recipe (no run-as on release APK):** `pnpm dev-android` (devtools APK) → CDP invoke `plugin:native-bridge|save_image_to_gallery` with a PNG staged via `plugin:fs|write_file` (body = Uint8Array 2nd arg, `headers:{path:encodeURIComponent(p),options:'{}'}`) → confirm with `adb shell content query --uri content://media/external/images/media --projection _display_name:relative_path:_size --where "relative_path='Pictures/Readest/'"`. Real 252 KB JPEGs from the live UI landed correctly. See [[android-cdp-e2e-lane]], [[cdp-android-webview-profiling]].

Some files were not shown because too many files have changed in this diff Show More