Files
readest/apps/readest-app/src/utils/settingsSync.ts
T
Huang Xin 56abcb4a6c feat(sync): S3-compatible cloud sync provider (#4990)
* feat(sync): S3-compatible cloud sync provider with premium-gated chooser

Add a third file-sync backend for any SigV4 object store (Cloudflare R2,
AWS S3, MinIO, Backblaze B2), end to end: SigV4 transport via aws4fetch
(path-style addressing, ListObjectsV2 with page draining, per-key
deletes, presigned streaming on Tauri, Drive-style error mapping and
backoff), S3 settings slice and defaults, exclusive provider activation
and cross-window flag broadcast, registry memoization, and an
Integrations chooser entry plus connect form that validates the bucket
with one signed listing.

Shared helpers settingsKeyForBackend and cloudProviderDisplayName
replace the scattered per-kind ternaries across the reader and library
sync hooks, fleet detection, and the settings surfaces.

The chooser now marks third-party providers with a Premium badge and
enforces the paywall (CLOUD_SYNC_REQUIRES_PREMIUM on): free plans see
the rows but route to the upgrade page instead of the config sub-pages,
and a downgraded account's still-selected provider is paused rather
than silently falling back to Readest Cloud uploads. Manual provider
sync now reports "N book(s) synced" like the native cloud sync, from
the engine result returned by runActiveFileLibrarySync.

The S3 transport passes the same provider semantic contract as WebDAV
and Google Drive.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* perf(sync): make the library row the ground truth for local file presence

Every sync run re-walked all books whose file is recorded nowhere and
paid two plugin:fs|exists IPC per book per run on Tauri, just to relearn
"no local source", ending in 0 books synced. The library row already
tracks local presence reliably (import, download, and delete all stamp
downloadedAt, and the metadata merge keeps it device-local), so the
file-push gate now trusts the row: a book the row marks as absent costs
zero filesystem and zero remote probes, keeping incremental sync a pure
metadata diff at any library size.

A session-scoped per-provider memo additionally suppresses re-probes of
drifted rows (the row claims a file the filesystem no longer has),
keyed to the book's updatedAt so any local change re-qualifies it.
Row-vs-filesystem split-brain in either direction is healed by Full
Sync, which bypasses the gate, the memo, and the uploaded-file record
and audits the real filesystem.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(sync): tiered request timeouts for the WebDAV client

An unreachable or dead server (a LAN host that went away) left PROPFIND
and HEAD requests pending indefinitely, pinning the Integrations panel
on "Syncing..." and the browse pane on a spinner. Metadata round-trips
(PROPFIND, HEAD, MKCOL, DELETE) answer with headers only, so they now
abort after 5 seconds; GET and PUT carry book-sized bodies over
possibly slow links and keep a 5 minute ceiling instead. Expiry aborts
the request via AbortController and surfaces as a "Request timed out"
NETWORK failure through the existing WebDAVRequestError taxonomy.

Since every library sync run opens with the HEAD etag probe on
library.json, a dead server now fails the whole run within seconds.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(settings): provider panel status and layout fixes

Three small fixes across the provider settings panels:

- A completed manual "Sync now" clears the provider's lastError so the
  Cloud Sync chooser row and the SettingsMenu sync row stop reading
  "Sync failed" after the server comes back; a failed manual run now
  records the error for those surfaces too. Covered by a render harness
  that drives the real form against a mocked engine.
- The sync row shows a relative "Synced a few seconds ago" label (same
  wording as the SettingsMenu row) instead of an absolute timestamp.
- The Google Drive configured-but-inactive state rendered its Tips above
  the action buttons; Tips now close the page in every provider panel
  state.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(i18n): translate the S3 provider and premium gating strings

New keys from the S3-compatible provider (form fields, chooser entry,
tips), the Premium badge, and the parameterized provider tips,
translated across all 33 locales.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(memory): record the S3 provider and sync optimization notes

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 09:34:28 +02:00

134 lines
5.3 KiB
TypeScript

import { emit, listen, type UnlistenFn } from '@tauri-apps/api/event';
import { getCurrentWindow } from '@tauri-apps/api/window';
import { isTauriAppPlatform } from '@/services/environment';
import type { SystemSettings } from '@/types/settings';
/**
* Cross-window global-settings sync.
*
* On desktop the app runs multiple windows (one library + one per open book),
* and each keeps its own in-memory settings loaded once at window open. Global
* settings persist to a single shared `settings.json`, and every window writes
* the whole object on save. A window that loaded before the user customized a
* global setting therefore clobbers that change with its own stale (often
* default) value the next time it saves — e.g. a reader window reverting
* "Click to Paginate" back to the default on close (issue #4580).
*
* To keep windows consistent, the persisting window broadcasts its global
* setting blobs and every other window adopts them, so a later save no longer
* carries stale globals. Only `globalViewSettings` / `globalReadSettings` —
* the truly-global objects edited in the Settings dialog — are synced; every
* device/window-local field (filesystem paths, `lastOpenBooks`, sync cursors,
* screen brightness, ...) is left untouched on the receiving window.
*/
export const SETTINGS_SYNC_EVENT = 'global-settings-window-sync';
/**
* Minimal cloud-sync provider selection payload. ONLY the enabled flags
* plus the selection timestamp — never credentials (`webdav.password`
* must not ride window events) and never `lastSyncedAt` (the file-sync
* engine writes it after every push; if whole slices were broadcast, a
* reader window's routine cursor save interleaving with a provider
* switch could win and silently flip the selection back).
*/
export interface CloudSyncProviderFlags {
webdav: { enabled: boolean; providerSelectedAt?: number };
googleDrive: { enabled: boolean; providerSelectedAt?: number };
/** Optional: absent on payloads from pre-S3 windows (treated as unchanged). */
s3?: { enabled: boolean; providerSelectedAt?: number };
}
export interface SettingsSyncPayload {
/** Label of the window that persisted the change, so receivers ignore their own echo. */
sourceLabel: string;
globalViewSettings: SystemSettings['globalViewSettings'];
globalReadSettings: SystemSettings['globalReadSettings'];
/**
* Present only on provider-switch broadcasts (see
* `persistActiveCloudProvider`), NOT on routine saves — so a stale
* window's ordinary settings write can never carry stale flags that
* revert someone else's switch.
*/
cloudSyncProviders?: CloudSyncProviderFlags;
}
/**
* Merge the global setting blobs broadcast by another window into this window's
* settings, preserving every device/window-local field on the local copy.
*/
export const mergeSyncedGlobalSettings = (
local: SystemSettings,
payload: Pick<
SettingsSyncPayload,
'globalViewSettings' | 'globalReadSettings' | 'cloudSyncProviders'
>,
): SystemSettings => {
const merged: SystemSettings = {
...local,
globalViewSettings: payload.globalViewSettings,
globalReadSettings: payload.globalReadSettings,
};
if (payload.cloudSyncProviders) {
merged.webdav = { ...local.webdav, ...payload.cloudSyncProviders.webdav };
merged.googleDrive = { ...local.googleDrive, ...payload.cloudSyncProviders.googleDrive };
if (payload.cloudSyncProviders.s3) {
merged.s3 = { ...local.s3, ...payload.cloudSyncProviders.s3 };
}
}
return merged;
};
/**
* Broadcast this window's global settings to all other windows after a
* settings write. Fire-and-forget and a no-op off Tauri.
*/
export const broadcastGlobalSettings = async (
settings: SystemSettings,
opts: { includeCloudSyncProviders?: boolean } = {},
): Promise<void> => {
if (!isTauriAppPlatform()) return;
if (!settings.globalViewSettings || !settings.globalReadSettings) return;
try {
const payload: SettingsSyncPayload = {
sourceLabel: getCurrentWindow().label,
globalViewSettings: settings.globalViewSettings,
globalReadSettings: settings.globalReadSettings,
};
if (opts.includeCloudSyncProviders) {
payload.cloudSyncProviders = {
webdav: {
enabled: !!settings.webdav?.enabled,
providerSelectedAt: settings.webdav?.providerSelectedAt,
},
googleDrive: {
enabled: !!settings.googleDrive?.enabled,
providerSelectedAt: settings.googleDrive?.providerSelectedAt,
},
s3: {
enabled: !!settings.s3?.enabled,
providerSelectedAt: settings.s3?.providerSelectedAt,
},
};
}
await emit(SETTINGS_SYNC_EVENT, payload);
} catch (err) {
console.warn('Failed to broadcast settings to other windows', err);
}
};
/**
* Subscribe to global-settings broadcasts from other windows. The callback is
* invoked only for events emitted by a different window. Returns an unlisten
* function (a no-op resolver off Tauri).
*/
export const subscribeSettingsSync = async (
onReceive: (payload: SettingsSyncPayload) => void,
): Promise<UnlistenFn> => {
if (!isTauriAppPlatform()) return () => {};
const currentLabel = getCurrentWindow().label;
return listen<SettingsSyncPayload>(SETTINGS_SYNC_EVENT, ({ payload }) => {
if (!payload || payload.sourceLabel === currentLabel) return;
onReceive(payload);
});
};