diff --git a/apps/readest-app/src/__tests__/app/library/demo-books-sync.test.tsx b/apps/readest-app/src/__tests__/app/library/demo-books-sync.test.tsx index a93567e10..d7d6b9a9d 100644 --- a/apps/readest-app/src/__tests__/app/library/demo-books-sync.test.tsx +++ b/apps/readest-app/src/__tests__/app/library/demo-books-sync.test.tsx @@ -66,10 +66,6 @@ vi.mock('@/services/sync/file/runLibrarySync', () => ({ runFileLibrarySyncPass: vi.fn(async () => ({ booksSynced: 0 })), })); -vi.mock('@/services/sync/fleetDetection', () => ({ - checkMixedFleetOnce: vi.fn(), -})); - const { useBooksSync } = await import('@/app/library/hooks/useBooksSync'); const { useLibraryStore } = await import('@/store/libraryStore'); diff --git a/apps/readest-app/src/__tests__/app/library/feed-books-sync.test.tsx b/apps/readest-app/src/__tests__/app/library/feed-books-sync.test.tsx index 8dd4f7173..953e989c4 100644 --- a/apps/readest-app/src/__tests__/app/library/feed-books-sync.test.tsx +++ b/apps/readest-app/src/__tests__/app/library/feed-books-sync.test.tsx @@ -64,10 +64,6 @@ vi.mock('@/services/sync/file/runLibrarySync', () => ({ runFileLibrarySyncPass: vi.fn(async () => ({ booksSynced: 0 })), })); -vi.mock('@/services/sync/fleetDetection', () => ({ - checkMixedFleetOnce: vi.fn(), -})); - vi.mock('@/services/rss/feedBook', () => ({ ensureFeedBookCover })); const { useBooksSync } = await import('@/app/library/hooks/useBooksSync'); diff --git a/apps/readest-app/src/__tests__/app/library/metadata-edit-sync.test.tsx b/apps/readest-app/src/__tests__/app/library/metadata-edit-sync.test.tsx index 8e5266168..f8e2b8397 100644 --- a/apps/readest-app/src/__tests__/app/library/metadata-edit-sync.test.tsx +++ b/apps/readest-app/src/__tests__/app/library/metadata-edit-sync.test.tsx @@ -61,10 +61,6 @@ vi.mock('@/services/sync/file/runLibrarySync', () => ({ runFileLibrarySyncPass: vi.fn(async () => ({ booksSynced: 0 })), })); -vi.mock('@/services/sync/fleetDetection', () => ({ - checkMixedFleetOnce: vi.fn(), -})); - vi.mock('@/services/rss/feedBook', () => ({ ensureFeedBookCover })); const { useBooksSync } = await import('@/app/library/hooks/useBooksSync'); diff --git a/apps/readest-app/src/__tests__/app/library/useBooksSync-routing.test.tsx b/apps/readest-app/src/__tests__/app/library/useBooksSync-routing.test.tsx index b857f687a..74bd1c23a 100644 --- a/apps/readest-app/src/__tests__/app/library/useBooksSync-routing.test.tsx +++ b/apps/readest-app/src/__tests__/app/library/useBooksSync-routing.test.tsx @@ -49,8 +49,6 @@ const runFileLibrarySyncPass = vi.hoisted(() => vi.fn(async (): Promise<{ booksSynced: number } | null> => ({ booksSynced: 1 })), ); -const checkMixedFleetOnce = vi.hoisted(() => vi.fn(async () => false)); - vi.mock('@/context/AuthContext', () => ({ useAuth: () => ({ user: { id: 'user-1' } }), })); @@ -88,10 +86,6 @@ vi.mock('@/services/sync/file/runLibrarySync', () => ({ runFileLibrarySyncPass, })); -vi.mock('@/services/sync/fleetDetection', () => ({ - checkMixedFleetOnce, -})); - const { useBooksSync } = await import('@/app/library/hooks/useBooksSync'); const { useLibraryStore } = await import('@/store/libraryStore'); const { eventDispatcher } = await import('@/utils/event'); @@ -236,28 +230,3 @@ describe('useBooksSync pullLibrary routing (issue #5062)', () => { expect(toastCalls[0]?.[1]).toMatchObject({ type: 'info', message: '4 book(s) synced' }); }); }); - -describe('useBooksSync handleAutoSync mixed-fleet probe gate (issue #5062)', () => { - it('runs the mixed-fleet probe when Readest Cloud is off', async () => { - routing.readestEnabled = false; - routing.backends = ['webdav']; - - renderHook(() => useBooksSync()); - - await waitFor(() => expect(checkMixedFleetOnce).toHaveBeenCalled()); - }); - - it('does not run the mixed-fleet probe when Readest Cloud is on', async () => { - routing.readestEnabled = true; - routing.backends = []; - - renderHook(() => useBooksSync()); - - // A probe warning "another device still syncs via Readest Cloud" is - // meaningless while this device also syncs via Readest Cloud. Wait for - // the native pull (which does run in this scenario) as a sync point - // before asserting the probe never fired. - await waitFor(() => expect(syncState.syncBooks).toHaveBeenCalled()); - expect(checkMixedFleetOnce).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 deleted file mode 100644 index e025cd66b..000000000 --- a/apps/readest-app/src/__tests__/services/sync/fleetDetection.test.ts +++ /dev/null @@ -1,146 +0,0 @@ -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(); - }); - - test('does not probe when Readest Cloud is enabled alongside a backend', async () => { - const pullChanges = vi.fn(); - const settings = { - readestCloud: { enabled: true }, - googleDrive: { enabled: true, providerSelectedAt: 1000 }, - } as unknown as SystemSettings; - - expect(await checkMixedFleetOnce({ pullChanges } as never, settings, translationFn)).toBe( - false, - ); - expect(pullChanges).not.toHaveBeenCalled(); - }); - - test('probes since readestCloud.disabledAt when Readest Cloud is off', async () => { - const pullChanges = vi.fn().mockResolvedValue({ books: [{ hash: 'h' }] }); - const settings = { - readestCloud: { enabled: false, disabledAt: 5000 }, - googleDrive: { enabled: true, providerSelectedAt: 1000 }, - } as unknown as SystemSettings; - - expect(await checkMixedFleetOnce({ pullChanges } as never, settings, translationFn)).toBe(true); - expect(pullChanges).toHaveBeenCalledWith(5000, 'books', undefined, undefined, 1); - }); - - test('falls back to the earliest providerSelectedAt for a legacy user', async () => { - const pullChanges = vi.fn().mockResolvedValue({ books: [] }); - const settings = { - // No readestCloud field: derived off, because a backend is enabled. - // webdav is enumerated before onedrive by getEnabledFileSyncBackends, - // but its providerSelectedAt is the LATER one, so a correct - // implementation must take the minimum across backends rather than - // the first enabled backend's value. - webdav: { enabled: true, providerSelectedAt: 9000 }, - onedrive: { enabled: true, providerSelectedAt: 3000 }, - } as unknown as SystemSettings; - - await checkMixedFleetOnce({ pullChanges } as never, settings, translationFn); - expect(pullChanges).toHaveBeenCalledWith(3000, 'books', undefined, undefined, 1); - }); -}); diff --git a/apps/readest-app/src/app/library/hooks/useBooksSync.ts b/apps/readest-app/src/app/library/hooks/useBooksSync.ts index a4d81d54f..8f995f373 100644 --- a/apps/readest-app/src/app/library/hooks/useBooksSync.ts +++ b/apps/readest-app/src/app/library/hooks/useBooksSync.ts @@ -18,8 +18,6 @@ import { isDemoBook } from '@/services/demoBooks'; import { isFeedBook } from '@/services/rss/feedBookUrl'; import { ensureFeedBookCover } from '@/services/rss/feedBook'; import { runFileLibrarySyncPass } from '@/services/sync/file/runLibrarySync'; -import { checkMixedFleetOnce } from '@/services/sync/fleetDetection'; -import { useSyncContext } from '@/context/SyncContext'; import { pickFresherReadingStatus, needsCoverRefresh, @@ -35,7 +33,6 @@ export const useBooksSync = () => { const { library, isSyncing, libraryLoaded } = useLibraryStore(); const { setLibrary, setIsSyncing, setSyncProgress } = useLibraryStore(); const { useSyncInited, syncedBooks, syncBooks, lastSyncedAtBooks } = useSync(); - const { syncClient } = useSyncContext(); const isPullingRef = useRef(false); const getNewBooks = useCallback(() => { @@ -136,15 +133,10 @@ export const useBooksSync = () => { throttle( async () => { if (isPullingRef.current) return; - // Readest Cloud unchecked: the native book channel is gated, so the - // interval runs the read-only mixed-fleet probe instead — a device - // still writing natively would otherwise fork progress silently - // (the auto library sync itself is useLibraryFileSync's). + // Readest Cloud unchecked: the native book channel is gated (the auto + // library sync itself is useLibraryFileSync's). const settingsNow = useSettingsStore.getState().settings; - if (!isReadestCloudEnabled(settingsNow)) { - void checkMixedFleetOnce(syncClient, settingsNow, _); - return; - } + if (!isReadestCloudEnabled(settingsNow)) return; const newBooks = getNewBooks(); if (!newBooks.lastSyncedAt) return; isPullingRef.current = true; diff --git a/apps/readest-app/src/services/sync/fleetDetection.ts b/apps/readest-app/src/services/sync/fleetDetection.ts deleted file mode 100644 index 4159e893a..000000000 --- a/apps/readest-app/src/services/sync/fleetDetection.ts +++ /dev/null @@ -1,70 +0,0 @@ -import type { SyncClient } from '@/libs/sync'; -import type { SystemSettings } from '@/types/settings'; -import type { TranslationFunc } from '@/hooks/useTranslation'; -import { useFileSyncStore } from '@/store/fileSyncStore'; -import { - getEnabledFileSyncBackends, - isReadestCloudEnabled, - settingsKeyForBackend, -} from '@/services/sync/cloudSyncProvider'; -import { eventDispatcher } from '@/utils/event'; - -/** - * Mixed-fleet detection (UC1). The cloud sync provider selection is - * device-local by design, so a desktop on WebDAV and a phone on Readest - * Cloud fork reading progress with zero errors on either side — the - * failure would otherwise present as "sync stopped working". The probe - * runs only while Readest Cloud is switched off on this device. - * - * While the native book/progress/note channels are gated, probe - * `/api/sync` READ-ONLY for any book row newer than the moment this - * device stopped writing them (`readestCloud.disabledAt`, falling back to - * the earliest `providerSelectedAt` for legacy users). Any newer row means - * another device is still on Readest Cloud. Nothing from the probe is - * applied locally, and no native writes resume. - * - * The notice fires once per app session (state in fileSyncStore, - * process-local); probe failures are silent — offline or logged-out is - * not a fleet problem, and the probe re-runs on the ordinary auto-sync - * cadence. - */ -export const checkMixedFleetOnce = async ( - syncClient: SyncClient, - settings: SystemSettings, - _: TranslationFunc, -): Promise => { - // Nothing to warn about while this device still writes the native rows: a - // peer on Readest Cloud is not a fork, it is the same channel (#5062). - if (isReadestCloudEnabled(settings)) return false; - if (useFileSyncStore.getState().fleetNoticeShown) return false; - - // When this device stopped writing native rows. `disabledAt` is exact; users - // who predate the Readest Cloud switch (it was derived, never stored) fall - // back to the earliest moment they selected a third-party backend. - const selectedAts = getEnabledFileSyncBackends(settings) - .map((kind) => settings[settingsKeyForBackend(kind)]?.providerSelectedAt) - .filter((t): t is number => typeof t === 'number'); - const since = - settings.readestCloud?.disabledAt ?? - (selectedAts.length > 0 ? Math.min(...selectedAts) : undefined); - if (!since) return false; - - try { - const result = await syncClient.pullChanges(since, 'books', undefined, undefined, 1); - if ((result.books?.length ?? 0) > 0) { - useFileSyncStore.getState().setFleetNoticeShown(); - console.info( - '[cloudSync] mixed fleet detected: native book rows newer than this device provider selection', - ); - eventDispatcher.dispatch('toast', { - type: 'info', - timeout: 8000, - message: _('Another device is still syncing this library via Readest Cloud'), - }); - return true; - } - } catch { - // Best-effort probe: offline / logged-out / server errors are silent. - } - return false; -}; diff --git a/apps/readest-app/src/store/fileSyncStore.ts b/apps/readest-app/src/store/fileSyncStore.ts index a6cf2146c..273431191 100644 --- a/apps/readest-app/src/store/fileSyncStore.ts +++ b/apps/readest-app/src/store/fileSyncStore.ts @@ -55,8 +55,6 @@ interface FileSyncState { * durable "last synced" timestamp lives in the provider settings slice. */ lastErrorByKind: Partial>; - /** Once-per-session latch for the mixed-fleet notice (see fleetDetection.ts). */ - fleetNoticeShown: boolean; /** * Acquire the library-sync mutex for `kind` and mark it syncing. Returns @@ -78,14 +76,12 @@ interface FileSyncState { updateProgress: (kind: FileSyncBackendKind, label: string, detail?: string | null) => void; endSync: (kind: FileSyncBackendKind) => void; setLastError: (kind: FileSyncBackendKind, message: string | null) => void; - setFleetNoticeShown: () => void; } export const useFileSyncStore = create((set, get) => ({ byKind: {}, activeKind: null, lastErrorByKind: {}, - fleetNoticeShown: false, beginSync: (kind, initialLabel) => { // Global mutex: only one backend's library sync at a time, since they all @@ -148,8 +144,6 @@ export const useFileSyncStore = create((set, get) => ({ set((s) => ({ lastErrorByKind: { ...s.lastErrorByKind, [kind]: message }, })), - - setFleetNoticeShown: () => set({ fleetNoticeShown: true }), })); /** Per-backend progress, idle when the backend has never started a run. */