Commit Graph

4 Commits

Author SHA1 Message Date
Huang Xin 57868a138e feat(settings): unified Cloud Sync chooser with Readest Cloud as a first-class provider (#4976)
The Integrations section is now one Cloud Sync chooser: Readest Cloud,
WebDAV, and Google Drive as radio rows, Readest Cloud first, with a
scope subtitle stating what the choice governs (library data, on this
device) and what always stays with the Readest account (settings,
statistics, dictionaries).

- Activation helpers move to services (cloudSyncActivation.ts) and
  accept 'readest' (= no third-party provider active); the component
  module re-exports for existing imports.
- Row status lines come from a pure, fully-tested state matrix
  (cloudSyncStatus.ts) so every string is enumerated in one place:
  signed-out / loading / active / available for Readest Cloud;
  not connected / configured / paused / syncing / sync failed /
  book-file-uploads-off warning / active for third-party rows. The
  paused state renders on the affected third-party row (the plan sketch
  placed it on the Readest row; the provider that is paused is the
  third-party one).
- The Readest Cloud row opens an inline sub-page (SubPageHeader + the
  storage/translation Quota + a NavigationRow out to Account) instead of
  ejecting from the Settings dialog; signed-out taps go to login and the
  radio is unchecked and disabled, so an idle signed-out state never
  shows a checked radio while nothing syncs.
- Premium-gated builds keep the Readest Cloud row and show the upgrade
  prompt only in place of the third-party rows.
- Two-direction capability Tips in the WebDAV/Drive sub-pages spell out
  both what syncs only to the user's server and what still flows through
  the Readest account; the Upload Book Files description notes that
  Readest Cloud uploads pause while the provider is selected.
- Manage Sync book/progress/note rows swap their description to
  'Managed by {{provider}}...' via the existing locked-row pattern while
  a third-party provider is selected; the toggles stay interactive and
  persist since they govern the native channels after switch-back.
- The chooser rows form a radiogroup (native same-name radios provide
  arrow-key group movement) with an accessible group label.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 18:16:14 +02:00
Huang Xin e08622b416 feat(sync): route library sync exclusively to the selected cloud provider (#4380) (#4975)
While WebDAV or Google Drive is the selected cloud sync backend, the
native Readest Cloud book/progress/note channels are gated off and the
file-sync engine owns library data end to end:

- isSyncCategoryEnabled returns false for book/progress/note (and their
  legacy aliases) when a third-party provider is selected. A runtime
  override, deliberately not written into syncCategories: the user's own
  toggles persist and take effect again on switch-back. Account channels
  (settings, stats, dictionaries, fonts, textures, OPDS catalogs) always
  stay native.
- persistActiveCloudProvider is the single write path for provider
  switching, used by the chooser, both connect/disconnect flows, and the
  Drive OAuth callback (which previously bypassed the cross-window
  broadcast). The broadcast carries ONLY the enabled flags plus
  providerSelectedAt, never credentials or sync cursors, and only on
  switch events, so a stale window's routine save cannot revert a switch.
- buildWebDAVConnectSettings no longer pre-sets enabled: activation
  belongs to withActiveCloudProvider, so the fresh-connect path now gets
  the syncBooks auto-flip and the providerSelectedAt stamp.
- Sync health: fileSyncStore records lastError per backend; the durable
  lastSyncedAt stays in provider settings. The SettingsMenu sync row
  reads Synced via provider / Sync failed and its tap (with pull to
  refresh and BackupWindow, all routed through pullLibrary) runs the
  file engine via the shared runActiveFileLibrarySync helper instead of
  a gated native pull that would toast undefined book(s) synced.
- Mixed-fleet detection: while gated, the auto-sync interval runs a
  read-only probe of /api/sync since providerSelectedAt; any newer book
  row means another device still syncs natively, and a once-per-session
  notice explains the fork instead of leaving it silent.
- Readest-Cloud-only affordances hide while a third-party provider is
  selected: the quota row becomes a caption naming the active provider,
  Auto Upload disappears from the menu and command palette, the
  BookItem upload badge and the Transfer Queue Upload All button hide.
- providerSelectedAt added to both provider settings types and the
  backup blacklist.

Stacked on the quota-decoupling change for #4959; requires the
metadata-parity change so gated channels lose nothing users can see.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 18:05:54 +02:00
Huang Xin 942c062d35 fix(sync): decouple Readest Cloud storage quota from third-party cloud sync (#4959) (#4971)
When a third-party provider (WebDAV or Google Drive) is the selected
cloud sync backend, Readest Cloud storage is no longer written to:

- New src/services/sync/cloudSyncProvider.ts policy module: the selected
  provider is derived from the existing per-device enabled flags
  (webdav wins deterministically if both are ever set); the premium
  guard resolves to a PAUSED state instead of silently falling back to
  Readest Cloud, with the user plan cached for non-React modules.
- transferManager gates book uploads on the selected provider: queueUpload
  returns null when gated; pending book uploads from before a provider
  switch are visibly cancelled (cancelReason policy) and pruned on the
  next restore; downloads and replica transfers are never gated.
- Book uploads are deferred until settings hydrate (settings.version
  barrier) so a persisted queue cannot be mis-processed at startup;
  replica transfers are not stalled.
- Quota-exceeded uploads fail fast with zero retries, and a batch import
  produces one summary toast instead of one toast per book.
- Policy cancellations are a distinct bucket via a shared predicate:
  excluded from failed stats, Retry All, and the per-item Retry button.
- Auto-upload call sites (ingest, OPDS, subscriptions) check the provider
  gate; the explicit Upload Book action explains the gate with a toast
  instead of silently doing nothing.
- Activating a provider auto-enables its syncBooks so books keep backing
  up somewhere; a one-time migration (20260706) applies the same flip for
  users who already had a provider enabled.
- webdav.deviceId and webdav.lastSyncedAt are excluded from backups,
  matching the existing googleDrive entries.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 17:31:15 +02:00
Huang Xin 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>
2026-06-27 10:28:08 +02:00