Commit Graph

10 Commits

Author SHA1 Message Date
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 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>
2026-06-28 18:39:52 +02:00
Huang Xin 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>
2026-06-27 19:11:17 +02:00
Huang Xin 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>
2026-06-27 16:59:55 +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
loveheaven c5384b2a6b fix: respect Android Back / Esc inside Settings sub-pages and Import-from-Folder dialog (#4286)
* fix(library): cancel Import-from-Folder dialog on Android Back / Esc

The dialog was previously relying on <Dialog>'s built-in
`native-key-down` listener to handle Back / Escape, but
`useKeyDownActions` (used here for the Enter-to-confirm shortcut)
registers its own sync listener that returns `true` on every Back
keypress, consuming the event before <Dialog> ever sees it. As a
result Android Back and Escape were silently swallowed inside this
dialog.

Wire `onCancel` so the hook actually performs the cancel itself, and
guard it (like Enter) while a folder pick is in flight to avoid
canceling mid-pick.

* fix(settings): step back to parent panel on Android Back / Esc inside sub-pages

Several settings panels render an in-place sub-view based on local
state (FontPanel -> Custom Fonts, LangPanel -> Custom Dictionaries,
IntegrationsPanel -> KOSync / WebDAV / Readwise / Hardcover / OPDS /
Send-to-Readest). Pressing Android Back (or Escape) while one of
these sub-pages was open used to close the entire Settings dialog
because only <Dialog>'s own `native-key-down` listener handled the
event.

Mount a `useKeyDownActions` hook at each parent panel, gated on the
sub-page being open, that calls the existing "go back" handler and
consumes the event. Because `dispatchSync` walks listeners LIFO, the
panel-level hook (registered after <Dialog>'s) claims Back first
while a sub-page is open; once the sub-page is closed the hook is
disabled and Back falls through to <Dialog> as before, closing the
whole Settings dialog.

This keeps all logic in the three parent panels — no changes needed
to the seven sub-page components — and a single hook in
IntegrationsPanel covers all six integrations sub-pages.
2026-05-25 16:00:33 +02:00
loveheaven 5c82351ab9 feat(integrations): add WebDAV sync to Reading Sync settings (#4204)
* feat(integrations): add WebDAV sync to Reading Sync settings

Adds a WebDAV entry under Settings -> Integrations -> Reading Sync with configure/browse UI, library-wide Sync now, and per-book sync of progress, annotations and (opt-in) book files + covers.

Reading progress and annotations are always synced when WebDAV is enabled; only Sync Book Files stays as a toggle since it's bandwidth-heavy.

* feat(webdav): add diagnostic sync history panel and document viewSettings invariant

Surface a per-run history for the WebDAV "Sync now" button so users can self-triage failures without rummaging through the dev console — a screenshot of the panel is now enough to file a useful bug report. The same change tightens the docs around viewSettings so the "device-local UI preferences" boundary is impossible to misread on the next refactor pass.

Sync history panel:

  * New WebDAVSettings.syncLog ring buffer (cap 10), persisted alongside the rest of settings so a screenshot survives across app restarts. WebDAVSyncLogEntry captures startedAt, finishedAt, status (success / partial / failure), trigger, the eight counters from SyncLibraryResult, the toast text, and an optional per-book failure list with a phase tag (download / upload-config / upload-file).

  * SyncLibraryResult gains a failedBooks: SyncFailureEntry[] field. The two existing failure points in syncLibrary (download catch, upload catch) now record per-book reason+phase via formatFailureReason(), which keeps the persisted blob small by stripping stacks/whitespace and capping length at 200 chars.

  * WebDAVForm.handleSyncNow now timestamps the run, builds an entry from the result on success/partial paths and from the caught error on failure paths, and appends through a fresh-read appendSyncLogEntry() so concurrent toggle changes can't clobber the log.

  * New SyncHistoryPanel + SyncStatusBadge + SyncHistoryDetails components render the log inline in the Settings page. The detail row groups counters into three semantic columns (activity, skipped, outcome) on a six-column grid so labels can wrap freely while numbers stay tabular and right-aligned. Per-book failures render as a separate stack below the counters.

viewSettings invariant:

  * buildRemotePayload and pullBookConfig already implement the right thing — only progress/location/xpointer/booknotes travel; viewSettings stays device-local. Comments now spell out the contract on both sides so future contributors don't reintroduce viewSettings on the wire by mistake.

* fix(webdav): preserve prior state across reconnect, drop stale closure in ensureDeviceId

Two bugs in the WebDAV sync flow surfaced during review:

1. WebDAVForm.handleConnect rebuilt the entire `webdav` settings block
   from the four credential fields the user just typed, dropping
   `deviceId`, `syncBooks`, `strategy`, `syncProgress`, `syncNotes`,
   `lastSyncedAt`, and `syncLog` on every reconnect. Most concerning is
   the deviceId rotation: a disconnect + reconnect made the next sync
   look like a brand-new device, defeating the cross-device clobber
   detection encoded in `RemoteBookConfig.writerDeviceId`. Extract a
   pure helper `buildWebDAVConnectSettings` that spreads the previous
   webdav object first so reconnect is non-destructive, matching the
   sibling pattern in KOSyncForm.

2. useWebDAVSync.ensureDeviceId merged the new deviceId into the closure
   variable `settings`, which can be stale when `pullNow → pushNow`
   fires back-to-back on book open or when the settings panel writes a
   sibling field concurrently. Read latest settings via
   `useSettingsStore.getState()` to match the pattern already used in
   `updateLastSyncedAt` and `persistWebdav`.

Adds three unit tests for the new helper, including the reconnect
preservation invariant.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(webdav): address review observations on encodePath, pull skip, and remote GC

Three follow-ups from the review pass on top of 3f721d04. Each one was
called out as a smaller observation the reviewer noted but did not push:

* WebDAVClient.encodePath silently re-escaped literal % characters
  despite a comment claiming existing %-escapes are preserved. A caller
  that pre-encoded a space as %20 would see %20 become %2520 in the
  request URL, breaking any path that came in already escaped. Tokenise
  each segment into already-escaped %XX runs and everything-else, and
  only run encodeURIComponent on the latter. Add four unit tests
  exercising pure-unicode, pure-pre-escaped, mixed, and root-slash
  paths.

  Implementation note: two regexes are needed because a /g RegExp.test
  is stateful and would skip every other token in this map; the split
  regex has /g for the iteration, the classifier regex is anchored
  without /g for the per-token check.

* OPEN_PULL_SKIP_MS doc-comment claimed it catches the
  close-then-reopen flow, but useWebDAVSync unmounts on reader close so
  lastPulledAtRef resets to 0 — the new instance always passes the
  cooldown check on remount. The guard actually only fires on
  re-invocations of the open-book effect inside one hook lifetime
  (book-to-book navigation, double-render before hasPulledOnce flips).
  Rewrite both the constant's doc-comment and the call-site comment to
  match the real semantics.

* WebDAVSync push path doesn't DELETE the per-hash directory of a
  tombstoned book. The deletion *is* propagated through library.json
  so other devices hide the book, but storage on the WebDAV server
  grows monotonically. Add a TODO at the pushLibraryIndex call with a
  sketch of what a future garbage-collection sweep would need (a per-
  device acknowledgment field on RemoteLibraryIndex so we don't wipe
  data a peer hasn't seen the deletion for yet).

* refactor(webdav): extract WebDAVBrowsePane and SyncHistoryPanel from WebDAVForm

The WebDAV settings form was nearing 1500 lines and hosted three
loosely related surfaces — credential entry, sync controls + manual
trigger, and the in-app file browser — that didn't share much state.
Reviewer flagged it as a refactor candidate; this commit does the
actual split.

* WebDAVBrowsePane (new, 534 lines): owns currentPath, the directory
  listing, per-entry download status, the navigation handlers and the
  per-file icon / filename helpers. Reads credentials from the
  settings prop and otherwise reaches for envConfig / useLibraryStore
  / useAuth itself rather than threading them through props (matches
  how the rest of the integrations panels are wired).

* SyncHistoryPanel (new, 293 lines): the diagnostic history surface
  plus its three private helpers (SyncStatusBadge, formatSyncSummary
  Line, formatSyncTimestamp, SyncHistoryDetails). Moved verbatim from
  the inline definitions at the bottom of WebDAVForm — the component
  was already presentation-only and accepting the translation fn as a
  prop, so no API change.

* WebDAVForm (676 lines, down from 1456): keeps the mode switch
  (configured vs. not), the credential form, the sync sub-controls
  (Upload Book Files / Sync Strategy / Sync now button), and the
  large handleSyncNow effect — those last two are intrinsically tied
  to the settings store and would have just been pushed back up the
  prop chain by any extraction. The standalone SyncHistoryPanel and
  WebDAVBrowsePane are now mounted as siblings inside the configured
  branch.

No behavioural change — both new files run the same effects, build
the same JSX, and read/write the same store fields as before. All
existing webdav-related unit tests still pass.

Resolves the last of the reviewer's smaller observations on
3f721d04 (file length).

* fix(webdav): stream book uploads to avoid renderer OOM on large files

Both syncLibrary (manual Sync now in WebDAVForm) and useWebDAVSync (per-book auto/manual sync triggered on book open) materialised the full book binary as an ArrayBuffer in the V8 heap before PUTting it. With multi-hundred-megabyte PDFs / scanned books, the renderer either accumulates buffers across sequential pushes (library sync) or blows its heap ceiling on a single book (per-book sync), surfacing as a blank white screen on desktop and a binder-OOM kill of the WebView on Android.

Add a BookFileStreamingLoader option to pushBookFile that, on Tauri targets, hands the file path off to tauriUpload's Rust-side streamer so bytes never enter JS. The HEAD short-circuit is shared across both paths, so steady-state syncs still cost a single round-trip per book. Web targets keep the buffered fallback (no streaming HTTP primitive available there).

Wire the streaming loader through SyncLibraryOptions.loadBookFileStreaming for the library Sync now path, and inline it in useWebDAVSync.pushBookFileNow for the per-book path. Covers stay on the buffered loader — they're capped at a few hundred KB and don't justify widening the API.

* fix(webdav): keep Sync now state alive across Settings navigation/close

WebDAVForm tracked the library-wide Sync now run in component state, so any navigation that unmounted the form (drilling back to the Integrations list, or closing the SettingsDialog entirely) destroyed the in-flight indicator while syncLibrary's promise kept running off-thread. On return the user saw a re-enabled button with no progress affordance, an empty Sync History (until the run finally finished), and could trigger a second concurrent syncLibrary against the server.

Hoist isSyncing / progressLabel into a process-local zustand store (webdavSyncStore) and consume it from WebDAVForm. The store outlives any single mount, so re-mounting the form picks up the running sync's state on first render — button stays disabled, progress label keeps ticking, and the re-entrancy gate (now reading the live store rather than a stale closure) blocks duplicate clicks. Also surface 'Syncing…' in the IntegrationsPanel row so users get the cue without drilling into the sub-page.

Not persisted: the store dies with the renderer, which is the right semantic — a sync killed by app exit shouldn't look like it's still going on next launch.

* feat(webdav): cleanup mode for orphan book directories on the server

WebDAV pushes set Book.deletedAt as a tombstone but never DELETE the per-hash directory on the server, so the remote Readest/books/ tree accumulates dead entries from books the user deleted long ago. Add a dedicated cleanup mode in the WebDAV browser to evict them in batch.

Cleanup mode is reached via a new sweep button next to Refresh. Entering it pins the listing to Readest/books/, filters down to directories whose local Book carries deletedAt, and replaces the per-row icon with a checkbox. The footer carries a single right-aligned Delete from server action; selecting one or more rows and clicking it sends a confirm dialog (appService.ask, so it actually blocks on Tauri) and then runs sequential DELETEs against the server. Each row splices out of the listing the moment its DELETE returns, so the listing itself is the progress indicator; the button keeps a stable width by always reserving space for the spinner via the invisible class.

The local library is left untouched. Book.deletedAt is the authoritative deletion signal in readest's sync model — clearing or rewriting it here would cause sibling devices to either resurrect the book or lose the deletion event. Restore is therefore not offered: the per-entry download button already provides full recovery (tauriDownload + ingestFile streams the file back, ingestFile clears deletedAt as a side-effect, and the next sync round-trip merges remote progress and notes), and a metadata-only restore would leave users staring at unopenable shelf rows whenever the bytes had been GCed off local disk.

Browse mode is friendlier too. Per-hash subdirectory rows under Readest/books/ resolve their hash to the local library's title and short-form hash for skimmability; soft-deleted entries get a folder-off icon plus a 60% dimmed title (a redundant signal for touch platforms where the desktop-only hover tooltip doesn't fire). Cleanup runs are persisted into the existing sync history with a kind: 'cleanup' discriminator and a booksDeleted counter, so destructive batch operations are auditable alongside regular Sync now runs without polluting the common case (the new counter is zero-suppressed on plain sync entries).

* test(webdav): cover deleteDirectory and deleteRemoteBookDir

Pin the contract of the cleanup-mode delete plumbing: HTTP method, Depth: infinity header, Authorization header and target URL on the low-level deleteDirectory; success/failure/auth-failure routing and per-hash path construction on the high-level deleteRemoteBookDir. Status-code semantics are exercised end to end (200/204 ok, 404 idempotent, 401/403 AUTH_FAILED, 5xx generic, network throw NETWORK), so a future refactor can't silently drop the explicit Depth header or merge the auth-failure path into the per-book result struct without tripping a regression.

---------

Co-authored-by: Huang Xin <chrox.huang@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 18:55:12 +02:00
Huang Xin 0b18de0581 feat(send): Send to Readest — multi-channel capture into your library (#4230)
* feat(send): Send to Readest — multi-channel capture into your library

A Send-to-Kindle equivalent: email, web-upload, share, or one-click
capture books and articles into the cloud library; they sync to every
device.

Architecture (client-side processing): out-of-app channels drop a raw
payload into a per-user send_inbox; Readest clients drain it through one
shared ingestService.ingestFile(). The server never parses or converts.

- ingestService.ingestFile() — channel-agnostic import orchestration
  extracted from library/page.tsx (DI-based, forceUpload support).
- send_addresses / send_allowed_senders / send_inbox tables + RLS + 4
  SECURITY DEFINER claim/lease RPCs (migration 012_send_to_readest.sql).
- Conversion subsystem (DOCX/RTF/HTML/article/TXT -> EPUB) in a Web Worker.
- send-email Cloudflare Email Worker; inbox-drainer controller +
  useInboxDrainer hook; /api/send/* routes.
- Send to Readest settings panel: inbound address, approved-sender
  allowlist, recent activity, per-device drain toggle.
- /send web page (file drop + article URL) + SSRF-guarded fetch-url proxy.
- OS-shared files routed through ingestFile; Manifest V3 browser extension.

Security: inbox state changes only via SECURITY DEFINER RPCs (clients get
SELECT-only on send_inbox); approved-sender allowlist gates email;
SSRF guard on the one server-side URL fetch; inbox payload signed URLs
authorize against send_inbox.user_id.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: run format:check in the pre-push hook

Biome format checking is fast (~0.4s), so gate pushes on it too — catches
mis-formatted files that bypassed the staged-only pre-commit hook before
they reach CI.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(send): address CodeQL security findings

- ReDoS (senders.ts): the email regex had ambiguous quantifiers around
  the literal dot. Rewrote it linear-time (domain labels exclude '.')
  and cap the input at 254 chars.
- XSS (convertToEpub.ts): run untrusted HTML through DOMPurify
  (sanitizeForParsing — keeps document structure) before DOMParser, so
  title extraction and Readability never parse executable markup.
- SSRF (fetch-url.ts): harden the host guard — block bare single-label
  hostnames, IPv4-mapped IPv6, CGNAT/benchmark/multicast ranges, and the
  unspecified address. DNS rebinding stays a documented residual risk.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 20:06:52 +02:00
Huang Xin 411d3ad687 fix: export annotations even without TOC, closes #4186 (#4188)
* i18n(ios): add more localized languages in plist

* fix: export annotations even without TOC, closes #4186
2026-05-16 19:22:18 +02:00
Huang Xin 772bb73b46 ui/ux: codify design system and migrate settings to shared primitives (#4116)
* ui/ux: codify design system and migrate settings to shared primitives

Document Readest's design language in DESIGN.md (Adwaita-aligned, e-ink-first,
RTL-correct) and migrate every settings panel onto a small set of primitives
(BoxedList, SettingsRow, SettingsSwitchRow, SettingsSelect, SettingsInput,
NavigationRow, Tips, SubPageHeader). AGENTS.md links to DESIGN.md so contributors
land there before inventing new chassis classes.

Replace the standalone KOReader/Readwise/Hardcover Config dialogs with a single
Integrations panel (Reading Sync + Content Sources sub-pages). The reader's
BookMenu now hides each provider until it's configured, and Hardcover's per-book
"Enable for This Book" toggle is dropped — there's no auto-sync to gate, so the
flag was just extra clicks.

Refresh highlight colors (two-trigger swatch + label, translatable default
names), background texture / theme color selectors (border-current keeps
selection legible on any backdrop), CustomFonts/CustomDictionaries (quiet
list-extension style + shared Tips primitive), the OPDS catalog manager
(debounced auto-download, right-aligned Browse), Set PIN, and the KOSync
conflict resolver. Translate the ~30 new strings across all 33 locales.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* ui/ux: responsive typography, OPDS card polish, deep-link return paths

Restore the .settings-content responsive cascade (14px desktop / 16px
mobile) the legacy panels relied on by dropping hardcoded `text-sm`/`text-xs`
from the new primitives. Secondary text moves to em-relative `text-[0.85em]`
so it scales with the parent. Form controls (`<input>`, `<select>`) re-apply
the cascade explicitly via the `settings-content` class since browsers don't
inherit font-size onto form elements.

Extract `<SectionTitle>` primitive (caseless-language aware via
`isCaselessUILang`/`isCaselessLang`) and route every uppercase tag-style
header through it: BoxedList groups, Reading Sync, Content Sources, Theme
Color, Background Image, integration form labels, KOSyncResolver device
labels, and the OPDS My Catalogs / Popular Catalogs sections. CJK / Arabic
/ Hebrew / Indic / Thai / Tibetan locales bump to `1em` since `uppercase`
is a no-op on those scripts.

Redesign the OPDS My Catalogs cards: whole card becomes the browse trigger
(role='button'), edit/delete collapse into a 3-dot dropdown menu, and the
sync-status moves to a sub-line under Auto-download so the card height stays
constant whether the toggle is on/off or sync data has arrived.

Plumb a `from=settings-integrations` URL marker through the OPDS browser so
both manual close and auto-close-on-failure (preserved as `router.back()`
for transient failures, paired with a new `stashOPDSReturnTarget` helper)
return the user to Settings -> Integrations -> OPDS Catalogs sub-page
rather than the dialog's top level. Backed by new `requestedSubPage`
deep-link store field.

Skip the OPDS catalog passphrase prompt when credentials sync is disabled
-- `replicaPublish` already drops encrypted fields at the wire, so prompting
was both pointless and confusing.

Fix `SettingsDialog` calling `setRequestedPanel(null)` inside a `useState`
lazy initializer (zustand setter during render -> React warning); move the
clear into a one-shot `useEffect`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* ui/ux: opt Settings into OverlayScrollbars + caseless typography polish

Add an opt-in `useOverlayScroll` prop to `<Dialog>` that swaps the body's
native `overflow-y-auto` for `<OverlayScrollbarsComponent>` (autohide,
click-scroll, no native overlaid bars). SettingsDialog flips it on so the
long Layout / Color panels keep a visible, theme-aware scroll track on
Android / iOS webviews where native scrollbars auto-hide entirely. Other
short-modal callers stay on the native scrollbar.

Drop the `uppercase tracking-wider` SectionTitle styling for caseless
scripts and pair it with body-weight `font-medium` instead — those
typographic effects are no-ops on Han / Hangul / Devanagari / Thai etc.,
so a plain medium-weight body-size title reads more correctly than a
shrunken pseudo-uppercase one. SettingsRow / NavigationRow primary labels
follow the same rule (drop `font-medium` in caseless locales since the
inherited body weight already carries; CJK fonts bold poorly at body
size).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* ui/ux: SettingLabel primitive + KOSyncForm select polish + Tips alignment

Add `<SettingLabel>` primitive — caseless-aware row/field label that pairs
with `<SectionTitle>` (groups) for per-item labels. Cased scripts get
`font-medium`; caseless scripts (CJK / Arabic / Hebrew / Indic / Thai /
Tibetan) drop the weight since Han / Hangul / Devanagari etc. bold poorly
at body size. No font-size class so it inherits the `.settings-content`
14/16 cascade. Routed through `SettingsRow`, `NavigationRow`, and the
~12 ad-hoc inline `text-sm font-medium` callsites in AIPanel / FontPanel
/ ColorPanel / IntegrationsPanel / KOSync / Readwise / Hardcover forms.

Refactor KOSyncForm's Sync Strategy + Checksum Method rows onto the
shared `<SettingsSelect>` primitive — the inline 17-line div/select/
MdArrowDropDown chassis becomes a single SettingsSelect call with an
options array. Drops the unused MdArrowDropDown import and ~25 lines.

Fix Tips list-item alignment: callers traditionally pass `<li>` elements
(semantic) but the primitive was double-wrapping into `<li><span><li>...</li></span></li>` — invalid HTML, and the inner `<li>`'s
`display: list-item` broke line-wrap alignment on multi-line items.
Unwrap caller `<li>` to its content; add `flex-1` on the text span so
wrapped lines align under the first line instead of falling back to the
bullet column. Bullet container switches to `h-[1.4em]` so it tracks the
text line-height and pins to the first line's optical center via
`items-center` regardless of how much the content wraps.

DESIGN.md §5 typography updated to point primary-label callers at
`<SettingLabel>`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 17:46:25 +02:00