* 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>
* feat(reader): random-access file reads on Android via rangefile scheme
NativeFile's per-chunk Tauri IPC (open+seek+read+close) is slow on Android, and RemoteFile can't replace it because the WebView mishandles Range requests on intercepted custom-protocol responses — it re-applies the offset to the already-sliced body, so any non-zero-start range returns corrupt data or net::ERR_FAILED (Chromium 40739128, tauri-apps/tauri#12019/#3725).
Add a `rangefile` custom URI scheme that carries the byte range in the URL query (?path=&start=&end=) instead of a Range header. With no Range header the WebView delivers the 200 body verbatim, while bytes still stream through the network stack rather than the IPC bridge. The handler is scope-gated by asset_protocol_scope (same boundary as the asset protocol) plus an explicit traversal/NUL/relative guard.
RemoteFile.fromNativePath() drives the scheme on Android (query-carried range, X-Total-Size for size); nativeAppService.openFile routes Android reads through it with a NativeFile fallback. Verified on-device (Android 16 / WebView 147) via CDP: byte-equal reads at every offset, ~1.8x faster small scattered reads, real book opens/renders; all out-of-scope/traversal/NUL paths rejected 403.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* ci(rust): run cargo unit tests in rust_lint
The rust_lint job ran only fmt + clippy, so the crate's ~40 Rust unit tests (parsers, parser_common, and the new range_file tests) never executed in CI. Add `cargo test -p Readest --lib` to rust_lint — the frontend dist is absent there, but generate_context! already compiles without it (clippy proves this) and the unit tests run headless.
Also add a `test:rust` pnpm script and document it as verification done-condition #6.
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 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(annotations): deep links for highlight exports
Embed an HTTPS deep link in markdown export so clicking a highlight in
Obsidian / Notion / Mail launches Readest at the exact CFI position.
Mobile App Links / Universal Links open the native app silently when
installed; desktop attempts the readest:// scheme automatically with a
manual fallback.
- Markdown export wraps the page-number text in a per-annotation link:
https://web.readest.com/o/book/{hash}/annotation/{id}?cfi=...
- New /o/... smart landing page handles platform routing (intent:// on
Android Chrome, scheme + visibility-cancel on other Android, auto
scheme + 1 s fallback on desktop, manual button on iOS).
- Reader honors a ?cfi= query param on initial load (overrides the
saved last-position for the primary book only).
- New useOpenAnnotationLink hook handles incoming readest:// and
https://web.readest.com/o/... URLs, including cold-start (getCurrent)
and library-load deferral; supports the legacy flat shape
readest://annotation/{hash}/{id} from previous Readwise syncs.
- ReadwiseClient now emits the HTTPS deep link instead of the legacy
custom scheme.
- AASA extended with /o/* matcher; Android intent-filter for the host
has no pathPrefix so it already covers it.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* i18n: translate annotation deep-link strings across all locales
Translates the 13 new keys introduced for the annotation deep-link
feature into all 31 supported locales. Replaces all 403
__STRING_NOT_TRANSLATED__ placeholders.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(windows): Add Windows Explorer thumbnail support for ebooks (closes#2534)
- Implement IThumbnailProvider COM handler for Windows Shell integration
- Support EPUB, MOBI, AZW, AZW3, KF8, FB2, CBZ, CBR formats
- Add cover extraction with Readest icon overlay
- Register thumbnail handler via NSIS installer hooks
- Only show thumbnails when Readest is the default app for the file type
* chore: clean up build script for thumbnail extension
---------
Co-authored-by: chrox <chrox.huang@gmail.com>