forked from akai/readest
c8fabd331c8d7a6237b839f39277f4342030cb90
13 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
c8fabd331c |
fix(reader): resolve KOReader sync conflict against non-KOReader servers (#4205)
The sync-conflict dialog had two issues with servers other than KOReader (e.g. Kavita's KOReader-compatible sync endpoint): - "This device" preview rendered a bare "undefined" because reflowable books built the string from `sectionLabel`, which is empty for spine items with no matching TOC entry. It now falls back to the page count. - Choosing "use remote" closed the dialog but never moved the reader: `applyRemoteProgress` only knew how to navigate via CREngine XPointers, so non-XPointer progress strings were silently ignored. It now falls back to `view.goToFraction` using the reported percentage. Also fixes the section-title indentation in the dialog (SectionTitle bakes in `ps-4`, which misaligned the labels against their values). Closes #4200 Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
ad1c2d6bb0 |
fix(reader): filter Magic Mouse wheel events to stop accidental page turns (#4195)
A touch-surface mouse like the Magic Mouse emits a flood of tiny, low- magnitude wheel events — plus an inertial momentum tail — for a single physical gesture, and even a light brush of the surface produces spurious deltas. The previous 100ms trailing debounce collapsed bursts but did not filter by magnitude, so isolated micro-touches and the momentum tail each turned a page, cascading into continuous accidental page turns in paginated mode. Add a wheel gesture detector that accumulates normalized wheel travel and only flips once it crosses a deliberate-intent threshold, then swallows the rest of the stream (the momentum tail) until the wheel goes idle — so one physical gesture flips exactly one page, mirroring native readers. Closes #4117 Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
2d30868d23 |
fix(fonts): hydrate custom fonts on library page, closes #4178 (#4191)
Custom fonts vanished from the Font panel after an app restart unless a book was opened first. The custom-font store is hydrated only by the reader's FoliateViewer (on book open) or by useReplicaPull (gated on a signed-in user), so opening Settings straight from the library left the store empty. Add a useCustomFonts hook that loads persisted custom fonts on mount, unconditional of auth or book state, and mount it on the library page. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
fed8ab7b67 |
fix(tts): restore cross-section auto-page-turn during TTS playback (#4148)
When TTS playback crosses a section boundary, the page would stay on the last page of the previous chapter while audio continued reading the next chapter — leaving the user stuck behind the "back-to-TTS" button. Two compounding issues since the paginator adjacent-section preloading landed: 1. `handleSectionChange` called `view.renderer.goTo(resolved)` without awaiting. `TTSController.#initTTSForSection` does `await this.onSectionChange?.(sectionIndex)` precisely so the view can finish navigating before audio of the new section starts, but the missing await defeated that contract. 2. `handleHighlightMark` returned silently on a cross-section mismatch (`viewSectionIndex !== ttsSectionIndex`), so when the renderer.goTo above completed only partially — which can happen on the new paginator when the target section is already loaded as an adjacent view and the post-goTo state appears reused without a visible page flip — there was no second chance to drag the view to the TTS cfi. Fix: - Await `view.renderer.goTo` in `handleSectionChange`. - In `handleHighlightMark`, run the cross-section branch *before* the `followingTTSLocationRef` check and call `view.goTo(cfi)` directly, stamping `sectionChangingTimestampRef` so the back-to-TTS button stays suppressed while progress.location catches up. Skip only when the user is actively selecting text. Adds unit tests covering both the cross-section navigation path and the in-section scrollToAnchor path. |
||
|
|
1eae2af23e |
feat(sync): batch replica sync into one /api/sync/replicas request (#4109)
Auto-sync triggers (boot non-settings, focus, visibilitychange, online,
periodic) used to fan out N parallel `GET /api/sync/replicas?kind=…`
requests, one per replica kind. With 5 kinds today and the focus path
firing on every foreground transition, that's 5x the Cloudflare Worker
invocations of what the work actually requires.
Server: extend POST /api/sync/replicas to accept a batched-pull body
(`{ cursors: [{kind, since}, …] }`) alongside the existing push
(`{ rows: […] }`). Per-kind queries fan out via Promise.all — Supabase
calls inside the Worker aren't billed as Cloudflare requests, so DB
load is unchanged while Worker invocations collapse from N to 1.
Client/manager: add `client.pullBatch` and `manager.pullMany` that
share the existing cursor/HLC machinery. The boot path's `since=null`
override carries over via `pullMany(kinds, { since: null })`.
Orchestrator: `triggerIncrementalPullAll` now does ONE pullMany call
then fans out per-kind apply via Promise.allSettled. Boot does the
same for non-settings kinds (settings stays a single call to preserve
its apply-first ordering invariant).
Foreground triggers: listen to BOTH `focus` and `visibilitychange`,
sharing one throttle. focus is fastest on iOS Tauri WKWebView (~T=0,
~400ms ahead of visibilitychange). visibilitychange is the only
signal that fires on browser tab switching — focus does not. Drops
the Supabase user-ref-change listener (was the slowest of the three
foreground signals; redundant with the DOM events).
Bonus: `useBooksSync` now serializes `handleAutoSync` against
`pullLibrary` via the shared `isPullingRef` gate. The two paths used
to fire two concurrent `/api/sync?type=books` requests on the same
`since` value at startup; now whichever runs first claims the gate
and the other skips (throttle's `emitLast` retries afterwards).
Per session: boot 5→2 Worker calls. Per foreground trigger: 5→1.
|
||
|
|
4110911011 |
fix(sync): keep dictionarySettings consistent across devices (#4105)
The bundled `settings` replica's `dictionarySettings.providerOrder`
and `providerEnabled` repeatedly drifted on multi-device setups: a
fresh-install Device B would overwrite Device A's authoritative
order with its own local default, dict tombstones referenced via
the settings replica left "skipped" gaps in the UI, and providerEnabled
keys missing from providerOrder rendered as silently lost imports.
Six related fixes (mostly orthogonal):
- **Disk-priming** in `initSettingsSync(initialSettings)`: seeds
`lastPublishedFields` from the just-loaded disk settings so the
first `setSettings(disk_default)` at boot diffs against the disk
baseline (no diff → no push), instead of diffing every whitelisted
field against `undefined` and clobbering the server with locals.
- **Settings boot pull is awaited first** in `useReplicaPull` (with
a shared `settingsBootPullPromise`) so the dict/font/texture/opds
pulls' auto-saves see server-primed `lastPublishedFields` rather
than disk defaults — implicit even when the caller didn't request
the `settings` kind.
- **Visibility / online / periodic auto-pull** in `useReplicaPull`:
module-level listeners with a 30s visibility throttle and a 5-min
interval keep long-lived foreground tabs in sync (previously the
hook only did the once-per-session boot pull and `ReplicaSyncManager.startAutoSync`'s
comments lied — it only flushed dirty pushes).
- **Tombstone scrubbing for no-local rows**: `softDeleteByContentId`
scrubs `providerOrder` / `providerEnabled` by contentId regardless
of whether a local dict matches, and `applyRow` always invokes it
on tombstones — so Device B fresh-installs that pulled tombstoned
contentIds via the settings replica without ever having a local row
still get the provider-side entries cleaned.
- **Orphan rescue** in `loadCustomDictionaries`: providerEnabled keys
that have no slot in providerOrder (per-field LWW splits a settings
push) get spliced before the first builtin so user-imported dicts
stay contiguous near the top of the list, not stranded after the
builtins where users miss them.
- **`addDictionary` prepends** to `providerOrder` so a fresh local
import shows up at the top of the list. Reviving a soft-deleted
entry preserves its existing slot.
- **Explicit-publish gate for `providerOrder`**: `markExplicitProviderOrderPublish()`
in `replicaSettingsSync` is the only way for `publishSettingsIfChanged`
to ship `dictionarySettings.providerOrder`. UI handlers that
intentionally reorder (drag-drop, dict import, dict delete,
web-search add) opt in via `saveCustomDictionaries(env, { publishOrderChange: true })`.
Auto-mutations from replica pull / orphan-rescue / tombstone-scrub
no longer ever republish the local view of order.
12 new tests across `replicaSettingsSync`, `replicaPullAndApply`,
`useReplicaPull`, and `customDictionaryStore`.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
712d564e9d |
feat(sync): encrypted OPDS credentials + Tauri keychain (PR 4c + 4d) (#4090)
* feat(sync): encrypt opds_catalog credentials end-to-end (TS path) Wires encrypted-credential sync for opds_catalog via the CryptoSession shipped in PR 4a (#4084) plus a new publish/pull crypto middleware. TS-only — native still uses ephemeral storage (re-enter passphrase per launch); PR 4d wires the OS keychain. - ReplicaAdapter gains optional `encryptedFields: readonly string[]`. Adapters stay sync; the middleware handles the crypto round trip. - replicaCryptoMiddleware.ts: encryptPackedFields drops the named fields from the push when the session is locked (no plaintext leak); decryptRowFields drops them on pull failure (local plaintext preserved by the store merge). - replicaPublish / replicaPullAndApply invoke the middleware. - OPDS adapter declares encryptedFields = [username, password] and now pack/unpack them as plaintext. - passphraseGate.ts: ensurePassphraseUnlocked coalesces concurrent calls, prompts via the registered prompter with kind=setup|unlock, throws NO_PASSPHRASE on cancel. - PassphrasePromptModal mounted at the Providers root; registers itself as the gate prompter. - CryptoSession.forget() wipes server-side envelopes + salts. - Migration 010 + replica_keys_forget RPC; DELETE /api/sync/replica-keys + client wrapper. - SyncPassphraseSection on the user page: status / Set / Unlock / Lock / Forgot. - CatalogManager pre-save: ensurePassphraseUnlocked when credentials are present; user cancel saves locally without sync. Plan updated: PR 4 split documented as 4a/4b/4c/4d. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(sync): persist sync passphrase via OS keychain (Tauri) Replaces the EphemeralPassphraseStore stub on native with real OS-keychain storage so users don't re-enter their sync passphrase every launch. Web stays on the in-memory ephemeral store by design. Native bridge plugin gains 4 commands wired across all platforms: - Rust desktop (`keyring` crate): macOS Keychain on apple-native, Windows Credential Manager on windows-native, Linux libsecret/ Secret Service on sync-secret-service. Per-target features so each platform compiles only the backend it needs. - iOS Swift: Security framework Keychain (kSecClassGenericPassword, SecItemAdd / Copy / Delete). - Android Kotlin: androidx.security EncryptedSharedPreferences (AndroidKeystore-derived AES-GCM master key, AES256_SIV / AES256_GCM key/value encryption). TS layer: - TauriPassphraseStore wraps the bridge calls. set is fail-loud (surfaces keychain rejection); get is fail-soft (returns null on any error so the gate prompts). - createPassphraseStore returns ephemeral synchronously; upgradeToKeychainIfAvailable swaps the singleton to TauriPassphraseStore on Tauri after probing the bridge. CryptoSession resolves the store via createPassphraseStore() each touch so the swap is transparent. - CryptoSession.tryRestoreFromStore: silent unlock at boot. Stale- entry recovery clears the store when the account has no salt server-side. unlock/setup persist; forget also clears the store. - Providers boot effect: upgrade keychain → silent restore. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(sync): make encrypted-credential pull actually decrypt + UX polish PR 4c shipped the encrypt path but the pull side silently dropped ciphers when locked, the modal was busy with double-rings, and web re-prompted on every page refresh. This rolls up the post-test fixes + UX polish: Pull-side decrypt: - decryptRowFields takes an `onLocked` callback the orchestrator wires to the passphrase gate; encountering a cipher field with a locked session now triggers the lazy-prompt path instead of dropping the field. - replicaPullAndApply re-applies the unpacked row for metadata-only kinds even when a local copy exists, so the now-decrypted creds reach the store (the binary-kind skip-if-local optimization doesn't apply). - Cipher fingerprint comparison: capture the row's `cipher.c` for each encrypted field, compare against the local record's lastSeenCipher. Same → skip prompt + decrypt entirely. Different (rotation / value change on another device) → prompt to re-decrypt. Fingerprint persists via OPDSCatalog.lastSeenCipher. Web persistence: - SessionStoragePassphraseStore: passphrase survives page refresh within the same tab, dies on tab close. Replaces EphemeralPassphraseStore as the default on web. Avoids localStorage / IndexedDB to keep the tab-scoped trust boundary. UI: - Renamed PassphrasePromptModal → PassphrasePrompt; modernized: filled input style with single subtle focus border, btn-primary + btn-ghost replaced with leaner custom buttons. eink-bordered + btn-primary classes give the dialog correct e-paper rendering. - globals.css: suppress redundant outline/box-shadow on focused text inputs / textareas (the element's own border is the focus indicator). - AGENTS.md: documents the e-ink convention (`eink-bordered`, `btn-primary` for inverted CTAs, etc.) so future widgets ship with e-paper support. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
981579c255 |
feat(sync): cross-device custom font sync (#4077)
* refactor(sync): kind-agnostic replica primitives Extract dict-only sync into shared primitives (registry, pull/apply orchestrator, persist env, schema allowlist) so other kinds can plug in. Companion changes: per-replica Storage Manager grouping, useReplicaPull boot-race recovery, manifest=null reconciliation on every boot pull, copyFile takes explicit srcBase + dstBase, settled- event helpers, lenient webDownload Content-Length (R2/S3 signed URLs commonly omit it), and generic "File" transfer toast copy any replica kind can share. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(sync): cross-device custom font sync Plug the font replica adapter into the kind-agnostic primitives: font store gains replica wiring, custom font import publishes the replica row + queues a binary upload, and bootstrap registers the font adapter and download-complete handler. Includes legacy flat-path migration so pre-existing fonts sync without re-import, full @font-face activation on auto-download (load + mount the rule, mirroring manual import), and a fix to createCustomFont so contentId / bundleDir / byteSize survive the trip through addFont — otherwise import-time publish silently no-oped on missing contentId. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
cbdc3b8f52 |
feat(sync): wire dictionary store through replica sync (follow-up to #4075) (#4076)
* feat(sync): cross-device dictionary sync Custom MDict / StarDict / DICT / SLOB dictionaries now sync across signed-in devices via the replica layer. - Store mutations publish replica rows with field-level LWW + tombstones. - Re-importing the same content (renamed or after delete) preserves the user's label and reincarnates the server row instead of duplicating. - Manifest commits after binary upload so other devices never see a row whose binaries aren't on cloud storage yet. - Pull-side orchestrator creates a placeholder dict, queues the binaries via TransferManager, and clears the unavailable flag on completion. - Toast copy branches by transfer kind so dict uploads don't read "Book uploaded". Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(sync): boot pull and binary download path - Defer the boot pull until TransferManager is initialized so download enqueues aren't dropped. - Auto-persist the local dict store after applyRemoteDictionary; otherwise the next loadCustomDictionaries wipes the in-memory rows. - Boot pull passes since=null so a device whose cursor advanced past unpersisted rows can still recover. - Skip pulling when not authenticated instead of logging "SyncError: Not authenticated" on every boot of a signed-out device. - downloadReplicaFile resolves the destination against the kind's base dir; binaries previously landed at the literal lfp and openFile then failed with "File not found". Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(sync): per-page useReplicaPull hook Lifts the boot-time pull out of EnvContext into a hook each page mounts for the kinds it needs: useReplicaPull({ kinds: ['dictionary'] }). Library page and the shared Reader component opt in. The hook fires 10s after page load (so feature mounts hydrate first), dedups per-kind across navigation, and releases the slot on failure so a later mount can retry. Future kinds plug into the hook's per-kind switch. Also closes two refresh-loop bugs: - Hydrate the dict store from settings BEFORE the apply loop, so the auto-persist doesn't clobber persisted rows that the in-memory store hadn't yet read. Library-page refresh was the visible victim. - Skip the download queue when every manifest file is already on disk under the resolved bundle dir. Refreshing is a no-op; partial- download recovery still queues because some files would be missing. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
030a7c0823 |
perf: optimize library operations for large collections (#3827)
* perf(store): decouple page turn from full library rewrite for large collections Previously every page turn triggered setLibrary() which copied the entire library array, ran refreshGroups() with MD5 hashing over all books, and caused cascading re-renders. With ~2800 books this made reading unusable. - Add hash-indexed Map to libraryStore for O(1) book lookups - Add lightweight updateBookProgress() that skips array copy and refreshGroups - Use hash index in setProgress, saveConfig, and initViewState - Batch cover URL generation with concurrency limit on library load Addresses #3714 * perf(import): replace filter()[0] with find() to short-circuit on first match * fix(store): replace Object.assign state mutation with immutable spread in setConfig * perf(persistence): remove JSON pretty-printing to reduce serialization overhead * fix(reader): stabilize debounce reference in useIframeEvents to prevent timer reset on re-render * perf(context): memoize provider values to prevent unnecessary consumer re-renders * perf(store): cache visible library to avoid refiltering on every access * perf(library): remove redundant refreshGroups call already triggered by setLibrary * perf(import): replace O(n) splice(0,0) with O(1) push for new book insertion * perf(import): defer library persistence to end of import batch instead of every 4 books * perf(library): skip full library reload on reader close since store is already in sync * fix: address PR review feedback for library perf optimizations Correctness fixes for issues found in code review: - fix(library): restore library reload on close-reader-window. Reader windows are independent Tauri webviews with their own libraryStore instance, so progress / readingStatus / move-to-front updates from the reader do not propagate to the main window. Reload from disk so the library reflects the changes the reader just persisted. - perf(import): wire BookLookupIndex into importBooks. The lookupIndex parameter on bookService.importBook had no caller, leaving the Map-based dedup path dead. Build the index once per import batch in app/library/page.tsx and thread it through appService.importBook so the O(1) dedup path is actually exercised. - perf(import): defer library save to end of batch. Add a skipSave option to libraryStore.updateBooks and call appService.saveLibraryBooks once after the entire import loop, instead of once per concurrency-4 sub-batch. - fix(store): make updateBookProgress immutable. The previous in-place mutation reused the same library array reference, bypassing Zustand change detection AND leaving the visibleLibrary cache holding stale Book references. Now slice the array, update the entry, and refresh visibleLibrary. Also make readingStatus a required parameter so future callers cannot accidentally clear it by omitting the argument. - fix(store): make saveConfig immutable. It previously mutated the Book object's progress / timestamps in place and used splice/unshift on the shared library array. Now spread to a new book object and rebuild via setLibrary. Also corrects the interface signature to return Promise<void> (the implementation was already async). - fix(store): make updateBook immutable for the same reason — it was mutating the previous-state library array before spreading. - fix(context): wrap AuthContext login/logout/refresh in useCallback. Without this, the useMemo deps array changed every render and the memo was a no-op, defeating the optimization the PR was trying to add. - fix(reader): use a ref for handlePageFlip in useMouseEvent's debounce. The empty-deps useMemo froze the first-render handler; with the ref the debounced wrapper always invokes the latest closure. Test coverage added: - library-store: immutable updateBookProgress, visibleLibrary cache refresh, deleted-book filtering, updateBooks skipSave option - book-data-store: immutable saveConfig, move-to-front correctness, visibleLibrary order, persistence behavior - import-metahash: BookLookupIndex update on new import, lookup-index consultation before scanning books array - auth-context (new file): context value identity stability across re-renders, callback identity stability - useIframeEvents (new file): debounced wheel handler dispatches to the latest handlePageFlip after re-render Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor(types): move BookLookupIndex to types/book.ts Avoids the inline `import('@/services/bookService').BookLookupIndex` type annotation in types/system.ts. Both the AppService interface and the bookService implementation now import BookLookupIndex from the canonical location alongside Book. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor(import): convert importBook params to options object Replace the long positional-argument list on appService.importBook (saveBook, saveCover, overwrite, transient, lookupIndex) with a single options object so callers no longer need to pad with `undefined, undefined, undefined, undefined` to reach the parameter they actually want to set. Before: await appService.importBook(file, library, undefined, undefined, undefined, undefined, lookupIndex); After: await appService.importBook(file, library, { lookupIndex }); The underlying bookService.importBook is also refactored to take an options object: required AppService callbacks (saveBookConfig, generateCoverImageUrl) are bundled with the optional flags via an ImportBookInternalOptions interface that extends the public ImportBookOptions defined in types/book.ts. All existing call sites updated to the new shape. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Huang Xin <chrox.huang@gmail.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> |
||
|
|
b679817fce |
fix(tts): prevent double playback on rapid TTS icon clicks (#3764)
Guard handleTTSSpeak with a single-flight ref so a second tts-speak event that arrives while the first is still inside its initial awaits (initMediaSession / backgroundAudio / TTSController.init) is ignored instead of racing ahead to construct a second TTSController. Without this, rapid clicks produced two concurrent speakers talking over each other because viewState.ttsEnabled is only set at the end of the first invocation, so the footer bar would dispatch tts-speak twice. Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> |
||
|
|
70b94d8986 | fix(layout): fixed layout of progress bar in vertical mode (#3749) | ||
|
|
f361698e05 |
feat(android): add D-pad navigation for Android TV remote controller (#3745)
* feat(android): add D-pad navigation for Android TV remote controller Add basic D-pad/arrow key navigation support for Android TV Bluetooth remote controllers, covering the full flow: library grid browsing, reader page turning, toolbar interaction, and TTS toggle. Library: - Custom spatial navigation hook for grid D-pad navigation - Arrow keys move between BookshelfItem elements with auto column detection - ArrowDown from header enters the bookshelf grid Reader: - Enter key toggles header/footer toolbar visibility - Left/Right navigate between toolbar buttons, Up/Down moves between header and footer bars - Back button dismisses toolbar (with blur) before sidebar/library - Focus-probe technique for reliable button visibility detection across fixed/hidden containers Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: only auto-focus toolbar buttons on keyboard activation, not mouse hover Track pointer activity to distinguish keyboard vs mouse toolbar activation. When the toolbar appears due to mouse hover, skip auto-focus to prevent unwanted focus outlines on desktop. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: track keyboard events instead of pointer events for auto-focus guard Pointer events from iframes don't bubble to the main document, so tracking pointermove/pointerdown missed hover interactions over book content. Track keydown instead — auto-focus only when a recent keyboard event (Enter key) triggered the toolbar, not mouse hover. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> |