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>
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>
The stats pull keyed the statistics book table by md5 alone, while
KOReader's native statistics plugin keys rows by exact (title, authors,
md5). When the two parsers extract slightly different metadata for the
same file, the first native open creates a second, zeroed book row that
the KOReader UI reads, and the reading time synced from Readest stays
stranded on the sync-created row.
applyRemote now inserts a book row only for an md5 the DB has never
seen, attaches pulled events to the row the native plugin reads (native
rows always set pages and last_open; sync-created rows leave pages
NULL), and folds never-adopted duplicate rows into the surviving row on
every pull, so existing databases heal and the stranded time reappears.
Adopted rows and the live session's cached book id are never deleted,
and the totals recompute no longer regresses last_open below a real
native open timestamp.
Fixes#4861
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
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>
* 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>
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>
* 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>
The App Store export re-sign (xcodebuild -exportArchive, automatic signing)
stripped com.apple.security.application-groups from the ReadestWidget and
ShareExtension binaries because both targets set
CODE_SIGN_ALLOW_ENTITLEMENTS_MODIFICATION: YES. The provisioning profiles and
source entitlements both grant the group, but the signed extension binaries did
not, so the widget read an empty snapshot from the shared App Group container
and showed only the placeholder book icon. Dev builds were unaffected.
Remove the flag from both extension targets (the main app already ships the
group correctly without it) so signing uses the exact CODE_SIGN_ENTITLEMENTS
content. Add scripts/verify-ios-appstore-entitlements.sh and run it from
release-ios-appstore.sh before upload so a stripped App Group fails the release
instead of shipping a dead widget.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
On macOS 26 (Tahoe), Apple regressed NSWindow ordering so that
orderOut: (what Tauri's hide() maps to) no longer removes the window
from the screen. The close-to-hide handler left a focused black
phantom window instead of hiding it, and the only recovery was to
quit and relaunch the app.
This is an OS-level regression, not a Readest bug: the same failure
hits native, non-webview apps such as kitty (kovidgoyal/kitty#8952),
and tao 0.34.8 calls a bare orderOut with no Tahoe workaround.
Fix: on macOS 26 or later, minimize() the main window instead of
hide(). Minimize is a different AppKit path that dodges the buggy
orderOut, keeps the app in the dock, and preserves the open book. The
existing Reopen handler already unminimizes on dock reopen, so no
extra restore logic is needed. Older macOS keeps the previous hide()
behavior. Version detection reads NSProcessInfo.operatingSystemVersion.
Closes#4875
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
* 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>
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>
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>
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>
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>
Bitmap.createBitmap returns the same immutable instance when the
center-crop covers the whole source, which happens for covers that
decode to exactly 2:3. writeThumbnail then recycled that instance
before createScaledBitmap used it, crashing with "cannot use a
recycled source in createBitmap". Guard the recycle the same way the
scaled vs cropped case is already guarded, and add an instrumented
regression test.
Also bundles a pending widget debugging note and a regenerated
fastlane README that were staged in the working tree.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(sync): never toast third-party cloud-sync errors; log to console only
The reader's per-book auto-sync surfaced an "Cloud sync authentication failed.
Reconnect in Settings." toast on any AUTH_FAILED (e.g. an expired web Google
Drive token), interrupting reading. Background sync failures shouldn't pop a
toast — drop it and console.warn every sync error instead (the AUTH_FAILED
branch only chose toast-vs-console, so it collapses to a plain log). Removes the
now-unused authFailedToast + useTranslation/FileSyncError imports.
Manual "Sync now" (FileSyncForm) still reports its result — it's a deliberate,
foreground action. Native cloud sync (useBooksSync) is unaffected.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(sync): surface an expired cloud-sync session in the reader + Settings
With sync-error toasts silenced, an expired third-party session (e.g. the
short-lived web Google Drive token) had no UI indicator. Surface it without the
old per-failure error toast:
- Reader: a single top-right `hint` ("Google Drive session expired. Reconnect in
Settings.") — the same affordance as the native "Reading Progress Synced"
hint. De-duplicated via a per-instance ref so it shows once, not on every
page-turn sync; reset on a successful sync / provider switch (web reconnect
reloads anyway).
- Settings → Google Drive: Disconnect swaps to Reconnect when the session is
expired, and "Sync now" is disabled (FileSyncForm gains a `syncNowDisabled`
prop) so a sync that would just fail isn't offered. No hint text in Settings.
- webTokenStore.hasValidWebDriveToken() backs the web detection (the token lives
in sessionStorage; native auto-refreshes so it doesn't apply there).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Double-click (mouse) or touch double-tap on a word now selects that word,
like a long-press selection, then runs the configured instant quick action
or raises the annotation toolbar when none is set.
The iframe posted iframe-double-click but nothing consumed it, so a touch
double-tap did nothing (Android has no native double-tap word-select; on
desktop the browser already selects the word natively via the pointerup
path).
- sel.ts: getWordRangeAt expands a caret to its word-like segment via
Intl.Segmenter (CJK and Latin); getWordRangeFromPoint resolves the caret
at a point and delegates.
- useTextSelector: handleDoubleClick selects the word and routes through the
existing makeSelection flow (guarded so the programmatic selectionchange
echo is ignored). It no-ops when a native selection already exists, so the
desktop double-click path is not double-fired.
- Annotator: consume iframe-double-click, resolve the visible section
doc/index, and set pointerDownTimeRef to 0 so the deliberate double-tap
bypasses the touch long-press hold gate before the instant action fires.
Tests: unit coverage for the word-range helpers and the selection routing
(plus the desktop guard), and an Android CDP e2e for the double-tap gesture
on a real device.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Inline translation wrappers set lang but never dir, so RTL target
languages (Arabic, Hebrew, Persian, etc.) inherited the source
document's LTR base direction. Justified text then pushed its last
line to the LTR start (left) instead of the RTL start (right).
Derive the wrapper's dir from the target language via
getDirFromLanguage so justified RTL translations align to the start.
Extract the node construction into createTranslationTargetNode to make
the behavior unit-testable.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Brings the Google Drive provider to the web build. Native uses PKCE + a
reverse-DNS redirect + keychain refresh token; none of that works in a browser,
and the GIS popup token model is broken by the app's COOP `same-origin` header
(needed for Turso's SharedArrayBuffer) which severs the popup's opener handle and
fires `popup_closed` instantly. So web uses a full-page redirect, which doesn't
rely on `window.opener` and works under COOP.
- auth/webRedirectFlow.ts: builds the implicit (response_type=token) auth URL,
begins the redirect (CSRF state + return path in sessionStorage), and parses
the token from the callback fragment. Implicit flow because a secretless Web
client can't do a code exchange.
- auth/webTokenStore.ts: sessionStorage-backed access-token store (no refresh
token in this model; the token is short-lived).
- WebDriveAuth: browser DriveAuth — reads the stored token, fails AUTH_FAILED
once expired (prompts a reconnect; no background refresh), accountLabel via
about.get.
- app/gdrive-callback: OAuth return route — validates state, stores the token,
marks Drive the active cloud provider (+ account label), routes back.
- buildGoogleDriveProvider: web branch builds the provider on WebDriveAuth +
globalThis.fetch (Drive REST is CORS-enabled; streaming stays Tauri-only so web
buffers). Official Web client id baked (NEXT_PUBLIC_GOOGLE_WEB_CLIENT_ID
overrides). googleDriveConnect web Connect = redirect; Disconnect clears the
token. Drive row shown on web.
No background token refresh: a secretless browser client gets no refresh token
and Google blocks hidden-iframe silent renewal, so the user reconnects per
session (a server-side token broker would be needed for auto-refresh; out of
scope). Tests cover the redirect helpers, token store, and WebDriveAuth.
Ops: add `https://web.readest.com/gdrive-callback` + `http://localhost:3000/gdrive-callback`
to the Web client's Authorized redirect URIs.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a resizable home-screen widget on iOS and Android showing recent
in-progress books with cover, reading progress, and tap-to-open.
- One responsive widget: Android resizable 1x1 to 4x3 (one book per
column, up to 3); iOS Small/Medium/Large families. Covers are cropped,
rounded, with a percent badge and a progress bar (baked into the bitmap
on Android, SwiftUI overlays on iOS).
- TTS controls (previous, play-pause, next) appear in 2+ row sizes when
TTS is active, wired to the existing media session. Reading progress
stays live during background TTS via a fraction computed from the baked
offline locations.
- Publishes a snapshot plus downsized cover thumbnails to the iOS App
Group and Android SharedPreferences through a new update_reading_widget
native-bridge command; refresh is debounced and driven by library and
progress changes, TTS, and app backgrounding.
- Tapping a cover opens readest://book/{hash}, switching the reader in
place when one is already open.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Customize Toolbar sub-page shows a content-width preview of the live
selection popup, copying its bg-gray-600 text-white styling. Unlike the real
reader popup (which gets its e-ink chrome from .popup-container in globals.css),
the preview Zone is a plain div with no e-ink override, so under
[data-eink='true'] the dark fill survived and the row painted as an unreadable
solid black bar.
Scope the dark fill to non-e-ink (not-eink:bg-gray-600 not-eink:text-white) and
let eink-bordered render the preview in e-ink as the popup's e-ink chrome: a
base-100 surface with a 1px base-content border. The chip icons already invert
to base-content via the global [data-eink] button rule. Also fall the empty-state
hint back to base-content in e-ink so it stays legible once the surface turns
base-100.
Verified via computed styles under [data-eink]: background oklch(1 0 0) (white),
1px oklch(0.2 0 0) border, dark icons — matching the reader's annotation toolbar.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Parity with native useBooksSync: keep library.json current on import, delete,
and book-close, not just on a manual "Sync now".
- useLibraryFileSync: new library-scoped hook (counterpart of useBooksSync),
mounted once on the library page. Builds the active provider's engine async
and runs engine.syncLibrary on every library change (import adds a row,
delete sets deletedAt, closing a book bumps updatedAt), debounced 5s and
gated on the global file-sync mutex + Sync Strategy + Upload Book Files. The
reader's per-book useFileSync is unchanged (it's the per-book progress sync).
- Pass the FULL library (incl. soft-deleted books) to engine.syncLibrary, in
both the new hook and the manual FileSyncForm "Sync now": the engine
tombstones deleted books in library.json so deletions propagate, and keeping
them in the input set stops the discovery pass from re-downloading a book the
user just deleted (its remote hash dir lingers until the GC sweep).
- Tests: engine tombstones a soft-deleted book in the pushed index and does not
re-download one whose remote dir still exists.
Gated only by the active provider's enabled flag + strategy (cloud sync is
currently ungated from premium). Never runs before the library loads from disk,
so it can't push an empty index over the remote.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Fira Code is the bundled monospace fallback used when the chosen mono
font is missing (e.g. Consolas on Android). Its default-on contextual
alternates ligate code operators such as "<=" and "=>" into single
glyphs, which misrepresents code in books like VHDL or math texts. Set
font-variant-ligatures: none on pre, code, kbd so operators render
literally. The underlying text is unchanged, so selection and copy
already produced the correct characters.
Fixes#4830
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add an opt-in "Recently read" carousel at the top of the library that
shows the most recently read books for quick resume. The strip reuses
the BookItem component and mirrors the bookshelf grid column widths, so
covers render and align identically at any column count. It scrolls
horizontally with arrow buttons, opens a book through a shared
availability-aware path (downloads cloud-only synced books first), and
is toggled from the View menu (off by default).
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(settings): clamp option-row description to a single line
SettingsRow descriptions wrapped to multiple lines on narrow (mobile)
widths, giving boxed-list rows uneven heights (e.g. "Uploads book files
to your other devices." in the Cloud Sync panel). Clamp the description
to one line with ellipsis in the shared primitive so every option row
stays uniform; the description is a hint, not a paragraph (longer copy
belongs in a Tips block). Codified in DESIGN.md.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* i18n(settings): shorten sync strategy labels to "Send only" / "Receive only"
Rename the Sync Strategy options (shared by the Cloud Sync and KOReader Sync
forms). Keys renamed in every locale, preserving existing translations.
* feat(sync): temporarily ungate third-party cloud sync from premium
Cloud sync (WebDAV / Google Drive) ships available to every plan, incl. free,
while the feature stabilises. Gated behind a single CLOUD_SYNC_REQUIRES_PREMIUM
flag (off) via isCloudSyncAllowed; the paywall code (CLOUD_SYNC_PLANS /
isCloudSyncInPlan) is intact, so re-gating in an upcoming release is a one-line
flip. Applies to the Settings provider rows and the reader auto-sync gate.
* fix(settings): polish cloud-sync connect buttons
Use btn-contrast for the WebDAV and Google Drive Connect CTAs (theme-neutral,
e-ink correct); rename "Connect Google Drive" to "Connect"; move the Google
Drive sign-in tips below the Connect button.
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Google Drive library sync failed on Android: after the first few requests
every files.list threw `error sending request for url (...)` and the sync
stuck at "Syncing 0 / N". The provider's backoff only retried 429/5xx
responses; a thrown fetch propagated immediately. On mobile a long
multi-request sync hits transient transport failures (a pooled keep-alive
connection to googleapis.com going bad), so without a retry every request
after the first batch failed.
- withBackoff now retries a thrown fetch with the same bounded exponential
backoff as 429/5xx, letting reqwest re-establish a fresh connection.
- mapDriveError classifies a thrown transport error (TypeError, or the
Tauri HTTP plugin's plain "error sending request" Error) as NETWORK
instead of UNKNOWN, so the engine's head-probe short-circuit treats it
as transient.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(sync): Google Drive sign-in on Android (Custom Tab OAuth)
Add the Android OAuth runner so Drive can be connected on Android, reusing the
same provider / token store / connect flow as desktop.
- oauthAndroid.ts: runAndroidOAuth wires the DI OAuth flow to a Chrome Custom
Tab via the existing authWithCustomTab native bridge (keeps the Tauri Activity
foregrounded so the in-flight redirect survives). Headless-unit-tested.
- googleDriveConnect: dispatch the platform runner by OS (Android -> Custom Tab,
desktop -> system browser deep link).
- IntegrationsPanel: show the Google Drive provider row on Android too.
- Native (device-verification pending — no Android toolchain in CI):
NativeBridgePlugin.kt handleIntent now also resolves the reverse-DNS
com.googleusercontent.apps.<id>:/oauthredirect redirect through the same
pending invoke as the Supabase callback; a matching BROWSABLE intent-filter
added to AndroidManifest.xml (mirrors the tauri.conf.json deep-link scheme).
Full suite 6475 green; lint + format clean. The native sign-in needs on-device
Android verification before this ships.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(sync): Google Drive sign-in on iOS (ASWebAuthenticationSession OAuth)
Add the iOS OAuth runner so the Drive provider connects on iPhone/iPad,
mirroring the Android Custom Tab flow.
- oauthIos.ts: runIosOAuth drives the shared PKCE flow through
authWithSafari, keyed to the client-id-derived reverse-DNS callback
scheme so the web-auth session intercepts the redirect.
- nativeAuth.ts: AuthRequest gains an optional callbackScheme; the
Supabase login keeps the native "readest" default.
- googleDriveConnect.ts: resolveOAuthRunner dispatches ios to runIosOAuth.
- IntegrationsPanel.tsx: show the Google Drive cloud-sync row on iOS.
Native (device-verify pending, no iOS toolchain in CI):
- auth_with_safari honors args.callbackScheme (default "readest").
- Info-ios.plist registers the reverse-DNS scheme in CFBundleURLTypes,
mirroring the AndroidManifest gdrive-oauth filter.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add uploadStream + downloadStream to the Google Drive provider so book
files sync straight from/to disk instead of buffering the whole file in
the JS heap. Marshaling a large book across the WebView<->Rust bridge as
a single Uint8Array crashes the renderer on mobile, so book sync over
Drive was effectively desktop-only; this unlocks it on Android/iOS and
keeps the heap flat for gigabyte-scale PDFs on desktop too.
- driveRest.ts: resumableCreateUrl / resumableUpdateUrl builders.
- GoogleDriveProvider: uploadStream opens a Drive resumable session
(POST new / PATCH existing; metadata in the initiation, so no reparent
follow-up), then PUTs the bytes to the one-time session URI via the
native upload plugin (tauriUpload). downloadStream GETs alt=media to
disk via tauriDownload with a bearer token. Attached on Tauri only;
web keeps the buffered fallback. Both swallow to false per the provider
contract (engine retries once).
Reuses @tauri-apps/plugin-upload already shipped for WebDAV streaming;
no new native code.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a bindable "Refresh Page" action to Settings > Behavior > Page Turner
that triggers a deep e-ink full refresh (GC16) to clear screen ghosting,
gated to e-ink mode on Android.
It reuses the existing hardware page-turner key-binding machinery: a new
'refresh' slot in HardwarePageTurnerSettings, shown only when isAndroidApp
and the e-ink view setting is on. Pressing the bound key calls a new native
bridge command instead of paginating.
The native side is device-agnostic: EinkRefreshController probes each vendor
mechanism via reflection and stops at the first that works, covering Onyx
BOOX (Qualcomm View.refreshScreen), Tolino/Nook (NTX postInvalidateDelayed)
and Boyue-style Rockchip (requestEpdMode) without bundling any vendor SDK.
A success:false result is a soft no-op on non-e-ink hardware. iOS gets a stub.
Verified on an Onyx BOOX Leaf5: the Onyx path fires and performs a visible
full GC16 refresh.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Bumps foliate-js to readest/foliate-js#43. In scrolled-mode PDF the page now zooms live under a pinch and commits without a layout shift (the inter-page gap scales with the zoom so the committed layout matches the transform-scaled preview, and the centre page is restored to its pre-commit on-screen rect), a page zoomed wider than the viewport is pannable horizontally, and the page iframes stay interactive when idle so native text selection keeps working. readest already drives the renderer's pinchZoom on a two-finger gesture, so the only reader-side change is the submodule bump plus a unit test for the new scroll pinch transform.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(sync): add Google Drive file-sync provider core
Second FileSyncProvider for the merged provider-agnostic file-sync engine,
behind the provider seam. This is the CI-testable core only: no settings UI
and no platform OAuth runners yet (those land in later phases).
- GoogleDriveProvider over the Drive v3 REST API: id-addressed path
resolution with a per-instance id cache, create-then-name uploads, real
idempotent ensureDir, files.list pagination, Retry-After-aware 429/5xx
backoff, per-path folder-creation locks with deterministic duplicate
collapse, stale-id eviction, and FileSyncError mapping (403 split into
rate-limit vs permission).
- DI OAuth layer: pkce, parseRedirect (redirect-target + CSRF state),
reverseDnsRedirect, tokenStore (iOS client, no secret), oauthFlow.
- PersistedDriveAuth with single-flight token refresh; keychain-backed
token store with no ephemeral fallback for the refresh token; account
label via about.get.
- providerRegistry (backend kind to provider) and buildGoogleDriveProvider
assembly.
- Shared transport-agnostic provider semantic contract, run against both
WebDAV and Drive.
- Keyed secure-KV bridge contract (set/get/clear_secure_item); the native
keychain implementation lands with the desktop OAuth slice that first
exercises it.
Adapted from ratatabananana-bit/Readest-google-drive-mod-patcher (AGPL-3.0)
with the author's explicit permission.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(sync): multi-provider file-sync settings + sync-state foundation
PR2 foundation for a second file-sync backend (Google Drive). The
behaviour-sensitive reader-hook and Sync-now form generalization land in
PR3 alongside OAuth, where Drive actually connects and the multi-provider
paths can be exercised and live-verified (and the extracted form gets its
second consumer, avoiding a single-use abstraction).
- GoogleDriveSettings type (mirrors WebDAVSettings minus URL/credentials/
rootPath, plus accountLabel) wired into SystemSettings, with
DEFAULT_GOOGLE_DRIVE_SETTINGS in the defaults.
- googleDrive.deviceId + googleDrive.lastSyncedAt added to the backup
blacklist so device-local sync identity / cursors never restore onto
another device. Covered by the existing backup-settings test.
- Generalize webdavSyncStore into fileSyncStore: per-backend progress keyed
by provider kind, plus a global library-sync mutex (beginSync returns
false when another backend already holds the lock) since every backend's
syncLibrary mutates the same local library. Migrate WebDAVForm and
IntegrationsPanel to the keyed API; WebDAV behaviour is unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(native-bridge): add keyed secure key-value store commands
A generic, keyed secret store over the same OS keychain backends as the
sync passphrase (set/get/clear_secure_item), so secrets that aren't the
single sync passphrase get the same XSS-free cross-launch persistence
without each needing its own native command. The Google Drive OAuth token
store (PR1's KeychainTokenPersistence) is the first consumer; a future
cloud provider's refresh token reuses it.
- Desktop (macOS/Windows/Linux): keyring-core, keyed by the item key as
the entry account under the existing "Readest Safe Storage" service.
- Android: EncryptedSharedPreferences (a dedicated readest_secure_items_v1
file, the item key as the pref key).
- iOS: Security framework Keychain (kSecClassGenericPassword, dedicated
service, the item key as kSecAttrAccount).
Registered in the plugin invoke handler + build COMMANDS + default
permission set (autogenerated permission files regenerated; the passphrase
entries are preserved). The TS bridge wrappers shipped in PR1.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(sync): desktop Google Drive OAuth runner + connect flow
The desktop half of Drive sign-in: open consent in the system browser, capture
the reverse-DNS redirect the OS routes back, and exchange the code for tokens.
- oauthDesktop.ts: runDesktopDeepLinkOAuth wires the DI OAuth flow to the
desktop mechanics (open default browser, capture via single-instance /
onOpenUrl, cold-browser fallback after a grace period, hard deadline). Fully
headless-unit-tested via injected deps.
- spawn_fresh_browser.rs (+ registration, Windows-only winreg dep): the cold
browser the runner falls back to when the user's already-running browser
snapshotted protocol associations before the scheme was registered (a
Windows-specific failure). Resolves the default browser from the registry and
spawns it cold with an isolated --user-data-dir; a no-op on macOS/Linux where
the default-browser open already routes the redirect. Pure helpers unit-tested.
- connectGoogleDrive.ts: run the platform OAuth runner, persist the token
(fail-loud — Drive is not reported connected if the refresh token does not
save), and resolve the account label via about.get (best-effort).
OAuth runner adapted from ratatabananana-bit/Readest-google-drive-mod-patcher
(AGPL-3.0) with the author's permission. Scheme registration + the ingress
redirect filter + the Drive connect UI land in the following commits; live
desktop verification follows once the official Google client id is provisioned.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(sync): filter Google OAuth redirects out of the deep-link ingress
The reverse-DNS OAuth redirect (com.googleusercontent.apps.<id>:/oauthredirect)
is delivered through the same single-instance / onOpenUrl channels as book-file
deep links. Without a filter the book-import consumer would treat the redirect
URL as a file path to open. Drop it at the ingress source (useAppUrlIngress)
before the app-incoming-url broadcast, so no consumer ever sees it; the Drive
sign-in runner still captures it via its own listeners.
isGoogleOAuthRedirectUrl matches the scheme prefix (not a specific client id),
so it stays correct regardless of which client is baked into the build.
Note: registering the scheme in tauri.conf.json (so the OS routes it back to the
app) needs the official Google client id, which is a provisioning prerequisite.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(sync): bake the official Google Drive OAuth client id + redirect scheme
Provisioned the Readest Google Cloud OAuth client (iOS application type, no
secret, drive.file scope). Bake the client id as the default in
getGoogleClientId (overridable via NEXT_PUBLIC_GOOGLE_CLIENT_ID for forkers,
who must also regenerate the manifest schemes) and register the derived
reverse-DNS redirect scheme com.googleusercontent.apps.<id> in tauri.conf.json
(desktop + mobile deep-link) so the OS routes the OAuth redirect back to the
app. The client id is a public client identifier, not a secret.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(sync): Google Drive connect UI + shared FileSyncForm
Make Drive usable from Settings, and extract the now-two-consumer sync controls.
- FileSyncForm: the provider-agnostic sync controls (sub-toggles, conflict
strategy, manual "Sync now" with progress + result toast), parameterised by
backend kind and building the provider through the registry. Extracted from
WebDAVForm now that a second consumer exists. WebDAVForm keeps its
URL/credentials connect panel + browse pane and renders FileSyncForm for the
sync section; behaviour is unchanged (WebDAV "Sync now" goes through the same
provider via the registry).
- GoogleDriveForm: an OAuth connect panel (Connect -> runGoogleDriveConnect ->
store token in keychain -> "Connected as <email>"; Disconnect) + FileSyncForm.
- googleDriveConnect.ts: assemble the env client id + keychain + desktop runner
into connectGoogleDrive/disconnectGoogleDrive for the UI.
- IntegrationsPanel: a "Google Drive" row + sub-page, shown only on desktop
(mobile OAuth runners land in later phases).
Reader-side auto-sync (generalizing useWebDAVSync) is a follow-up; manual
"Sync now" already exercises the full Drive stack. Full suite 6412 green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(settings): unified Third-party Cloud Sync section (exclusive provider)
Group WebDAV + Google Drive into a new "Third-party Cloud Sync" section and make
them mutually exclusive — only one cloud provider syncs the library at a time.
- New unified "Cloud Sync" sub-page (CloudSyncForm): a provider picker (radio,
the AIPanel mutually-exclusive pattern) on top, the shared FileSyncForm sync
options below for whichever provider is active. Google Drive is offered only on
desktop; on mobile the page is WebDAV only and the picker is hidden.
- withActiveCloudProvider helper: enabling one provider disables the other in one
save. Both panels' connect/activate paths use it. Unit-tested.
- WebDAVForm / GoogleDriveForm refactored into embeddable panels (the unified
page owns the header). Drive gains a "configured but inactive" state so
switching back re-activates it without a fresh sign-in; explicit Disconnect
clears the keychain token.
- IntegrationsPanel: remove the two separate WebDAV / Google Drive rows from
"Reading Sync" (now KOReader Sync / Readwise / Hardcover only); add the
Third-party Cloud Sync section with one Cloud Sync row (status = active
provider). Old webdav/gdrive deep-links route to the unified page.
Also removes the temporary Drive concurrency probe (the upload already runs at
the intended concurrency 4; the probe confirmed it).
Full suite 6416 green; lint + format clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(reader): auto-sync the active cloud provider while reading
Generalize the reader sync hook from useWebDAVSync to useFileSync so the active
third-party cloud provider (WebDAV OR Google Drive) syncs per-book while reading
— pull-on-open, debounced push on progress/booknote changes, cover/file upload —
not just via the manual "Sync now" in settings.
Since the providers are mutually exclusive, the hook drives exactly the one
enabled backend, built through the provider registry. The build is async (the
Google Drive provider probes the OS keychain), so the engine lives in state and
the pull-on-open waits for it; switching providers mid-session resets the
per-book locks. The engine is keyed on connection-relevant settings so a
lastSyncedAt write doesn't re-probe the keychain. deviceId / lastSyncedAt now
write the active provider's settings slice; the auth-failed toast is
provider-neutral; the per-book events are renamed *-file-sync.
WebDAV reader-sync behaviour is unchanged. Full suite 6416 green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(settings): surface cloud providers in the section with inline switch
Show WebDAV + Google Drive as separate rows in the Third-party Cloud Sync
section (instead of one "Cloud Sync" row), so both providers are visible and the
active one can be switched right there.
- CloudProviderRow: a trailing radio makes a provider the single active sync
target inline (enabled only when it's already configured — WebDAV creds / a
Drive token); the row body / chevron opens its config sub-page (connect, sync
options, disconnect). Status reads Active / Configured / Not connected, with a
Syncing… indicator.
- Each provider drills into its own sub-page again (WebDAV / Google Drive),
rendering the embeddable panel under a SubPageHeader; the brief unified
CloudSyncForm picker page is removed (its old deep-link maps to Google Drive).
- Switching stays exclusive via withActiveCloudProvider; an inline switch trusts
the stored credentials/token (no re-validate / re-OAuth).
Full suite 6416 green; lint + format clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(sync): gate third-party cloud sync behind a premium plan
WebDAV + Google Drive sync is now a premium feature: available on any paid plan
(Plus, Pro, or Lifetime), not on free.
- isCloudSyncInPlan(plan) helper (mirrors isEmailInPlan; plus/pro/purchase).
- IntegrationsPanel: free users see the Third-party Cloud Sync section with an
upgrade row ("Available on Plus, Pro, or Lifetime") that opens the plans page
instead of the provider rows; the cloud-sync deep-links are gated too (waiting
for the plan to load before deciding).
- useFileSync: the reader's auto-sync only runs on a paid plan, so a downgraded
user's sync stops even if a provider's enabled flag lingers.
Full suite 6418 green; lint + format clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(sync): escape backslashes in Drive query literals (CodeQL)
escapeDriveLiteral escaped single quotes but not the backslash escape
character, so a file name containing a backslash (or ending in one) could
break out of the single-quoted Drive `files.list` query literal and malform
the query. Escape backslashes first, then single quotes, so the backslashes
added for the quotes are not doubled.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The corner-dwell auto page-turn zone is a quarter-ellipse whose radius is
a fraction (0.15) of the reading area on each axis. On wide screens such
as desktop or multi-column pages, that fraction grows the zone until it
reaches deep into the text, so selecting in a column and resting the
pointer there turns the page unexpectedly.
Cap each axis of the corner radius at 50px so the engagement zone stays a
real corner regardless of page width, while preserving the existing feel
on phones.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
WebDAV connection settings were never part of the bundled settings
replica, so the "Credentials" sync toggle had no effect on them and
users had to re-enter their WebDAV server, username, and password on
every device.
Add webdav.serverUrl / username / password / rootPath to
SETTINGS_WHITELIST and gate username / password behind
SETTINGS_ENCRYPTED_FIELDS, matching how KOSync / Readwise / Hardcover
credentials are handled. Per-device bookkeeping (enabled, deviceId,
lastSyncedAt, sync sub-toggles) stays local, mirroring KOSync which
syncs credentials but not its enabled flag: a fresh device pre-fills
the connect form and the user clicks Connect.
Also add a webdav deep-merge case to mergeSettings. Without it the
top-level shallow merge on pull would replace the whole webdav object
with the four-field patch and wipe the local per-device fields.
Update the credentials category description to mention WebDAV and
migrate the i18n key across all locales.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Open standalone .md files in the reader without converting to EPUB. A new
makeMarkdownBook (src/utils/md.ts) parses Markdown to sanitized HTML with
marked + DOMPurify, splits the document into sections at H1 boundaries, and
builds an in-memory foliate book (modeled on fb2.js) with a nested heading
TOC. DocumentLoader routes .md/.markdown before the TXT path so a Markdown
file served as text/plain is not converted to EPUB. Layout, font and theme
settings apply the same as for any other format.
Relative-image resolution and Markdown bundle/folder packages are left as
follow-ups (a standalone file has no sibling-asset access on the web).
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
PDF pages rendered blank while scrolling in scrolled mode (#4795,
resurfacing #4031). On-device profiling showed each page takes hundreds
of ms to render while the preload margin gave only about half a page of
lead, and loads were unbounded and unprioritized.
Bump the foliate-js submodule to widen the scrolled-mode preload margin
and drive page loading through a bounded, viewport-prioritized scheduler
(readest/foliate-js#40). Adds unit coverage for the new planScrollModePages
scheduler.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Fixesreadest/readest#4785
Swipe page turns dropped frames, worst when crossing .xhtml section
boundaries. The paginator's #replaceBackground rebuilt its whole paint
context every animation frame (a getComputedStyle plus one
getBoundingClientRect per rendered view), and that per-frame cost scales
with the number of loaded views, which peaks at a boundary where adjacent
sections are preloaded.
Bumps the foliate-js submodule to 15fc999 (readest/foliate-js#41) to
snapshot the paint context once per gesture and reuse it on every frame,
and to defer the heavy mid-drag section preload off the active drag. The
bump also advances foliate-js to current main, picking up the
gpu-composite page-turn opt-in (readest/foliate-js#39).
Adds a real-browser test that drives an animated turn and a synthetic drag
and asserts the section <html> computed style is read a small constant
number of times instead of once per frame (snap 39 -> <=3, drag 7 -> <=1).
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A decorative table-of-contents page lays out as nested tables where the inner
table pulls itself up with a negative top margin and the CONTENTS heading uses
line-height:1em. Since #4400 wraps every table in a `.scroll-wrapper`
(overflow:auto), that negative margin bled the heading above the wrapper's clip
box and overflow cut off the top half of its glyphs.
Hoist any negative margins from the wrapped element onto the wrapper and zero
them on the element: the box stays in place, the element sits flush inside it so
overflow cannot clip it, and scrollWidth is no longer inflated by the margin so a
table that actually fits still gets marked fit. Positive and auto margins are
left alone, so an over-wide table still scrolls and a centered table stays
centered.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a Filter section to the annotation export dialog so users who color-code
highlights (e.g. red for important, yellow for difficult words) can export only
selected colors and styles.
The selection is stored as exclusions in NoteExportConfig, so an empty filter
exports everything and any color or style added later is included by default.
A new pure helper filterExportGroups applies the filter to both the default
formatter and the custom-template paths, and only filters a dimension when at
least two distinct values are present so a hidden row never silently drops notes.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Clicking "Annotate" on a selection eagerly creates a highlight (with an
empty note) as the anchor for the note being typed, so the selection stays
visible while the NoteEditor is open. Cancelling the note instead of saving
left that empty highlight behind: it leaked into the config DB, showed as a
stale card in the Booknotes list, and left a phantom yellow highlight.
handleHighlight now returns the created BookNote only when it pushes a new
record (null when it restyles an existing highlight, which predates the flow
and must survive a cancel). handleAnnotate tracks that id via the new
notebookNewHighlightId store field; cleanup is keyed on the id, not the cfi,
so a fresh selection that collides with an existing highlight's cfi can't
wrongly delete it.
removeEmptyAnnotationPlaceholder tombstones the tracked placeholder only when
it still has no note text, and the Notebook tears its overlay down. Cleanup is
presentation-driven: an effect removes the placeholder whenever the creation
editor stops being shown (Cancel, Escape, overlay, close, swipe, navigate),
plus a second effect for book-switch and reader-close. Save survives the guard
and clears the tracked id.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
On desktop the app runs multiple windows (one library plus one per open
book), and each keeps its own in-memory settings loaded once at window
open. Global settings persist to a single shared settings.json, and every
window writes the whole object on save. A window opened before the user
customized a global view setting therefore clobbers that change with its
own stale (often default) value on its next save, most visibly a reader
window reverting Click to Paginate back to the default on close.
Broadcast the global view and read settings after every save and have all
other windows adopt them, preserving each window's device-local fields
(paths, lastOpenBooks, sync cursors, brightness). The receive path only
updates the in-memory store, so there is no save or broadcast loop. No-op
off Tauri.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The list-mode book item used a fixed h-28 height. When a book belongs to
a series, the title, authors, series, description, and progress row
together exceed 112px and overflow, so the series and summary lines
collide and get clipped. Larger system font scaling (such as the Android
accessibility font size setting) inflates line heights and makes the
overlap worse, which is what the reporter saw on a Pixel 10 Pro.
Use min-h-28 instead so the row grows to fit its content. Non-series rows
keep the same 112px height, and the list is virtualized with measured
heights so variable row heights are fine.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a Contrast stepper to the reader View menu for fixed-layout
(PDF/CBZ) documents. It increases and decreases page contrast via a
CSS filter on the rendered page images, applies to the whole book,
and is stored per-book (local to the current document).
The filter is built in applyFixedlayoutStyles by combining any
dark-mode invert with the contrast amount into a single filter
declaration. Persisted with skipGlobal so it never touches global
view settings, and added to FoliateViewer's effect dependencies so
the change re-applies across all rendered pages.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Russian typography requires short function words (prepositions,
conjunctions, particles) to never hang at the end of a line. Add an
`nbsp` content transformer that inserts U+00A0 after such words so they
stick to the following word. The source file is never modified.
The transformer is language-driven via an NBSP_LANGUAGES registry keyed
by language code (only `ru` is configured today), so adding another
language is a single entry. It runs only for matching books and rewrites
text between tags with a regex, leaving tags, attributes, and the XML
declaration intact. Runs after whitespace normalization so the inserted
spaces are not stripped under the override-layout setting.
The space-to-NBSP swap is length-preserving (both are single UTF-16 code
units), so DOM character offsets and CFIs stay valid for every word
before and after the transform; tests enforce this invariant.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>