Commit Graph

1983 Commits

Author SHA1 Message Date
Huang Xin 57868a138e feat(settings): unified Cloud Sync chooser with Readest Cloud as a first-class provider (#4976)
The Integrations section is now one Cloud Sync chooser: Readest Cloud,
WebDAV, and Google Drive as radio rows, Readest Cloud first, with a
scope subtitle stating what the choice governs (library data, on this
device) and what always stays with the Readest account (settings,
statistics, dictionaries).

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

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

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

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

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

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

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

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

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

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

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

Fixes READEST-F
Fixes READEST-G

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

* fix(library): create the book directory idempotently

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

Fixes READEST-H

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

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

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

Fixes READEST-N

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

Fixes READEST-9

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

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

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

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

Fixes #555

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

Fixes #4272

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

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

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

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

Fixes READEST-A

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

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

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

Fixes READEST-7

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

---------

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

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

Fixes #4942

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

Closes #4811
2026-07-05 18:08:36 +02:00
Huang Xin 843ab3448b feat(tts): keep TTS playing when the book is closed (#4941)
* refactor(tts): controller owns its foliate TTS instance and emits lifecycle events

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* docs: record background TTS decoupling design decisions

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 10:50:22 +02:00
Huang Xin 4dbe9cc9f1 fix: Sentry production hardening (release/OS tags, unhandled-rejection & render-loop guards) (#4929) 2026-07-05 08:50:36 +09:00
Huang Xin c86decc2c1 fix(test): make Android double-tap e2e pass on default-config CI devices (#4921) 2026-07-04 19:46:02 +09:00
Huang Xin 1d3dfd395f fix(ios): stop share extension hijacking shared .txt files (#4917)
The iOS share extension is a web-article URL clipper, but its activation
rule enabled NSExtensionActivationSupportsText. A .txt file is
public.plain-text (conforms to public.text), so that key made the
URL-only extension activate for plain-text files it cannot handle: the
share sheet hung instead of the file taking the main app's
CFBundleDocumentTypes "Copy to Readest" import path, which handles txt
fine (like EPUB and PDF, which never matched the extension).

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

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

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

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

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

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

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

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

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

Two causes, both in the reader-mounted path:

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

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

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

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

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

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

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

* feat(library): add useAutoImportFolders trigger hook

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

Fixes #624

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

Closes #4853

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

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

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

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

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

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

* i18n: add proofread edit and toggle strings

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

---------

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

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

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

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

Adds a unit test for the computeSpreadSpineOverlap helper.

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

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

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

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

Fixes #4862

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 04:16:30 +02:00
Huang Xin 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 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