Bitmap.createBitmap returns the same immutable instance when the
center-crop covers the whole source, which happens for covers that
decode to exactly 2:3. writeThumbnail then recycled that instance
before createScaledBitmap used it, crashing with "cannot use a
recycled source in createBitmap". Guard the recycle the same way the
scaled vs cropped case is already guarded, and add an instrumented
regression test.
Also bundles a pending widget debugging note and a regenerated
fastlane README that were staged in the working tree.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
* feat(sync): Google Drive sign-in on Android (Custom Tab OAuth)
Add the Android OAuth runner so Drive can be connected on Android, reusing the
same provider / token store / connect flow as desktop.
- oauthAndroid.ts: runAndroidOAuth wires the DI OAuth flow to a Chrome Custom
Tab via the existing authWithCustomTab native bridge (keeps the Tauri Activity
foregrounded so the in-flight redirect survives). Headless-unit-tested.
- googleDriveConnect: dispatch the platform runner by OS (Android -> Custom Tab,
desktop -> system browser deep link).
- IntegrationsPanel: show the Google Drive provider row on Android too.
- Native (device-verification pending — no Android toolchain in CI):
NativeBridgePlugin.kt handleIntent now also resolves the reverse-DNS
com.googleusercontent.apps.<id>:/oauthredirect redirect through the same
pending invoke as the Supabase callback; a matching BROWSABLE intent-filter
added to AndroidManifest.xml (mirrors the tauri.conf.json deep-link scheme).
Full suite 6475 green; lint + format clean. The native sign-in needs on-device
Android verification before this ships.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(sync): Google Drive sign-in on iOS (ASWebAuthenticationSession OAuth)
Add the iOS OAuth runner so the Drive provider connects on iPhone/iPad,
mirroring the Android Custom Tab flow.
- oauthIos.ts: runIosOAuth drives the shared PKCE flow through
authWithSafari, keyed to the client-id-derived reverse-DNS callback
scheme so the web-auth session intercepts the redirect.
- nativeAuth.ts: AuthRequest gains an optional callbackScheme; the
Supabase login keeps the native "readest" default.
- googleDriveConnect.ts: resolveOAuthRunner dispatches ios to runIosOAuth.
- IntegrationsPanel.tsx: show the Google Drive cloud-sync row on iOS.
Native (device-verify pending, no iOS toolchain in CI):
- auth_with_safari honors args.callbackScheme (default "readest").
- Info-ios.plist registers the reverse-DNS scheme in CFBundleURLTypes,
mirroring the AndroidManifest gdrive-oauth filter.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add 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>
* 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>
Implement on-device iOS text-to-speech using AVSpeechSynthesizer,
mirroring the Android native TextToSpeech plugin so the shared
NativeTTSClient drives both platforms through the same command and
tts_events contract.
- Swift NativeTTSPlugin: speak/stop/pause/resume/rate/pitch/voice and
voice enumeration, with region-disambiguated duplicate voice names and
a small preUtteranceDelay to avoid first-word clipping.
- Enable the native TTS client on iOS in TTSController.
- Make TTS teardown resilient: reset UI state up front and tear down the
controller, media session, and background audio in parallel so a slow
native shutdown can never leave the TTS icon or lock-screen session
stuck on.
- Keep iOS on navigator.mediaSession for the lock screen (Android uses
the native foreground service), which restores the Edge TTS cover and
current-sentence metadata.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(reader): add save/share button to image gallery toolbar
Add a button to the top-right toolbar of the fullscreen image viewer
that saves the currently viewed image to the device. It uses the native
or web Share flow where available (iOS/Android/macOS, navigator.share)
and falls back to a save dialog or browser download otherwise, reusing
the existing export path via appService.saveFile.
The button icon and label reflect the active flow (share vs save).
Adds dataUrlToBytes/imageExtensionFromMime helpers, unit and component
tests, and translations for the new strings across all locales.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(share): write shareable file to a Temp subdirectory to avoid 0-byte share
On Android, Tauri's Temp dir is the app cache dir, and the sharekit plugin
copies the shared file to <cacheDir>/<name> before firing the share intent.
When saveFile wrote the shareable file to the Temp root, that copy became a
copy onto itself whose output stream truncated the source to 0 bytes, so the
shared image (and any shared export) arrived as a 0 KB file. Write the file
to a Temp subdirectory instead so the plugin's copy has a distinct source.
Verified on a Xiaomi device: sharing a file in the Temp root truncated it to
0 bytes, while sharing from the subdirectory produced a real, non-empty copy.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(reader): save image to system gallery on Android
The Android share sheet cannot save an image to a file (no file manager
registers as an ACTION_SEND target), so the Save Image button now writes
the image straight into the system photo gallery via MediaStore. It lands
in Pictures/Readest, visible in Gallery and the Files app, with no picker
and no storage permission on Android 10+.
Adds a save_image_to_gallery command to the native-bridge plugin (Rust +
Kotlin MediaStore insert) and an appService.saveImageToGallery method. On
Android the Save button uses it; iOS/macOS/desktop/web keep the existing
share/export flow, and the button label/icon reflect the actual action.
Also includes local agent memory notes that were staged alongside.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Backports the Android ANR/stability fixes from the julianshen fork,
adapted to upstream — notably preserving the cold-start shared-intent
replay queue and the #4559 dictionary-dispatch logic, neither of which
the fork kept.
- NativeBridgePlugin: run blocking @Command I/O (copy_uri_to_path,
install_package, get_sys_fonts_list, and show_lookup_popover's
queryIntentActivities) on Dispatchers.IO via a Main-dispatched
pluginScope; startActivity hops back to Main; resolves are
isActive-guarded; onDestroy cancels the scope and clears the static
instance; the system-font scan is cached (@Volatile). The cold-start
shared-intent queue (emitOrQueue / registerListener) is left intact.
- MediaPlaybackService: unmarshal the artwork Bitmap off the main thread
(serviceScope + Dispatchers.Default), isActive-guarded; cancel the
scope in onDestroy.
- ClipUrlController: hold the Activity via WeakReference and check
isFinishing/isDestroyed before presenting, to avoid leaking the
Activity/WebView during the up-to-30s clip window.
- MainActivity (#3297): on Android 14+ the window can gain focus before
the WebView paints its first frame, leaving a blank screen. Force one
repaint when both the window has focus and the WebView exists
(whichever happens last).
Kotlin-only; not exercised by the JS/Rust test suites. Verified via
ktlint parse + a release `tauri android build` and on-device smoke test
(Xiaomi). The touch-event throttle and intent-handling rewrite from the
fork are intentionally NOT backported (they dropped touchmove forwarding
and the cold-start queue).
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
On targetSdk 36, ACTION_PROCESS_TEXT handlers were hidden by Android 11+
package-visibility filtering — only auto-visible web browsers resolved the
intent, so system-dictionary lookups landed in the OEM browser (VIVO/iQOO)
even with a dictionary like Eudic installed. Add a <queries> declaration so
dictionary apps are visible, and filter web browsers out of the handler set
so an OEM browser that registers PROCESS_TEXT can't swallow the lookup:
- no browser among handlers → unchanged implicit dispatch (keeps native Always)
- browser + one dictionary → launch it directly (explicit component)
- browser + several dictionaries → chooser excluding browsers, remembering the
pick via EXTRA_CHOSEN_COMPONENT so later lookups go straight through
- only a browser installed → report unavailable instead of opening it
Routing is a pure, JUnit-tested decideLookupDispatch(). Adds get/clear
lookup-dictionary commands + an Android-only reset row in the dictionary
settings to switch the remembered app.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Tapping an EPUB in the system file browser and choosing Readest could
silently fail to open the book in two distinct scenarios on Android:
1. Cold launch — the system delivers the ACTION_VIEW intent to
onCreate / onNewIntent before the JS layer has finished hydrating
and called addPluginListener('native-bridge', 'shared-intent', ...).
The upstream Tauri Plugin.trigger() drops events when the per-event
listener list is empty, so the intent vanishes. Fix this in
NativeBridgePlugin by queueing emits whose event has no listener,
then overriding registerListener so the queue is drained whenever
a listener becomes available.
2. React strict-mode re-mount — useAppUrlIngress had a one-shot
listened.current ref guard meant to avoid double registration. In
strict mode (and any subsequent effect re-run) the cleanup
unregister()'d the underlying native plugin listener but the next
mount short-circuited on the ref and never re-registered. The
shared-intent listener list ended up empty for the rest of the
session, so any subsequent Open-with intent went into the queue and
never came out. Drop the guard and let the effect register on every
mount; cleanup balances each registration.
On Android, tapping an EPUB/MOBI/AZW3 in the system file browser and choosing Readest only opens the library — the book itself never opens in the reader. Now Readest can open the book now.
* feat(import): support folder picker on iOS via native-bridge
Tauri's dialog plugin rejects folder picks on both mobile platforms with FolderPickerNotImplemented, so previously only Android could pick an import directory (it already routed through the native-bridge plugin's ACTION_OPEN_DOCUMENT_TREE). iOS users had no working folder-import entry point at all.
Add an iOS implementation of the native-bridge select_directory command using UIDocumentPickerViewController(forOpeningContentTypes: [.folder], asCopy: false), with a dedicated FolderPickerDelegate that:
- holds a strong reference until the picker dismisses (UIKit keeps the delegate weak), and
- calls startAccessingSecurityScopedResource on the picked URL and retains it for the app's lifetime so plain Foundation/POSIX reads against url.path work for the rest of the session.
Route NativeAppService.selectDirectory through the bridge for both iOS and Android, then call allowPathsInScopes so the picked directory is reachable via fs_scope and the asset protocol. The library page's pickImportDirectory entry point now also takes the mobile branch on iOS, while keeping the Android-only MANAGE_EXTERNAL_STORAGE prompt gated behind isAndroidApp.
* feat(ios): persist security-scoped bookmarks for picked folders
iOS hands the folder picker back a security-scoped URL whose access
right is granted only to the running process. The previous
implementation kept the URL alive for the lifetime of the process via a
static `urlsToKeepAlive` array, which worked for the current session
but forced the user to re-pick the same folder after every relaunch.
Add a `FolderBookmarkStore` that:
- Right after the picker returns, calls
`URL.bookmarkData(.minimalBookmark)` and stashes the bytes in
`UserDefaults` keyed by the POSIX path.
- On every `NativeBridgePlugin.load(webview:)`, walks every persisted
bookmark, resolves it back into a URL, and calls
`startAccessingSecurityScopedResource`. Holds the URL alive in a
process-scoped dictionary so subsequent Foundation / POSIX reads
against `url.path` succeed.
- Handles `isStale` by re-encoding the bookmark against the resolved
URL, and drops permanently unresolvable bookmarks (folder gone,
provider uninstalled) from `UserDefaults` so the next launch
doesn't re-attempt them.
Pair this with a Tauri-side change so the same paths are reachable
through both `dir_scanner::read_dir` and the fs plugin's `readDir`:
- `allow_paths_in_scopes` now has an iOS branch that widens
`fs_scope` / `asset_protocol_scope` for any path the frontend hands
it, intentionally without the desktop-side "must already be in
fs_scope" gate. The OS sandbox + bookmark store is the real
access-control boundary on iOS; widening Tauri's in-memory scope
set cannot escalate access beyond what the OS already grants. The
security comment on the command was rewritten to spell this
contract out.
- `allow_file_in_scopes` is now compiled for iOS too (previously
desktop-only) so the file-grant path is available when needed.
* feat(send): iOS share-extension picker + App Group queue + reliable host launch
Rework the iOS Share Extension to a Zotero-style sheet: URL preview row +
library group picker + "Save & Open". Queues each save into the shared
App Group container and best-effort launches the host app via the
Chrome-style responder-chain trick (IMP cast against
`openURL:options:completionHandler:`). The host plugin drains the queue
on `applicationDidBecomeActive`, so if the launch ever fails the article
still ingests next time Readest is opened.
A `WKScriptMessageHandler` named `readestShareBridge` lets the JS hook
post `{type:'ready'}` on mount, fixing the cold-start race when the
extension wakes the app before the React side has loaded.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(send): cover generator, favicon fetcher, share-extension polish, locale sync
Builds on the previous commit (iOS share-extension picker + App Group queue +
reliable host launch) with three further additions to the send-to-Readest
pipeline:
* **Cover generator** — `services/send/conversion/coverGenerator.ts` renders a
deterministic cover image into clipped EPUBs (favicon + page title + host).
Hooked into `buildEpub.ts` so every clip path (desktop, mobile, browser
extension) produces the same cover for the same source.
* **Favicon fetcher** — `services/send/conversion/faviconFetcher.ts` resolves
the best-available site icon (Open Graph image → apple-touch-icon →
/favicon.ico), with size + format normalization. Feeds the cover generator.
* **Unified page conversion** — `convertToEpub({kind:'page', ...})` replaces
the older `convertPageToEpub(html, url)` so the share extension, /send page,
and browser extension share one entry point. Test: `send-convert-page-unified`.
* **Share-extension project.yml comment** — clarifies why the ShareExtension
target carries no `.lproj` files (system bar buttons + JS-supplied "Default"
label, no per-locale strings to wire).
* **Locale sync** — 33 translation.json files updated with new "Default",
"Saving article…", and cover-generator strings extracted by i18next-scanner.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
iOS and Android now run the same Web-URL clip flow as desktop. Paste
an article URL, the native side opens a full-screen WKWebView /
WebView with the same Chrome UA + fingerprint mask + "Saving to
Readest" overlay as the desktop hidden window, waits for load +
settle, captures `document.documentElement.outerHTML` via the
platform's `evaluateJavaScript`, and returns it through the existing
`convertToEpub` pipeline.
JS surface stays `invoke('clip_url', { url, options })` — no changes
in `library/page.tsx` or `send/page.tsx`. The platform branch lives
entirely in `clip_url.rs`.
Why not Tauri's `Window::add_child`
`add_child` is gated `#[cfg(any(test, all(desktop, feature =
"unstable")))]` in tauri 2.10. No public API for attaching a second
webview to the main window on mobile, so the clip flow can't be a
`#[cfg(mobile)]` branch of the existing `WebviewWindowBuilder` shape
— it needs native code. Extend `tauri-plugin-native-bridge` rather
than create a separate plugin: the Swift / Kotlin scaffolding +
Tauri IPC are already there.
Layout
- `src-tauri/src/clip_url.rs` — desktop branch unchanged; new
`#[cfg(mobile)]` `clip_url` command routes through
`app.native_bridge().clip_url(request)`. Shared `ClipOptions`
struct exposes its fields `pub` so the mobile branch can map into
the plugin's `ClipUrlRequest`.
- `plugins/tauri-plugin-native-bridge/src/models.rs` — `ClipUrlRequest`
+ `ClipUrlResponse` mirroring `ClipOptions` field-for-field so the
payload travels untouched from JS through to Swift/Kotlin.
- `plugins/tauri-plugin-native-bridge/src/{desktop,mobile}.rs` — desktop
returns an error (desktop has its own path); mobile dispatches via
`run_mobile_plugin("clip_url", payload)`.
- `ios/Sources/ClipUrlController.swift` — `UIViewController` hosting
`WKWebView` with the loading overlay drawn as native UIKit views
(not an injected user script, so the page's own hydration can't
wipe the spinner). 30 s hard timeout + 3 s settle window after
`didFinish`, same as desktop. Fingerprint mask injected as
`WKUserScript` at `.atDocumentStart`.
- `android/src/main/java/ClipUrlController.kt` — full-screen Dialog
hosting a `WebView`, mirrors the iOS controller's behaviour. JSON-
decodes the `evaluateJavascript` callback (raw return value is a
JSON-encoded string).
- `NativeBridgePlugin.{swift,kt}` — new `clip_url` method that parses
args via `invoke.parseArgs`, presents the controller, resolves the
invoke with `{ html }` on success or `invoke.reject` on failure.
Same rejection vocabulary as desktop (`"Invalid URL"`, `"Page took
too long to load"`, etc.) so the calling JS doesn't need a
platform branch.
- `build.rs` — adds `clip_url` to the plugin's `COMMANDS` array.
Notes
- The Swift overlay reserves the iOS safe-area-edge-to-edge so notch /
Dynamic Island devices don't see the underlying app peek through
during the brief capture window.
- The Android overlay's spinner tint follows the foreground theme
colour at 85 % alpha — same idea as the iOS controller.
- `WKWebView`'s JS keeps running while the controller is presented;
no off-screen / `isHidden` trick that would let iOS throttle the
page mid-capture.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
On iOS the system text-selection menu (Copy / Look Up / Translate /
Share) appeared on top of Readest's annotation toolbar. The previous
workaround removed and re-added the selection range on a timer
(makeSelectionOnIOS) to shake the menu off — flaky on iOS 16 and on the
first long-press of a word.
Suppress the menu natively instead, in the native-bridge iOS plugin.
ContextMenuSuppressor swizzles WKContentView so non-editable web
selections produce an empty menu that is never presented:
* editMenuInteraction(_:menuForConfiguration:suggestedActions:) — the
UIEditMenuInteraction delegate WebKit uses to build the menu on
iOS 16+ (the menu users actually see on modern iOS).
* presentEditMenu(with:) — a present-time backstop.
* canPerformAction(_:withSender:) — the legacy UIMenuController gate
for iOS 15 and earlier.
Editable HTML fields keep their native menu (Paste / Select All still
work) via a cut:/paste: probe. Text selection and drag handles are
unaffected, so the annotation toolbar still triggers.
With suppression handled natively, makeSelectionOnIOS is removed and iOS
selections take the same path as desktop.
Closes#4218
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Hand selected words off to the platform's native dictionary surface
when the user opts into the new "System Dictionary" entry under
Settings → Languages → Dictionaries. The setting is exclusive: enabling
it disables all other providers (and vice versa) so the in-app lookup
button either always opens the popup or always invokes the OS — no
mixed states.
Per platform:
- macOS: AppKit's -[NSView showDefinitionForAttributedString:atPoint:]
via a top-level Tauri command in src-tauri/src/macos/system_dictionary.rs.
Anchored at the selection's bottom-center (CSS pixels mapped into
NSView coords), so the inline Lookup HUD appears just below the
highlighted text without raising Dictionary.app to the foreground.
- iOS: UIReferenceLibraryViewController presented as a half-detent
pageSheet on iPhone (medium → large drag-to-expand) and as a
formSheet on iPad. Implemented in the native-bridge plugin.
- Android: ACTION_PROCESS_TEXT intent with EXTRA_PROCESS_TEXT_READONLY,
dispatched without createChooser so users get the standard system
disambiguation dialog with "Just once / Always" buttons. Reports
unavailable=true when no app handles the intent so the TS layer can
silently skip rather than open an empty chooser.
Web/Linux/Windows hide the row entirely. The provider is a sentinel —
the registry filters it out of the popup tab list (it has no in-popup
UI) and the annotator's handleDictionary checks isSystemDictionaryEnabled
to dispatch directly to the native bridge before opening the in-app
DictionaryPopup.
The window-level `overrideUserInterfaceStyle` applied by
`set_system_ui_visibility` pins the WKWebView's trait collection, so the
`prefers-color-scheme` media query never fires while the app stays
foregrounded and `get_system_color_scheme` returned the stale pinned
value. Detect appearance at the window-scene level instead — it sits
above the per-window override — and push changes to JS via
`window.onNativeColorSchemeChange`.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(reader): add custom hardware-button page turning (#4139)
Lets users bind hardware remote keys (media keys, D-pad/arrow keys) to
previous/next page via a learn-mode capture UI in reader settings — an
accessibility feature for page-turner remotes.
- New global hardwarePageTurner system setting (enabled + key bindings).
- hardwareKeys.ts: key normalization, matching, and page-turn resolution.
- deviceStore: reference-counted media-key interception + learn mode.
- usePagination: flips pages from bound media keys (native bridge) and
D-pad/keyboard keys (DOM keydown), scoped to the active book and
suppressed while the toolbar is visible.
- Page Turner settings section on all platforms; web/desktop bind keys
via DOM keydown only, native media-key interception stays mobile-only.
- Android: intercept media + learn-mode keys in dispatchKeyEvent.
- iOS: forward media keys via MPRemoteCommandCenter.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(i18n): add and translate hardware page turner strings (#4139)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(reader): refine hardware page turner (#4139)
- Handle book-iframe key events (iframe-keydown messages) so custom
bindings work as soon as a book is open, not only after the settings
panel has been shown.
- Add Previous/Next Section bindings alongside the page bindings.
- Rename the hardwareKeys util to keybinding.
- Wire the Page Turner section into the settings Reset action.
- Drop the focus ring on the capture buttons; BoxedList gains an
optional description.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(i18n): translate page turner section and key strings (#4139)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add tauri-plugin-webview-upgrade as a git submodule under
apps/readest-app/src-tauri/plugins/. On Android devices whose system
WebView is locked to an old Chromium build (Huawei phones, Moaan / Onyx
/ Kobo e-ink readers, AOSP forks without Play Store, etc.), the reader
bundle renders as a blank screen. The plugin bootstraps before
Application.onCreate via androidx.startup and redirects the in-process
WebView loader to a recent com.google.android.webview when the user has
one sideloaded — opening the only window in which WebViewUpgrade can
swap the provider, before Tauri/Wry creates any WebView.
Thresholds (minUpgradeMajor / minSupportedMajor) come from
plugins.webview-upgrade in tauri.conf.json and are baked into Kotlin
constants at Gradle build time. Below the supported threshold with no
upgrade option, the plugin shows a localized AlertDialog (15 languages,
English fallback) prompting the user to install Android System WebView.
Plugin source: https://github.com/readest/tauri-plugin-webview-upgrade
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(sync): add opt-in Credentials toggle to Manage Sync
Adds a new Credentials category (default OFF) that gates the encrypted
fields (OPDS / KOSync / Readwise / Hardcover usernames, passwords, and
tokens) at both the publish and pull pipelines. When off, sensitive
fields never leave the device, the proactive passphrase prompt never
fires, and the Sync passphrase panel is hidden entirely.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* security: bump keyring to version 4
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* 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>
When the Android native TTS engine is paused or stopped but not shut down,
it holds resources and drains battery. This adds a 30-minute idle timer that
automatically shuts down the TextToSpeech engine and MediaPlaybackService
after inactivity. The engine transparently re-initializes on next use.
Also adds missing androidx.lifecycle:lifecycle-process dependency to fix
ProcessLifecycleOwner build error.
Closes#3713
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>