Advances the tauri-plugin-turso submodule to include
readest/tauri-plugin-turso#2, which serializes execute/select/batch on a
single turso connection behind an async mutex. Fixes the "concurrent use
forbidden" crash seen in production (unhandled promise rejection in the
reader on Android): turso rejects overlapping operations on one connection,
and the plugin previously drove a shared connection from concurrent Tauri
commands with no serialization.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(calibre): add Readest calibre plugin to push books and metadata (#4863)
Add apps/readest-calibre-plugin, a calibre GUI plugin that uploads
selected books with their metadata into the user's Readest cloud
library, modeled on BookFusion's open-source plugin.
- Selective manual push from the calibre toolbar with per-book status
(uploaded / updated / up to date / failed) and quota handling
- Books are content-addressed with the same partial MD5 as the apps,
so re-pushing updates the existing entry instead of duplicating;
metadata edits re-push without re-uploading the file
- Metadata mapping includes series, tags, identifiers and optional
calibre custom columns; carries over server-side fields (progress,
reading status, grouping, cover) that POST /sync would null out
- Auth mirrors readest.koplugin and the desktop app: email/password
plus browser OAuth (Google/Apple/GitHub/Discord) through a localhost
callback server with the fragment-to-query relay
- Pure-logic modules (api.py, wire.py, oauth.py) are calibre-free and
covered by 56 unit tests (make test); make zip builds the plugin
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* ci(release): package readest-calibre-plugin in releases
Mirror the KOReader plugin packaging: a build-calibre-plugin job stamps
PLUGIN_VERSION in __init__.py with the release version from
apps/readest-app/package.json, builds the zip via make, and uploads
Readest-<version>.calibre-plugin.zip to the GitHub release. The version
committed in git stays a development placeholder.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* chore(agent): add calibre plugin project memory
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(calibre): embed metadata in OPF and dedupe by calibre uuid
Rework book identity so metadata can be embedded into the uploaded file
without creating duplicates, as requested in review:
- Embed calibre metadata (including custom columns) into a temporary
copy of the book file at upload time via calibre's set_metadata; the
library file is never modified
- Dedupe by the calibre book uuid carried in the entry's metadata
identifier, which survives file-byte changes, with a live-row
preference when both hash and uuid match rows
- Detect file content changes via calibreSourceHash, the raw library
file fingerprint stored in the pushed metadata, so detection works
from any machine; v1 rows fall back to book_hash which equals the
raw hash for them
- A changed file now replaces the old entry in one sync push (new row
with carried-over reading status, grouping, progress and created
date, plus a tombstone for the old row) and deletes the old cloud
files to reclaim quota
- Metadata-only edits still update the library entry without
re-uploading the file
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* chore(calibre): set copyright holder to Bilingify LLC
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix: update flake lock and properly pass zlib to pkgconfig
* chore: install nixfmt formatter
* chore: add startup script
* fix: ignore files in the nix store
* docs: change required node version to v24
* chore: upgrade to node v24
* fix: specify XDG_DATA_DIRS to fix webkitgtk issues
* fix: add required config to get android emulator working
- Add a `postInit` script for the android shell to auto-configure an Android emulator
- Required compile-time dependencies
- Required compilation targets for app to successfully build and run.
* fix: silence warning by using stdenv.hostPlatform.system instead of system
* chore: remove android-studio
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>
Report crashes and unhandled errors from every layer to one Sentry project via a
single build-time SENTRY_DSN (empty => disabled, so local and fork builds do not
report):
- JS/WebView + Rust panics via tauri-plugin-sentry (rustls transport; minidump
handler desktop-only).
- Android native (JVM/NDK/ANR) via sentry-android 8.47.0 with manifest auto-init
(excludes the discontinued lifecycle-common-java8 transitive).
- iOS native via sentry-cocoa started from a tracked SentrySupport/+load bootstrap
that reads the DSN from a Rust readest_sentry_dsn() FFI (no generated-file edits).
- SENTRY_DSN resolved at build time from the environment, then .env.local, then
.env (build.rs bakes it via cargo:rustc-env; build.gradle.kts for Android). CI
passes the SENTRY_DSN secret through the existing .env.local step.
Crashes + errors only: traces sample rate 0, no session replay, no PII.
Symbolication (source maps / ProGuard / dSYM upload) is a follow-up.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
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>
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>
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>
* 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>
Since 2026-06-29 the nightly Linux legs hang forever while bundling the
AppImage and hit the job-level timeout, which reports the legs as
'cancelled'. The assemble-manifest guard treats 'cancelled' as run
cancellation and skips promoting nightly/latest.json, so nightly update
detection has been broken for ALL platforms since then (#4906).
Root cause: the truly-portable AppImage bundler in the tauri fork
downloads quick-sharun.sh from the unpinned main branch of
pkgforge-dev/Anylinux-AppImages. Upstream strace-mode changes on
2026-06-29 (acb1d719, 867c0b15) made the script execute the staged app
launcher scripts and WebKitGTK binaries under Xvfb to trace dlopened
libraries; the spawned WebKit processes survive the process-group kill
and quick-sharun waits forever.
Fixes:
- Pre-seed the tauri tools cache with quick-sharun.sh pinned to the
last known-good revision (b3a9e985, used by the green 06-27/06-28
nightlies) in both nightly.yml and release.yml. The bundler only
downloads the moving main-branch script when the file is absent.
- Add step-level timeout-minutes to the nightly build steps and raise
the job timeout to a 75-minute backstop, so a future hang fails only
that leg ('failure') instead of tripping the job timeout
('cancelled'), and assemble-manifest still promotes the manifest
fragments from the healthy legs.
Closes#4906
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
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>
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>
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>
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>
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>
Add actions/attest-build-provenance to both build workflows so every
binary is attested in the same job that builds it, the only point
where provenance meaningfully proves an artifact was built from source
rather than uploaded by hand.
release.yml (build-tauri): grant id-token and attestations write
permissions, then attest the desktop bundles via the tauri-action
artifactPaths output, the Android apks, and the Windows portable exe.
nightly.yml (build): same permissions plus one step attesting the
staged nightly-out binaries. Nightlies ship via download.readest.com,
but gh attestation verify is digest based so it verifies them too.
Verify a download with:
gh attestation verify <file> --repo readest/readest
Closes#4848
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Bitmap.createBitmap returns the same immutable instance when the
center-crop covers the whole source, which happens for covers that
decode to exactly 2:3. writeThumbnail then recycled that instance
before createScaledBitmap used it, crashing with "cannot use a
recycled source in createBitmap". Guard the recycle the same way the
scaled vs cropped case is already guarded, and add an instrumented
regression test.
Also bundles a pending widget debugging note and a regenerated
fastlane README that were staged in the working tree.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(sync): never toast third-party cloud-sync errors; log to console only
The reader's per-book auto-sync surfaced an "Cloud sync authentication failed.
Reconnect in Settings." toast on any AUTH_FAILED (e.g. an expired web Google
Drive token), interrupting reading. Background sync failures shouldn't pop a
toast — drop it and console.warn every sync error instead (the AUTH_FAILED
branch only chose toast-vs-console, so it collapses to a plain log). Removes the
now-unused authFailedToast + useTranslation/FileSyncError imports.
Manual "Sync now" (FileSyncForm) still reports its result — it's a deliberate,
foreground action. Native cloud sync (useBooksSync) is unaffected.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(sync): surface an expired cloud-sync session in the reader + Settings
With sync-error toasts silenced, an expired third-party session (e.g. the
short-lived web Google Drive token) had no UI indicator. Surface it without the
old per-failure error toast:
- Reader: a single top-right `hint` ("Google Drive session expired. Reconnect in
Settings.") — the same affordance as the native "Reading Progress Synced"
hint. De-duplicated via a per-instance ref so it shows once, not on every
page-turn sync; reset on a successful sync / provider switch (web reconnect
reloads anyway).
- Settings → Google Drive: Disconnect swaps to Reconnect when the session is
expired, and "Sync now" is disabled (FileSyncForm gains a `syncNowDisabled`
prop) so a sync that would just fail isn't offered. No hint text in Settings.
- webTokenStore.hasValidWebDriveToken() backs the web detection (the token lives
in sessionStorage; native auto-refreshes so it doesn't apply there).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Double-click (mouse) or touch double-tap on a word now selects that word,
like a long-press selection, then runs the configured instant quick action
or raises the annotation toolbar when none is set.
The iframe posted iframe-double-click but nothing consumed it, so a touch
double-tap did nothing (Android has no native double-tap word-select; on
desktop the browser already selects the word natively via the pointerup
path).
- sel.ts: getWordRangeAt expands a caret to its word-like segment via
Intl.Segmenter (CJK and Latin); getWordRangeFromPoint resolves the caret
at a point and delegates.
- useTextSelector: handleDoubleClick selects the word and routes through the
existing makeSelection flow (guarded so the programmatic selectionchange
echo is ignored). It no-ops when a native selection already exists, so the
desktop double-click path is not double-fired.
- Annotator: consume iframe-double-click, resolve the visible section
doc/index, and set pointerDownTimeRef to 0 so the deliberate double-tap
bypasses the touch long-press hold gate before the instant action fires.
Tests: unit coverage for the word-range helpers and the selection routing
(plus the desktop guard), and an Android CDP e2e for the double-tap gesture
on a real device.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Inline translation wrappers set lang but never dir, so RTL target
languages (Arabic, Hebrew, Persian, etc.) inherited the source
document's LTR base direction. Justified text then pushed its last
line to the LTR start (left) instead of the RTL start (right).
Derive the wrapper's dir from the target language via
getDirFromLanguage so justified RTL translations align to the start.
Extract the node construction into createTranslationTargetNode to make
the behavior unit-testable.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Brings the Google Drive provider to the web build. Native uses PKCE + a
reverse-DNS redirect + keychain refresh token; none of that works in a browser,
and the GIS popup token model is broken by the app's COOP `same-origin` header
(needed for Turso's SharedArrayBuffer) which severs the popup's opener handle and
fires `popup_closed` instantly. So web uses a full-page redirect, which doesn't
rely on `window.opener` and works under COOP.
- auth/webRedirectFlow.ts: builds the implicit (response_type=token) auth URL,
begins the redirect (CSRF state + return path in sessionStorage), and parses
the token from the callback fragment. Implicit flow because a secretless Web
client can't do a code exchange.
- auth/webTokenStore.ts: sessionStorage-backed access-token store (no refresh
token in this model; the token is short-lived).
- WebDriveAuth: browser DriveAuth — reads the stored token, fails AUTH_FAILED
once expired (prompts a reconnect; no background refresh), accountLabel via
about.get.
- app/gdrive-callback: OAuth return route — validates state, stores the token,
marks Drive the active cloud provider (+ account label), routes back.
- buildGoogleDriveProvider: web branch builds the provider on WebDriveAuth +
globalThis.fetch (Drive REST is CORS-enabled; streaming stays Tauri-only so web
buffers). Official Web client id baked (NEXT_PUBLIC_GOOGLE_WEB_CLIENT_ID
overrides). googleDriveConnect web Connect = redirect; Disconnect clears the
token. Drive row shown on web.
No background token refresh: a secretless browser client gets no refresh token
and Google blocks hidden-iframe silent renewal, so the user reconnects per
session (a server-side token broker would be needed for auto-refresh; out of
scope). Tests cover the redirect helpers, token store, and WebDriveAuth.
Ops: add `https://web.readest.com/gdrive-callback` + `http://localhost:3000/gdrive-callback`
to the Web client's Authorized redirect URIs.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a resizable home-screen widget on iOS and Android showing recent
in-progress books with cover, reading progress, and tap-to-open.
- One responsive widget: Android resizable 1x1 to 4x3 (one book per
column, up to 3); iOS Small/Medium/Large families. Covers are cropped,
rounded, with a percent badge and a progress bar (baked into the bitmap
on Android, SwiftUI overlays on iOS).
- TTS controls (previous, play-pause, next) appear in 2+ row sizes when
TTS is active, wired to the existing media session. Reading progress
stays live during background TTS via a fraction computed from the baked
offline locations.
- Publishes a snapshot plus downsized cover thumbnails to the iOS App
Group and Android SharedPreferences through a new update_reading_widget
native-bridge command; refresh is debounced and driven by library and
progress changes, TTS, and app backgrounding.
- Tapping a cover opens readest://book/{hash}, switching the reader in
place when one is already open.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Customize Toolbar sub-page shows a content-width preview of the live
selection popup, copying its bg-gray-600 text-white styling. Unlike the real
reader popup (which gets its e-ink chrome from .popup-container in globals.css),
the preview Zone is a plain div with no e-ink override, so under
[data-eink='true'] the dark fill survived and the row painted as an unreadable
solid black bar.
Scope the dark fill to non-e-ink (not-eink:bg-gray-600 not-eink:text-white) and
let eink-bordered render the preview in e-ink as the popup's e-ink chrome: a
base-100 surface with a 1px base-content border. The chip icons already invert
to base-content via the global [data-eink] button rule. Also fall the empty-state
hint back to base-content in e-ink so it stays legible once the surface turns
base-100.
Verified via computed styles under [data-eink]: background oklch(1 0 0) (white),
1px oklch(0.2 0 0) border, dark icons — matching the reader's annotation toolbar.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Parity with native useBooksSync: keep library.json current on import, delete,
and book-close, not just on a manual "Sync now".
- useLibraryFileSync: new library-scoped hook (counterpart of useBooksSync),
mounted once on the library page. Builds the active provider's engine async
and runs engine.syncLibrary on every library change (import adds a row,
delete sets deletedAt, closing a book bumps updatedAt), debounced 5s and
gated on the global file-sync mutex + Sync Strategy + Upload Book Files. The
reader's per-book useFileSync is unchanged (it's the per-book progress sync).
- Pass the FULL library (incl. soft-deleted books) to engine.syncLibrary, in
both the new hook and the manual FileSyncForm "Sync now": the engine
tombstones deleted books in library.json so deletions propagate, and keeping
them in the input set stops the discovery pass from re-downloading a book the
user just deleted (its remote hash dir lingers until the GC sweep).
- Tests: engine tombstones a soft-deleted book in the pushed index and does not
re-download one whose remote dir still exists.
Gated only by the active provider's enabled flag + strategy (cloud sync is
currently ungated from premium). Never runs before the library loads from disk,
so it can't push an empty index over the remote.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Fira Code is the bundled monospace fallback used when the chosen mono
font is missing (e.g. Consolas on Android). Its default-on contextual
alternates ligate code operators such as "<=" and "=>" into single
glyphs, which misrepresents code in books like VHDL or math texts. Set
font-variant-ligatures: none on pre, code, kbd so operators render
literally. The underlying text is unchanged, so selection and copy
already produced the correct characters.
Fixes#4830
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add an opt-in "Recently read" carousel at the top of the library that
shows the most recently read books for quick resume. The strip reuses
the BookItem component and mirrors the bookshelf grid column widths, so
covers render and align identically at any column count. It scrolls
horizontally with arrow buttons, opens a book through a shared
availability-aware path (downloads cloud-only synced books first), and
is toggled from the View menu (off by default).
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(settings): clamp option-row description to a single line
SettingsRow descriptions wrapped to multiple lines on narrow (mobile)
widths, giving boxed-list rows uneven heights (e.g. "Uploads book files
to your other devices." in the Cloud Sync panel). Clamp the description
to one line with ellipsis in the shared primitive so every option row
stays uniform; the description is a hint, not a paragraph (longer copy
belongs in a Tips block). Codified in DESIGN.md.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* i18n(settings): shorten sync strategy labels to "Send only" / "Receive only"
Rename the Sync Strategy options (shared by the Cloud Sync and KOReader Sync
forms). Keys renamed in every locale, preserving existing translations.
* feat(sync): temporarily ungate third-party cloud sync from premium
Cloud sync (WebDAV / Google Drive) ships available to every plan, incl. free,
while the feature stabilises. Gated behind a single CLOUD_SYNC_REQUIRES_PREMIUM
flag (off) via isCloudSyncAllowed; the paywall code (CLOUD_SYNC_PLANS /
isCloudSyncInPlan) is intact, so re-gating in an upcoming release is a one-line
flip. Applies to the Settings provider rows and the reader auto-sync gate.
* fix(settings): polish cloud-sync connect buttons
Use btn-contrast for the WebDAV and Google Drive Connect CTAs (theme-neutral,
e-ink correct); rename "Connect Google Drive" to "Connect"; move the Google
Drive sign-in tips below the Connect button.
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Google Drive library sync failed on Android: after the first few requests
every files.list threw `error sending request for url (...)` and the sync
stuck at "Syncing 0 / N". The provider's backoff only retried 429/5xx
responses; a thrown fetch propagated immediately. On mobile a long
multi-request sync hits transient transport failures (a pooled keep-alive
connection to googleapis.com going bad), so without a retry every request
after the first batch failed.
- withBackoff now retries a thrown fetch with the same bounded exponential
backoff as 429/5xx, letting reqwest re-establish a fresh connection.
- mapDriveError classifies a thrown transport error (TypeError, or the
Tauri HTTP plugin's plain "error sending request" Error) as NETWORK
instead of UNKNOWN, so the engine's head-probe short-circuit treats it
as transient.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(sync): Google Drive sign-in on Android (Custom Tab OAuth)
Add the Android OAuth runner so Drive can be connected on Android, reusing the
same provider / token store / connect flow as desktop.
- oauthAndroid.ts: runAndroidOAuth wires the DI OAuth flow to a Chrome Custom
Tab via the existing authWithCustomTab native bridge (keeps the Tauri Activity
foregrounded so the in-flight redirect survives). Headless-unit-tested.
- googleDriveConnect: dispatch the platform runner by OS (Android -> Custom Tab,
desktop -> system browser deep link).
- IntegrationsPanel: show the Google Drive provider row on Android too.
- Native (device-verification pending — no Android toolchain in CI):
NativeBridgePlugin.kt handleIntent now also resolves the reverse-DNS
com.googleusercontent.apps.<id>:/oauthredirect redirect through the same
pending invoke as the Supabase callback; a matching BROWSABLE intent-filter
added to AndroidManifest.xml (mirrors the tauri.conf.json deep-link scheme).
Full suite 6475 green; lint + format clean. The native sign-in needs on-device
Android verification before this ships.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(sync): Google Drive sign-in on iOS (ASWebAuthenticationSession OAuth)
Add the iOS OAuth runner so the Drive provider connects on iPhone/iPad,
mirroring the Android Custom Tab flow.
- oauthIos.ts: runIosOAuth drives the shared PKCE flow through
authWithSafari, keyed to the client-id-derived reverse-DNS callback
scheme so the web-auth session intercepts the redirect.
- nativeAuth.ts: AuthRequest gains an optional callbackScheme; the
Supabase login keeps the native "readest" default.
- googleDriveConnect.ts: resolveOAuthRunner dispatches ios to runIosOAuth.
- IntegrationsPanel.tsx: show the Google Drive cloud-sync row on iOS.
Native (device-verify pending, no iOS toolchain in CI):
- auth_with_safari honors args.callbackScheme (default "readest").
- Info-ios.plist registers the reverse-DNS scheme in CFBundleURLTypes,
mirroring the AndroidManifest gdrive-oauth filter.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add uploadStream + downloadStream to the Google Drive provider so book
files sync straight from/to disk instead of buffering the whole file in
the JS heap. Marshaling a large book across the WebView<->Rust bridge as
a single Uint8Array crashes the renderer on mobile, so book sync over
Drive was effectively desktop-only; this unlocks it on Android/iOS and
keeps the heap flat for gigabyte-scale PDFs on desktop too.
- driveRest.ts: resumableCreateUrl / resumableUpdateUrl builders.
- GoogleDriveProvider: uploadStream opens a Drive resumable session
(POST new / PATCH existing; metadata in the initiation, so no reparent
follow-up), then PUTs the bytes to the one-time session URI via the
native upload plugin (tauriUpload). downloadStream GETs alt=media to
disk via tauriDownload with a bearer token. Attached on Tauri only;
web keeps the buffered fallback. Both swallow to false per the provider
contract (engine retries once).
Reuses @tauri-apps/plugin-upload already shipped for WebDAV streaming;
no new native code.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>