* fix(opds): allow LAN catalogs through the proxy in development
The SSRF host blocklist added in #4638 unconditionally rejected private
addresses, so the dev proxy returned 400 for LAN OPDS catalogs even though
next dev runs on the developer's own machine where reaching the LAN is the
normal use case. Skip the blocklist when NODE_ENV is development, matching
the existing CatalogManager gate that only forbids LAN URLs in production.
Production and test behavior are unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(opds): negotiate Digest auth when the server rejects preemptive Basic with 400
Calibre's content server in digest mode, or auto mode over http, answers a
Basic Authorization header with 400 Unsupported authentication method
instead of a 401 challenge. The preemptive Basic header introduced in #4206
therefore dead-ended the request, since the auth retry only fired on 401 or
403, and digest catalogs failed with Failed to load OPDS feed: 400 Bad
Request on every platform. When a request that carried preemptive Basic
comes back 400, re-issue it once without credentials to surface the
WWW-Authenticate challenge and let the existing negotiation pick the scheme
the server actually wants. Verified end to end against a live Calibre
server on web and Android.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(opds): skip SSL verification in auto-download like the manual download path
The native download_file command validates TLS with rustls, which ignores
the OS trust store, so downloads from self-signed or private-CA OPDS
servers fail in the TLS handshake before any request reaches the server.
The manual download path has passed skipSslVerification since #2900 as the
workaround for #2871, but the auto-download path never did, so subscribed
shelves failed to sync while feed browsing and manual downloads of the same
books worked. Pass the same flag in downloadAndImport.
Closes#4988
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Instant Highlight engages after a 300ms still-hold on text and locks
scrolling (renderer.scrollLocked) so the finger extends the highlight
instead of turning the page. The push paginator honors that lock in its
native swipe, but slide and page curl run through the app-side captured
turn: applyPageTurnAttributes sets no-swipe, so the native swipe bows
out and the captured-turn touch interceptor drives the turn instead.
That interceptor started a page turn on any horizontal swipe without
checking the lock, so a hold-then-swipe paginated with the slide/curl
effect instead of extending the highlight.
Gate the interceptor on renderer.scrollLocked before it begins a drag,
mirroring the native swipe. Bump the foliate-js submodule
(readest/foliate-js#51) to expose scrollLocked via a getter (it was
write-only) and add a regression test.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Teleprompter-style continuous scrolling toggled from the View menu
(Shift+A), available only in scrolled mode. A PacedScroller drives
whole-pixel forward steps at a constant, user-adjustable velocity;
the speed (25-500 percent, persisted as autoScrollSpeed) is tuned
from a floating control pill that also offers pause/resume and exit,
and fades away while scrolling to keep the mode immersive.
Tapping the page pauses and resumes instead of turning pages or
toggling the bars; manual wheel or drag input simply composes with
the paced scrolling. Escape or leaving scrolled mode ends the
session. When forward progress stalls the session hops to the next
section (single-section scroll mode) or stops with a toast at the
end of the book. Vertical-writing books scroll along the horizontal
axis with the sign convention foliate uses for scrolled offsets.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(reader): add formatCountdown helper for TTS timer chips
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(reader): extract shared TTS playback info hook
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(reader): add TTS scrubber with buffer-ahead fill
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(reader): add TTS speed preset chips
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(reader): add persistent TTS mini player
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(reader): add full TTS player sheet with voice and timer sub-views
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(reader): cover player sheet view reset on reopen
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(reader): replace TTS icon and popup with mini player and player sheet
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(reader): reserve mini player clearance and retire showTTSBar setting
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* chore: retire shipped TTS follow-ups from TODOS
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* chore(i18n): translate the TTS player strings
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(reader): pin mini player transport LTR and unmount the closed player sheet
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(reader): collapse sheet speed, voice, and timer controls into one row
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(tts): stabilize timeline estimates with cumulative voice calibration
Replace the per-sentence EMA with the cumulative ratio of all measured chars to all measured seconds per voice, so the estimated remainder converges instead of re-pricing on every quirky sentence. Legacy stored calibrations migrate as a small prior.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(reader): hide the mini player while the player sheet is open
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(tts): clear stale highlights across sections and stop sentence flash in word mode
Entering a section now scrubs the TTS highlight from every live view, not just the primary, so the outgoing section's last word no longer stays lit in the preloaded neighbor. reapplyCurrentHighlight no longer redraws the whole sentence during word-mode playback while awaiting the first word boundary.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* style(reader): move the mini player progress line to the bottom edge
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(reader): make the TTS progress bar and scrubber legible in eink mode
Grey tints wash out on e-ink: the mini player track gets a 1px hairline with a solid base-content fill (buffer fill hidden), and the sheet scrubber gets a crisp 1px border marking the track extent.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* style(reader): drop the player sheet header label on the main view
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* fix(android): keep background TTS media controls alive when backgrounded
The media-session update commands pushed metadata and playback state to
MediaPlaybackService via Context.startService(), which Android 8+ rejects
with "app is in background" once the app leaves the foreground. Every
per-sentence update then failed, so the lock-screen control went stale and
the foreground service lost the notification refresh that keeps it alive,
dropping background playback.
Deliver updates to the running service in-process (the pattern the existing
requestDeactivation() already uses) so they can never trigger a service
start. Also request POST_NOTIFICATIONS whenever the session activates, not
only when alwaysInForeground is set, so the foreground-service media
notification (the lock-screen control) is not suppressed on Android 13+.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(android): show section duration and enable seek on the TTS media session
The Edge/WebAudio TTS engine already reports an estimated section timeline
(position and duration) to the media session on every sentence, but the
Android service never surfaced it: the lock screen had no scrubber and no
seek. Set METADATA_KEY_DURATION so the scrubber shows its length, add
ACTION_SEEK_TO, and handle onSeekTo by handing the target back to the JS
controller (seekToTime) through the existing media-session-seek event.
Playback-state updates that only flip play/pause omit position and duration,
so the service now preserves the last known values instead of resetting the
scrubber to 0. Native TextToSpeech has no timeline (duration stays 0), so the
scrubber simply does not appear there.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(android): harden TTS foreground-service promotion + add diagnostics
On MIUI the media service was reclaimed on idle ("Stopping service due to app
idle"), which means startForeground never promoted it to a real foreground
service. Promote with ServiceCompat and an explicit
FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK type (robust on targetSdk 34+) and log
any failure instead of swallowing it.
Decouple the POST_NOTIFICATIONS request from the service start in setActive: a
throwing or hung permission request no longer aborts set_media_session_active,
so the foreground service always starts.
Add trace logging (set_media_session_active, activateSession, startForeground)
to pinpoint the promotion path on-device.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(android): drop required keepAppInForeground so the TTS service starts
The set_media_session_active payload required keep_app_in_foreground, but the
media bridge activates with just { active: true }. Tauri rejected the invoke
at the serde layer ("missing field keepAppInForeground") before the command
ran, so the foreground service never started: no media notification, and
Android 15 audio hardening then muted background playback.
The flag was dead everywhere: no platform read it (the foreground service
always starts and the POST_NOTIFICATIONS request is now unconditional). Remove
it from the Rust, Kotlin, iOS, and TS payloads. Effective behavior is
always-foreground.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(tts): fade sentence-buffer edges to stop Edge TTS clicks
The silence trim (findSpeechBounds) cuts at an amplitude threshold, not a zero
crossing, so each Edge TTS sentence buffer begins and ends on a non-zero
sample. An AudioBufferSourceNode steps straight from/to silence at its edges,
and that discontinuity clicks/pops between sentences (WSOLA is not involved:
it is a no-op at rate 1.0 and cross-fades its internal splices).
Apply a ~3ms linear fade to each buffer's own copy after WSOLA so the edges
ramp to zero. The trim and the controlled inter-sentence gap are unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* chore(android): remove the dead "Background Read Aloud" setting
alwaysInForeground only drove the removed keepAppInForeground flag; nothing
reads it now (the foreground service always starts, and POST_NOTIFICATIONS is
requested at play time). Remove the setting, its default, the Android
library-menu toggle (which just requested the notification permission), and
prune the now-unused i18n key from all locales.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
In auto-spread mode a portrait viewport shows a single page of the
two-page spread, but the fixed-layout renderer kept the spread-centering
one-sided inline margin on the shown page. With no partner page to meet
at the spine, that margin stranded the lone page in one half of the
viewport whenever it was narrower than the viewport (any zoom below
100%, or a page whose fit-scaled width is less than the viewport width),
which also pushed the page over a page-turn tap zone so every tap turned
the page instead of opening the menu.
Bump the foliate-js submodule (readest/foliate-js#50) to center the lone
portrait page and add a regression test for computeSpreadInlineMargins.
Fixes#4984
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(sync): S3-compatible cloud sync provider with premium-gated chooser
Add a third file-sync backend for any SigV4 object store (Cloudflare R2,
AWS S3, MinIO, Backblaze B2), end to end: SigV4 transport via aws4fetch
(path-style addressing, ListObjectsV2 with page draining, per-key
deletes, presigned streaming on Tauri, Drive-style error mapping and
backoff), S3 settings slice and defaults, exclusive provider activation
and cross-window flag broadcast, registry memoization, and an
Integrations chooser entry plus connect form that validates the bucket
with one signed listing.
Shared helpers settingsKeyForBackend and cloudProviderDisplayName
replace the scattered per-kind ternaries across the reader and library
sync hooks, fleet detection, and the settings surfaces.
The chooser now marks third-party providers with a Premium badge and
enforces the paywall (CLOUD_SYNC_REQUIRES_PREMIUM on): free plans see
the rows but route to the upgrade page instead of the config sub-pages,
and a downgraded account's still-selected provider is paused rather
than silently falling back to Readest Cloud uploads. Manual provider
sync now reports "N book(s) synced" like the native cloud sync, from
the engine result returned by runActiveFileLibrarySync.
The S3 transport passes the same provider semantic contract as WebDAV
and Google Drive.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* perf(sync): make the library row the ground truth for local file presence
Every sync run re-walked all books whose file is recorded nowhere and
paid two plugin:fs|exists IPC per book per run on Tauri, just to relearn
"no local source", ending in 0 books synced. The library row already
tracks local presence reliably (import, download, and delete all stamp
downloadedAt, and the metadata merge keeps it device-local), so the
file-push gate now trusts the row: a book the row marks as absent costs
zero filesystem and zero remote probes, keeping incremental sync a pure
metadata diff at any library size.
A session-scoped per-provider memo additionally suppresses re-probes of
drifted rows (the row claims a file the filesystem no longer has),
keyed to the book's updatedAt so any local change re-qualifies it.
Row-vs-filesystem split-brain in either direction is healed by Full
Sync, which bypasses the gate, the memo, and the uploaded-file record
and audits the real filesystem.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(sync): tiered request timeouts for the WebDAV client
An unreachable or dead server (a LAN host that went away) left PROPFIND
and HEAD requests pending indefinitely, pinning the Integrations panel
on "Syncing..." and the browse pane on a spinner. Metadata round-trips
(PROPFIND, HEAD, MKCOL, DELETE) answer with headers only, so they now
abort after 5 seconds; GET and PUT carry book-sized bodies over
possibly slow links and keep a 5 minute ceiling instead. Expiry aborts
the request via AbortController and surfaces as a "Request timed out"
NETWORK failure through the existing WebDAVRequestError taxonomy.
Since every library sync run opens with the HEAD etag probe on
library.json, a dead server now fails the whole run within seconds.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(settings): provider panel status and layout fixes
Three small fixes across the provider settings panels:
- A completed manual "Sync now" clears the provider's lastError so the
Cloud Sync chooser row and the SettingsMenu sync row stop reading
"Sync failed" after the server comes back; a failed manual run now
records the error for those surfaces too. Covered by a render harness
that drives the real form against a mocked engine.
- The sync row shows a relative "Synced a few seconds ago" label (same
wording as the SettingsMenu row) instead of an absolute timestamp.
- The Google Drive configured-but-inactive state rendered its Tips above
the action buttons; Tips now close the page in every provider panel
state.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* chore(i18n): translate the S3 provider and premium gating strings
New keys from the S3-compatible provider (form fields, chooser entry,
tips), the Premium badge, and the parameterized provider tips,
translated across all 33 locales.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* chore(memory): record the S3 provider and sync optimization notes
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* fix(reader): gate route View Transitions on the API, turns on groups (READEST-9)
Reverts #4949, which opened books through the plain router to dodge the
"Transition was aborted because of timeout in DOM update" TimeoutError
(Sentry READEST-9). Rather than carve the transition out of one flow, gate it
at the router: useAppRouter routes through the View Transition router only
where the engine has the View Transitions API, and every into-reader path
(including the reverted ones) goes back through useAppRouter.
The base View Transitions API and nested view-transition groups reach very
different browsers, so they become two separate appService capability flags,
each backed by a probe in utils/viewTransition:
* supportsViewTransitionsAPI (document.startViewTransition): the baseline a
route crossfade needs, landing on Chrome 111+, Safari 18+, recent WebView.
Gates the router.
* supportsViewTransitionGroup (view-transition-group: nearest, Chrome/WebView
140+): the far narrower target the paginator's layered turns require. Gates
the turn-style options and the captured-turn fallback.
Both flags fold in the Linux WebKitGTK carve-out because it crashes on the
snapshot, matching the supportsCanvasContext2DFilter precedent.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(tts): enlarge the Now Playing bar and scale its controls responsively
Grow the collapsed bar to h-14 with a 10x10 cover and symmetric px-2 padding,
drive the play/pause and close icons through useResponsiveSize instead of fixed
pixel sizes, and cut the bottom safe-area contribution to a third so the bar
sits closer to the screen edge.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(sync): record remote-present files for no-source books in the upload cursor
With "Upload Book Files" on, a device that holds no local copy of a book
(e.g. the web app with a cloud-only library) HEAD-probed the remote for
every book on every sync: pushBookFile returned 'no-source' and the hash
was never recorded in library.json's uploadedHashes, so needsFilePush
stayed true for the whole library and each run (tab focus, Sync Now,
library change) issued one Drive/WebDAV request per book, 646 requests
per sweep in the reported case.
The HEAD probe already answers whether the file is on the remote. Carry
that in PushBookFileResult.remoteExists and record the hash when a
no-source book's file is already mirrored, so the next incremental sync
skips it and stays O(changed). Books absent both locally and remotely
stay unrecorded so a device that has the bytes can upload them later.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(sync): reach the no-source verdict without probing the remote
A book file sync with "Upload Book Files" on probed the remote for every
book before checking whether this device even holds the bytes. On a
device with a cloud-only library (the web app), none of the books have a
local source, so every sync run (tab focus, Sync Now, library change)
issued one name-lookup request per book against Google Drive, a full
per-book request storm that never converged: with no local file there is
nothing to upload and nothing to record, so the next run repeated it.
Resolve the local source first and return 'no-source' from local state
alone; the remote head probe now runs only when there is a local file to
compare or upload. The probe keeps its non-NETWORK rethrow semantics so
the auth-failure latch (#4981) still stops a run on an expired session.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(sync): incremental file sync and per-book transfers for the active provider
Cut the redundant remote work a file-sync run does and route explicit
per-book uploads/downloads to the active third-party provider (WebDAV /
Google Drive) instead of the gated Readest Cloud transfer queue.
Engine (services/sync/file/engine.ts, wire.ts):
- etag change-probe: one HEAD on library.json, cached per provider for the
session. When the etag matches the last successful pull, reuse the cached
index and skip both the index download and the discovery scan. An AUTH
failure on the probe aborts the run like the full pull does.
- emptyDirs memo carried in the index: dirs found to hold no book file are
recorded so clients stop re-listing them every run. Re-checked when the
file arrives (uploadedHashes), on Full Sync, or when a legacy client drops
the record; pruned only against a listing that actually ran.
- skip the index re-push when the rebuilt index is semantically identical to
the pulled one, so a restamped byte-copy no longer churns the remote and
invalidates peers' etag change detection.
- downloadBookFile() for the explicit per-book Download action.
Provider reuse (services/sync/file/providerRegistry.ts):
- memoise one provider per connection key, shared by every surface (reader
per-book sync, library auto-sync, Sync now / pull to refresh). Reuses the
Drive path->id cache instead of re-resolving /Readest, books/ and
library.json by name query on every engine build. Drive connect/disconnect
resets the cache since its token source changes identity with no key input
changing.
Google Drive (services/sync/providers/gdrive/GoogleDriveProvider.ts):
- write fast-path: PATCH a path whose id is cached in place with no lookup,
falling back to a full resolve on a 404 (stale id). Removes a files.list
per PUT in the steady state.
- dev-only request diagnostics: one line per provider op and per HTTP attempt
so a run's request budget can be attributed from the console.
Per-book transfers (services/sync/file/runLibrarySync.ts):
- runActiveFileBookUpload / runActiveFileBookDownload build the active
provider's engine and push/pull a single book, stamping downloadedAt like
the native path. Wired into the reader/library book actions with toasts.
UI, status, and i18n:
- Readest Cloud sub-page: drop the quota stats and wrap the "Account and
Storage" row in the BoxedList primitive so it aligns to the design system.
- Shorten provider status/toast copy ("Active", "Google Drive session
expired", "Library sync via {{provider}}", "KOReader") and translate the
new keys across all locales.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Adds the 22 strings introduced by the provider-selection series (#4971,
#4973, #4975, #4976) to all 33 locales with real translations, plus the
per-locale CLDR plural forms for the quota batch-failure message and the
hand-curated en _one/_other variants.
Keys were appended without running the scanner's removeUnusedKeys pass,
which would have pruned unrelated live translations; the diff is
additions only. Terminology matches each locale's existing sync strings
(library, reading progress, highlights, storage quota).
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
With an expired Google Drive web session, syncLibrary swallowed the
AUTH_FAILED from the index pull and proceeded with remoteIndex = null,
which is indistinguishable from a first sync: every book looked
unpushed, uploadedHashes was empty, and the engine attempted to upload
the entire library (Uploading 16 / 682), logging one failure per book.
Worse, a null index also skips the peers-tombstone union in the final
index re-push, so a transient index-pull failure could have rewritten
library.json and resurrected deleted books (#4860 class).
- An unreadable index (throw) now aborts the run; an absent one
(404 -> null) keeps first-sync semantics.
- A terminal-failure latch stops the work pools on the first mid-run
AUTH_FAILED (a token can expire mid-run), skips the index re-push,
and rethrows so callers surface one re-auth error instead of a
per-book failure list. Mirrors the existing deleteRemoteBookDir
contract: auth failures rethrow, everything else aggregates.
- On web, the auto library sync now skips while the Drive session is
expired (hasValidWebDriveToken), matching the settings form's
disabled Sync now with its Reconnect CTA.
Tests: unreadable-index abort with zero writes, mid-run abort bounded
to in-flight work with no index re-push, non-auth failures still
non-aborting, absent-index first sync unchanged.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
The readest#4727 regression test set scrollTop=0, dispatched a synthetic
wheel, waited 60ms, then asserted scrollTop stayed 0. On slow CI runners it
flaked with "expected 4 to be +0".
As sibling scroll pages finish loading, the renderer runs
restoreScrollModeAnchor asynchronously, which at scrollTop=0/page-index-0
snaps scrollTop to page 0's offsetTop, the 4px scroll-page-gap margin. The
60ms post-dispatch delay raced that re-anchoring, so the assertion observed 4
instead of 0. That 4 is unrelated to the wheel bug, which is a 120px jump.
The buggy handler was scrollBy with instant behavior, a synchronous scroll
that lands before dispatchEvent returns. Measure scrollTop synchronously
before and after the dispatch with no await in between and assert they match.
This isolates the wheel handler's own effect and is immune to the async
re-anchoring. Reintroducing the bug still fails the test (before=4, after=124,
a clean 120px delta).
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Integrations section is now one Cloud Sync chooser: Readest Cloud,
WebDAV, and Google Drive as radio rows, Readest Cloud first, with a
scope subtitle stating what the choice governs (library data, on this
device) and what always stays with the Readest account (settings,
statistics, dictionaries).
- Activation helpers move to services (cloudSyncActivation.ts) and
accept 'readest' (= no third-party provider active); the component
module re-exports for existing imports.
- Row status lines come from a pure, fully-tested state matrix
(cloudSyncStatus.ts) so every string is enumerated in one place:
signed-out / loading / active / available for Readest Cloud;
not connected / configured / paused / syncing / sync failed /
book-file-uploads-off warning / active for third-party rows. The
paused state renders on the affected third-party row (the plan sketch
placed it on the Readest row; the provider that is paused is the
third-party one).
- The Readest Cloud row opens an inline sub-page (SubPageHeader + the
storage/translation Quota + a NavigationRow out to Account) instead of
ejecting from the Settings dialog; signed-out taps go to login and the
radio is unchecked and disabled, so an idle signed-out state never
shows a checked radio while nothing syncs.
- Premium-gated builds keep the Readest Cloud row and show the upgrade
prompt only in place of the third-party rows.
- Two-direction capability Tips in the WebDAV/Drive sub-pages spell out
both what syncs only to the user's server and what still flows through
the Readest account; the Upload Book Files description notes that
Readest Cloud uploads pause while the provider is selected.
- Manage Sync book/progress/note rows swap their description to
'Managed by {{provider}}...' via the existing locked-row pattern while
a third-party provider is selected; the toggles stay interactive and
persist since they govern the native channels after switch-back.
- The chooser rows form a radiogroup (native same-name radios provide
arrow-key group movement) with an accessible group label.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
While WebDAV or Google Drive is the selected cloud sync backend, the
native Readest Cloud book/progress/note channels are gated off and the
file-sync engine owns library data end to end:
- isSyncCategoryEnabled returns false for book/progress/note (and their
legacy aliases) when a third-party provider is selected. A runtime
override, deliberately not written into syncCategories: the user's own
toggles persist and take effect again on switch-back. Account channels
(settings, stats, dictionaries, fonts, textures, OPDS catalogs) always
stay native.
- persistActiveCloudProvider is the single write path for provider
switching, used by the chooser, both connect/disconnect flows, and the
Drive OAuth callback (which previously bypassed the cross-window
broadcast). The broadcast carries ONLY the enabled flags plus
providerSelectedAt, never credentials or sync cursors, and only on
switch events, so a stale window's routine save cannot revert a switch.
- buildWebDAVConnectSettings no longer pre-sets enabled: activation
belongs to withActiveCloudProvider, so the fresh-connect path now gets
the syncBooks auto-flip and the providerSelectedAt stamp.
- Sync health: fileSyncStore records lastError per backend; the durable
lastSyncedAt stays in provider settings. The SettingsMenu sync row
reads Synced via provider / Sync failed and its tap (with pull to
refresh and BackupWindow, all routed through pullLibrary) runs the
file engine via the shared runActiveFileLibrarySync helper instead of
a gated native pull that would toast undefined book(s) synced.
- Mixed-fleet detection: while gated, the auto-sync interval runs a
read-only probe of /api/sync since providerSelectedAt; any newer book
row means another device still syncs natively, and a once-per-session
notice explains the fork instead of leaving it silent.
- Readest-Cloud-only affordances hide while a third-party provider is
selected: the quota row becomes a caption naming the active provider,
Auto Upload disappears from the menu and command palette, the
BookItem upload badge and the Transfer Queue Upload All button hide.
- providerSelectedAt added to both provider settings types and the
backup blacklist.
Stacked on the quota-decoupling change for #4959; requires the
metadata-parity change so gated channels lose nothing users can see.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Opening the Library with a large grouped library was slow because each
folder's 2x2 cover mosaic was recomposed from scratch on every paint (up
to 4 MuPDF cover decodes plus scales per cell). On a 685-book library this
dominated the synchronous open path (254ms of 300ms) and ran again on the
post-sync refresh. Navigation felt fast only because drilling into a group
shows single covers, not mosaics.
Mirror cloud_covers' async pattern in group_covers:
- Cache the composed master bb per group, keyed by a signature that flips
when the child set or any child's cover availability changes; serve
cheap copies on a hit. Cache a nil result too, so a group whose covers
are not ready yet keeps its placeholder without recomposing on every
refresh; a later cover download flips the signature and recomposes once.
- Compose off the first-paint path: a miss enqueues a background job (one
mosaic per UI tick) and returns nil so the cell paints its FakeCover
placeholder immediately; finished mosaics coalesce into one refresh.
- Free cached masters when the Library closes.
Measured synchronous open path drops from 300ms to 151ms on a 685-book
library; mosaic compositing moves off the blocking paint and fills in
progressively. Adds open-path timing logs to librarywidget and
localscanner for on-device diagnosis of future large-library reports.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The library.json index already carries full Book objects, but the
metadata merge overlay dropped tags and readingStatus on apply, so
tagging or marking a book Finished never reached peers syncing via
WebDAV or Google Drive (the same overlay gap that hit group membership
in #4942):
- mergeBookMetadata carries tags with the metadata LWW subset (raw
assignment, so tag removal clears on peers) and merges readingStatus
on its own readingStatusUpdatedAt clock, the client-side mirror of the
native field-level server merge. This survives the asymmetric race
where one device edits metadata after a peer changes the status;
whole-book LWW alone would drop the status change.
- New shouldApplyRemoteBookMetadata reconciliation predicate triggers on
either clock; the engine's index reconcile uses it so a status-only
change propagates without a metadata edit.
- Merge-law tests (direction, removal, idempotence, asymmetric races)
plus engine-level propagation tests for both fields.
Prerequisite for gating native sync when a third-party provider is
selected (#4380): third-party sync should reach metadata parity before
it becomes the only channel.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
When a third-party provider (WebDAV or Google Drive) is the selected
cloud sync backend, Readest Cloud storage is no longer written to:
- New src/services/sync/cloudSyncProvider.ts policy module: the selected
provider is derived from the existing per-device enabled flags
(webdav wins deterministically if both are ever set); the premium
guard resolves to a PAUSED state instead of silently falling back to
Readest Cloud, with the user plan cached for non-React modules.
- transferManager gates book uploads on the selected provider: queueUpload
returns null when gated; pending book uploads from before a provider
switch are visibly cancelled (cancelReason policy) and pruned on the
next restore; downloads and replica transfers are never gated.
- Book uploads are deferred until settings hydrate (settings.version
barrier) so a persisted queue cannot be mis-processed at startup;
replica transfers are not stalled.
- Quota-exceeded uploads fail fast with zero retries, and a batch import
produces one summary toast instead of one toast per book.
- Policy cancellations are a distinct bucket via a shared predicate:
excluded from failed stats, Retry All, and the per-item Retry button.
- Auto-upload call sites (ingest, OPDS, subscriptions) check the provider
gate; the explicit Upload Book action explains the gate with a toast
instead of silently doing nothing.
- Activating a provider auto-enables its syncBooks so books keep backing
up somewhere; a one-time migration (20260706) applies the same flip for
users who already had a provider enabled.
- webdav.deviceId and webdav.lastSyncedAt are excluded from backups,
matching the existing googleDrive entries.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* fix(sentry): drop more benign View Transition rejections
Broaden is_ignored_browser_error to also drop "Transition was skipped"
(navigation superseded, READEST-F) and "aborted because of invalid state"
(READEST-G), matched case-insensitively alongside the existing hidden-tab case.
These are expected browser behavior — the navigation completes, only the
animation is skipped/aborted. A transition timeout stays visible (real perf
signal, handled separately).
Fixes READEST-F
Fixes READEST-G
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(library): create the book directory idempotently
Importing a book did a check-then-create with a non-recursive createDir. Two
concurrent imports of the same book both pass the exists check, then the second
create fails — on Windows with "Cannot create a file when that file already
exists" (Sentry READEST-H). Use a recursive create (create_dir_all), a no-op
when the directory already exists.
Fixes READEST-H
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(stats): serialize applyRemoteEvents to avoid nested transactions
The statistics connection is shared across ReadingStatsTracker instances (split
view). applyRemoteEvents runs a manual BEGIN/COMMIT that the per-op native
connection lock does not make atomic, so two concurrent pulls opened a BEGIN
inside a BEGIN ("cannot start a transaction within a transaction", Sentry
READEST-N). Serialize applyRemoteEvents against itself with a small promise
mutex. Adds a regression test that fails without the guard.
Fixes READEST-N
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Bumps foliate-js to include readest/foliate-js#49, which corrects PDF text
selection and highlighting drifting down and spilling into the margins when
the device's system font-size accessibility setting is larger than default.
Android scales the transparent text layer's glyphs but not the page canvas,
so the text layer now divides that scale back out of the glyph size.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a speaker button to the dictionary popup/sheet header that pronounces
the current headword. Tapping it speaks via Edge TTS, falling back to the
platform speech engine (Web Speech on desktop/web, native on the mobile
app) when Edge is unavailable.
To speak as soon as possible, a dedicated wordPronouncer bypasses the
reader's TTSController entirely: it never runs EdgeTTSClient.init() (which
wastes a round trip synthesizing "test"), calls EdgeSpeechTTS directly
(whose static MP3 cache makes repeat words instant), and schedules one
chunk on a dedicated Web Audio context isolated from any active read-aloud
session. The context is warmed synchronously inside the click gesture so
playback is not blocked by autoplay policy after the network await.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(reader): add middle mouse button autoscroll in scrolled mode
Middle-clicking a book in scrolled mode on desktop apps plants an anchor
indicator and scrolls with a velocity proportional to the pointer's
distance from it, like browser autoscroll (#4951). A quick click sticks
until the next click, wheel, or Escape; press-move-release scrolls only
while held. Vertical-writing books autoscroll along the horizontal axis.
The middle button's default is suppressed while the feature is armed so
WebView2's native autoscroll cannot double-drive on Windows. A new
Middle-Click Autoscroll toggle in the Scroll settings section (desktop
only, default on) turns it off.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(reader): drop the middle-click autoscroll toggle
Middle-click autoscroll is a common desktop convention and middle click
has no other use in the reader, so it is always enabled on desktop apps
in scrolled mode instead of being a setting.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* chore(i18n): translate missing strings across all locales
Fill in translations for 69 keys that landed on main without an i18n
pass (search modes, cloud sync and Google Drive settings, page turn
animation styles, TTS states, Word Lens hints, file browser sorting,
watched-folder auto-import) in all 33 locales, plus the English plural
variants for the search result count.
Keys the scanner would prune (strings it cannot see statically on this
branch) are left untouched.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Forwarded browser events carry os/rust/device context but no browser context,
so crashes couldn't be correlated with the WebView version. The app now reports
its User-Agent at startup via a set_webview_info command; the parsed engine
(Chromium/WebKit) and major version are stored and attached as webview.engine
and webview.version tags in before_send, covering both Rust panics and
forwarded browser events.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
useAppRouter wraps every navigation in a View Transition. Opening a book is a
heavy render (the reader mounts and loads the book) that can overrun the
transition's ~4s DOM-update budget and abort with a TimeoutError (Sentry
READEST-9). Navigate to the reader with the plain router instead, matching
every other into-reader path in the app; the transition router stays for
lighter navigation. Applies to the tap-to-open flow (useOpenBook) and the
post-import queued open (library/page).
Fixes READEST-9
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(reader): slide and page curl turn animations (#555)
Add an Animation Style setting (Push, Slide, Page Curl) next to the
Paging Animation switch. Slide moves the turning page over the still
previous or next page like the Apple Books slide; Page Curl folds it
open in 3D so the page underneath is partially visible as it turns.
Both styles track the finger: the page follows a horizontal drag and
commits past halfway or on a flick, or settles back. The page header
and footer stay in place while the page turns.
The styles layer a View Transitions snapshot of the outgoing page over
the live, stationary incoming page, since the pages of one section live
in a single iframe and can never be on screen twice. They work for all
writing modes including vertical-rl, and on engines without the View
Transitions API (older WebViews) the paginator falls back to the
existing push animation, so all platforms keep working page turns.
The paginator changes live in the foliate-js submodule; this bumps the
pointer, wires viewSettings.pageTurnStyle to the renderer turn-style
attribute, and adds browser tests covering slide layering, curl,
vertical-rl, finger tracking with commit and revert, and the push
fallback.
Fixes#555
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(reader): add WebGL page curl renderer for mesh turn animations (#555)
Grid mesh deformed around a cylinder: content past the fold wraps over
and lands mirrored on top with a whitened page back, transparent where
the page has curled away. Corner grabs start as a steep diagonal pinch
that straightens as the turn completes so the whole page clears by the
end. Groundwork for the Tauri mesh curl; capture and orchestration land
separately.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(native-bridge): capture webview region as PNG on macOS and iOS (#555)
New capture_webview_region plugin command returns a binary PNG snapshot
of the calling webview (tauri::ipc::Response, no JSON overhead) for the
mesh page-curl texture. macOS goes through WKWebView
takeSnapshotWithConfiguration via with_webview on the main thread with
a 500ms timeout; iOS snapshots in Swift and hands the PNG across the
JSON-only plugin boundary base64-encoded, decoded back to bytes in
mobile.rs. Windows, Linux, and Android reject for now so the JS side
falls back to the CSS curl.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(reader): drive the mesh page curl on Tauri platforms (#555)
Wire the WebGL curl renderer and the native webview capture into page
turns. A MeshCurlTurn controller runs the pipeline per turn: snapshot
the content box, overlay the captured page drawn flat, turn the live
view instantly underneath (the paginator's animated paths all gate on
the animated attribute), then curl the capture away. Backward turns
mirror the fold to the spine edge, matching the layered VT curl's
old-page-recedes choreography.
useMeshPageCurl wraps the view's prev/next so taps, keys, and wheel
turns all curl, and registers a touch interceptor (between the reading
ruler and the fixed-layout swipe) that scrubs the curl from the finger,
committing past halfway or on a flick and otherwise un-curling and
turning back under the overlay. The paginator stays out of the way via
no-swipe while the mesh is active; if the native capture ever fails the
session falls back to the paginator's CSS arc-fold curl and the shared
applyPageTurnAttributes helper restores turn-style.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(build): restore iOS builds on Xcode 26.2 with a vendored swift-rs
Swift 6.2's driver no longer honors swift-rs 1.0.7's cross-compilation
style (swift build --arch <host> with per-swiftc -target overrides and
an inherited SDKROOT): plugin sources compile against the wrong
platform's Swift overlays and fail with baffling errors like type
'Bundle' has no member 'main' and extra argument 'privacy' in call.
Upstream swift-rs is unmaintained, so vendor it under packages/swift-rs
via a crates-io patch and build with SPM's first-class --triple/--sdk
flags instead, dropping the leaked SDKROOT so the host-targeted
manifest compile stays clean. Artifacts land in the unversioned-triple
directory now, so the link search path follows.
With --triple, SPM enforces the deployment floor declared in
Package.swift (the old override bypassed it): bump native-bridge to
iOS 15.0, matching the app's deployment target, since StoreKit's
Storefront is used unguarded.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(native-bridge): capture webview region on Android via PixelCopy (#555)
Implements the Android side of capture_webview_region so the mesh page
curl works there too. The Kotlin command scales the CSS-pixel rect by
the display density, offsets it by the webview's window position, and
reads the pixels back from the window surface with PixelCopy (API 26+,
the app's minSdk), which includes the hardware-accelerated WebView that
View.draw would miss. PNG encoding runs off the main thread and the
result crosses the JSON plugin boundary base64-encoded, decoded back to
bytes in mobile.rs like iOS.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(reader): right the upside-down page curl on iOS (#555)
The renderer oriented its texture with UNPACK_FLIP_Y_WEBGL, which WebKit
ignores for ImageBitmap uploads: on iOS the captured page rendered
upside down, and the mirrored page back read as rotated 180 degrees
instead of the ink-through-paper horizontal mirror Apple Books shows.
Upload unflipped and sample page coordinates directly so no pixel-store
flag is involved.
The page texture in the browser test was only horizontally asymmetric,
which is how the flip slipped through; it now uses four quadrants fed
through the production PNG-blob-to-ImageBitmap path and pins the
vertical orientation. Verified red/green by running the suite on
Playwright WebKit, which reproduces the iOS behavior.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(reader): curl the whole page including header, footer, and margins (#555)
The mesh curl captured only the margin-inset content box, leaving the
running header, footer, and page margins static while just the text
column turned. A physical page turn takes the whole sheet with it, as
Apple Books does, so the capture and overlay now span the full reader
cell. The overlay mounts above the in-cell header (z-10) and footer, so
the static copies never show through the turning page.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(reader): gate layered View Transition turns and slide from a capture instead (#555)
iOS 18 WebKit ships document.startViewTransition but crashes the WebContent
process when a page-turn transition snapshots the reader, so the mere
presence of the API is not enough for the layered slide/curl turns. Require
nested view-transition groups (Chrome/WebView 140+) as the marker of a
mature engine before setting turn-style on the renderer.
Engines that fail the check no longer lose the slide on Tauri: the mesh
curl's capture pipeline generalizes to CapturedPageTurn and now also drives
a flat slide overlay (capture the outgoing page, turn instantly underneath,
translate the captured page out toward the spine, mirrored for backward
turns), clipped to the content box with an edge shadow like the VT slide.
On the web, engines without full support fall back to push and the
Slide/Page Curl options are hidden from the Animation Style select; a
synced slide/curl setting from another device reads as Push there.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(reader): make the Android page curl start instantly (#555)
The Android capture encoded a full-density PNG: 1080x2400 on a 3x
Xiaomi 13 took ~1.5s per turn, so the page sat frozen long enough to
read as the curl not working at all. Encode JPEG instead (the page is
opaque) and cap the destination bitmap at 2x CSS pixels - PixelCopy
scales into a smaller bitmap for free and the moving page stays sharp.
Measured on device over CDP: the capture invoke drops from 1550ms to
34ms and the curl overlay mounts 132ms after the tap.
The JS side stops hardcoding an image/png blob type and lets the
decoder sniff the platform's actual format.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* perf(reader): encode iOS page-curl captures as capped JPEG (#555)
Apply the Android speedup to iOS: encode the snapshot as JPEG (the
page is opaque) off the main thread, and cap it at 2x CSS pixels via
WKSnapshotConfiguration.snapshotWidth on 3x screens, cutting both the
encode time and the base64 payload crossing the JSON plugin boundary.
The JS side already sniffs the image format from the bytes.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
The iOS reading widget downsampled covers to a fractional target size.
UIGraphicsImageRenderer allocates a whole-pixel buffer while draw(in:)
fills only the exact fractional rect, so for portrait covers whose scaled
width rounds up the rightmost pixel column was left partially covered and
semi-transparent. Encoded to JPEG that column flattened into a visible
bright hairline along the right edge (intermittent, portrait covers only).
Round both target dimensions to whole pixels so the draw rect matches the
pixel buffer and every edge pixel is fully covered. Android is unaffected
because it scales to a fixed 240x360 and center-crops.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Copyparty and other file servers expose each folder as an OPDS feed where
subfolders are rel="subsection" navigation entries. Auto-download only
followed the catalog's "by newest" feed or the subscribed feed itself, so
books in subfolders were never discovered, and a folder containing only
subfolders was skipped entirely.
When a catalog has no "by newest" feed, treat it as a directory-style
listing and crawl its subsection navigation entries breadth-first, bounded
by MAX_CRAWL_DEPTH levels, MAX_FEEDS_PER_CRAWL fetches, and the visited
set. Library catalogs with a "by newest" feed keep the previous behavior
and are never crawled. Facet and structural rels (self, up, start, top,
search) are excluded so the crawl cannot escape the subscribed folder.
Fixes#4272
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Clear Completed, Clear Failed and Clear All mutated the Zustand store
directly, so the cleared items were never written back to localStorage.
On the next load the persisted queue restored them and they reappeared
in the Transfer Queue panel.
Route these clears through transferManager (like clearPending already
does) so each calls persistQueue() after mutating the store.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(library): request storage permission when saving to a custom folder
On Android a custom library folder on shared storage needs All Files Access.
The import/settings/migrate paths request it, but the library-save path
(updateBook/updateBooks -> saveLibraryBooks) did not, so on a device without
the permission every save failed with EACCES; because callers (sync, imports)
don't await/catch it, it surfaced as an unhandled-rejection crash (Sentry
READEST-A). saveLibraryBooks now catches a storage-permission error, requests
the permission through the existing flow, and retries once. It prompts at most
once per session so background saves don't repeatedly open system settings; a
still-denied save is logged rather than crashing (the user was already shown
the All Files Access screen).
Fixes READEST-A
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(sentry): drop only the benign hidden-tab View Transition error
The View Transition API skips a transition when the tab is hidden; that
unhandled rejection is expected browser behavior and pure noise, so it is
dropped in before_send. A transition timeout abort (READEST-9) is NOT dropped:
a slow DOM update can signal a real performance problem, so it stays visible.
Fixes READEST-7
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>