Forwarded browser events carry os/rust/device context but no browser context,
so crashes couldn't be correlated with the WebView version. The app now reports
its User-Agent at startup via a set_webview_info command; the parsed engine
(Chromium/WebKit) and major version are stored and attached as webview.engine
and webview.version tags in before_send, covering both Rust panics and
forwarded browser events.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
useAppRouter wraps every navigation in a View Transition. Opening a book is a
heavy render (the reader mounts and loads the book) that can overrun the
transition's ~4s DOM-update budget and abort with a TimeoutError (Sentry
READEST-9). Navigate to the reader with the plain router instead, matching
every other into-reader path in the app; the transition router stays for
lighter navigation. Applies to the tap-to-open flow (useOpenBook) and the
post-import queued open (library/page).
Fixes READEST-9
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(reader): slide and page curl turn animations (#555)
Add an Animation Style setting (Push, Slide, Page Curl) next to the
Paging Animation switch. Slide moves the turning page over the still
previous or next page like the Apple Books slide; Page Curl folds it
open in 3D so the page underneath is partially visible as it turns.
Both styles track the finger: the page follows a horizontal drag and
commits past halfway or on a flick, or settles back. The page header
and footer stay in place while the page turns.
The styles layer a View Transitions snapshot of the outgoing page over
the live, stationary incoming page, since the pages of one section live
in a single iframe and can never be on screen twice. They work for all
writing modes including vertical-rl, and on engines without the View
Transitions API (older WebViews) the paginator falls back to the
existing push animation, so all platforms keep working page turns.
The paginator changes live in the foliate-js submodule; this bumps the
pointer, wires viewSettings.pageTurnStyle to the renderer turn-style
attribute, and adds browser tests covering slide layering, curl,
vertical-rl, finger tracking with commit and revert, and the push
fallback.
Fixes#555
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(reader): add WebGL page curl renderer for mesh turn animations (#555)
Grid mesh deformed around a cylinder: content past the fold wraps over
and lands mirrored on top with a whitened page back, transparent where
the page has curled away. Corner grabs start as a steep diagonal pinch
that straightens as the turn completes so the whole page clears by the
end. Groundwork for the Tauri mesh curl; capture and orchestration land
separately.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(native-bridge): capture webview region as PNG on macOS and iOS (#555)
New capture_webview_region plugin command returns a binary PNG snapshot
of the calling webview (tauri::ipc::Response, no JSON overhead) for the
mesh page-curl texture. macOS goes through WKWebView
takeSnapshotWithConfiguration via with_webview on the main thread with
a 500ms timeout; iOS snapshots in Swift and hands the PNG across the
JSON-only plugin boundary base64-encoded, decoded back to bytes in
mobile.rs. Windows, Linux, and Android reject for now so the JS side
falls back to the CSS curl.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(reader): drive the mesh page curl on Tauri platforms (#555)
Wire the WebGL curl renderer and the native webview capture into page
turns. A MeshCurlTurn controller runs the pipeline per turn: snapshot
the content box, overlay the captured page drawn flat, turn the live
view instantly underneath (the paginator's animated paths all gate on
the animated attribute), then curl the capture away. Backward turns
mirror the fold to the spine edge, matching the layered VT curl's
old-page-recedes choreography.
useMeshPageCurl wraps the view's prev/next so taps, keys, and wheel
turns all curl, and registers a touch interceptor (between the reading
ruler and the fixed-layout swipe) that scrubs the curl from the finger,
committing past halfway or on a flick and otherwise un-curling and
turning back under the overlay. The paginator stays out of the way via
no-swipe while the mesh is active; if the native capture ever fails the
session falls back to the paginator's CSS arc-fold curl and the shared
applyPageTurnAttributes helper restores turn-style.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(build): restore iOS builds on Xcode 26.2 with a vendored swift-rs
Swift 6.2's driver no longer honors swift-rs 1.0.7's cross-compilation
style (swift build --arch <host> with per-swiftc -target overrides and
an inherited SDKROOT): plugin sources compile against the wrong
platform's Swift overlays and fail with baffling errors like type
'Bundle' has no member 'main' and extra argument 'privacy' in call.
Upstream swift-rs is unmaintained, so vendor it under packages/swift-rs
via a crates-io patch and build with SPM's first-class --triple/--sdk
flags instead, dropping the leaked SDKROOT so the host-targeted
manifest compile stays clean. Artifacts land in the unversioned-triple
directory now, so the link search path follows.
With --triple, SPM enforces the deployment floor declared in
Package.swift (the old override bypassed it): bump native-bridge to
iOS 15.0, matching the app's deployment target, since StoreKit's
Storefront is used unguarded.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(native-bridge): capture webview region on Android via PixelCopy (#555)
Implements the Android side of capture_webview_region so the mesh page
curl works there too. The Kotlin command scales the CSS-pixel rect by
the display density, offsets it by the webview's window position, and
reads the pixels back from the window surface with PixelCopy (API 26+,
the app's minSdk), which includes the hardware-accelerated WebView that
View.draw would miss. PNG encoding runs off the main thread and the
result crosses the JSON plugin boundary base64-encoded, decoded back to
bytes in mobile.rs like iOS.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(reader): right the upside-down page curl on iOS (#555)
The renderer oriented its texture with UNPACK_FLIP_Y_WEBGL, which WebKit
ignores for ImageBitmap uploads: on iOS the captured page rendered
upside down, and the mirrored page back read as rotated 180 degrees
instead of the ink-through-paper horizontal mirror Apple Books shows.
Upload unflipped and sample page coordinates directly so no pixel-store
flag is involved.
The page texture in the browser test was only horizontally asymmetric,
which is how the flip slipped through; it now uses four quadrants fed
through the production PNG-blob-to-ImageBitmap path and pins the
vertical orientation. Verified red/green by running the suite on
Playwright WebKit, which reproduces the iOS behavior.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(reader): curl the whole page including header, footer, and margins (#555)
The mesh curl captured only the margin-inset content box, leaving the
running header, footer, and page margins static while just the text
column turned. A physical page turn takes the whole sheet with it, as
Apple Books does, so the capture and overlay now span the full reader
cell. The overlay mounts above the in-cell header (z-10) and footer, so
the static copies never show through the turning page.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(reader): gate layered View Transition turns and slide from a capture instead (#555)
iOS 18 WebKit ships document.startViewTransition but crashes the WebContent
process when a page-turn transition snapshots the reader, so the mere
presence of the API is not enough for the layered slide/curl turns. Require
nested view-transition groups (Chrome/WebView 140+) as the marker of a
mature engine before setting turn-style on the renderer.
Engines that fail the check no longer lose the slide on Tauri: the mesh
curl's capture pipeline generalizes to CapturedPageTurn and now also drives
a flat slide overlay (capture the outgoing page, turn instantly underneath,
translate the captured page out toward the spine, mirrored for backward
turns), clipped to the content box with an edge shadow like the VT slide.
On the web, engines without full support fall back to push and the
Slide/Page Curl options are hidden from the Animation Style select; a
synced slide/curl setting from another device reads as Push there.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(reader): make the Android page curl start instantly (#555)
The Android capture encoded a full-density PNG: 1080x2400 on a 3x
Xiaomi 13 took ~1.5s per turn, so the page sat frozen long enough to
read as the curl not working at all. Encode JPEG instead (the page is
opaque) and cap the destination bitmap at 2x CSS pixels - PixelCopy
scales into a smaller bitmap for free and the moving page stays sharp.
Measured on device over CDP: the capture invoke drops from 1550ms to
34ms and the curl overlay mounts 132ms after the tap.
The JS side stops hardcoding an image/png blob type and lets the
decoder sniff the platform's actual format.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* perf(reader): encode iOS page-curl captures as capped JPEG (#555)
Apply the Android speedup to iOS: encode the snapshot as JPEG (the
page is opaque) off the main thread, and cap it at 2x CSS pixels via
WKSnapshotConfiguration.snapshotWidth on 3x screens, cutting both the
encode time and the base64 payload crossing the JSON plugin boundary.
The JS side already sniffs the image format from the bytes.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
The iOS reading widget downsampled covers to a fractional target size.
UIGraphicsImageRenderer allocates a whole-pixel buffer while draw(in:)
fills only the exact fractional rect, so for portrait covers whose scaled
width rounds up the rightmost pixel column was left partially covered and
semi-transparent. Encoded to JPEG that column flattened into a visible
bright hairline along the right edge (intermittent, portrait covers only).
Round both target dimensions to whole pixels so the draw rect matches the
pixel buffer and every edge pixel is fully covered. Android is unaffected
because it scales to a fixed 240x360 and center-crops.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Copyparty and other file servers expose each folder as an OPDS feed where
subfolders are rel="subsection" navigation entries. Auto-download only
followed the catalog's "by newest" feed or the subscribed feed itself, so
books in subfolders were never discovered, and a folder containing only
subfolders was skipped entirely.
When a catalog has no "by newest" feed, treat it as a directory-style
listing and crawl its subsection navigation entries breadth-first, bounded
by MAX_CRAWL_DEPTH levels, MAX_FEEDS_PER_CRAWL fetches, and the visited
set. Library catalogs with a "by newest" feed keep the previous behavior
and are never crawled. Facet and structural rels (self, up, start, top,
search) are excluded so the crawl cannot escape the subscribed folder.
Fixes#4272
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Clear Completed, Clear Failed and Clear All mutated the Zustand store
directly, so the cleared items were never written back to localStorage.
On the next load the persisted queue restored them and they reappeared
in the Transfer Queue panel.
Route these clears through transferManager (like clearPending already
does) so each calls persistQueue() after mutating the store.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(library): request storage permission when saving to a custom folder
On Android a custom library folder on shared storage needs All Files Access.
The import/settings/migrate paths request it, but the library-save path
(updateBook/updateBooks -> saveLibraryBooks) did not, so on a device without
the permission every save failed with EACCES; because callers (sync, imports)
don't await/catch it, it surfaced as an unhandled-rejection crash (Sentry
READEST-A). saveLibraryBooks now catches a storage-permission error, requests
the permission through the existing flow, and retries once. It prompts at most
once per session so background saves don't repeatedly open system settings; a
still-denied save is logged rather than crashing (the user was already shown
the All Files Access screen).
Fixes READEST-A
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(sentry): drop only the benign hidden-tab View Transition error
The View Transition API skips a transition when the tab is hidden; that
unhandled rejection is expected browser behavior and pure noise, so it is
dropped in before_send. A transition timeout abort (READEST-9) is NOT dropped:
a slow DOM update can signal a real performance problem, so it stays visible.
Fixes READEST-7
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Group membership synced only for newly-imported books. Re-grouping a
book already present on both devices bumped book.updatedAt and won the
library-index LWW race, but mergeBookMetadata dropped groupId/groupName
from the overlay, so the change never reached peers. New books instead
arrive via addBookToLibrary with the full remote object, which is why
their group did travel.
Carry groupId/groupName in mergeBookMetadata, matching native cloud
sync (transform.ts maps group_id/group_name). Values are assigned raw
so a group removal also propagates.
Fixes#4942
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Parse calibre's embedded user metadata (custom columns) from the OPF
in foliate-js, store it on BookMetadata.calibreColumns, render the
columns in the book details view, and match column names and values
in the library search so a value like a recommends tag can be found
by typing it.
Closes#4811
The KOReader plugin's incremental books pull went permanently stale: after
a while it stopped receiving any updates made from other devices, and only
deleting readest_library.sqlite3 + "Pull books now" recovered it (until it
re-broke). The iOS/web library was unaffected.
Root cause: since #4678 the server keys the books pull on the server-stamped
synced_at column, and the web client (computeMaxTimestamp) advances its
cursor from synced_at. The koplugin was left on updated_at: pullBooks
advanced last_books_pulled_at from max(updated_at, deleted_at). updated_at
is client-supplied, and the koplugin bumps it from the device clock
(touchBook = os.time()*1000). An e-reader clock ahead of the server (or any
row anywhere carrying a future updated_at) drove the cursor past server-now,
so the server's synced_at > since filter returned nothing forever.
The cursor was also shared between the pull side (compared vs server
synced_at) and push-delta detection (getChangedBooks vs local updated_at),
so it could not simply be retargeted.
Fix:
- parseSyncRow reads synced_at; new row_pull_cursor() prefers it and falls
back to max(updated_at, deleted_at) for a pre-synced_at server, mirroring
computeMaxTimestamp.
- Split the cursor: last_books_pulled_at now tracks server synced_at (pull
only); new last_books_pushed_at tracks local updated_at for getChangedBooks
and is advanced on both pull and push, preserving push dedup.
- v2 -> v3 migration seeds the push watermark from the old shared value and
resets the pull cursor to 0, auto-healing already-stale installs with one
full re-pull (no manual sqlite deletion) and no re-push storm.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(tts): controller owns its foliate TTS instance and emits lifecycle events
view.close() nulls view.tts, so the controller keeps its own handle
(mirrored to view.tts while attached; reads prefer the public mirror).
state becomes an accessor that dispatches tts-state-change on a
microtask, and terminal conditions (end of content, error exhaustion)
fire an explicit tts-session-ended: 'stopped' is a transit value that
occurs on every paragraph advance and must never be read as death.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(tts): make TTSController detachable from the reader view
detachView enters headless mode: layout-dependent work is guarded, the
dead hook's preprocess/section-change closures are severed, and text
supply continues through created documents while position events keep
flowing. attachView adopts a new view without touching in-flight audio,
re-seeding the fresh text instance from the old cursor AT the
synchronous swap point (auto-advance during async prep would otherwise
replay a paragraph) and aborting via an attach epoch when a detach
supersedes it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(tts): move media session ownership to a session-scoped bridge
ttsMediaBridge binds directly to the controller (metadata per mark,
clamped position state, transport handlers, the silent keep-alive
element) so the lock screen keeps working when the reader hook is
unmounted. The hook's last-writer-wins handler effect and its
per-render re-registration are gone; the panel now derives isPlaying
from the controller's state channel, so lock-screen transport keeps
the in-reader UI truthful. useTTSMediaSession had no consumers left
and is removed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(tts): add hash-keyed TTS session manager with sleep timer and headless persistence
Sessions key by book hash (bookKey is regenerated per open), the
playback-state relay dedupes transit stopped values so paragraph
advances never flicker followers, and terminal handling rides the
explicit tts-session-ended event. The sleep timer survives reader
unmount, and headless positions persist through the book config on
disk (view/progress stores are cleared on close and reopen loads
from disk).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(tts): keep TTS playing across book close and reattach on reopen
Back-to-library and Android back dispatch tts-close-book (detach when
the session is not terminated: transit stopped states during chapter
transitions must not kill it); quit and window-destroying closes keep
the hard tts-stop so the foreground service tears down with the
webview. The unmount cleanup transfers ownership to the manager
instead of shutting down, covering deep-link book switches and
split-view pane closes. Mounting a book adopts a matching background
session once the view is ready (primary pane only) and stops a
different book's session unless it is still mounted elsewhere. The
sleep timer moves to the manager and a one-time toast announces the
first background continuation.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(tts): now-playing bar in the library for background sessions
Floating pill above the shelf while a TTS session outlives its
reader: cover, title, sleep-timer countdown, play/pause following the
manager-relayed playback channel, and a hard stop. Tapping the body
reopens the book in the SAME window regardless of the new-window
preference, since the session is a per-webview singleton. Deleting
the playing book stops the session before its data is cleared. The
bookshelf reserves scroll clearance via a --now-playing-inset var the
bar sets while visible.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(tts): make the header close button background-eligible
The header X routes through handleCloseBook (onCloseBook), not
handleCloseBooksToLibrary, so the sticky eligibility ref never got
set and closing a book from the header hard-stopped a live TTS
session. Replace the ref with an explicit keepTTSAlive parameter on
saveConfigAndCloseBook/handleCloseBooks: back-to-library, Android
back, and pane closes pass true; beforeunload, quit-app, and window
close invoke handleCloseBooks with an event object, which coerces to
a hard stop. This also removes the stickiness where one background
close would have made a later quit detach instead of stop.
Verified live in Chrome dev-web: close from the header keeps audio
playing with the now-playing bar shown; reopening reattaches the
same session (generation numbering continues); opening a different
book stops it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* feat(tts): add PCM speech-bounds detection for sentence audio trimming
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(tts): add WSOLA time-stretch for pitch-preserved playback rate
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(tts): add sentence duration store with per-voice speaking-rate calibration
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(tts): serve edge audio as ArrayBuffer with in-flight fetch dedup
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(tts): add WebAudioPlayer with gapless chunk scheduling and backpressure
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(tts): play edge TTS through gapless Web Audio pipeline
Replaces the per-sentence audio element with trimmed, time-stretched
buffers scheduled on the shared AudioContext. Marks dispatch at audible
time so schedule-ahead cannot run foliate's cursor past the voice; a
decode failure or missing audio skips the chunk instead of wedging the
session; pause and resume ride context suspend and resume with no iOS
rewind hack; the object-URL cache is gone.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(tts): add section timeline with measured and estimated sentence durations
Includes the foliate-js submodule bump for the getSentences export
(fork branch feat/tts-get-sentences; fork PR must merge before this
lands so the pinned SHA resolves).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(tts): expose section playback position and sentence-snapped seeking
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(tts): surface playback position and seek in the media session
Position state is clamped, never skipped, so the lock-screen scrubber
stays live when estimates overshoot; seekto units map per backend
(native ms, web seconds). The AudioContext warms up in the tts-speak
gesture path before any await, and the silent keep-alive element now
runs on all platforms so desktop hardware media keys survive the
removal of the per-sentence audio element.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(tts): add seekable chapter progress bar to the TTS panel
The scrubber joins the transport cluster with a thin range-xs track and
flanking tabular time labels so it cannot be misgrabbed for the chunky
rate slider (which persists a global setting). States: reserved
disabled slot until the lazy timeline lands, persists across chapter
transitions, optimistic thumb with failure toast, monotonic position,
tilde-prefixed estimated totals, sentence-event updates under e-ink.
The popup grows only when a timeline-capable client is active.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs: record deferred TTS listening-engine follow-ups
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs: record background TTS decoupling design decisions
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(tts): slim the panel scrubber to a native track with remaining time
Match the footer Jump to Location slider (plain native range: thin
track, small thumb) instead of the chunky daisyUI pill, show remaining
time with a minus prefix on the right, and drop the This chapter
caption. Popup height shrinks accordingly. Verified live in Chrome.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* chore: pin foliate-js to merged main with getSentences export
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(tts): catch autoplay rejection from the keep-alive element
Running the silent keep-alive on all platforms exposed an un-awaited
play() that headless Chromium rejects without a user gesture, failing
CI on unhandled rejections while every test passed. The keep-alive is
best-effort; the production path is gesture-qualified.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Waking the device with a book already open now pulls progress,
annotations and stats like reopening the book does, instead of
requiring a manual sync or book reopen. The pull is delayed 1s so
Wi-Fi can come back up after wake (same delay upstream kosync uses
on resume), debounced against rapid Suspend/Resume pairs (Android
fires them on focus changes), and the pending task is dropped on
widget close so it cannot run against a torn-down ReaderUI.
Closes#4924
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
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>