75f1fafe9f229e4ef048a97b3857ae5c8b461699
1974 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
75f1fafe9f |
feat(reader): slide and page curl turn animations (#555) (#4940)
* feat(reader): slide and page curl turn animations (#555) Add an Animation Style setting (Push, Slide, Page Curl) next to the Paging Animation switch. Slide moves the turning page over the still previous or next page like the Apple Books slide; Page Curl folds it open in 3D so the page underneath is partially visible as it turns. Both styles track the finger: the page follows a horizontal drag and commits past halfway or on a flick, or settles back. The page header and footer stay in place while the page turns. The styles layer a View Transitions snapshot of the outgoing page over the live, stationary incoming page, since the pages of one section live in a single iframe and can never be on screen twice. They work for all writing modes including vertical-rl, and on engines without the View Transitions API (older WebViews) the paginator falls back to the existing push animation, so all platforms keep working page turns. The paginator changes live in the foliate-js submodule; this bumps the pointer, wires viewSettings.pageTurnStyle to the renderer turn-style attribute, and adds browser tests covering slide layering, curl, vertical-rl, finger tracking with commit and revert, and the push fallback. Fixes #555 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(reader): add WebGL page curl renderer for mesh turn animations (#555) Grid mesh deformed around a cylinder: content past the fold wraps over and lands mirrored on top with a whitened page back, transparent where the page has curled away. Corner grabs start as a steep diagonal pinch that straightens as the turn completes so the whole page clears by the end. Groundwork for the Tauri mesh curl; capture and orchestration land separately. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(native-bridge): capture webview region as PNG on macOS and iOS (#555) New capture_webview_region plugin command returns a binary PNG snapshot of the calling webview (tauri::ipc::Response, no JSON overhead) for the mesh page-curl texture. macOS goes through WKWebView takeSnapshotWithConfiguration via with_webview on the main thread with a 500ms timeout; iOS snapshots in Swift and hands the PNG across the JSON-only plugin boundary base64-encoded, decoded back to bytes in mobile.rs. Windows, Linux, and Android reject for now so the JS side falls back to the CSS curl. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(reader): drive the mesh page curl on Tauri platforms (#555) Wire the WebGL curl renderer and the native webview capture into page turns. A MeshCurlTurn controller runs the pipeline per turn: snapshot the content box, overlay the captured page drawn flat, turn the live view instantly underneath (the paginator's animated paths all gate on the animated attribute), then curl the capture away. Backward turns mirror the fold to the spine edge, matching the layered VT curl's old-page-recedes choreography. useMeshPageCurl wraps the view's prev/next so taps, keys, and wheel turns all curl, and registers a touch interceptor (between the reading ruler and the fixed-layout swipe) that scrubs the curl from the finger, committing past halfway or on a flick and otherwise un-curling and turning back under the overlay. The paginator stays out of the way via no-swipe while the mesh is active; if the native capture ever fails the session falls back to the paginator's CSS arc-fold curl and the shared applyPageTurnAttributes helper restores turn-style. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(build): restore iOS builds on Xcode 26.2 with a vendored swift-rs Swift 6.2's driver no longer honors swift-rs 1.0.7's cross-compilation style (swift build --arch <host> with per-swiftc -target overrides and an inherited SDKROOT): plugin sources compile against the wrong platform's Swift overlays and fail with baffling errors like type 'Bundle' has no member 'main' and extra argument 'privacy' in call. Upstream swift-rs is unmaintained, so vendor it under packages/swift-rs via a crates-io patch and build with SPM's first-class --triple/--sdk flags instead, dropping the leaked SDKROOT so the host-targeted manifest compile stays clean. Artifacts land in the unversioned-triple directory now, so the link search path follows. With --triple, SPM enforces the deployment floor declared in Package.swift (the old override bypassed it): bump native-bridge to iOS 15.0, matching the app's deployment target, since StoreKit's Storefront is used unguarded. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(native-bridge): capture webview region on Android via PixelCopy (#555) Implements the Android side of capture_webview_region so the mesh page curl works there too. The Kotlin command scales the CSS-pixel rect by the display density, offsets it by the webview's window position, and reads the pixels back from the window surface with PixelCopy (API 26+, the app's minSdk), which includes the hardware-accelerated WebView that View.draw would miss. PNG encoding runs off the main thread and the result crosses the JSON plugin boundary base64-encoded, decoded back to bytes in mobile.rs like iOS. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(reader): right the upside-down page curl on iOS (#555) The renderer oriented its texture with UNPACK_FLIP_Y_WEBGL, which WebKit ignores for ImageBitmap uploads: on iOS the captured page rendered upside down, and the mirrored page back read as rotated 180 degrees instead of the ink-through-paper horizontal mirror Apple Books shows. Upload unflipped and sample page coordinates directly so no pixel-store flag is involved. The page texture in the browser test was only horizontally asymmetric, which is how the flip slipped through; it now uses four quadrants fed through the production PNG-blob-to-ImageBitmap path and pins the vertical orientation. Verified red/green by running the suite on Playwright WebKit, which reproduces the iOS behavior. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(reader): curl the whole page including header, footer, and margins (#555) The mesh curl captured only the margin-inset content box, leaving the running header, footer, and page margins static while just the text column turned. A physical page turn takes the whole sheet with it, as Apple Books does, so the capture and overlay now span the full reader cell. The overlay mounts above the in-cell header (z-10) and footer, so the static copies never show through the turning page. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(reader): gate layered View Transition turns and slide from a capture instead (#555) iOS 18 WebKit ships document.startViewTransition but crashes the WebContent process when a page-turn transition snapshots the reader, so the mere presence of the API is not enough for the layered slide/curl turns. Require nested view-transition groups (Chrome/WebView 140+) as the marker of a mature engine before setting turn-style on the renderer. Engines that fail the check no longer lose the slide on Tauri: the mesh curl's capture pipeline generalizes to CapturedPageTurn and now also drives a flat slide overlay (capture the outgoing page, turn instantly underneath, translate the captured page out toward the spine, mirrored for backward turns), clipped to the content box with an edge shadow like the VT slide. On the web, engines without full support fall back to push and the Slide/Page Curl options are hidden from the Animation Style select; a synced slide/curl setting from another device reads as Push there. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(reader): make the Android page curl start instantly (#555) The Android capture encoded a full-density PNG: 1080x2400 on a 3x Xiaomi 13 took ~1.5s per turn, so the page sat frozen long enough to read as the curl not working at all. Encode JPEG instead (the page is opaque) and cap the destination bitmap at 2x CSS pixels - PixelCopy scales into a smaller bitmap for free and the moving page stays sharp. Measured on device over CDP: the capture invoke drops from 1550ms to 34ms and the curl overlay mounts 132ms after the tap. The JS side stops hardcoding an image/png blob type and lets the decoder sniff the platform's actual format. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * perf(reader): encode iOS page-curl captures as capped JPEG (#555) Apply the Android speedup to iOS: encode the snapshot as JPEG (the page is opaque) off the main thread, and cap it at 2x CSS pixels via WKSnapshotConfiguration.snapshotWidth on 3x screens, cutting both the encode time and the base64 payload crossing the JSON plugin boundary. The JS side already sniffs the image format from the bytes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
e7f0b53bdf |
fix(opds): crawl subdirectories when auto-downloading directory-style catalogs (#4948)
Copyparty and other file servers expose each folder as an OPDS feed where subfolders are rel="subsection" navigation entries. Auto-download only followed the catalog's "by newest" feed or the subscribed feed itself, so books in subfolders were never discovered, and a folder containing only subfolders was skipped entirely. When a catalog has no "by newest" feed, treat it as a directory-style listing and crawl its subsection navigation entries breadth-first, bounded by MAX_CRAWL_DEPTH levels, MAX_FEEDS_PER_CRAWL fetches, and the visited set. Library catalogs with a "by newest" feed keep the previous behavior and are never crawled. Facet and structural rels (self, up, start, top, search) are excluded so the crawl cannot escape the subscribed folder. Fixes #4272 Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
3f4d4b8643 |
fix(transfer): persist queue when clearing completed/failed/all (#4947)
Clear Completed, Clear Failed and Clear All mutated the Zustand store directly, so the cleared items were never written back to localStorage. On the next load the persisted queue restored them and they reappeared in the Transfer Queue panel. Route these clears through transferManager (like clearPending already does) so each calls persistQueue() after mutating the store. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
9202864846 |
fix: real fix for library-save storage-permission crash + narrowed view-transition filter (#4943)
* fix(library): request storage permission when saving to a custom folder On Android a custom library folder on shared storage needs All Files Access. The import/settings/migrate paths request it, but the library-save path (updateBook/updateBooks -> saveLibraryBooks) did not, so on a device without the permission every save failed with EACCES; because callers (sync, imports) don't await/catch it, it surfaced as an unhandled-rejection crash (Sentry READEST-A). saveLibraryBooks now catches a storage-permission error, requests the permission through the existing flow, and retries once. It prompts at most once per session so background saves don't repeatedly open system settings; a still-denied save is logged rather than crashing (the user was already shown the All Files Access screen). Fixes READEST-A Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(sentry): drop only the benign hidden-tab View Transition error The View Transition API skips a transition when the tab is hidden; that unhandled rejection is expected browser behavior and pure noise, so it is dropped in before_send. A transition timeout abort (READEST-9) is NOT dropped: a slow DOM update can signal a real performance problem, so it stays visible. Fixes READEST-7 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
2963e75bdd |
fix(sync): propagate group membership for already-synced books (#4946)
Group membership synced only for newly-imported books. Re-grouping a book already present on both devices bumped book.updatedAt and won the library-index LWW race, but mergeBookMetadata dropped groupId/groupName from the overlay, so the change never reached peers. New books instead arrive via addBookToLibrary with the full remote object, which is why their group did travel. Carry groupId/groupName in mergeBookMetadata, matching native cloud sync (transform.ts maps group_id/group_name). Values are assigned raw so a group removal also propagates. Fixes #4942 Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
ec45a080fc |
feat(metadata): surface calibre custom columns from EPUB metadata (#4939)
Parse calibre's embedded user metadata (custom columns) from the OPF in foliate-js, store it on BookMetadata.calibreColumns, render the columns in the book details view, and match column names and values in the library search so a value like a recommends tag can be found by typing it. Closes #4811 |
||
|
|
843ab3448b |
feat(tts): keep TTS playing when the book is closed (#4941)
* refactor(tts): controller owns its foliate TTS instance and emits lifecycle events view.close() nulls view.tts, so the controller keeps its own handle (mirrored to view.tts while attached; reads prefer the public mirror). state becomes an accessor that dispatches tts-state-change on a microtask, and terminal conditions (end of content, error exhaustion) fire an explicit tts-session-ended: 'stopped' is a transit value that occurs on every paragraph advance and must never be read as death. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(tts): make TTSController detachable from the reader view detachView enters headless mode: layout-dependent work is guarded, the dead hook's preprocess/section-change closures are severed, and text supply continues through created documents while position events keep flowing. attachView adopts a new view without touching in-flight audio, re-seeding the fresh text instance from the old cursor AT the synchronous swap point (auto-advance during async prep would otherwise replay a paragraph) and aborting via an attach epoch when a detach supersedes it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(tts): move media session ownership to a session-scoped bridge ttsMediaBridge binds directly to the controller (metadata per mark, clamped position state, transport handlers, the silent keep-alive element) so the lock screen keeps working when the reader hook is unmounted. The hook's last-writer-wins handler effect and its per-render re-registration are gone; the panel now derives isPlaying from the controller's state channel, so lock-screen transport keeps the in-reader UI truthful. useTTSMediaSession had no consumers left and is removed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(tts): add hash-keyed TTS session manager with sleep timer and headless persistence Sessions key by book hash (bookKey is regenerated per open), the playback-state relay dedupes transit stopped values so paragraph advances never flicker followers, and terminal handling rides the explicit tts-session-ended event. The sleep timer survives reader unmount, and headless positions persist through the book config on disk (view/progress stores are cleared on close and reopen loads from disk). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(tts): keep TTS playing across book close and reattach on reopen Back-to-library and Android back dispatch tts-close-book (detach when the session is not terminated: transit stopped states during chapter transitions must not kill it); quit and window-destroying closes keep the hard tts-stop so the foreground service tears down with the webview. The unmount cleanup transfers ownership to the manager instead of shutting down, covering deep-link book switches and split-view pane closes. Mounting a book adopts a matching background session once the view is ready (primary pane only) and stops a different book's session unless it is still mounted elsewhere. The sleep timer moves to the manager and a one-time toast announces the first background continuation. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(tts): now-playing bar in the library for background sessions Floating pill above the shelf while a TTS session outlives its reader: cover, title, sleep-timer countdown, play/pause following the manager-relayed playback channel, and a hard stop. Tapping the body reopens the book in the SAME window regardless of the new-window preference, since the session is a per-webview singleton. Deleting the playing book stops the session before its data is cleared. The bookshelf reserves scroll clearance via a --now-playing-inset var the bar sets while visible. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(tts): make the header close button background-eligible The header X routes through handleCloseBook (onCloseBook), not handleCloseBooksToLibrary, so the sticky eligibility ref never got set and closing a book from the header hard-stopped a live TTS session. Replace the ref with an explicit keepTTSAlive parameter on saveConfigAndCloseBook/handleCloseBooks: back-to-library, Android back, and pane closes pass true; beforeunload, quit-app, and window close invoke handleCloseBooks with an event object, which coerces to a hard stop. This also removes the stickiness where one background close would have made a later quit detach instead of stop. Verified live in Chrome dev-web: close from the header keeps audio playing with the now-playing bar shown; reopening reattaches the same session (generation numbering continues); opening a different book stops it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
42f9b8fe3c |
feat(tts): gapless Web Audio playback engine for Edge TTS with chapter timeline and seek (#4931)
* feat(tts): add PCM speech-bounds detection for sentence audio trimming Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(tts): add WSOLA time-stretch for pitch-preserved playback rate Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(tts): add sentence duration store with per-voice speaking-rate calibration Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(tts): serve edge audio as ArrayBuffer with in-flight fetch dedup Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(tts): add WebAudioPlayer with gapless chunk scheduling and backpressure Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(tts): play edge TTS through gapless Web Audio pipeline Replaces the per-sentence audio element with trimmed, time-stretched buffers scheduled on the shared AudioContext. Marks dispatch at audible time so schedule-ahead cannot run foliate's cursor past the voice; a decode failure or missing audio skips the chunk instead of wedging the session; pause and resume ride context suspend and resume with no iOS rewind hack; the object-URL cache is gone. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(tts): add section timeline with measured and estimated sentence durations Includes the foliate-js submodule bump for the getSentences export (fork branch feat/tts-get-sentences; fork PR must merge before this lands so the pinned SHA resolves). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(tts): expose section playback position and sentence-snapped seeking Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(tts): surface playback position and seek in the media session Position state is clamped, never skipped, so the lock-screen scrubber stays live when estimates overshoot; seekto units map per backend (native ms, web seconds). The AudioContext warms up in the tts-speak gesture path before any await, and the silent keep-alive element now runs on all platforms so desktop hardware media keys survive the removal of the per-sentence audio element. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(tts): add seekable chapter progress bar to the TTS panel The scrubber joins the transport cluster with a thin range-xs track and flanking tabular time labels so it cannot be misgrabbed for the chunky rate slider (which persists a global setting). States: reserved disabled slot until the lazy timeline lands, persists across chapter transitions, optimistic thumb with failure toast, monotonic position, tilde-prefixed estimated totals, sentence-event updates under e-ink. The popup grows only when a timeline-capable client is active. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: record deferred TTS listening-engine follow-ups Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: record background TTS decoupling design decisions Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(tts): slim the panel scrubber to a native track with remaining time Match the footer Jump to Location slider (plain native range: thin track, small thumb) instead of the chunky daisyUI pill, show remaining time with a minus prefix on the right, and drop the This chapter caption. Popup height shrinks accordingly. Verified live in Chrome. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore: pin foliate-js to merged main with getSentences export Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(tts): catch autoplay rejection from the keep-alive element Running the silent keep-alive on all platforms exposed an un-awaited play() that headless Chromium rejects without a user gesture, failing CI on unhandled rejections while every test passed. The keep-alive is best-effort; the production path is gesture-qualified. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
4dbe9cc9f1 | fix: Sentry production hardening (release/OS tags, unhandled-rejection & render-loop guards) (#4929) | ||
|
|
c86decc2c1 | fix(test): make Android double-tap e2e pass on default-config CI devices (#4921) | ||
|
|
1d3dfd395f |
fix(ios): stop share extension hijacking shared .txt files (#4917)
The iOS share extension is a web-article URL clipper, but its activation rule enabled NSExtensionActivationSupportsText. A .txt file is public.plain-text (conforms to public.text), so that key made the URL-only extension activate for plain-text files it cannot handle: the share sheet hung instead of the file taking the main app's CFBundleDocumentTypes "Copy to Readest" import path, which handles txt fine (like EPUB and PDF, which never matched the extension). Drop NSExtensionActivationSupportsText so the extension activates only for web URLs. Shared .txt files now route to the working document-open import path; sharing a web page URL from Safari or Chrome still works. Add a regression guard asserting project.yml (the xcodegen source of truth for the generated, skip-worktree Info.plist) never re-enables text activation. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
6391bfe788 |
feat(settings): redesign theme mode toggle as a segmented control (#4831) (#4913)
The three theme-mode toggles were small btn-circle btn-sm icons spaced by gap-4, so on mobile they were hard to hit and easy to mis-tap. Replace them with a segmented control: an ARIA radiogroup of three adjacent radio segments sharing one track. Each segment is a full-height tap target (min 44px wide, 36px tall) with no dead space between them, and the active segment gets the app's canonical base-300 fill. In e-ink mode the active segment uses a solid eink-inverted fill instead of a nested border, so it stays legible without a second border clashing with the track outline. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
745f28f346 |
fix(reader): distinguish two-finger scroll from pinch-zoom on touchscreens (#4858) (#4912)
On touchscreen laptops (e.g. Surface), scrolling a fixed-layout book webtoon-style with two fingers moving the same direction accidentally triggered pinch-zoom. The old code committed to a pinch on the first two-finger touch and applied the raw distance ratio from the first move, so a slightly non-parallel scroll drifted the finger spacing and zoomed. Defer the decision with a pending state: on two fingers, compare the change in finger separation against the midpoint travel. A pinch changes separation while the midpoint stays put; a scroll moves the midpoint while separation barely shifts. Zoom only engages once separation change crosses a 24px deadzone and outweighs the pan distance; a 12px pan locks the gesture as a scroll and lets the page scroll natively. On pinch confirm, re-baseline the distance so zoom starts at 1x with no snap. Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
2b524439bf |
fix(reader): keep running header/footer readable over light PDFs in dark mode (#4901) (#4911)
The running section title and page-number footer used text-neutral-content, which is a light color in dark mode. A light-mode PDF stays white under a dark theme (invertImgColorInDark defaults to false), so the light text sat on the white page and became unreadable. Blend the header/footer text against whatever is behind it using mix-blend-mode: difference with a fixed white/75 anchor, so it inverts to dark on a light page and stays light on a dark margin. white/75 matches the former neutral-content brightness over the dark theme, so reflowable books look unchanged. E-ink keeps its plain base-content text; StatusInfo and the sticky progress bar manage their own colors and are left untouched. Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
8c91ad411c |
fix(reader): open annotation deep link when a different book is open (#4887) (#4910)
An annotation deep link (readest://book/{hash}/annotation/{id}?cfi=...) for a
book that is not the one currently shown in the reader was ignored: the reader
stayed on the open book. It only worked from the library page.
Two causes, both in the reader-mounted path:
- useOpenAnnotationLink fell through to navigateToReader when the target book
had no live view. router.push to the same /reader route does not re-run the
reader's one-shot init effect, so it was a no-op and the book never changed.
Route it through the in-place switch event (open-book-in-reader) carrying the
cfi, mirroring useOpenBookLink.
- The "already open, jump in place" check scanned all viewStates, which keep
stale entries for books switched away from (their views are detached from the
DOM, never cleared on switch). Switching A -> B -> A matched the stale A view
and called goTo on a dead view. Scope the check to the currently displayed
bookKeys instead.
useBooksManager.openBookInReader now accepts an optional cfi and jumps to it
once the switched-in view is ready (marking it a preview so the saved position
is not overwritten).
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
|
||
|
|
c8e2c95335 |
feat(library): auto-import new books from watched folders (#3889) (#4902)
* feat(library): add autoImportFromFolders setting (default off) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(library): add selectNewImportableFiles folder-scan filter Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(library): add useAutoImportFolders trigger hook Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(library): auto-import new books from watched folders on open and focus Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(library): add Auto Import New Books from Folders toggle Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(library): don't resurrect deleted books or re-toast bad files on folder auto-import - Add collectKnownSourcePaths() pure helper that includes soft-deleted books - importBooks() accepts { silent } option and returns failedPaths - autoImportFromWatchedFolders uses collectKnownSourcePaths and session-scoped autoImportFailedPathsRef to skip already-failed files on subsequent scans Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(library): make folder auto-import a per-folder option in the import dialog Replace the global autoImportFromFolders toggle (and its standalone Settings menu entry) with a per-folder opt-in shown as a sub-option of Read in place in the Import-from-Folder dialog. Store the watched set as settings.autoImportFolders (a subset of externalLibraryFolders; device-local, backup-blacklisted). The library rescan now iterates autoImportFolders instead of a global-gated externalLibraryFolders. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
71cb3ace91 |
feat(android): Android Auto media support for TTS playback (#3919) (#4907)
Readest now shows up in the Android Auto launcher as a media app and projects the TTS media session so playback can be controlled from the car display (play/pause, previous/next sentence). - Declare the com.google.android.gms.car.application meta-data and the automotive_app_desc media capability that Android Auto requires to list the app - Make MediaPlaybackService safe to bind for browsing: audio focus, the silent keep-alive player, and the foreground notification no longer start in onCreate but on an explicit ACTIVATE_SESSION command, so a car client connecting to browse does not steal audio focus or post a phantom playing notification - Deactivate the session with an in-process call instead of stopService, which would neither run onDestroy nor clear the foreground state while a media browser keeps the service bound - Serve the current book as a playable browse item (cover downscaled to stay under the binder transaction limit) and handle onPlayFromMediaId/onPlayFromSearch - Honor the foreground service contract when MediaButtonReceiver cold-starts the service with no active session Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
4b2c5f93ab |
fix(window): keep Linux window opaque so it can't turn invisible (#3682) (#4904)
On Linux the window was created fully transparent to draw rounded corners (#1982), but on WebKitGTK a transparent window composites as transparent whenever its web process is too busy to repaint damaged regions (for example during a library backup). Interacting with the app then makes it appear to turn invisible, showing the desktop through the window. Make the window opaque everywhere: the main window in lib.rs and the reader/extra windows in nav.ts. Drop the rounded-window treatment (hasRoundedWindow=false) so no rounded 1px border floats on the now square opaque window, and give the Linux loading placeholders a solid background. An opaque window retains its last painted frame instead of going invisible; the tradeoff is square corners on Linux. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
84c5a9dae6 |
fix(window): enter fullscreen from maximized windows (#4034) (#4903)
The fullscreen toggle had an isMaximized branch (from #872) that called unmaximize() and never setFullscreen() when the window was maximized. Phosh windows are always maximized, so the button appeared to do nothing; on Windows it only worked when the window was not maximized. Toggle fullscreen unconditionally. The maximize handler already exits fullscreen first, so the two controls stay consistent. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
c5304cd46c |
fix(reader): turn pages horizontally for vertical-rl books (#624) (#4899)
Vertical-rl books paged along the vertical scroll axis: page turns slid up/down and only vertical swipes turned pages. Vertical books read with right-to-left page progression, so page turns now work horizontally, matching printed vertical books: - Swipes track the finger: the page follows a horizontal drag and the release commits the turn (past half a page width or a flick in the drag direction) or settles the page back. - Arrow keys, tap zones, and the wheel follow the same rtl mapping that horizontal-rtl books use. - Animated turns run a two-phase horizontal slide that continues from the dragged offset: the outgoing page exits along the page progression, the scroll jumps while off-screen, and the incoming page follows in from the opposite edge. A single-phase push is impossible because CSS multicol stacks vertical-rl pages along the vertical scroll axis inside one iframe, so the outgoing and incoming page can never be on screen side by side. - With animation disabled (or e-ink), turns swap instantly as before. The paginator changes live in the foliate-js submodule; this bumps the pointer and adds browser tests with a vertical-rl EPUB fixture covering direction detection, drag tracking, drag revert, horizontal swipe mapping in both directions, the legacy vertical swipe, the horizontal slide animation, the instant non-animated swap, and the unchanged horizontal-ltr swipe behavior. Fixes #624 Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
fd8fbb178c |
fix(reader): apply page margin changes live on all platforms (#4898) (#4900)
Adjusting the top, bottom, left, or right page margin had no visible effect until an unrelated setting (e.g. Show Header) was toggled. The BooksGrid perf refactor (#4562) memoized the derived view/content insets on the ViewSettings object identity. saveViewSettings mutates ViewSettings in place (same reference), so the memo never recomputed on a margin edit and the new margin never reached the paginator. Left and right margins were always stale; top and bottom only refreshed when the header/footer visibility (an effect dependency) changed, which is why toggling the header appeared to apply a pending change. Extract the inset derivation into useContentInsets and memoize by the resolved numeric values instead of the object reference: identical numbers across a page turn keep a stable reference (no re-render storm), while a changed margin yields a new one that propagates to the renderer. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
77ea87c344 |
fix(updater): disable in-app updater on non-AppImage Linux (#4874) (#4897)
Tauri's Linux updater can only self-update AppImage bundles, so deb/rpm/ pacman and Flatpak installs showed a "Software Update" prompt that could never apply. READEST_DISABLE_UPDATER also had no effect: the variable reached the process, but its value only flowed to the frontend through a WebView init-script global (window.__READEST_UPDATER_DISABLED) that is not reliably visible to page scripts on Linux/WebKitGTK. Make the decision authoritative in Rust and read it over IPC: - Add compute_updater_disabled (pure, unit-tested) plus the is_updater_disabled desktop command: an env opt-out, Flatpak, or a Linux non-AppImage install disables the updater. setup() reuses the same helper for the init-script global. - NativeAppService.init() sets hasUpdater from the command for desktop apps instead of relying on the init-script global. Non-AppImage Linux installs now defer to the system package manager and fall back to the "What's New" release notes. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
849f151166 |
fix(ios): release screen brightness on background so auto-brightness resumes (#4885) (#4896)
On iOS `UIScreen.main.brightness` is a global device setting, not a per-window one like Android. Once Readest overrode it (brightness slider or left-edge swipe gesture) the override survived backgrounding, so swiping to the home screen left the system stuck at an extreme level and ambient auto-brightness appeared locked. The only cleanup lived in the reader's unmount effect, which never runs when the app is merely sent to the background, and the native `brightness < 0` "release" branch was a no-op stub. Native (NativeBridgePlugin.swift): capture the system brightness before the first override, restore it on `appDidEnterBackground` so iOS resumes auto-brightness, and re-apply the app's value on `appWillEnterForeground`. Implement the negative-value release path (restore + forget state), mirroring Android's BRIGHTNESS_OVERRIDE_NONE. JS (useScreenBrightness hook, replacing the racy inline Reader effect): apply the manual brightness while reading, release via setScreenBrightness(-1) on unmount and when "System Screen Brightness" is toggled back on. Excludes screenBrightness from deps so live slider/gesture drags don't flash release-then-reapply. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
9f65e3d415 |
fix(auth): surface OAuth callback errors on desktop deeplink (#4881) (#4894)
* fix(auth): handle OAuth callback errors on desktop deeplink (#4881) The Tauri deeplink OAuth handler only parsed the URL hash for an access_token, so error callbacks were silently swallowed and the login screen froze with no feedback. This is how an expired Apple provider secret (GoTrue "Unable to exchange external code") surfaced to users as a dead login screen on macOS and Linux. Extract a pure, tested parseOAuthCallbackUrl() that reads both the hash (implicit-flow tokens) and the query string (provider/GoTrue errors), and route errors to /auth/error before the token branch, matching the web callback page. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(scripts): add Apple client secret generator (#4881) Apple caps the "Sign in with Apple" client secret JWT at 6 months, so the web OAuth flow (macOS non-store and Linux) breaks with "Unable to exchange external code" when it expires. This script regenerates the ES256 JWT using Node built-in crypto (no new dependency) for pasting into the Supabase Apple provider config. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
4d645befde |
feat(library): add "Progress Read" sort option (#4427) (#4893)
Sort the library by reading progress (current/total pages). Books that have never been opened read 0% and sort to the unread end; groups sort by their most-progressed book. Direction reuses the existing ascending/descending toggle, so "most read first" is Descending. Adds the "Progress Read" entry to the Sort by menu and translates it across all locales. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
df34de1c38 |
fix(sync): WebDAV upload-after-enable and deletion propagation (#4856, #4860) (#4892)
* fix(sync): upload book files when Upload Book Files is enabled after first sync (#4856) Incremental sync decided what to push purely from `isLocalNewer` (`book.updatedAt` vs the shared index). A book's config/cover change over time, but its FILE is immutable per hash and only needs uploading once. After a first sync with "Upload Book Files" off, toggling it on never bumped `book.updatedAt`, so the book was skipped and its file never reached the remote. Record which book FILES are already on the remote in library.json (`uploadedHashes`) and split the push decision: config/cover stay gated on the incremental "changed locally" cursor, while a file is (re)uploaded only when syncBooks is on and its hash isn't recorded yet. This keeps an incremental "Sync now" O(changed) — once a file is recorded, later syncs skip it with no per-book HEAD probe, so large libraries don't pay an O(library) cost on every sync. Full Sync bypasses the record as an escape hatch for out-of-band drift. The record is additive and optional, so an old client that rewrites the index just drops it and the next new-client sync re-verifies each file once and re-records it. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(sync): propagate WebDAV book deletions to peers and the server (#4860) A book deleted on one device only tombstoned itself in library.json — the deletion never reached other devices or the server: - peers kept the book: the reconcile pass skipped deletedAt entries, so a tombstone never removed the local copy; - the server kept the files: the per-hash directory was never GC'd; - the tombstone could vanish entirely: a device that had never seen the book rebuilt the index purely from its own library, dropping the tombstone and silently reviving the book for everyone. Fixes all three in engine.syncLibrary: - apply a peer's tombstone locally (LocalStore.deleteBookLocally removes the app-managed copy and persists the tombstone), with edit-wins-over-delete LWW so a book still being read isn't yanked; - GC the remote per-hash directory of tombstoned books, scoped to the dirs the discovery scan saw so removed dirs are never re-DELETEd; - union remote-only entries (chiefly tombstones) into the re-pushed index so a deletion can't be dropped by a device that never had the book. Books tombstoned mid-run are excluded from the push pass via the merged state so a just-deleted book isn't re-uploaded right before it is GC'd. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
7a8354d63b |
fix(android): avoid black screen when external cache dir is unavailable (#4889)
The fs capability granted the built-in `fs:allow-cache-read` and `fs:allow-cache-write` sets. Those sets bundle `scope-cache`, which carries the external `$CACHE` base directory. At startup Tauri resolves every granted scope entry, so `$CACHE` resolves through Android's `getExternalCacheDir`. On devices whose external storage volume cannot be prepared (e.g. custom ROMs where `/storage/emulated/0/Android/data/ <pkg>/cache` fails to mkdir) that returns null, the resolve errors, and the graceful "skip unresolvable entry" arm is gated to non-Android, so the error propagates: fs scope build fails, app init aborts, and the window stays black. Readest only performs I/O under the internal app cache (`$APPCACHE` -> `getCacheDir`, always available), so it never needs the external `$CACHE` scope. Grant the scope-free `fs:read-all` and `fs:write-all` command sets to preserve command coverage, and move the `$APPCACHE` scope (plus the iOS container path) into `fs:scope`. Startup then never resolves external storage. Add a regression guard asserting the default capability grants no external-`$CACHE` fs permission while keeping the internal cache scope and full read/write command coverage. Closes #4853 Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
5bc8eda50b |
feat(proofread): editable Find pattern and per-rule enable/disable toggle (#4859) (#4888)
* fix(proofread): keep disabled book rules visible in the manager list Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(proofread): add per-rule enable/disable toggle in the manager Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(proofread): allow editing Find pattern, regex, and case on existing rules Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * i18n: add proofread edit and toggle strings Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
49391124c5 |
fix(reader): correct reading ruler direction for vertical-rl books (#4865) (#4879)
Vertical-rl (Japanese/Chinese vertical) books read top-to-bottom with columns progressing right-to-left, but getDirection only derived rtl from the horizontal dir/direction, which stays ltr for these books. As a result viewSettings.rtl was false and the reading ruler laid columns out left-to-right, advancing the band the wrong way (reverse reading order). Treat writing-mode: vertical-rl as RTL in getDirection so vertical-rl runs through the same rtl paths that horizontal-rtl already uses: the reading ruler coordinate mapping, page-turn tap mapping, footer navigation, and the progress bar. Page-turn taps for these books now follow the vertical-rl convention (tap left to go forward), matching horizontal-rtl behavior. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
17e60f1e49 |
fix(reader): fix fixed-layout spread spine seam and zoomed-out blank page (#4857) (#4873)
Bump foliate-js with two fixed-layout (EPUB and PDF) two-page spread fixes: - Spine seam: overlap the two pages by one device pixel to hide the 1px white seam that appeared at the spine at a fractional devicePixelRatio (e.g. Windows 150% display scale). - Zoomed-out blank page: keep non-PDF pages in block flow below 100% zoom; the PDF-only zoom-out centering was pushing the un-scaled iframe out of view and blanking the page. Adds a unit test for the computeSpreadSpineOverlap helper. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
4d0be496b2 |
fix(layout): respect author vertical-align on inline images (#4866) (#4878)
img.has-text-siblings forced vertical-align: baseline on every inline image with text siblings, out-specifying a book's own value (for example a CJK glyph-substitution image nudged with vertical-align: -0.15em). Because baseline is the CSS initial value, the declaration only ever mattered when it clobbered an authored value. Keep baseline as a default only: move it to a new has-text-siblings-baseline class that applyImageStyle adds only when the image has no author-set vertical-align (detected via getComputedStyle). Refactor applyImageStyle to a two-phase read-then-write pass to avoid a getComputedStyle-after-write style recalc. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
3ac1a1a45b |
fix(reader): remember last read position for markdown files (#4871)
Markdown sections were created with `cfi: ''`, but foliate-js builds a location CFI as `section.cfi ?? CFI.fake.fromIndex(index)`. Nullish coalescing does not fall back for an empty string, so every saved position collapsed to a section-less CFI that resolves to no section. Reopening a `.md` book then fell back to the start even though the library still showed the correct read percentage. Set each section's `cfi` to `CFI.fake.fromIndex(index)`, the same fake spine CFI foliate synthesizes for single-file formats that omit it (e.g. fb2), so positions round-trip across reopens. Fixes #4862 Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
ea99106677 |
fix(sync): silence third-party cloud-sync error toasts (#4845)
* fix(sync): never toast third-party cloud-sync errors; log to console only The reader's per-book auto-sync surfaced an "Cloud sync authentication failed. Reconnect in Settings." toast on any AUTH_FAILED (e.g. an expired web Google Drive token), interrupting reading. Background sync failures shouldn't pop a toast — drop it and console.warn every sync error instead (the AUTH_FAILED branch only chose toast-vs-console, so it collapses to a plain log). Removes the now-unused authFailedToast + useTranslation/FileSyncError imports. Manual "Sync now" (FileSyncForm) still reports its result — it's a deliberate, foreground action. Native cloud sync (useBooksSync) is unaffected. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(sync): surface an expired cloud-sync session in the reader + Settings With sync-error toasts silenced, an expired third-party session (e.g. the short-lived web Google Drive token) had no UI indicator. Surface it without the old per-failure error toast: - Reader: a single top-right `hint` ("Google Drive session expired. Reconnect in Settings.") — the same affordance as the native "Reading Progress Synced" hint. De-duplicated via a per-instance ref so it shows once, not on every page-turn sync; reset on a successful sync / provider switch (web reconnect reloads anyway). - Settings → Google Drive: Disconnect swaps to Reconnect when the session is expired, and "Sync now" is disabled (FileSyncForm gains a `syncNowDisabled` prop) so a sync that would just fail isn't offered. No hint text in Settings. - webTokenStore.hasValidWebDriveToken() backs the web detection (the token lives in sessionStorage; native auto-refreshes so it doesn't apply there). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
70bad93ebf |
feat(reader): select word on double-click and run instant action or toolbar (#4846)
Double-click (mouse) or touch double-tap on a word now selects that word, like a long-press selection, then runs the configured instant quick action or raises the annotation toolbar when none is set. The iframe posted iframe-double-click but nothing consumed it, so a touch double-tap did nothing (Android has no native double-tap word-select; on desktop the browser already selects the word natively via the pointerup path). - sel.ts: getWordRangeAt expands a caret to its word-like segment via Intl.Segmenter (CJK and Latin); getWordRangeFromPoint resolves the caret at a point and delegates. - useTextSelector: handleDoubleClick selects the word and routes through the existing makeSelection flow (guarded so the programmatic selectionchange echo is ignored). It no-ops when a native selection already exists, so the desktop double-click path is not double-fired. - Annotator: consume iframe-double-click, resolve the visible section doc/index, and set pointerDownTimeRef to 0 so the deliberate double-tap bypasses the touch long-press hold gate before the instant action fires. Tests: unit coverage for the word-range helpers and the selection routing (plus the desktop guard), and an Android CDP e2e for the double-tap gesture on a real device. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
eaf307e71e |
fix(translate): align RTL translated text to the start (#4844)
Inline translation wrappers set lang but never dir, so RTL target languages (Arabic, Hebrew, Persian, etc.) inherited the source document's LTR base direction. Justified text then pushed its last line to the LTR start (left) instead of the RTL start (right). Derive the wrapper's dir from the target language via getDirFromLanguage so justified RTL translations align to the start. Extract the node construction into createTranslationTargetNode to make the behavior unit-testable. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
b87cbfa21a |
feat(sync): Google Drive on web via full-page redirect OAuth (#4843)
Brings the Google Drive provider to the web build. Native uses PKCE + a reverse-DNS redirect + keychain refresh token; none of that works in a browser, and the GIS popup token model is broken by the app's COOP `same-origin` header (needed for Turso's SharedArrayBuffer) which severs the popup's opener handle and fires `popup_closed` instantly. So web uses a full-page redirect, which doesn't rely on `window.opener` and works under COOP. - auth/webRedirectFlow.ts: builds the implicit (response_type=token) auth URL, begins the redirect (CSRF state + return path in sessionStorage), and parses the token from the callback fragment. Implicit flow because a secretless Web client can't do a code exchange. - auth/webTokenStore.ts: sessionStorage-backed access-token store (no refresh token in this model; the token is short-lived). - WebDriveAuth: browser DriveAuth — reads the stored token, fails AUTH_FAILED once expired (prompts a reconnect; no background refresh), accountLabel via about.get. - app/gdrive-callback: OAuth return route — validates state, stores the token, marks Drive the active cloud provider (+ account label), routes back. - buildGoogleDriveProvider: web branch builds the provider on WebDriveAuth + globalThis.fetch (Drive REST is CORS-enabled; streaming stays Tauri-only so web buffers). Official Web client id baked (NEXT_PUBLIC_GOOGLE_WEB_CLIENT_ID overrides). googleDriveConnect web Connect = redirect; Disconnect clears the token. Drive row shown on web. No background token refresh: a secretless browser client gets no refresh token and Google blocks hidden-iframe silent renewal, so the user reconnects per session (a server-side token broker would be needed for auto-refresh; out of scope). Tests cover the redirect helpers, token store, and WebDriveAuth. Ops: add `https://web.readest.com/gdrive-callback` + `http://localhost:3000/gdrive-callback` to the Web client's Authorized redirect URIs. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
7da41a65ad |
feat(widget): add mobile home-screen reading widgets (#1602) (#4842)
Add a resizable home-screen widget on iOS and Android showing recent
in-progress books with cover, reading progress, and tap-to-open.
- One responsive widget: Android resizable 1x1 to 4x3 (one book per
column, up to 3); iOS Small/Medium/Large families. Covers are cropped,
rounded, with a percent badge and a progress bar (baked into the bitmap
on Android, SwiftUI overlays on iOS).
- TTS controls (previous, play-pause, next) appear in 2+ row sizes when
TTS is active, wired to the existing media session. Reading progress
stays live during background TTS via a fraction computed from the baked
offline locations.
- Publishes a snapshot plus downsized cover thumbnails to the iOS App
Group and Android SharedPreferences through a new update_reading_widget
native-bridge command; refresh is debounced and driven by library and
progress changes, TTS, and app backgrounding.
- Tapping a cover opens readest://book/{hash}, switching the reader in
place when one is already open.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
7972de1909 |
fix(eink): render Customize Toolbar preview as bordered surface, not black bar (#4839) (#4841)
The Customize Toolbar sub-page shows a content-width preview of the live selection popup, copying its bg-gray-600 text-white styling. Unlike the real reader popup (which gets its e-ink chrome from .popup-container in globals.css), the preview Zone is a plain div with no e-ink override, so under [data-eink='true'] the dark fill survived and the row painted as an unreadable solid black bar. Scope the dark fill to non-e-ink (not-eink:bg-gray-600 not-eink:text-white) and let eink-bordered render the preview in e-ink as the popup's e-ink chrome: a base-100 surface with a 1px base-content border. The chip icons already invert to base-content via the global [data-eink] button rule. Also fall the empty-state hint back to base-content in e-ink so it stays legible once the surface turns base-100. Verified via computed styles under [data-eink]: background oklch(1 0 0) (white), 1px oklch(0.2 0 0) border, dark icons — matching the reader's annotation toolbar. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
5f44c95592 |
feat(sync): library-scoped auto-sync for third-party cloud (WebDAV / Drive) (#4835)
Parity with native useBooksSync: keep library.json current on import, delete, and book-close, not just on a manual "Sync now". - useLibraryFileSync: new library-scoped hook (counterpart of useBooksSync), mounted once on the library page. Builds the active provider's engine async and runs engine.syncLibrary on every library change (import adds a row, delete sets deletedAt, closing a book bumps updatedAt), debounced 5s and gated on the global file-sync mutex + Sync Strategy + Upload Book Files. The reader's per-book useFileSync is unchanged (it's the per-book progress sync). - Pass the FULL library (incl. soft-deleted books) to engine.syncLibrary, in both the new hook and the manual FileSyncForm "Sync now": the engine tombstones deleted books in library.json so deletions propagate, and keeping them in the input set stops the discovery pass from re-downloading a book the user just deleted (its remote hash dir lingers until the GC sweep). - Tests: engine tombstones a soft-deleted book in the pushed index and does not re-download one whose remote dir still exists. Gated only by the active provider's enabled flag + strategy (cloud sync is currently ungated from premium). Never runs before the library loads from disk, so it can't push an empty index over the remote. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
69599e2bcc |
fix(reader): render code operators literally instead of as ligatures (#4832)
Fira Code is the bundled monospace fallback used when the chosen mono font is missing (e.g. Consolas on Android). Its default-on contextual alternates ligate code operators such as "<=" and "=>" into single glyphs, which misrepresents code in books like VHDL or math texts. Set font-variant-ligatures: none on pre, code, kbd so operators render literally. The underlying text is unchanged, so selection and copy already produced the correct characters. Fixes #4830 Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
4d08b01b41 |
feat(library): add recently read shelf to the library (#3797) (#4829)
Add an opt-in "Recently read" carousel at the top of the library that shows the most recently read books for quick resume. The strip reuses the BookItem component and mirrors the bookshelf grid column widths, so covers render and align identically at any column count. It scrolls horizontally with arrow buttons, opens a book through a shared availability-aware path (downloads cloud-only synced books first), and is toggled from the View menu (off by default). Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
d932444b78 |
fix(sync): cloud-sync settings polish + temporary premium ungate (#4828)
* fix(settings): clamp option-row description to a single line SettingsRow descriptions wrapped to multiple lines on narrow (mobile) widths, giving boxed-list rows uneven heights (e.g. "Uploads book files to your other devices." in the Cloud Sync panel). Clamp the description to one line with ellipsis in the shared primitive so every option row stays uniform; the description is a hint, not a paragraph (longer copy belongs in a Tips block). Codified in DESIGN.md. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * i18n(settings): shorten sync strategy labels to "Send only" / "Receive only" Rename the Sync Strategy options (shared by the Cloud Sync and KOReader Sync forms). Keys renamed in every locale, preserving existing translations. * feat(sync): temporarily ungate third-party cloud sync from premium Cloud sync (WebDAV / Google Drive) ships available to every plan, incl. free, while the feature stabilises. Gated behind a single CLOUD_SYNC_REQUIRES_PREMIUM flag (off) via isCloudSyncAllowed; the paywall code (CLOUD_SYNC_PLANS / isCloudSyncInPlan) is intact, so re-gating in an upcoming release is a one-line flip. Applies to the Settings provider rows and the reader auto-sync gate. * fix(settings): polish cloud-sync connect buttons Use btn-contrast for the WebDAV and Google Drive Connect CTAs (theme-neutral, e-ink correct); rename "Connect Google Drive" to "Connect"; move the Google Drive sign-in tips below the Connect button. --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
c6f2a83d92 |
fix(sync): retry thrown transport errors in Google Drive sync (#4827)
Google Drive library sync failed on Android: after the first few requests every files.list threw `error sending request for url (...)` and the sync stuck at "Syncing 0 / N". The provider's backoff only retried 429/5xx responses; a thrown fetch propagated immediately. On mobile a long multi-request sync hits transient transport failures (a pooled keep-alive connection to googleapis.com going bad), so without a retry every request after the first batch failed. - withBackoff now retries a thrown fetch with the same bounded exponential backoff as 429/5xx, letting reqwest re-establish a fresh connection. - mapDriveError classifies a thrown transport error (TypeError, or the Tauri HTTP plugin's plain "error sending request" Error) as NETWORK instead of UNKNOWN, so the engine's head-probe short-circuit treats it as transient. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
ae9fb05f2c |
feat(sync): Google Drive sign-in on Android + iOS (mobile OAuth) (#4823)
* feat(sync): Google Drive sign-in on Android (Custom Tab OAuth) Add the Android OAuth runner so Drive can be connected on Android, reusing the same provider / token store / connect flow as desktop. - oauthAndroid.ts: runAndroidOAuth wires the DI OAuth flow to a Chrome Custom Tab via the existing authWithCustomTab native bridge (keeps the Tauri Activity foregrounded so the in-flight redirect survives). Headless-unit-tested. - googleDriveConnect: dispatch the platform runner by OS (Android -> Custom Tab, desktop -> system browser deep link). - IntegrationsPanel: show the Google Drive provider row on Android too. - Native (device-verification pending — no Android toolchain in CI): NativeBridgePlugin.kt handleIntent now also resolves the reverse-DNS com.googleusercontent.apps.<id>:/oauthredirect redirect through the same pending invoke as the Supabase callback; a matching BROWSABLE intent-filter added to AndroidManifest.xml (mirrors the tauri.conf.json deep-link scheme). Full suite 6475 green; lint + format clean. The native sign-in needs on-device Android verification before this ships. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(sync): Google Drive sign-in on iOS (ASWebAuthenticationSession OAuth) Add the iOS OAuth runner so the Drive provider connects on iPhone/iPad, mirroring the Android Custom Tab flow. - oauthIos.ts: runIosOAuth drives the shared PKCE flow through authWithSafari, keyed to the client-id-derived reverse-DNS callback scheme so the web-auth session intercepts the redirect. - nativeAuth.ts: AuthRequest gains an optional callbackScheme; the Supabase login keeps the native "readest" default. - googleDriveConnect.ts: resolveOAuthRunner dispatches ios to runIosOAuth. - IntegrationsPanel.tsx: show the Google Drive cloud-sync row on iOS. Native (device-verify pending, no iOS toolchain in CI): - auth_with_safari honors args.callbackScheme (default "readest"). - Info-ios.plist registers the reverse-DNS scheme in CFBundleURLTypes, mirroring the AndroidManifest gdrive-oauth filter. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
531f0b58ae |
feat(sync): stream Google Drive book uploads/downloads from disk (#4824)
Add uploadStream + downloadStream to the Google Drive provider so book files sync straight from/to disk instead of buffering the whole file in the JS heap. Marshaling a large book across the WebView<->Rust bridge as a single Uint8Array crashes the renderer on mobile, so book sync over Drive was effectively desktop-only; this unlocks it on Android/iOS and keeps the heap flat for gigabyte-scale PDFs on desktop too. - driveRest.ts: resumableCreateUrl / resumableUpdateUrl builders. - GoogleDriveProvider: uploadStream opens a Drive resumable session (POST new / PATCH existing; metadata in the initiation, so no reparent follow-up), then PUTs the bytes to the one-time session URI via the native upload plugin (tauriUpload). downloadStream GETs alt=media to disk via tauriDownload with a bearer token. Attached on Tauri only; web keeps the buffered fallback. Both swallow to false per the provider contract (engine retries once). Reuses @tauri-apps/plugin-upload already shipped for WebDAV streaming; no new native code. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
324bb8a366 |
feat(reader): add e-ink screen refresh page-turner action (#4687) (#4822)
Add a bindable "Refresh Page" action to Settings > Behavior > Page Turner that triggers a deep e-ink full refresh (GC16) to clear screen ghosting, gated to e-ink mode on Android. It reuses the existing hardware page-turner key-binding machinery: a new 'refresh' slot in HardwarePageTurnerSettings, shown only when isAndroidApp and the e-ink view setting is on. Pressing the bound key calls a new native bridge command instead of paginating. The native side is device-agnostic: EinkRefreshController probes each vendor mechanism via reflection and stops at the first that works, covering Onyx BOOX (Qualcomm View.refreshScreen), Tolino/Nook (NTX postInvalidateDelayed) and Boyue-style Rockchip (requestEpdMode) without bundling any vendor SDK. A success:false result is a soft no-op on non-e-ink hardware. iOS gets a stub. Verified on an Onyx BOOX Leaf5: the Onyx path fires and performs a visible full GC16 refresh. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
f8916e128e |
fix(reader): smooth pinch-zoom and pan for scrolled-mode PDF (#4817)
Bumps foliate-js to readest/foliate-js#43. In scrolled-mode PDF the page now zooms live under a pinch and commits without a layout shift (the inter-page gap scales with the zoom so the committed layout matches the transform-scaled preview, and the centre page is restored to its pre-commit on-screen rect), a page zoomed wider than the viewport is pannable horizontally, and the page iframes stay interactive when idle so native text selection keeps working. readest already drives the renderer's pinchZoom on a two-finger gesture, so the only reader-side change is the submodule bump plus a unit test for the new scroll pinch transform. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
7e78f80e14 |
feat(sync): Google Drive cloud sync + premium Third-party Cloud Sync section (desktop) (#4821)
* feat(sync): add Google Drive file-sync provider core Second FileSyncProvider for the merged provider-agnostic file-sync engine, behind the provider seam. This is the CI-testable core only: no settings UI and no platform OAuth runners yet (those land in later phases). - GoogleDriveProvider over the Drive v3 REST API: id-addressed path resolution with a per-instance id cache, create-then-name uploads, real idempotent ensureDir, files.list pagination, Retry-After-aware 429/5xx backoff, per-path folder-creation locks with deterministic duplicate collapse, stale-id eviction, and FileSyncError mapping (403 split into rate-limit vs permission). - DI OAuth layer: pkce, parseRedirect (redirect-target + CSRF state), reverseDnsRedirect, tokenStore (iOS client, no secret), oauthFlow. - PersistedDriveAuth with single-flight token refresh; keychain-backed token store with no ephemeral fallback for the refresh token; account label via about.get. - providerRegistry (backend kind to provider) and buildGoogleDriveProvider assembly. - Shared transport-agnostic provider semantic contract, run against both WebDAV and Drive. - Keyed secure-KV bridge contract (set/get/clear_secure_item); the native keychain implementation lands with the desktop OAuth slice that first exercises it. Adapted from ratatabananana-bit/Readest-google-drive-mod-patcher (AGPL-3.0) with the author's explicit permission. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(sync): multi-provider file-sync settings + sync-state foundation PR2 foundation for a second file-sync backend (Google Drive). The behaviour-sensitive reader-hook and Sync-now form generalization land in PR3 alongside OAuth, where Drive actually connects and the multi-provider paths can be exercised and live-verified (and the extracted form gets its second consumer, avoiding a single-use abstraction). - GoogleDriveSettings type (mirrors WebDAVSettings minus URL/credentials/ rootPath, plus accountLabel) wired into SystemSettings, with DEFAULT_GOOGLE_DRIVE_SETTINGS in the defaults. - googleDrive.deviceId + googleDrive.lastSyncedAt added to the backup blacklist so device-local sync identity / cursors never restore onto another device. Covered by the existing backup-settings test. - Generalize webdavSyncStore into fileSyncStore: per-backend progress keyed by provider kind, plus a global library-sync mutex (beginSync returns false when another backend already holds the lock) since every backend's syncLibrary mutates the same local library. Migrate WebDAVForm and IntegrationsPanel to the keyed API; WebDAV behaviour is unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(native-bridge): add keyed secure key-value store commands A generic, keyed secret store over the same OS keychain backends as the sync passphrase (set/get/clear_secure_item), so secrets that aren't the single sync passphrase get the same XSS-free cross-launch persistence without each needing its own native command. The Google Drive OAuth token store (PR1's KeychainTokenPersistence) is the first consumer; a future cloud provider's refresh token reuses it. - Desktop (macOS/Windows/Linux): keyring-core, keyed by the item key as the entry account under the existing "Readest Safe Storage" service. - Android: EncryptedSharedPreferences (a dedicated readest_secure_items_v1 file, the item key as the pref key). - iOS: Security framework Keychain (kSecClassGenericPassword, dedicated service, the item key as kSecAttrAccount). Registered in the plugin invoke handler + build COMMANDS + default permission set (autogenerated permission files regenerated; the passphrase entries are preserved). The TS bridge wrappers shipped in PR1. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(sync): desktop Google Drive OAuth runner + connect flow The desktop half of Drive sign-in: open consent in the system browser, capture the reverse-DNS redirect the OS routes back, and exchange the code for tokens. - oauthDesktop.ts: runDesktopDeepLinkOAuth wires the DI OAuth flow to the desktop mechanics (open default browser, capture via single-instance / onOpenUrl, cold-browser fallback after a grace period, hard deadline). Fully headless-unit-tested via injected deps. - spawn_fresh_browser.rs (+ registration, Windows-only winreg dep): the cold browser the runner falls back to when the user's already-running browser snapshotted protocol associations before the scheme was registered (a Windows-specific failure). Resolves the default browser from the registry and spawns it cold with an isolated --user-data-dir; a no-op on macOS/Linux where the default-browser open already routes the redirect. Pure helpers unit-tested. - connectGoogleDrive.ts: run the platform OAuth runner, persist the token (fail-loud — Drive is not reported connected if the refresh token does not save), and resolve the account label via about.get (best-effort). OAuth runner adapted from ratatabananana-bit/Readest-google-drive-mod-patcher (AGPL-3.0) with the author's permission. Scheme registration + the ingress redirect filter + the Drive connect UI land in the following commits; live desktop verification follows once the official Google client id is provisioned. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(sync): filter Google OAuth redirects out of the deep-link ingress The reverse-DNS OAuth redirect (com.googleusercontent.apps.<id>:/oauthredirect) is delivered through the same single-instance / onOpenUrl channels as book-file deep links. Without a filter the book-import consumer would treat the redirect URL as a file path to open. Drop it at the ingress source (useAppUrlIngress) before the app-incoming-url broadcast, so no consumer ever sees it; the Drive sign-in runner still captures it via its own listeners. isGoogleOAuthRedirectUrl matches the scheme prefix (not a specific client id), so it stays correct regardless of which client is baked into the build. Note: registering the scheme in tauri.conf.json (so the OS routes it back to the app) needs the official Google client id, which is a provisioning prerequisite. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(sync): bake the official Google Drive OAuth client id + redirect scheme Provisioned the Readest Google Cloud OAuth client (iOS application type, no secret, drive.file scope). Bake the client id as the default in getGoogleClientId (overridable via NEXT_PUBLIC_GOOGLE_CLIENT_ID for forkers, who must also regenerate the manifest schemes) and register the derived reverse-DNS redirect scheme com.googleusercontent.apps.<id> in tauri.conf.json (desktop + mobile deep-link) so the OS routes the OAuth redirect back to the app. The client id is a public client identifier, not a secret. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(sync): Google Drive connect UI + shared FileSyncForm Make Drive usable from Settings, and extract the now-two-consumer sync controls. - FileSyncForm: the provider-agnostic sync controls (sub-toggles, conflict strategy, manual "Sync now" with progress + result toast), parameterised by backend kind and building the provider through the registry. Extracted from WebDAVForm now that a second consumer exists. WebDAVForm keeps its URL/credentials connect panel + browse pane and renders FileSyncForm for the sync section; behaviour is unchanged (WebDAV "Sync now" goes through the same provider via the registry). - GoogleDriveForm: an OAuth connect panel (Connect -> runGoogleDriveConnect -> store token in keychain -> "Connected as <email>"; Disconnect) + FileSyncForm. - googleDriveConnect.ts: assemble the env client id + keychain + desktop runner into connectGoogleDrive/disconnectGoogleDrive for the UI. - IntegrationsPanel: a "Google Drive" row + sub-page, shown only on desktop (mobile OAuth runners land in later phases). Reader-side auto-sync (generalizing useWebDAVSync) is a follow-up; manual "Sync now" already exercises the full Drive stack. Full suite 6412 green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(settings): unified Third-party Cloud Sync section (exclusive provider) Group WebDAV + Google Drive into a new "Third-party Cloud Sync" section and make them mutually exclusive — only one cloud provider syncs the library at a time. - New unified "Cloud Sync" sub-page (CloudSyncForm): a provider picker (radio, the AIPanel mutually-exclusive pattern) on top, the shared FileSyncForm sync options below for whichever provider is active. Google Drive is offered only on desktop; on mobile the page is WebDAV only and the picker is hidden. - withActiveCloudProvider helper: enabling one provider disables the other in one save. Both panels' connect/activate paths use it. Unit-tested. - WebDAVForm / GoogleDriveForm refactored into embeddable panels (the unified page owns the header). Drive gains a "configured but inactive" state so switching back re-activates it without a fresh sign-in; explicit Disconnect clears the keychain token. - IntegrationsPanel: remove the two separate WebDAV / Google Drive rows from "Reading Sync" (now KOReader Sync / Readwise / Hardcover only); add the Third-party Cloud Sync section with one Cloud Sync row (status = active provider). Old webdav/gdrive deep-links route to the unified page. Also removes the temporary Drive concurrency probe (the upload already runs at the intended concurrency 4; the probe confirmed it). Full suite 6416 green; lint + format clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(reader): auto-sync the active cloud provider while reading Generalize the reader sync hook from useWebDAVSync to useFileSync so the active third-party cloud provider (WebDAV OR Google Drive) syncs per-book while reading — pull-on-open, debounced push on progress/booknote changes, cover/file upload — not just via the manual "Sync now" in settings. Since the providers are mutually exclusive, the hook drives exactly the one enabled backend, built through the provider registry. The build is async (the Google Drive provider probes the OS keychain), so the engine lives in state and the pull-on-open waits for it; switching providers mid-session resets the per-book locks. The engine is keyed on connection-relevant settings so a lastSyncedAt write doesn't re-probe the keychain. deviceId / lastSyncedAt now write the active provider's settings slice; the auth-failed toast is provider-neutral; the per-book events are renamed *-file-sync. WebDAV reader-sync behaviour is unchanged. Full suite 6416 green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(settings): surface cloud providers in the section with inline switch Show WebDAV + Google Drive as separate rows in the Third-party Cloud Sync section (instead of one "Cloud Sync" row), so both providers are visible and the active one can be switched right there. - CloudProviderRow: a trailing radio makes a provider the single active sync target inline (enabled only when it's already configured — WebDAV creds / a Drive token); the row body / chevron opens its config sub-page (connect, sync options, disconnect). Status reads Active / Configured / Not connected, with a Syncing… indicator. - Each provider drills into its own sub-page again (WebDAV / Google Drive), rendering the embeddable panel under a SubPageHeader; the brief unified CloudSyncForm picker page is removed (its old deep-link maps to Google Drive). - Switching stays exclusive via withActiveCloudProvider; an inline switch trusts the stored credentials/token (no re-validate / re-OAuth). Full suite 6416 green; lint + format clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(sync): gate third-party cloud sync behind a premium plan WebDAV + Google Drive sync is now a premium feature: available on any paid plan (Plus, Pro, or Lifetime), not on free. - isCloudSyncInPlan(plan) helper (mirrors isEmailInPlan; plus/pro/purchase). - IntegrationsPanel: free users see the Third-party Cloud Sync section with an upgrade row ("Available on Plus, Pro, or Lifetime") that opens the plans page instead of the provider rows; the cloud-sync deep-links are gated too (waiting for the plan to load before deciding). - useFileSync: the reader's auto-sync only runs on a paid plan, so a downgraded user's sync stops even if a provider's enabled flag lingers. Full suite 6418 green; lint + format clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(sync): escape backslashes in Drive query literals (CodeQL) escapeDriveLiteral escaped single quotes but not the backslash escape character, so a file name containing a backslash (or ending in one) could break out of the single-quoted Drive `files.list` query literal and malform the query. Escape backslashes first, then single quotes, so the backslashes added for the quotes are not doubled. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
9496de301b |
fix(node-app-service): ensure correct cross-platform path resolution in NodeAppService (#4819)
* refactor(node): use path.join() in path resolver * fix(node): use native path separators in resolveFilePath. |
||
|
|
348c85f648 |
fix(reader): cap auto page-turn corner zone size (#4812) (#4820)
The corner-dwell auto page-turn zone is a quarter-ellipse whose radius is a fraction (0.15) of the reading area on each axis. On wide screens such as desktop or multi-column pages, that fraction grows the zone until it reaches deep into the text, so selecting in a column and resting the pointer there turns the page unexpectedly. Cap each axis of the corner radius at 50px so the engagement zone stays a real corner regardless of page width, while preserving the existing feel on phones. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |