* fix(cbz): order split chapter folders base-first (#5745)
A CBZ chapter split across folders sharing a chapter number, such as
Chapter 0060/ and Chapter 0060 (2)/, rendered part 2 before part 1:
image paths were flattened and sorted as plain strings, and a space
sorts before a slash. Bump foliate-js to compare paths segment by
segment with a numeric collator so a folder that is a prefix of a
sibling sorts first, and add a regression test for the reported layout.
Fixes#5745
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* chore: pin foliate-js to the squash-merged commit of readest/foliate-js#79
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* chore: bump foliate-js to latest main
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Register the ka locale in i18n-langs.json and TRANSLATED_LANGS, and add a
fully translated public/locales/ka/translation.json (2156 keys).
Georgian also becomes available as a translation target: TRANSLATOR_LANGS
spreads TRANSLATED_LANGS, so the reader's translator picker and the language
settings panel pick up ka without further changes.
Conventions followed from the existing locales:
- CLDR gives ka two plural forms (one/other). Georgian nouns do not inflect
after numerals, so both forms are identical, matching hu and uz.
- The Trans component tags on the "pages left" keys keep the leading <1>
wrapper used by ru, ja, zh-CN, tr and pl.
- Font name keys use the English display values (LXGW WenKai SC, Source Han
Serif, Huiwen Mincho, KingHwa Song), matching de, ru, fr and uz.
- Brand and format names stay verbatim.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Auto-download switch sits on a scrollable card list, and flipping it on
immediately starts downloading every publication in the catalog. On touch
devices -- worst of all on slow-refreshing e-ink screens -- it is easy to hit
by accident while scrolling, with no visual feedback in time to catch it.
Flipping the switch from the list now opens a confirmation alert instead of
mutating anything; only the confirm applies the change. Both directions are
confirmed, with copy matched to the direction: enabling warns that a large
number of books may be downloaded, disabling just states that new publications
will stop arriving. The switch inside the Edit dialog is untouched -- reaching
it already takes a deliberate tap. The existing 5s debounce stays as a second
chance after a confirmed enable.
Catalog cards also gain a drag handle for manual ordering, so the list can be
arranged instead of being fixed to import order. The order is persisted as a
new `sortOrder` field carried by the replica adapter, so it follows the user
across devices like the rest of the catalog record. Catalogs that have never
been dragged keep the legacy newest-first order and sort above the arranged
block, so a freshly added catalog still lands on top.
The list mirror now renders through `getAvailableCatalogs()`. It previously
re-sorted only on first paint and then fell back to raw store order, which
would have let drag stamp positions against a sequence the user never saw.
Existing lists reshuffle once to newest-first; any drag pins the order for good.
Both surfaces from the report -- Settings > Integrations and Import Books >
Online Library -- render the same CatalogManager, so one change covers both.
Verified in the web app: confirm dialog layers correctly above the OPDS modal,
Cancel leaves the switch untouched, and a drag reorder survives a full reload
with sortOrder persisted.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
OPDS catalog downloads and subscription auto-sync queued Readest Cloud
uploads gated only on the provider being active, so book files were
uploaded even when the user had turned Books sync off in Manage Sync.
Normal library imports already honor the toggle in ingestService.
Extract the shared policy into queueOPDSBookUploads, which checks
isSyncCategoryEnabled('book') alongside the provider gate, and use it
at both call sites. The explicit per-book Upload action and Upload All
stay ungated by design.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
In a novel whose notes and pictures share a chapter file, opening a footnote
popup and dismissing it twice made that chapter's illustrations impossible to
open until the book was closed and reopened. The pictures stayed on screen,
so only tapping one was broken.
The footnote popup opens a second foliate-view on the same book, so it loads
a section the reading view already holds and unloads it on dismiss. foliate-js
counted that second load in a shared bucket keyed by an absent parent, so the
reference was skipped while the dismiss still decremented, and the count
underflowed to zero and revoked the chapter's blob URLs, images included,
under the view still showing them. An img that has already decoded stays
painted after its blob URL is revoked, which is why the page looked fine and
only convertBlobUrlToDataUrl failed, leaving the image viewer unopened.
Bump foliate-js for the Loader refcount fix (readest/foliate-js#78) and cover
both halves of it: repeated popups must revoke nothing while the reader holds
the section, and a section must still be freed once its views close, since the
renderer reads a section's content in addition to loading it.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(tts): make sentence and paragraph pauses consistent (#5750)
The pause was scaled for the playback rate twice. TTSPlayerSheet derived
it as base/rate^0.6 and persisted that, then BufferedTTSClient and
TTSController divided by the rate again at schedule time, leaving
base/rate^1.6: 0.60s between sentences at 0.5x and 0.085s at 2x, correct
only at 1.0x. Scale it once, in scaleGapForRate, and derive it in
useTTSControl.handleSetRate, the one funnel both the speed ruler and the
RSVP overlay's tts-set-rate already pass through, so the stored value can
no longer go stale against the rate it was scaled for.
The pause between paragraphs was wall clock rather than audio clock. One
speak() is one paragraph, so the next paragraph's session was built after
a JS timeout: session teardown, SSML preprocess, synthesis, decode, then
a first chunk scheduled at ctx.currentTime + 0.03. None of that latency
was compensated, so paragraph pauses swung with the network while pauses
inside a paragraph stayed sample exact. The player now carries the end of
a naturally ended session forward and starts the next one at that point
plus the paragraph gap, so synthesis and decode run inside the pause
instead of after it, and the controller skips its own sleep for clients
that report scheduledGaps. A session cut short by a stop or a skip
carries nothing over.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* test(tts): give the browser Edge mock the setParagraphGap it now receives
TTSController.setParagraphGap forwards to the real ttsEdgeClient instance
whatever the active engine is, exactly as setSentenceGap already did. The
auto-advance browser test mocks that module, so the missing method threw
at session start and TTS never published a position or crossed the
chapter boundary.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(tts): derive the pauses on a rate change with no session running
handleSetRate returned early when no controller existed, before the
pauses were re-derived. The RSVP overlay persists ttsRate whether or not
Read Aloud is running, so a rate set with playback stopped left the
stored pauses scaled for the previous rate, and the next session picked
them up. Derive first, then handle the controller.
Found by CodeRabbit on #5753.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat(annotator): support text selection tools in footnote popups
Selections inside the footnote popup window now surface the annotation
toolbar (closes#5646). The popup document is a mutated copy of the
section (foliate extracts the note fragment into an empty body), so a
new mapping utility translates selection ranges into CFIs that resolve
in the pristine section, using the extraction info the footnote handler
now emits. Highlights created from the popup are saved against the real
book position, drawn immediately inside the popup, redrawn when the
popup reopens, and removed when deleted. Clicking a drawn highlight in
the popup opens the toolbar in its annotated state (restyle or delete)
and a note bubble click opens the note view, like the same clicks in
the main view. Notes added from the popup merge into the existing
highlight record (the notebook save now uses the mapped CFI instead of
recomputing one from the popup range) and draw their bubble overlay in
the popup as well.
For popups whose text is synthesized from a data or alt attribute there
is no real text node to anchor to: the toolbar still opens for copy,
search, dictionary, and translate, while highlight, annotate, copylink,
and proofread are disabled. TTS is disabled for all popup selections
since it reads from the main view documents.
The Annotator now renders after FootnotePopup so the selection toolbar
stacks above the popup and its dismiss overlay.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* chore: bump foliate-js to the merged extraction CFI mapping commit
Points the submodule at readest/foliate-js#77 as squash-merged on the
fork's main branch (57c9358), content-identical to the previous branch
head.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(annotator): address review findings on footnote popup selections
- Guard the popup selection handler with an epoch so a handler still
awaiting getAnnotationText cannot overwrite a cleared or newer
selection with stale state.
- Block the annotate keyboard shortcut for CFI-less popup selections,
matching the disabled toolbar button; it opened the notebook against
an anchor that could never save.
- Resolve and validate the CFI before the copy-to-notebook toast so an
excerpt that cannot be anchored is not reported as saved.
- Normalize every element-container range boundary, not just ones on the
popup body: CFI drops offsets on even element steps, so an inner
boundary silently collapsed to the element start.
- Reject notes from other sections with a string spine-prefix compare
before the parse and DOM work in getFootnoteLocalCfi; a heavy library
re-mapped every annotation in the book on each store update.
- Extract the repeated popup reset into resetPopupAnnotationState, add a
pointerup trigger to the host-document selection path for parity with
the popup-document path, and rename the shadowing view binding in
handleBeforeRender to popupView.
- Assert resolved node and offsets in the CFI round-trip tests, and cover
the null paths of getFootnoteSelectionCfi.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* feat(library): show download progress overlay on book covers
Clicking a book's cloud download icon gave no visual feedback: file-backend
downloads (WebDAV/Drive/S3/OneDrive) ran without reporting progress, and the
queue path's initial 0% was swallowed by a \|| null\ in Bookshelf.
- Thread byte progress from the native transfer plugin through
FileSyncProvider.downloadStream -> engine.downloadBookFile ->
runFileBookDownload; the gdrive/s3/onedrive provider attachments were also
dropping the onProgress arg.
- Mirror transfer-queue download progress into booksTransferProgress so
Readest Cloud queue downloads show the overlay too.
- Render progress on the cover as a percentage overlay (indeterminate spinner
when the backend reports no byte total), replacing the row radial; fix the
\|| null\ that dropped a 0% start.
* fix(library): clear cover progress after direct downloads
Addresses CodeRabbit review on #5736:
- clear the progress entry after a successful direct download so the
overlay cannot linger and the row action buttons are not hidden.
- add aria-label to the cover progress overlay.
- exercise progress state transitions in the hook tests (indeterminate
start, percentage updates, cleanup) and assert onProgress is forwarded
to the streaming downloader.
* fix(library): derive cover transfer progress from the queue on read
The cover progress overlay had two writers for one state map: the
transfer actions hook and a new effect that mirrored transfer-queue rows
into it. Neither marked ownership, so the reconciliation guessed wrong
three ways:
- The queue keeps completed and failed rows as history, persisted to
localStorage and restored on launch. Any store mutation re-ran the
mirror, which took its inactive branch on a stale row and deleted
progress the hook had just written for a live download of that book.
- Clear Pending, Clear Completed and Clear All remove rows outright
rather than transitioning their status, so the mirror never reached
its delete branch for them and orphaned the entry. The cover kept a
frozen overlay with no action button until the page remounted.
- restoreTransfers resets an interrupted transfer to pending with
progress 0, so a restored row painted a stuck "0%" on every launch,
permanently while the queue was paused.
Drop the mirror and derive queue progress where it is consumed, via
selectActiveBookDownloadProgress. Only pending and in_progress rows are
reported, which makes the result independent of iteration order and of
whatever history the queue holds, and a pending row reports the
indeterminate sentinel instead of a frozen 0%. Bookshelf merges that
with the hook's direct-download progress and feeds both the grid and the
recently-read strip, which previously hard-coded null and so offered a
Download button for a book the grid already showed downloading.
Also:
- Latch the per-transfer teardown. Tauri delivers Channel progress
messages over IPC independently of the invoke response, so a final
payload could arrive after cleanup ran, re-arm the throttle and strand
the cover behind a stale overlay that nothing would clear again.
- Use one condition for the overlay and for hiding the row's transfer
buttons, so a book can no longer show neither at 100%.
- Prime the overlay on the direct download path too, so the queued:false
callers are not blank until the first byte.
- Give the overlay an e-ink treatment: a translucent wash dithers over
the cover art and drop shadows do not render, so e-ink gets a solid
base-100 panel with a 1px base-content border and ink-colored content.
- Name the progress sentinel and the bytes-to-percentage conversion in
utils/transfer instead of repeating a bare -1.
- Name the progressbar for screen readers, which previously announced
only the bare book title.
- Restore the 500ms progress cadence: the throttle now runs once per
in-flight transfer rather than once for the page.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Huang Xin <chrox.huang@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat(tts): queue chapter downloads with per-book persistence
Replace the cancel-and-replace download flow with a persistent per-book
download queue mirroring the cloud-sync transfer queue (transferStore +
transferManager).
- ttsDownloadStore: zustand queue of chapter rows (pending/in_progress/
failed) keyed by bookHash:chapterKey, with section-range snapshots so
restored rows download without re-resolving the TOC; interrupted runs
demote to pending on restore.
- ttsDownloadManager: sequential single-slot processor bound to the live
TTS session, per-item AbortController, batch priority for Download all,
localStorage persistence, and removeBook cleanup on book deletion.
- Player sheet: Spotify-style row badges (queued clock, active ring,
failed/partial resume arc) and a Download all / Cancel all toggle.
- Fixes a race where starting a second download left the first run's
teardown resetting the shared progress state, silently stalling every
subsequent download until restart.
* fix(tts): make offline downloads durable and race-safe
Use stable book identities and per-attempt ownership for the persistent queue. Pin explicit downloads in SQLite so cache eviction, cancellation, and restart recovery cannot discard them.
* fix(tts): join playback teardown before deleting audio
Centralize local-book cleanup in the app service and track controller shutdowns per book. Deletion now waits for active synthesis and cache handles to settle without clobbering replacement reader sessions.
* feat(tts): finish persistent offline audio queue controls
Key the reader UI by stable book hash, expose pending, active, failed, retry, cancel-all, and clear-all states, and prevent stale refreshes or clear/download races.
* docs: document persistent offline audio downloads
Co-Authored-By: OpenAI Codex <noreply@openai.com>
* fix(i18n): add en plural forms for the Cancel all count key
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Huang Xin <chrox.huang@gmail.com>
Co-authored-by: OpenAI Codex <noreply@openai.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* feat(library): add hide-covers privacy option for the bookshelf
* feat(library): localize the hide-covers toggle across 33 locales
* fix(library): suppress the cover spine and refine locale labels
Review follow-up. manifestKey compared only file and sha256, but resolvePack
routes a book language to a pack through source and target, so the key did not
state what clients actually depend on.
Redundant in practice: packEntry derives source and target from the pack's meta,
and sha256 hashes the whole file including that meta, so routing cannot shift
without the hash shifting. Adding them costs one line and keeps the key honest
if either half of that ever changes.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat(wordlens): add the en-hu gloss pack
WikDict publishes no en-hu dictionary either, so Hungarian is built the same way
as en-vi: the kaikki `build` mode, reading the Hungarian `translations` off each
English Wiktionary entry. The cached 3.2 GB extract already covered it, so this
needed no new download and no build-script change.
13641 entries and 10813 inflections, lemmatized through the en-en table so
difficulty is gated by the lemma rank (whispered -> whisper). Coverage lands
close to the WikDict pairs, well ahead of en-vi's 9759.
Hungarian is a target language only. It is agglutinative, so surface forms like
"hazaimban" need a lemmatizer to reach a headword, and michmech publishes no
Hungarian list. As with vi, no code gates this: the hu-en pack simply does not
exist, which is how every unbuilt pair already behaves.
Also switched the README's kaikki download to aria2c. kaikki throttles a single
connection to ~270 KB/s, which puts the 3.2 GB extract at six hours; 16 parallel
ranges finish in about twenty minutes.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(wordlens): republish the manifest when only it changed
Follow-up to the review on #5737. Retiring a pair changes the manifest while
leaving every remaining pack byte-identical, so planSync correctly returns no
packs to upload, and the early return then skipped the manifest too. The CDN
kept advertising the retired pack, with a sha256 clients would try to fetch.
manifestChanged compares what clients actually act on, the schema version plus
each pack's file -> sha256, ignoring order and the derived bytes/entries fields
so a regenerated but identical manifest still uploads nothing. A run with no
pack changes now republishes the manifest alone.
Also normalize the direct-execution guard with pathToFileURL(resolve(argv[1])).
String concatenation mismatches on Windows paths and on URL-reserved characters
in the path, which would silently turn a real sync into a no-op. Note that
build-wordlens-data.mjs still carries the unnormalized form.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat(wordlens): add the en-vi gloss pack
WikDict publishes no en-vi dictionary, so this pair is built with the existing
kaikki `build` mode, which reads the Vietnamese `translations` off each English
Wiktionary entry. The pack ships 9759 entries and 10813 inflections, lemmatized
through the en-en table like every other English-source pack, so difficulty is
gated by the lemma rank (whispered -> whisper, reluctantly -> reluctant).
Vietnamese is a target language only. Its words are multi-syllable with spaces
inside them ("hoc sinh"), so the planner's whitespace tokenizer would gloss
syllables rather than words; a vi-en pack needs a segmenter first, like ja/ko/th.
No code gates this, the pack simply does not exist, which is how every unbuilt
pair already behaves.
Coverage is thinner than its peers (9759 vs 14769 for en-es), which reflects
Wiktionary's English to Vietnamese translation coverage rather than the build.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* perf(wordlens): sync only the packs that changed
A data refresh usually touches a single pair, but the sync re-uploaded all 15
packs (~20 MB) on every run. The published manifest already carries each pack's
sha256, so fetch it and upload only the packs that are new or changed. Adding
en-vi now uploads 652 KB instead of 20 MB.
An unreachable manifest (first sync, offline) falls back to a full upload, and
--force does the same on demand, for when the manifest is current but an object
was deleted from the bucket.
Also stop publishing the manifest when a pack upload failed. The script always
claimed the manifest goes last so it never points at a missing pack, but it
uploaded the manifest regardless of failures, which is exactly the state that
invariant exists to prevent.
planSync is pure and unit tested. Guarded the CLI entry point behind the direct
execution check that build-wordlens-data.mjs already uses, so importing the
module in tests does not run main().
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
On B&W e-ink the highlight overlay is composited with `mix-blend-mode:
difference` at full opacity, so its color is an inversion mask, not paint.
Difference is `|backdrop - source|`, which makes black the identity element,
and the overlay was filled with the theme background: on a dark page that is
`#000000`, so every highlight left the page pixel-for-pixel unchanged. Because
overlays keep the fill they were drawn with, switching back to light stayed
broken until reload. One white mask inverts page and ink in both themes, so
the fill must not follow the theme at all. The TTS read-along highlight took
the same wrong color and is fixed with the same helper.
The highlight options row had a second problem. The marker glyph and the
selected-color check sit on a base-content chip and set their own contrasting
ink inline, but they also carried `text-base-content`, whose e-ink rule
flattens the color with `!important` and outranks an inline style. Both were
painted base-content on base-content: a solid black square with an invisible
"A", and a color dot with no visible check.
The e-ink `bg-base-content` rule matches the class attribute as plain text, so
it also fired on variant-prefixed utilities. `hover:bg-base-content/10` was
painted solid at rest on every screen, which swallowed the footer bar's page
indicator and the progress panel's page bubble whole, and had already forced
`eink-inverted` workarounds elsewhere (#4454). Skip elements whose only match
is variant-prefixed; `eink:bg-base-content` emits its own rule and still wins.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(reader): neutralize fixed backgrounds and drop negative margins, closes#5711
Books authored for Duokan use background-attachment: fixed on decorative
title images and fake full-bleed color bands with negative horizontal
margins sized to Duokan's 2em page padding. In the paginated iframe the
fixed attachment anchors the image to the transformed multi-column
viewport, painting it far from its element and smearing the text above
it, and negative margins overhang the column box so the band paints onto
the adjacent page.
Rewrite fixed attachment to scroll (the spec-mandated behavior inside a
transformed subtree) with url() values masked so file names containing
"fixed" survive, and zero the horizontal margins on rules that both
paint a background and pull themselves sideways with negative margins.
The band stops at the column edge instead of full-bleeding, the same
rendering the issue reporter picked as their custom-CSS workaround.
Rules without a painted background are untouched so hanging indents
keep their layout.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(reader): address review findings on CSS margin and attachment rewrites
Mask all function tokens, not just url(), so a custom property like
var(--fixed) is never rewritten to var(--scroll). Resolve each horizontal
margin side in declaration order and zero only the sides whose final
value is negative, so margin: 1em -2em 3em 4em keeps its valid left
margin. Gate on any painting background declaration instead of the first
match, so background: none followed by background-color still counts,
and treat alpha-zero rgba()/hsla() colors as non-painting.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(reader): mask url() tokens before splitting background declarations
An unquoted data URI contains semicolons, so the declaration match ended
early and the trailing fixed keyword survived; a quoted url can contain
closing parens or the word fixed, which the per-value function mask
mishandled. Mask every url() token across the sheet before the
declaration split and restore afterwards.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
With Instant Dictionary on, dismissing the lookup popup opened the selection
toolbar. #5213 made every lookup popup return to the toolbar while a selection
is still live, and the in-app dictionary never deselects; on phones the lookup
renders as DictionarySheet, so a backdrop tap goes through onDismiss and raises
the toolbar, which is the report's "tap anywhere".
The instant quick action is the other side of that boundary: the word was
tapped to be looked up, not selected, so the lookup owns the gesture end to
end. It now clears isTextSelected and deselects as the popup opens. The order
is load bearing: view.deselect() fires a selectionchange whose empty selection
branch dismisses the popup while the flag is still set.
The same change fixes the iOS report that the selection grabbers and the blue
highlight paint on top of the dictionary window, since WKWebView draws
selection UI in a native overlay above web content.
The selection toolbar route into the dictionary is untouched: keeping its
selection alive so the word can still be highlighted or copied is exactly what
issue #5213 asked for.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
PR #5389 rewired the reader's in-book search onto the shared per-book
search index, which applies MAX_BOOK_SEARCH_RESULTS = 500 per book so a
single book cannot flood a library-wide sweep. Reader search inherited
that budget and silently stopped at 500 matches, with the footer showing
a plain "500 results" and no truncation marker.
Make the per-book budget an option on searchLibraryBooks. The library
scan keeps the 500 default; the reader passes Infinity, which is already
the no-limit value every matcher (contains, whole-words, regex, fuzzy,
nearby-words) defaults to.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
On a paginated multi-column page straddling a chapter boundary, foliate's
relocate range only covers the primary section's document, so results in the
adjacent visible section miss the location-based list highlight even though
the body-text mark is shown. Resolve each non-primary result's DOM rect
against the view container and highlight it when on screen.
Also subscribe the list to readerProgressStore so the highlight tracks page
turns even when a page has no hits (the sidebar store stays quiet then).
* fix(sync): sync the reference page count and stop import wiping configs, closes#5716
Two independent data-loss faults reported in the same issue.
1. A reference page count typed on one device never reached the others.
The count lives in per-book viewSettings, and both sync backends treat
viewSettings as device-local: the Readest cloud pull applies only
proofreadRules out of a remote config, and the file-sync wire envelope
strips viewSettings entirely. Worse, the cloud still stored it in
book_configs.view_settings under whole-row last-writer-wins, so a peer
that had no count pushed an empty view_settings and erased it.
The count stands in for a page list the book does not ship, so it
describes the book's print edition, not the screen. Give it a shared
merge policy (resolveReferencePageCount) and apply it on both
backends: the cloud pull merges it alongside proofreadRules, and the
file-sync envelope carries it as its own additive key so the scalar
spread in mergeBookConfig cannot replace a peer's whole viewSettings.
A peer that has no count never clears one, since the wire cannot tell
a cleared value from a client that predates this merge.
2. Importing a book wrote an empty config over an existing
Books/<hash>/config.json, losing reading position, bookmarks and
annotations. The guard checked for a library record rather than for
the file, so any book whose hash dir already held a config but whose
library.json row was missing got INIT_BOOK_CONFIG stamped over it.
restoreBackup hits this directly: for a hash dir the archive's
library.json does not list, it extracts config.json and then imports
the book file. Guard on the config file instead.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(sync): resolve reference page count ties the same way on both backends
mergeBookConfig passed `remoteConfigUpdated >= localConfigUpdated` to
resolveReferencePageCount while useProgressSync passed a strict `>`, so
with both peers holding a count and equal config timestamps the file-sync
path picked the remote value and the cloud path picked the local one. The
point of sharing one merge policy is that the backends cannot drift, and
two callers deriving the predicate differently defeats that.
Use the strict comparison in both, keeping the local count on a tie. The
scalar spread above it still resolves ties toward the remote; changing
that would alter progress and location semantics for every file-sync
user and is not in scope here.
A tie is the ordinary steady state rather than a rare race: a remote-wins
merge copies remote.updatedAt onto the local config, so every later pull
of an unchanged remote ties. Counts are already equal by then, which is
why no user-visible divergence was reproducible, but the invariant the
shared policy claims should actually hold.
Pinned with an equal-timestamp test on both backends so a future caller
cannot reintroduce the drift silently.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
The mixed-fleet probe compared native book rows against a fixed anchor
(readestCloud.disabledAt or the earliest providerSelectedAt) while the
"shown once" latch lived in process-local state, so any row written
after the anchor kept re-firing the "Another device is still syncing
this library via Readest Cloud" toast on every app launch with no way
to dismiss it. Remove the probe, its store latch, and the toast; the
provider gating of the native sync channels is unchanged.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* fix(reader): restore scrolled PDF highlights, closes#5586
* chore: point foliate-js to merged PDF fix
* test(reader): assert stale PDF overlayer is attached
Standard Ebooks EPUBs set text-wrap: pretty on body. Engines that apply
pretty to justified text (Safari 26+, recent Chromium) overshoot
inter-word spacing when combined with the reader's full justification,
and the Word Spacing setting stops having any visible effect.
Reset the text-wrap-style longhand on justified text containers when
full justification is enabled. The longhand keeps an authored
text-wrap: nowrap intact, headings keep their balanced rag, and the
authored pretty still applies when justification is off.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* fix(reader): target the active book when the settings shortcut opens the dialog
The Shift+F shortcut opened the settings dialog without setting its book
key, so the dialog ran in bookless mode: writing mode and other per-book
changes were saved under an empty key, nothing applied to the open book,
and recreateViewer('') threw Book not found. Pass the sidebar book key
like the view menu entry does.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(reader): right-to-left page order for fixed-layout books, closes#5591
Books bound right-to-left (Japanese photo books, manga, RTL PDFs) showed
two-page spreads paired and ordered left-to-right with no discoverable
way to fix it.
Add a Right-to-Left Pages toggle to the view menu's fixed-layout section
next to the spread controls. It drives the existing per-book writingMode
setting (horizontal-rl / horizontal-tb) so it stays consistent with the
Layout panel and persists like any view setting.
Auto-detect the binding for PDFs that declare it: makePDF now surfaces
the catalog's ViewerPreferences /Direction /R2L as book.dir, and the
viewSettings.rtl derivation considers the loaded document's dir so
page-turn taps and swipes follow the reading direction.
Mirror the @pdfjs alias into the browser vitest config so browser tests
can load foliate-js/pdf.js, and translate the new label across all
locales along with the previously untranslated Send Document Metadata
key that extraction surfaced.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* chore(reader): bump foliate-js to the merged R2L direction commit
readest/foliate-js#75 was squash merged, so point the submodule at the
commit on main instead of the feature branch.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Auto Scroll was session-only, so every app suspend or book close dropped
the mode and it had to be toggled again. Remember whether a session was
engaged, per book, and resume it on open. No new setting: a session that
is explicitly stopped stays stopped, and only one left running at close
comes back.
The flag is a per-book view setting written with the global write
skipped, so it never leaks into globalViewSettings, and config
serialization prunes it when it matches the default.
Skip the resume on a deep-link landing (?cfi=, library search hit).
Scrolling one unprompted would promote the preview into a reading
position the user never navigated to, so setPreviewMode now runs before
setViewInited and the resume effect reads it.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat(sync): optional document metadata on KOSync progress uploads
KOReader 2026.05 added an opt-in "Send document metadata" setting
(koreader/koreader#15306): when enabled, progress uploads carry an
optional metadata field with the document's filename, title and authors.
The official sync server ignores it; custom KOSync-compatible servers
use it to tell which book a document hash is, so books identify
themselves instead of needing manual pairing per book. CrossPoint
shipped the same field in 1.5.0.
This adds the same opt-in to Readest's KOSync integration: a Send
Document Metadata toggle in the KOReader Sync settings (off by default,
matching KOReader), and the metadata field on progress uploads when
enabled. Field shapes follow KOReader exactly - authors is a single
string, newline-joined when there are several, which Book.author
already is. The filename derives from sourceTitle/title plus the
lowercased format rather than EXTS, keeping KOSyncClient off the
document lib's foliate-js dependency chain.
Also helps the hash-divergence case discussed in #1838: when a Calibre
re-export changes a file's checksum, a server that receives title and
authors can still recognise both hashes as the same book.
* fix(sync): preserve multiple KOReader metadata authors
Build the KOReader-compatible newline-separated author field from structured book metadata instead of the locale-formatted display string.
Add a regression test covering a multi-author EPUB.
---------
Co-authored-by: Chris Butler <chris@chrisbutler.me>
Co-authored-by: Huang Xin <chrox.huang@gmail.com>
* refactor(reader): extract isForcedMobileLayout helper
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(reader): drop duplicate TOC toggle on tablet portrait, closes#5634
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(reader): remove header font button that opened the last panel, part of #5652
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(reader): rename view menu Font & Layout entry to Settings, part of #5652
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat(reader): add More Settings shortcut to the quick font panel, part of #5652
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* i18n: add More Settings string
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(reader): rename layout helper so Next does not treat it as a route layout
Any layout.ts under src/app is parsed as a route segment layout, so the
helper was type checked against LayoutConfig<'/reader/utils'> and failed
the web build. pnpm lint does not run that check; only next build does.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(reader): route e2e settings helpers through the view menu
The header's Font & Layout button is gone, so the three page-object
helpers that opened Settings through it timed out. Add openSettings()
using the view menu, the header's remaining route into the dialog, and
move the Settings entry after the theme mode item to match the library
menu.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(reader): match the library menu Settings entry exactly
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Rearrange the minimal TTS mini-player into a symmetric transport, closes#5636:
the speed glyph keeps the left end, the remaining time moves to the far right,
and the play/pause button sits on the card's exact midpoint.
All seven items live in one between-spread row whose widths mirror about the
middle (a fixed 4rem box at each end, two skip glyphs per side), so the equal
gaps land the play glyph dead-center. That makes it a halfway mark against the
progress line on the card's bottom edge, and puts the remaining time over the
un-played part of that line.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Remount provider containers when the lookup key changes so a card hidden by an empty result can render the next query without closing the dictionary popup.
Add fixture-backed regression coverage for a miss followed by a known word.
Co-authored-by: OpenAI Codex <noreply@openai.com>
* fix(android): gate gamepad polling on controller connection
Use Android InputManager only to report controller presence, then enable the existing Web Gamepad API hook while a controller is connected. Preserve browser-normalized button and axis mappings without starting Chromium's polling thread on idle readers.
* test: remove fake implementation coverage
Delete source-text, copied-algorithm, copied-CSS, and scaffold tests that do not execute production behavior. Replace the gamepad source scanner with Kotlin/JUnit coverage of the production device detector.
- add a build-koreader-plugin job to the nightly workflow that mirrors
release.yml and uploads Readest-<version>-1.koplugin.zip to R2 under
nightly/<version>/ next to the other nightly artifacts
- build-localsend-bins.mjs now builds armv7 + arm64 by default; the
macos target is opt-in via --only macos
- fix the build-koplugin.mjs usage comment (.js -> .mjs)
- update koplugin agent memories: LocalSend receive+send merge status
and the Kindle SSH deploy/debug recipe
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* fix(sync): create the replica bundle dir before downloading, closes#5675
Custom fonts synced from another device could stay stuck as placeholders
that never download. The native downloader writes with File::create,
which does not create parent directories, so the transfer failed with
"No such file or directory (os error 2)".
The pull path only creates a bundle dir when it mints a fresh one for a
record it has never seen. Three routes reach the download with nothing on
disk: a record whose directory was lost afterwards (custom root dir
changed, external storage cleared), a transfer replayed from the
persisted queue, and Retry All. Book downloads have always guarded this
in downloadBook; replica downloads did not.
Create the destination directory in downloadReplicaFile so every route is
covered. createDir is recursive, so it is a no-op when the directory
already exists.
Verified on Android with a custom root dir on external storage: with the
bundle dir removed the download failed with os error 2, and with the
directory present it completed with an exact byte match and the font
mounted.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(sync): keep replica sync transfers silent in the background
Fonts, textures, dictionaries and OPDS catalogs sync on their own
schedule rather than because the user asked for a given file, but every
replica transfer toasted. A fresh device fired one success toast per
synced file, and a broken sync fired one "Failed to download file" toast
per file.
Default replica transfers to isBackground, and honor that flag on the
failure path. The success path already checked it; the failure path did
not, which is why the errors were the loudest part. Failures are still
recorded on the transfer, so the Transfer Queue panel still shows them.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat(koplugin): scaffold localsend-ffi crate with start config
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(koplugin): port localsend identity into ffi crate
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(koplugin): add localsend ffi event queue and payloads
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(koplugin): fuse flaky event tests and add comprehensive wire-shape assertions
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(koplugin): receive-only localsend service in ffi crate
Ports the receive side of apps/readest-app/src-tauri/src/localsend/service.rs
into the standalone localsend-ffi crate: the HTTPS server plus discovery
bind, a staging-dir file pipeline that stages uploads under
.localsend-inbox and renames them into download_dir on success, and an
event pump that feeds the Task 3 event queue instead of emitting Tauri
events.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(koplugin): sanitize peer-supplied file names before writing them
handle_file_upload joined the wire-supplied file name straight into the
staging and download paths. Protocol v2 allows directory components in
that field, so a peer could send a traversal payload or an absolute path
and write outside .localsend-inbox / download_dir.
Add safe_file_name, which takes the last non-relative path segment and
sanitizes it under the crate's strictest Rules::Universal set, falling
back to received.bin when nothing safe survives (empty name, "..", or
all separators). Use it for both the staging path and the final rename
target.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(koplugin): expose localsend ffi c abi with smoke test
Adds the poll-based C ABI for KOReader's LuaJIT FFI: ls_version,
ls_start, ls_stop, ls_status, ls_poll_event, ls_accept, ls_decline,
and ls_string_free, all wrapped in catch_unwind with poison-safe
locking around a process-global service handle. Adds an ignored
smoke test that binds real ports and confirms a started event
and running status.
ls_string_free is marked unsafe extern C to satisfy clippy's
not_unsafe_ptr_arg_deref lint; this only affects Rust-side callers,
the exported C symbol is unchanged. Running cargo fmt with default
settings also reformatted events.rs and identity.rs from earlier
tasks.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(koplugin): serialize event-queue tests and cover ffi idempotency
Adds a test-only TEST_QUEUE_GUARD mutex in events.rs so
event_queue_contract and ffi_surface_contract cannot interleave their
clear/push/pop calls on the process-global EVENTS queue when the test
runner uses parallel threads.
Extends the ignored smoke test with assertions that a second ls_start
while already running returns OK and leaves the service running, and
that a second ls_stop after a real stop also returns OK, since neither
idempotency contract had test coverage before.
Also tightens the crate doc comment and ls_string_free's Safety doc to
call out that ls_version's return value is a static string and must
never be passed to ls_string_free.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(koplugin): add localsend native lib build script
Builds the LocalSend FFI crate into apps/readest.koplugin/libs/ for the
KOReader plugin. Supports arm64-mac (plain cargo build, verified on this
host) and armv7 (Kindle, via cargo-zigbuild, wired up in Task 7).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(koplugin): pin kindle glibc floor for armv7 localsend lib
Empirically verified the kindle userland glibc floor against the
koreader-kindle-v2026.07.1.zip release: every shipped .so and native
binary tops out at GLIBC_2.12 (koreader/libs/libzmq.so.5), lower than
the previously assumed 2.19, so lower KINDLE_GLIBC to match.
Also fix the cargo-zigbuild availability check, which always failed
because cargo forwards "zigbuild" as the subcommand name and that
subcommand rejects --version; probe the cargo-zigbuild binary
directly instead.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(koplugin): localsend ffi loader with arch-keyed lib table
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(koplugin): localsend receive service module with poll loop
Adds readest_localsend.lua: the module singleton that owns the native
LocalSend service lifecycle (start/stop/toggle), polls queued events on
a 0.5s UIManager loop, and routes them to per-type handlers (receive
request confirmation, file-done library registration, end-of-transfer
summary, status text for the settings row).
Also adds a ConfirmBox stub to koreader_stubs.lua and requires
spec.koreader_stubs from localsend_spec.lua so its stubs (InfoMessage,
ConfirmBox, NetworkMgr, UIManager) are registered before the new
dispatch spec requires readest_localsend, regardless of spec file load
order.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(koplugin): wire localsend receive into menu and lifecycle
Adds localsend_enabled/localsend_alias settings, initializes the
readest_localsend singleton in init(), adds a toggle + status menu
entry after Pull books now, forwards network connect/disconnect and
suspend events to the service, and unschedules its poll task on
onCloseWidget.
addToReadest and _addLocalRow now take an opts table with a silent
flag that suppresses every InfoMessage on that path, so LocalSend's
onReceiveFileDone can register received files without popping
messages over the transfer dialog. The default no-opts call path is
unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(koplugin): bundle localsend libs in plugin zip and release ci
Excludes native/ (Rust source) from the koplugin zip in both the local
build script and the release workflow, and adds a release CI step
that builds the armv7 LocalSend lib before zipping so libs/ ships in
published releases. build-koplugin.mjs now warns when libs/ is empty
so a local sideload build without prebuilt libs is not silent about
losing LocalSend receive.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* chore(koplugin): extract localsend i18n strings
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(koplugin): build kobo hard-float localsend lib
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(koplugin): offer kobo hard-float localsend lib in loader
Kobo devices are hard-float armv7 (gnueabihf), distinct from Kindle's
soft-float armv7 (gnueabi). Key libNameFor on Device:isKobo() so Kobo
loads liblocalsend-armv7hf.so instead of falling through unsupported;
Kindle and Kobo are unambiguous device classes, so no try-load
fallback is needed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* ci(koplugin): build kobo hard-float localsend lib in release
The release workflow only built the Kindle softfp LocalSend lib, so
release zips never shipped liblocalsend-armv7hf.so for Kobo devices.
Add the armv7hf rustup target and a second build-localsend-libs.mjs
invocation to the existing CI step.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(koplugin): start localsend off the ui thread and fix accept race
ls_start ran identity keygen and socket bind synchronously on the
LuaJIT UI thread, freezing KOReader for several seconds on first run.
Move that work to a dedicated OS thread that owns the tokio runtime;
ls_start now returns immediately and ls_status gains a starting field
so Lua can poll for readiness. ls_stop likewise signals the worker
thread and detaches instead of blocking on shutdown.
Also fix service::accept registering the receiving session after
sending the Accept decision, which could race the first FileUpload
against its own session registration.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(koplugin): close identity keygen race and stale-failed start lockout
Concurrency review of the previous commit found five reachability
issues that only became possible once ls_start stopped blocking:
- Two overlapping ls_start workers (e.g. screen sleep mid-keygen then
a quick stop/start) could both generate an identity.pem and race on
create_new(), leaving the loser stuck on a File exists error instead
of adopting the winner's cert.
- A failed start left STATE occupied by a dead LiveStatus::Failed
entry forever, since ls_start's idempotent re-attach check never
distinguished it from a genuinely running service, so retries
(e.g. onNetworkConnected) could never restart the service.
- service::start ran off the UI thread now but outside catch_unwind,
so a panic there would strand the worker with no event and
LiveStatus stuck at Starting forever.
- The started event was pushed before LiveStatus flipped to Running,
so a consumer reacting to it could still see running:false.
Also drops the now-redundant Option around Running.stop_tx (state()
already guarantees single consumption) and comments the narrow
inbox/event-queue overlap window between a detached worker's teardown
and the next ls_start.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(koplugin): harden localsend lua lifecycle and add crate ci
The started/error event handlers took a first parameter named `_`,
shadowing the module-level i18n function of the same name; the error
handler crashed uncaught the first time an async ls_start failed. Also
stop client-side polling on that failure so a later toggle can retry,
restart localsend on device resume, guard the accept dialog against a
stray tap dismissing it, normalize an empty download-dir setting, and
show a "starting" status during the async keygen/bind window.
apps/readest.koplugin/native/localsend-ffi had no PR-time CI at all;
add a paths-filtered job mirroring the existing rust_lint job's
toolchain setup and update the verification rules to match.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(koplugin): build arm64 linux localsend lib for remarkable
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(koplugin): offer arm64 linux and remarkable localsend libs in loader
Wires liblocalsend-arm64.so and the reMarkable device class into
libNameFor: reMarkable 2 (hard-float armv7) maps to the same lib as
Kobo, reMarkable Paper Pro and other arm64 Linux e-readers map to the
new arm64 .so, and Android arm64 is excluded since its linker
namespace forbids dlopen of a plugin-bundled .so.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* ci(koplugin): build arm64 linux localsend lib in release
Extend the release workflow's LocalSend native lib build step to also
build the arm64-linux target (reMarkable Paper Pro), alongside the
existing armv7 and armv7hf targets. build-koplugin.mjs already globs
libs/ so it ships the new .so without any change.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* chore(koplugin): extract localsend arm64 and starting i18n strings
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(koplugin): harden localsend against suspend resume keygen races
Three defensive fixes for the localsend suspend/resume-during-first-run
keygen edge on slow e-readers:
- identity.rs: retry the AlreadyExists recovery read a handful of times
instead of once, so a loser worker does not fail on a winner's
identity.pem write that has not finished landing yet.
- lib.rs: spawn the ls_start worker via thread::Builder instead of
thread::spawn, so an OS thread-creation failure surfaces as a queued
Event::Error and ERR_START instead of a silent ERR_PANIC.
- readest_localsend.lua: the error handler now checks ls_status before
stopping the service, so a stale error from a superseded overlapping
worker cannot tear down an already-healthy service.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(koplugin): replace localsend ffi cdylib with a helper binary
Old Kindles' glibc is missing symbols the precompiled Rust std pulls in
as weak (getrandom, statx, clock_gettime64), so the cdylib segfaults on
first call. A static-musl subprocess has no glibc dependency and was
verified running on that hardware. This step replaces the C ABI with a
localsend-helper binary that speaks newline-delimited JSON over a local
TCP control socket: a start command plus accept/decline/status/stop,
and started/receive_*/error/status events streamed back. lib.rs keeps
only the reusable config/events/identity/service modules; the musl
cross build, Lua spawn/socket glue, and packaging are follow-up work.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(koplugin): build static musl localsend helper binaries
Rewrite build-localsend-libs.mjs as build-localsend-bins.mjs: it now builds
the localsend-helper standalone binary (static musl for armv7/arm64, plain
cargo for macos) into apps/readest.koplugin/bin/ instead of the old FFI
cdylib into libs/. Static musl has zero glibc dependency and no
softfp/hardfloat split, so the four old glibc-pinned targets collapse into
three binary targets with the exec bit set on the copied artifact.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(koplugin): spawn localsend helper binary over a local socket
Replace the FFI cdylib transport in the koplugin LocalSend integration
(ffi.load + ls_* calls), which crashed on old-glibc Kindles, with the
static-musl localsend-helper binary spawned as a background process and
driven over a local TCP control socket speaking newline-delimited JSON.
library/localsend_ffi.lua becomes library/localsend_helper.lua: pure
binaryNameFor (device to binary filename) and parseLines (socket buffer
to decoded events, no socket/os dependency) plus socket wrappers
(pickPort, spawn, connect, send, recvChunk).
readest_localsend.lua keeps its handlers/dialogs/lifecycle shape as a
process-level singleton that owns the socket across KOReader context
switches; only the FFI calls become socket sends and a non-blocking poll
that frames and dispatches decoded events, adding a status handler that
caches alias/port/localIps/multicastError for statusText().
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(koplugin): package static musl helper binaries in plugin zip and ci
build-koplugin.mjs and the release workflow still referenced the retired
libs/ dir and build-localsend-libs.mjs script from the FFI cdylib era.
Point both at bin/ and build-localsend-bins.mjs, and rebuild the release
CI step around the two static-musl targets (armv7, arm64) instead of the
old three-way gnueabi/gnueabihf/arm64-linux split.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(koplugin): open the kindle firewall for localsend ports on start
Kindle firmware runs iptables with INPUT policy DROP and only allow-lists
explicit ports (KOReader's SSH plugin opens 2222 the same way), so
LocalSend's TCP 53317-53327 server range and UDP 53317 multicast
discovery were unreachable from other devices even though the service
was running. Add library/localsend_firewall.lua, ACCEPT the LocalSend
ports on service start and remove them on stop, matching kaikozlov's
localsend_firewall.lua and KOReader's own SSHPlugin. Gated on iptables
being present and working rather than Device:isKindle(), so it also
covers other DROP-policy devices and safely no-ops where iptables is
absent.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(koplugin): keep localsend poll alive across context switches and refresh filemanager
The receive event poll belonged to the singleton service but was torn down
on a per-instance onCloseWidget, racing the next init's re-attach and
stranding the service with no live poll (incoming transfers silently
ignored). Make the poll service-lifecycle-owned. Also refresh the KOReader
FileManager on receive completion so a received book appears without leaving
and re-entering the folder.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(koplugin): add send flow (list devices, send, cancel) to the helper
Ports the send side of the desktop app's LocalSend service (device_payloads,
run_send, and the cancel plumbing) onto the koplugin helper binary, which
was receive-only. Adds three control-socket commands (list_devices, send,
cancel_send) and three queued events (devices, send_progress, send_end).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(koplugin): add send with localsend file action and device picker
Adds the SEND-side UI on top of S1's helper protocol: a "Send with
LocalSend" file-menu button in FileManager (gated on supported book
formats and helper availability), a ButtonDialog device picker driven
by list_devices, and send progress/result InfoMessages driven by
send_progress/send_end. Includes Helper.deviceLabel, a pure formatter
for the picker's device rows.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* build(koplugin): pin localsend fork to 3cae1825 for best-effort SO_REUSEPORT
Older e-reader kernels (Kindle Voyage on 3.0.35) reject SO_REUSEPORT with
ENOPROTOOPT, and the fork previously propagated that error, which disabled
multicast discovery for the whole device. Bump the readest/localsend pin
to 3cae1825 (set_reuse_port made best-effort) in both the koplugin helper
crate and the src-tauri app, and drop the temporary local path override
used while testing the patch. The app lockfile also refreshes some
Windows-only transitive deps to their latest compatible versions.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(koplugin): register HTTP peers so iOS and Android appear in localsend scan
The helper no-oped ServerEventV2::Register, assuming the discovery crate
added registered peers on its own. Peers that answer a scan over HTTP
/register (iOS, which cannot multicast without the entitlement, and
Android) were therefore never added to the device list, so KOReader only
ever discovered multicast responders such as macOS.
Intercept Register in the event pump and feed the peer into the discovery
store via a ported register_peer(), skipping a device's own scan probes by
fingerprint. Mirrors the Tauri app's src-tauri/src/localsend/service.rs.
Verified on a Kindle: the Android peer now appears in list_devices.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(koplugin): send from the library menu and tag devices by platform
Add a "Send to nearby Readest devices" action to the Readest Library
long-press menu (parity with the FileManager file dialog), gated on the
book having a local copy and a helper binary being available. Show the
peer platform (e.g. macOS, Android) as a tag in the device picker label,
matching the app's device picker. Rename the FileManager actions to
"Add to Readest library" and "Send to nearby Readest devices", and
re-extract the koplugin translation catalogs.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* refactor(koplugin): rename localsend-ffi crate to localsend-bin
The crate stopped being an FFI cdylib when the transport moved to a
static-musl helper binary reached over a local socket; there is no C ABI
anymore, so "ffi" is a misnomer. Rename the crate and its lib, the
directory, the build-script crate path, the koplugin_rust_lint working
directories, the verification rule, and the gitignore target path. The
produced binary is still localsend-helper, so nothing the plugin spawns
or ships changes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* i18n(koplugin): translate the LocalSend strings into all locales
Fill the 31 previously-empty LocalSend msgstr entries (send/receive menu
actions, device picker, and status/error messages) across every koplugin
translation catalog. Product names (Readest, LocalSend, KOReader, Wi-Fi)
stay in Latin script and the %1/%2/%3 placeholders are preserved verbatim.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Cells carried `word-break: break-word; overflow-wrap: anywhere`, and both of
those count mid-word break opportunities when the engine computes min-content
intrinsic sizes. Every cell minimum dropped to a single character, so auto
table layout handed the whole width to the widest column and squeezed a short
label column below its own word width: an interview transcript rendered
"Angela:" as a vertical stack of letters.
Use `overflow-wrap: break-word` instead. It renders the same for a long token
that overflows its cell but leaves intrinsic sizes alone, so columns keep their
word-width minimums. A table too wide for its column still scrolls in the
wrapper added by #4391, which is what contains wide tables now (#4029).
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Import from Folder dialog forced the Read books in place checkbox
ON and disabled once a folder was registered as an external library
folder, and nothing anywhere exposed a way to turn it off again. The
toggle is now editable: it still defaults to ON for a registered
folder, and confirming with it unchecked removes the folder from
settings.externalLibraryFolders so this import and future imports copy
books into the library again. Books previously imported in place keep
working and are converted to managed copies as re-imports encounter
them, since the reader prefers the managed copy over book.filePath.
The drag-drop / URL ingress path that bypasses the dialog now passes
the folder's actual registration state instead of a blanket false, so
it can never silently unregister a folder.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Auto Scroll stepped visibly instead of gliding, worst at the slow end. Scroll
offsets quantize to whole CSS pixels in both Blink and WebKit, so PacedScroller
could only ever emit whole pixels and carried the remainder between frames. At
the minimum speed of 5px/s that is one 1px jump every 200ms.
The scroller now reports the carried remainder every frame, and the session
renders it through the paginator's new subpixelOffset, a composited transform
on the scrollport of less than one pixel. Whole pixels stay in the scroll
position, so progress, relocate, preloading and annotations are unaffected, and
the offset is cleared when the session stops.
Measured on iPhone XR / iOS 18.5 at 5px/s over 2s: 11 distinct on screen
positions with a 1px largest jump before, 121 of 121 frames with a 0.105px
largest jump after, over the same distance. End to end in the web app under
WebKit: 150 of 150 frames distinct, largest jump 0.52px.
Bumps foliate-js for the subpixelOffset API.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(reader): keep progress updating while auto scroll never pauses
The scrolled-mode relocate in foliate sits behind a 250ms trailing
debounce, and Auto Scroll steps the container every frame, so the timer
reset forever and percentage plus time remaining only updated when the
scrolling paused. Bump foliate-js so a continuous run of scroll events
relocates at most once per second while it lasts, and add a regression
test driving a 2.5s scroll burst.
Fixes item 2 of #5635.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(reader): show the slider overlay value above the track
While the swipe gesture adjusts speed or brightness the finger sits on
or below the edge capsule, hiding a readout placed under the slider.
Move the value label to the top and the icon to the bottom in both the
Auto Scroll speed overlay and the brightness overlay.
Fixes item 3 of #5635.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* chore(submodule): point foliate-js at merged main for the relocate max-wait fix
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
The Last Synced labels display the newest pulled record's updated_at,
which is stamped by the authoring device's clock. A peer whose clock is
ahead makes the label read "Synced in an hour" on every other device
(issue #5661). Clamp the value to local now at the three display sites
(reader Sync Info dialog, reader view menu, library settings menu); the
record-derived pull cursor is intentionally left unclamped.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Home and End had no binding in the reader. Add onGoBookStart and
onGoBookEnd to the Navigation section of the shortcut table, both routed
through view.goToFraction so they work for reflowable and fixed-layout
books, in paginated and scrolled modes alike.
Both handlers wait for the view's `inited` flag: the store registers the
view before its opening navigation runs, so a jump fired in that window
would be discarded by it anyway, after needlessly paging in the far end
of the book.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
The stats push sent the entire backlog since the push cursor in a single
/sync request under the client's 10s total socket timeout. A device with
a large backlog, e.g. a fresh push cursor after pulling the full
multi-device history, could never complete the request; the cursor only
advanced on success, so every retry re-sent the same payload and
"Push stats now" failed forever.
Mirror the app's statsSync.ts: send 500 page events per request, never
split events sharing a start_time across chunks, and advance the cursor
after each successful chunk so an interrupted push resumes instead of
restarting. Chunks chain via UIManager:nextTick to avoid nesting a
coroutine resume per chunk on the synchronous HTTP path.
Also switch syncstats_spec to the shared koreader_stubs preloads; its
private ones were dead code losing the package.loaded race in full
suite runs.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* fix(library): stop purge toggle from resizing the delete confirmation popup
* fix(ui): size the alert surface off its container, not its content
Every call site mounts Alert as the lone child of a `flex justify-center`
bar. A flex item without a definite width sizes to its content, so the
alert box measured itself off its own longest line: the delete confirmation
resized mid-dialog whenever the purge toggle swapped in longer copy, and no
two alerts in the app rendered at the same width.
Add `w-full` to the alert wrapper so the existing
`max-w-md sm:max-w-lg md:max-w-xl` caps decide the width instead. This
replaces the `max-w-md` wrapper around DeleteConfirmAlert, which pinned
that one component while leaving the other alerts jittery, and pinned it
416px below the shared desktop cap.
Covered by a browser test that measures real laid-out widths.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Huang Xin <chrox.huang@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>