From e08622b416f1db977592c658fb1257afd5013057 Mon Sep 17 00:00:00 2001 From: Huang Xin Date: Tue, 7 Jul 2026 01:05:54 +0900 Subject: [PATCH] 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 --- apps/readest-app/TODOS.md | 25 ++++ .../components/settings/cloudSync.test.ts | 51 ++++++++ .../services/sync/file/runLibrarySync.test.ts | 109 ++++++++++++++++++ .../services/sync/fleetDetection.test.ts | 106 +++++++++++++++++ .../services/sync/syncCategories.test.ts | 45 ++++++++ .../services/webdav-connect-settings.test.ts | 9 +- .../src/__tests__/store/fileSyncStore.test.ts | 16 ++- .../src/__tests__/utils/settings-sync.test.ts | 42 +++++++ .../src/app/gdrive-callback/page.tsx | 22 ++-- .../src/app/library/components/BookItem.tsx | 9 +- .../app/library/components/SettingsMenu.tsx | 98 +++++++++++----- .../library/components/TransferQueuePanel.tsx | 10 +- .../src/app/library/hooks/useBooksSync.ts | 45 +++++++- .../app/library/hooks/useLibraryFileSync.ts | 2 + .../CommandPaletteProvider.tsx | 15 ++- .../components/settings/IntegrationsPanel.tsx | 10 +- .../settings/integrations/GoogleDriveForm.tsx | 22 ++-- .../settings/integrations/WebDAVForm.tsx | 21 ++-- .../settings/integrations/cloudSync.ts | 40 ++++++- .../readest-app/src/services/backupService.ts | 2 + .../src/services/sync/file/runLibrarySync.ts | 87 ++++++++++++++ .../src/services/sync/fleetDetection.ts | 57 +++++++++ .../sync/providers/webdav/connectSettings.ts | 12 +- .../src/services/sync/syncCategories.ts | 26 +++++ apps/readest-app/src/store/fileSyncStore.ts | 21 ++++ apps/readest-app/src/types/settings.ts | 7 ++ apps/readest-app/src/utils/settingsSync.ts | 59 ++++++++-- 27 files changed, 869 insertions(+), 99 deletions(-) create mode 100644 apps/readest-app/src/__tests__/services/sync/file/runLibrarySync.test.ts create mode 100644 apps/readest-app/src/__tests__/services/sync/fleetDetection.test.ts create mode 100644 apps/readest-app/src/services/sync/file/runLibrarySync.ts create mode 100644 apps/readest-app/src/services/sync/fleetDetection.ts diff --git a/apps/readest-app/TODOS.md b/apps/readest-app/TODOS.md index 2852bfa3..51137f48 100644 --- a/apps/readest-app/TODOS.md +++ b/apps/readest-app/TODOS.md @@ -1,5 +1,30 @@ # TODOS +## Cloud Sync provider selection follow-ups (deferred by /autoplan, 2026-07-06) + +Deferred from the Cloud Sync provider-selection plan (#4959/#4380). See the +Decision Audit Trail in the plan for reasoning. + +- [ ] Pre-switch "download all Readest Cloud books" affordance so a fresh device + gets full library completeness when a third-party provider is selected. (S) +- [ ] Library-page sync-status indicator for the active third-party provider + (fileSyncStore already exposes aggregate progress). (S) +- [ ] Account page active-provider chip. (XS) +- [ ] File-engine parity: reading stats + per-book viewSettings sync via the + file layout (readingStatus/tags parity shipped as its own PR). (M) +- [ ] Server-side quota error code (`code: 'quota_exceeded'`, mirroring the share + import route) so the client stops string-matching 'Insufficient storage + quota'; message drift silently restores retry behavior. (S) +- [ ] Pre-existing: Manage Sync "Books" category gates metadata rows but NOT + binary uploads to Readest Cloud (`queueUpload` never consults + `isSyncCategoryEnabled('book')` despite the category docs claiming it) β€” + align behavior or docs. (S) +- [ ] Sentry `cloudSyncProvider` tag: Sentry tagging is Rust-mediated + (`set_webview_info` pattern in `sentry_config.rs`); add a + `set_cloud_sync_provider` command + before_send tag so sync-related + reports carry the active provider. Console log lines ship in the + meantime. (S, needs src-tauri) + Deferred items from the Edge TTS Web Audio plan review (/autoplan, 2026-07-04). Each was explicitly deferred, not forgotten β€” see the Decision Audit Trail in `.agents/plans/2026-07-03-edge-tts-webaudio.md`. diff --git a/apps/readest-app/src/__tests__/components/settings/cloudSync.test.ts b/apps/readest-app/src/__tests__/components/settings/cloudSync.test.ts index cc09b521..827396d3 100644 --- a/apps/readest-app/src/__tests__/components/settings/cloudSync.test.ts +++ b/apps/readest-app/src/__tests__/components/settings/cloudSync.test.ts @@ -1,5 +1,7 @@ import { describe, expect, test } from 'vitest'; import { withActiveCloudProvider } from '@/components/settings/integrations/cloudSync'; +import { buildWebDAVConnectSettings } from '@/services/sync/providers/webdav/connectSettings'; +import type { WebDAVSettings } from '@/types/settings'; import { CLOUD_SYNC_REQUIRES_PREMIUM, isCloudSyncAllowed, isCloudSyncInPlan } from '@/utils/access'; import type { SystemSettings } from '@/types/settings'; @@ -71,6 +73,55 @@ describe('withActiveCloudProvider', () => { const next = withActiveCloudProvider(active, null); expect(next.webdav.syncBooks).toBe(true); }); + + test('fresh WebDAV connect flow (builder + activation) auto-enables syncBooks', () => { + // Regression: the builder must not pre-set `enabled`, or the + // activation never sees a disabled -> enabled transition and the + // most common path keeps the books-backed-up-nowhere default. + const previous = { enabled: false, syncBooks: false } as WebDAVSettings; + const connected = { + webdav: buildWebDAVConnectSettings(previous, { + serverUrl: 'https://dav.example.com', + username: 'alice', + password: 'hunter2', + rootPath: '/Readest', + }), + googleDrive: { enabled: false }, + } as unknown as SystemSettings; + const next = withActiveCloudProvider(connected, 'webdav'); + expect(next.webdav.enabled).toBe(true); + expect(next.webdav.syncBooks).toBe(true); + }); + }); + + describe('providerSelectedAt stamp (mixed-fleet detection anchor)', () => { + const inactive = { + webdav: { enabled: false }, + googleDrive: { enabled: false }, + } as unknown as SystemSettings; + + test('stamps the newly-activated provider only', () => { + const next = withActiveCloudProvider(inactive, 'webdav'); + expect(typeof next.webdav.providerSelectedAt).toBe('number'); + expect(next.webdav.providerSelectedAt!).toBeGreaterThan(0); + expect(next.googleDrive.providerSelectedAt).toBeUndefined(); + }); + + test('does not re-stamp an already-active provider', () => { + const active = { + webdav: { enabled: true, providerSelectedAt: 111 }, + googleDrive: { enabled: false }, + } as unknown as SystemSettings; + expect(withActiveCloudProvider(active, 'webdav').webdav.providerSelectedAt).toBe(111); + }); + + test('deactivation leaves the stamp untouched', () => { + const active = { + webdav: { enabled: true, providerSelectedAt: 111 }, + googleDrive: { enabled: false }, + } as unknown as SystemSettings; + expect(withActiveCloudProvider(active, null).webdav.providerSelectedAt).toBe(111); + }); }); }); diff --git a/apps/readest-app/src/__tests__/services/sync/file/runLibrarySync.test.ts b/apps/readest-app/src/__tests__/services/sync/file/runLibrarySync.test.ts new file mode 100644 index 00000000..a1782b55 --- /dev/null +++ b/apps/readest-app/src/__tests__/services/sync/file/runLibrarySync.test.ts @@ -0,0 +1,109 @@ +import { describe, test, expect, beforeEach, vi } from 'vitest'; +import { useSettingsStore } from '@/store/settingsStore'; +import { useLibraryStore } from '@/store/libraryStore'; +import { useFileSyncStore } from '@/store/fileSyncStore'; +import type { SystemSettings } from '@/types/settings'; + +const syncLibrary = vi.fn().mockResolvedValue({ booksSynced: 0 }); + +vi.mock('@/services/sync/file/providerRegistry', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + createFileSyncProvider: vi.fn(async () => ({}) as never), + }; +}); + +vi.mock('@/services/sync/file/appLocalStore', () => ({ + createAppLocalStore: vi.fn(() => ({}) as never), +})); + +vi.mock('@/services/sync/file/engine', () => ({ + FileSyncEngine: vi.fn(function (this: Record) { + this['syncLibrary'] = syncLibrary; + }), +})); + +import { runActiveFileLibrarySync } from '@/services/sync/file/runLibrarySync'; + +const translationFn = (key: string, params?: Record) => { + if (params) { + return Object.entries(params).reduce((acc, [k, v]) => acc.replace(`{{${k}}}`, String(v)), key); + } + return key; +}; + +const envConfig = { + getAppService: vi.fn(async () => ({ saveSettings: vi.fn() }) as never), +} as never; + +const setProvider = (patch: Partial): void => { + useSettingsStore.setState({ + settings: { + version: 1, + webdav: { enabled: false }, + googleDrive: { enabled: false }, + ...patch, + } as SystemSettings, + setSettings: (s: SystemSettings) => useSettingsStore.setState({ settings: s }), + saveSettings: vi.fn(), + } as never); +}; + +beforeEach(() => { + vi.clearAllMocks(); + syncLibrary.mockClear().mockResolvedValue({ booksSynced: 0 }); + useFileSyncStore.setState({ byKind: {}, activeKind: null, lastErrorByKind: {} }); + useLibraryStore.setState({ library: [], libraryLoaded: true } as never); +}); + +describe('runActiveFileLibrarySync', () => { + test('returns false without syncing when readest is the provider', async () => { + setProvider({}); + expect(await runActiveFileLibrarySync(envConfig, translationFn)).toBe(false); + expect(syncLibrary).not.toHaveBeenCalled(); + }); + + test('runs the engine for the active provider and clears lastError on success', async () => { + setProvider({ + webdav: { enabled: true, deviceId: 'd1', syncBooks: true, strategy: 'silent' }, + } as Partial); + useFileSyncStore.getState().setLastError('webdav', 'stale error'); + + expect(await runActiveFileLibrarySync(envConfig, translationFn)).toBe(true); + + expect(syncLibrary).toHaveBeenCalledTimes(1); + const [, options] = syncLibrary.mock.calls[0]!; + expect(options.syncBooks).toBe(true); + expect(options.deviceId).toBe('d1'); + expect(useFileSyncStore.getState().lastErrorByKind.webdav).toBeNull(); + // Mutex released. + expect(useFileSyncStore.getState().activeKind).toBeNull(); + }); + + test('records lastError and releases the mutex when the engine fails', async () => { + setProvider({ + webdav: { enabled: true, deviceId: 'd1' }, + } as Partial); + syncLibrary.mockRejectedValueOnce(new Error('server unreachable')); + + expect(await runActiveFileLibrarySync(envConfig, translationFn)).toBe(false); + + expect(useFileSyncStore.getState().lastErrorByKind.webdav).toContain('server unreachable'); + expect(useFileSyncStore.getState().activeKind).toBeNull(); + }); + + test('skips when the library has not loaded (would push an empty index)', async () => { + setProvider({ webdav: { enabled: true } } as Partial); + useLibraryStore.setState({ libraryLoaded: false } as never); + expect(await runActiveFileLibrarySync(envConfig, translationFn)).toBe(false); + expect(syncLibrary).not.toHaveBeenCalled(); + }); + + test('skips when another backend holds the library-sync mutex', async () => { + setProvider({ webdav: { enabled: true } } as Partial); + useFileSyncStore.getState().beginSync('gdrive', 'busy'); + expect(await runActiveFileLibrarySync(envConfig, translationFn)).toBe(false); + expect(syncLibrary).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/readest-app/src/__tests__/services/sync/fleetDetection.test.ts b/apps/readest-app/src/__tests__/services/sync/fleetDetection.test.ts new file mode 100644 index 00000000..618c378a --- /dev/null +++ b/apps/readest-app/src/__tests__/services/sync/fleetDetection.test.ts @@ -0,0 +1,106 @@ +import { describe, test, expect, beforeEach, vi } from 'vitest'; +import type { SystemSettings } from '@/types/settings'; +import { useFileSyncStore } from '@/store/fileSyncStore'; + +vi.mock('@/utils/event', () => ({ + eventDispatcher: { + dispatch: vi.fn(), + }, +})); + +import { checkMixedFleetOnce } from '@/services/sync/fleetDetection'; +import { eventDispatcher } from '@/utils/event'; +import type { SyncClient } from '@/libs/sync'; + +const translationFn = (key: string) => key; + +const makeSyncClient = (books: unknown[] | null): SyncClient => + ({ + pullChanges: vi.fn(async () => ({ books, configs: null, notes: null })), + }) as unknown as SyncClient; + +const settingsWith = (patch: Partial): SystemSettings => + ({ + version: 1, + webdav: { enabled: false }, + googleDrive: { enabled: false }, + ...patch, + }) as SystemSettings; + +beforeEach(() => { + vi.clearAllMocks(); + useFileSyncStore.setState({ + byKind: {}, + activeKind: null, + lastErrorByKind: {}, + fleetNoticeShown: false, + }); +}); + +describe('checkMixedFleetOnce', () => { + test('no probe when readest is the provider', async () => { + const client = makeSyncClient([]); + expect(await checkMixedFleetOnce(client, settingsWith({}), translationFn)).toBe(false); + expect(client.pullChanges).not.toHaveBeenCalled(); + }); + + test('no probe without a providerSelectedAt anchor', async () => { + const client = makeSyncClient([]); + const settings = settingsWith({ webdav: { enabled: true } } as Partial); + expect(await checkMixedFleetOnce(client, settings, translationFn)).toBe(false); + expect(client.pullChanges).not.toHaveBeenCalled(); + }); + + test('probes read-only since the selection anchor and notifies when another writer exists', async () => { + const client = makeSyncClient([{ book_hash: 'h1' }]); + const settings = settingsWith({ + webdav: { enabled: true, providerSelectedAt: 12345 }, + } as Partial); + + expect(await checkMixedFleetOnce(client, settings, translationFn)).toBe(true); + + expect(client.pullChanges).toHaveBeenCalledWith(12345, 'books', undefined, undefined, 1); + expect(vi.mocked(eventDispatcher.dispatch)).toHaveBeenCalledWith( + 'toast', + expect.objectContaining({ + message: expect.stringContaining('Another device is still syncing'), + }), + ); + }); + + test('notifies only once per session', async () => { + const client = makeSyncClient([{ book_hash: 'h1' }]); + const settings = settingsWith({ + webdav: { enabled: true, providerSelectedAt: 12345 }, + } as Partial); + + await checkMixedFleetOnce(client, settings, translationFn); + await checkMixedFleetOnce(client, settings, translationFn); + + expect(vi.mocked(eventDispatcher.dispatch)).toHaveBeenCalledTimes(1); + }); + + test('quiet when no newer rows exist', async () => { + const client = makeSyncClient([]); + const settings = settingsWith({ + webdav: { enabled: true, providerSelectedAt: 12345 }, + } as Partial); + + expect(await checkMixedFleetOnce(client, settings, translationFn)).toBe(false); + expect(vi.mocked(eventDispatcher.dispatch)).not.toHaveBeenCalled(); + }); + + test('probe failures are silent (offline is not a fleet problem)', async () => { + const client = { + pullChanges: vi.fn(async () => { + throw new Error('Not authenticated'); + }), + } as unknown as SyncClient; + const settings = settingsWith({ + webdav: { enabled: true, providerSelectedAt: 12345 }, + } as Partial); + + expect(await checkMixedFleetOnce(client, settings, translationFn)).toBe(false); + expect(vi.mocked(eventDispatcher.dispatch)).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/readest-app/src/__tests__/services/sync/syncCategories.test.ts b/apps/readest-app/src/__tests__/services/sync/syncCategories.test.ts index b963c64d..d4b8a002 100644 --- a/apps/readest-app/src/__tests__/services/sync/syncCategories.test.ts +++ b/apps/readest-app/src/__tests__/services/sync/syncCategories.test.ts @@ -49,6 +49,51 @@ describe('isSyncCategoryEnabled', () => { expect(isSyncCategoryEnabled('font')).toBe(true); }); + describe('cloud sync provider gate (exclusive routing, #4380)', () => { + test('book/progress/note are gated off while a third-party provider is selected', () => { + setSettings({ webdav: { enabled: true } } as Partial); + expect(isSyncCategoryEnabled('book')).toBe(false); + expect(isSyncCategoryEnabled('progress')).toBe(false); + expect(isSyncCategoryEnabled('note')).toBe(false); + }); + + test('legacy plural/singular aliases are gated too', () => { + setSettings({ googleDrive: { enabled: true } } as Partial); + expect(isSyncCategoryEnabled('books')).toBe(false); + expect(isSyncCategoryEnabled('configs')).toBe(false); + expect(isSyncCategoryEnabled('config')).toBe(false); + expect(isSyncCategoryEnabled('notes')).toBe(false); + }); + + test('account channels stay native while a third-party provider is selected', () => { + setSettings({ webdav: { enabled: true } } as Partial); + expect(isSyncCategoryEnabled('stats')).toBe(true); + expect(isSyncCategoryEnabled('settings')).toBe(true); + expect(isSyncCategoryEnabled('dictionary')).toBe(true); + expect(isSyncCategoryEnabled('font')).toBe(true); + expect(isSyncCategoryEnabled('opds_catalog')).toBe(true); + }); + + test('the gate overrides an explicitly-true user category toggle', () => { + setSettings({ + webdav: { enabled: true }, + syncCategories: { book: true, progress: true }, + } as Partial); + expect(isSyncCategoryEnabled('book')).toBe(false); + expect(isSyncCategoryEnabled('progress')).toBe(false); + }); + + test('no gating when readest is the provider', () => { + setSettings({ + webdav: { enabled: false }, + googleDrive: { enabled: false }, + } as Partial); + expect(isSyncCategoryEnabled('book')).toBe(true); + expect(isSyncCategoryEnabled('progress')).toBe(true); + expect(isSyncCategoryEnabled('note')).toBe(true); + }); + }); + test('settings is togglable on its own', () => { setSettings({ syncCategories: { settings: false, dictionary: false } as Partial< diff --git a/apps/readest-app/src/__tests__/services/webdav-connect-settings.test.ts b/apps/readest-app/src/__tests__/services/webdav-connect-settings.test.ts index 03ae8e1c..28859f09 100644 --- a/apps/readest-app/src/__tests__/services/webdav-connect-settings.test.ts +++ b/apps/readest-app/src/__tests__/services/webdav-connect-settings.test.ts @@ -10,8 +10,10 @@ describe('buildWebDAVConnectSettings', () => { password: 'hunter2', rootPath: '/Readest', }); + // The builder is activation-agnostic: `enabled` (and the activation + // side effects like the syncBooks auto-flip) belong to + // withActiveCloudProvider, which the connect flow applies on top. expect(result).toEqual({ - enabled: true, serverUrl: 'https://dav.example.com', username: 'alice', password: 'hunter2', @@ -44,7 +46,10 @@ describe('buildWebDAVConnectSettings', () => { rootPath: '/Readest', }); - expect(next.enabled).toBe(true); + // Still disabled: the connect flow activates via withActiveCloudProvider + // so the disabled -> enabled transition (and its syncBooks auto-flip) + // happens exactly once, in one place. + expect(next.enabled).toBe(false); // Stable per-device id MUST survive β€” losing it makes the next sync // look like a brand-new device and breaks cross-device clobber // detection in `RemoteBookConfig.writerDeviceId`. diff --git a/apps/readest-app/src/__tests__/store/fileSyncStore.test.ts b/apps/readest-app/src/__tests__/store/fileSyncStore.test.ts index ded19d85..52b2ebdf 100644 --- a/apps/readest-app/src/__tests__/store/fileSyncStore.test.ts +++ b/apps/readest-app/src/__tests__/store/fileSyncStore.test.ts @@ -1,7 +1,8 @@ import { beforeEach, describe, expect, test } from 'vitest'; import { useFileSyncStore } from '@/store/fileSyncStore'; -const reset = () => useFileSyncStore.setState({ byKind: {}, activeKind: null }); +const reset = () => + useFileSyncStore.setState({ byKind: {}, activeKind: null, lastErrorByKind: {} }); describe('fileSyncStore', () => { beforeEach(reset); @@ -44,4 +45,17 @@ describe('fileSyncStore', () => { expect(p?.progressLabel).toBe('Uploading 2 / 3'); expect(p?.progressDetail).toBe('Project Hail Mary'); }); + + test('lastError is recorded per backend and survives endSync until cleared', () => { + const { beginSync, setLastError, endSync } = useFileSyncStore.getState(); + beginSync('webdav', 'a'); + setLastError('webdav', 'AUTH_FAILED: 401'); + endSync('webdav'); + // The health surface reads this after the run finished. + expect(useFileSyncStore.getState().lastErrorByKind.webdav).toBe('AUTH_FAILED: 401'); + expect(useFileSyncStore.getState().lastErrorByKind.gdrive).toBeUndefined(); + // A later successful run clears it. + setLastError('webdav', null); + expect(useFileSyncStore.getState().lastErrorByKind.webdav).toBeNull(); + }); }); diff --git a/apps/readest-app/src/__tests__/utils/settings-sync.test.ts b/apps/readest-app/src/__tests__/utils/settings-sync.test.ts index 0385da44..11d12714 100644 --- a/apps/readest-app/src/__tests__/utils/settings-sync.test.ts +++ b/apps/readest-app/src/__tests__/utils/settings-sync.test.ts @@ -16,6 +16,48 @@ const makeLocal = (overrides: Partial = {}): SystemSettings => ...overrides, }) as SystemSettings; +describe('mergeSyncedGlobalSettings cloud sync provider flags', () => { + test('adopts enabled flags and providerSelectedAt, preserving credentials and cursors', () => { + const local = makeLocal({ + webdav: { + enabled: false, + serverUrl: 'https://dav', + password: 'secret', + deviceId: 'd1', + lastSyncedAt: 42, + }, + googleDrive: { enabled: true, accountLabel: 'a@b' }, + } as Partial); + const merged = mergeSyncedGlobalSettings(local, { + globalViewSettings: local.globalViewSettings, + globalReadSettings: local.globalReadSettings, + cloudSyncProviders: { + webdav: { enabled: true, providerSelectedAt: 999 }, + googleDrive: { enabled: false }, + }, + }); + expect(merged.webdav.enabled).toBe(true); + expect(merged.webdav.providerSelectedAt).toBe(999); + expect(merged.webdav.password).toBe('secret'); + expect(merged.webdav.deviceId).toBe('d1'); + expect(merged.webdav.lastSyncedAt).toBe(42); + expect(merged.googleDrive.enabled).toBe(false); + expect(merged.googleDrive.accountLabel).toBe('a@b'); + }); + + test('a payload without provider flags leaves the slices untouched', () => { + const local = makeLocal({ + webdav: { enabled: true, password: 'secret' }, + } as Partial); + const merged = mergeSyncedGlobalSettings(local, { + globalViewSettings: local.globalViewSettings, + globalReadSettings: local.globalReadSettings, + }); + expect(merged.webdav.enabled).toBe(true); + expect(merged.webdav.password).toBe('secret'); + }); +}); + describe('mergeSyncedGlobalSettings', () => { test('adopts the broadcasting window global view settings', () => { const local = makeLocal(); diff --git a/apps/readest-app/src/app/gdrive-callback/page.tsx b/apps/readest-app/src/app/gdrive-callback/page.tsx index a1b728e5..80c26770 100644 --- a/apps/readest-app/src/app/gdrive-callback/page.tsx +++ b/apps/readest-app/src/app/gdrive-callback/page.tsx @@ -15,7 +15,7 @@ import { parseImplicitRedirect, tokenSetFromRedirect, } from '@/services/sync/providers/gdrive/auth/webRedirectFlow'; -import { withActiveCloudProvider } from '@/components/settings/integrations/cloudSync'; +import { persistActiveCloudProvider } from '@/components/settings/integrations/cloudSync'; /** * OAuth return route for the web Google Drive connect (full-page implicit flow). @@ -50,20 +50,16 @@ export default function GDriveCallback() { .catch(() => null); // Mark Drive the single active cloud provider (turns WebDAV off) and - // stamp the account label. Load + save through appService so this works - // even though the settings store may not be hydrated on this route. - const appService = await envConfig.getAppService(); - const settings = await appService.loadSettings(); - const base = withActiveCloudProvider(settings, 'gdrive'); - const next = { - ...base, + // stamp the account label. persistActiveCloudProvider loads via + // appService when the settings store isn't hydrated on this route, + // persists, hydrates the store, and broadcasts to other windows. + await persistActiveCloudProvider(envConfig, 'gdrive', (s) => ({ + ...s, googleDrive: { - ...base.googleDrive, - accountLabel: accountLabel ?? base.googleDrive?.accountLabel, + ...s.googleDrive, + accountLabel: accountLabel ?? s.googleDrive?.accountLabel, }, - }; - await appService.saveSettings(next); - setSettings(next); + })); eventDispatcher.dispatch('toast', { type: 'info', message: _('Connected') }); } catch (e) { console.warn('[gdrive] web callback failed', e); diff --git a/apps/readest-app/src/app/library/components/BookItem.tsx b/apps/readest-app/src/app/library/components/BookItem.tsx index 8d679a4d..9c0e0ab5 100644 --- a/apps/readest-app/src/app/library/components/BookItem.tsx +++ b/apps/readest-app/src/app/library/components/BookItem.tsx @@ -16,6 +16,7 @@ import { useSettingsStore } from '@/store/settingsStore'; import { useResponsiveSize } from '@/hooks/useResponsiveSize'; import { LibraryCoverFitType, LibraryViewModeType } from '@/types/settings'; import { navigateToLogin } from '@/utils/nav'; +import { isReadestCloudStorageActive } from '@/services/sync/cloudSyncProvider'; import { formatAuthors, formatDescription, formatSeries } from '@/utils/book'; import ReadingProgress from './ReadingProgress'; import BookCover from '@/components/BookCover'; @@ -199,9 +200,11 @@ const BookItem: React.FC = ({ } }} > - {!book.uploadedAt && settings.autoUpload && ( - - )} + {!book.uploadedAt && + settings.autoUpload && + isReadestCloudStorageActive(settings) && ( + + )} {book.uploadedAt && !book.downloadedAt && ( )} diff --git a/apps/readest-app/src/app/library/components/SettingsMenu.tsx b/apps/readest-app/src/app/library/components/SettingsMenu.tsx index 72149263..29d09a77 100644 --- a/apps/readest-app/src/app/library/components/SettingsMenu.tsx +++ b/apps/readest-app/src/app/library/components/SettingsMenu.tsx @@ -15,6 +15,8 @@ import { useAuth } from '@/context/AuthContext'; import { useEnv } from '@/context/EnvContext'; import { useThemeStore } from '@/store/themeStore'; import { useQuotaStats } from '@/hooks/useQuotaStats'; +import { useFileSyncStore } from '@/store/fileSyncStore'; +import { getCloudSyncProvider } from '@/services/sync/cloudSyncProvider'; import { useLibraryStore } from '@/store/libraryStore'; import { useSettingsStore } from '@/store/settingsStore'; import { useTranslation } from '@/hooks/useTranslation'; @@ -100,6 +102,8 @@ const SettingsMenu: React.FC = ({ onPullLibrary, setIsDropdow setIsDropdownOpen?.(false); }; const { isSyncing, setLibrary } = useLibraryStore(); + const fileSyncByKind = useFileSyncStore((s) => s.byKind); + const fileSyncLastError = useFileSyncStore((s) => s.lastErrorByKind); const { stats, hasActiveTransfers, setIsTransferQueueOpen } = useTransferQueue(); const openTransferQueue = () => { @@ -294,11 +298,38 @@ const SettingsMenu: React.FC = ({ onPullLibrary, setIsDropdow const coverDir = savedBookCoverPath ? savedBookCoverPath.split('/').pop() : 'Images'; const savedBookCoverDescription = `πŸ’Ύ ${coverDir}/last-book-cover.png`; - const lastSyncTime = Math.max( - settings.lastSyncedAtBooks || 0, - settings.lastSyncedAtConfigs || 0, - settings.lastSyncedAtNotes || 0, - ); + // While a third-party provider is selected the native cursors freeze (the + // book/progress/note channels are gated), so the sync row must report the + // file engine's health instead β€” otherwise it reads "Synced 3 months ago" + // forever and looks broken. + const cloudProvider = getCloudSyncProvider(settings); + const cloudProviderName = cloudProvider === 'gdrive' ? 'Google Drive' : 'WebDAV'; + const providerSyncing = cloudProvider !== 'readest' && !!fileSyncByKind[cloudProvider]?.isSyncing; + const providerLastError = + cloudProvider !== 'readest' ? fileSyncLastError[cloudProvider] : undefined; + const lastSyncTime = + cloudProvider !== 'readest' + ? (cloudProvider === 'gdrive' + ? settings.googleDrive?.lastSyncedAt + : settings.webdav?.lastSyncedAt) || 0 + : Math.max( + settings.lastSyncedAtBooks || 0, + settings.lastSyncedAtConfigs || 0, + settings.lastSyncedAtNotes || 0, + ); + const syncRowLabel = + cloudProvider !== 'readest' + ? providerLastError + ? _('Sync failed') + : lastSyncTime + ? _('Synced via {{provider}} {{time}}', { + provider: cloudProviderName, + time: dayjs(lastSyncTime).fromNow(), + }) + : _('Never synced') + : lastSyncTime + ? _('Synced {{time}}', { time: dayjs(lastSyncTime).fromNow() }) + : _('Never synced'); return ( = ({ onPullLibrary, setIsDropdow onClick={openTransferQueue} /> - + {cloudProvider === 'readest' ? ( + + ) : ( + // Non-interactive caption in the quota slot β€” no hover/focus + // affordance (it is not a dead tappable row) β€” so the quota's + // disappearance reads as intentional, not broken. + + )} @@ -370,11 +412,13 @@ const SettingsMenu: React.FC = ({ onPullLibrary, setIsDropdow )} - + {cloudProvider === 'readest' && ( + + )} {isTauriAppPlatform() && ( { const onClose = () => setIsOpen(false); const divRef = useKeyDownActions({ onCancel: onClose, onConfirm: onClose }); + // Uploads target Readest Cloud storage; while a third-party provider is + // selected the queue refuses book uploads, so hide the affordance + // (downloads and replica transfers keep flowing regardless). + const settings = useSettingsStore((s) => s.settings); + const readestStorageActive = isReadestCloudStorageActive(settings); + const booksToUpload = getVisibleLibrary().filter((book) => book.downloadedAt && !book.uploadedAt); const booksToDownload = getVisibleLibrary().filter( (book) => book.uploadedAt && !book.downloadedAt, @@ -246,7 +254,7 @@ const TransferQueuePanel: React.FC = () => {

{_('Transfer Queue')}

- {booksToUpload.length > 0 && ( + {readestStorageActive && booksToUpload.length > 0 && (