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>
This commit is contained in:
Huang Xin
2026-07-07 01:05:54 +09:00
committed by GitHub
parent f805477091
commit e08622b416
27 changed files with 869 additions and 99 deletions
+25
View File
@@ -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`.
@@ -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);
});
});
});
@@ -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<typeof import('@/services/sync/file/providerRegistry')>();
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<string, unknown>) {
this['syncLibrary'] = syncLibrary;
}),
}));
import { runActiveFileLibrarySync } from '@/services/sync/file/runLibrarySync';
const translationFn = (key: string, params?: Record<string, string | number>) => {
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<SystemSettings>): 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<SystemSettings>);
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<SystemSettings>);
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<SystemSettings>);
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<SystemSettings>);
useFileSyncStore.getState().beginSync('gdrive', 'busy');
expect(await runActiveFileLibrarySync(envConfig, translationFn)).toBe(false);
expect(syncLibrary).not.toHaveBeenCalled();
});
});
@@ -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>): 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<SystemSettings>);
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<SystemSettings>);
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<SystemSettings>);
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<SystemSettings>);
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<SystemSettings>);
expect(await checkMixedFleetOnce(client, settings, translationFn)).toBe(false);
expect(vi.mocked(eventDispatcher.dispatch)).not.toHaveBeenCalled();
});
});
@@ -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<SystemSettings>);
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<SystemSettings>);
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<SystemSettings>);
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<SystemSettings>);
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<SystemSettings>);
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<
@@ -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`.
@@ -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();
});
});
@@ -16,6 +16,48 @@ const makeLocal = (overrides: Partial<SystemSettings> = {}): 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<SystemSettings>);
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<SystemSettings>);
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();
@@ -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);
@@ -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<BookItemProps> = ({
}
}}
>
{!book.uploadedAt && settings.autoUpload && (
<LiaCloudUploadAltSolid size={iconSize15} />
)}
{!book.uploadedAt &&
settings.autoUpload &&
isReadestCloudStorageActive(settings) && (
<LiaCloudUploadAltSolid size={iconSize15} />
)}
{book.uploadedAt && !book.downloadedAt && (
<LiaCloudDownloadAltSolid size={iconSize15} />
)}
@@ -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<SettingsMenuProps> = ({ 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<SettingsMenuProps> = ({ 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 (
<Menu
@@ -342,27 +373,38 @@ const SettingsMenu: React.FC<SettingsMenuProps> = ({ onPullLibrary, setIsDropdow
onClick={openTransferQueue}
/>
<MenuItem
label={
lastSyncTime
? _('Synced {{time}}', {
time: dayjs(lastSyncTime).fromNow(),
})
: _('Never synced')
}
label={syncRowLabel}
Icon={user ? MdSync : MdSyncProblem}
labelClass='ps-2 pe-1 !mx-0'
iconClassName={user && isSyncing ? 'animate-reverse-spin' : ''}
iconClassName={(user && isSyncing) || providerSyncing ? 'animate-reverse-spin' : ''}
onClick={handleSyncLibrary}
/>
<button
onClick={handleUserProfile}
className='hover:bg-base-300 w-full rounded-md'
style={{
paddingInlineStart: `${iconSize}px`,
}}
>
<Quota quotas={quotas} labelClassName='h-10 pl-3 pr-2' />
</button>
{cloudProvider === 'readest' ? (
<button
onClick={handleUserProfile}
className='hover:bg-base-300 w-full rounded-md'
style={{
paddingInlineStart: `${iconSize}px`,
}}
>
<Quota quotas={quotas} labelClassName='h-10 pl-3 pr-2' />
</button>
) : (
// 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.
<div
aria-hidden='true'
className='text-base-content/60 w-full px-3 py-2 text-[0.85em]'
style={{
paddingInlineStart: `${iconSize}px`,
}}
>
{_("Books sync via {{provider}} - Readest Cloud storage isn't used", {
provider: cloudProviderName,
})}
</div>
)}
<MenuItem label={_('Account')} onClick={handleUserProfile} />
</ul>
</MenuItem>
@@ -370,11 +412,13 @@ const SettingsMenu: React.FC<SettingsMenuProps> = ({ onPullLibrary, setIsDropdow
<MenuItem label={_('Sign In')} Icon={PiUserCircle} onClick={handleUserLogin}></MenuItem>
)}
<MenuItem
label={_('Auto Upload Books to Cloud')}
toggled={isAutoUpload}
onClick={toggleAutoUploadBooks}
/>
{cloudProvider === 'readest' && (
<MenuItem
label={_('Auto Upload Books to Cloud')}
toggled={isAutoUpload}
onClick={toggleAutoUploadBooks}
/>
)}
{isTauriAppPlatform() && (
<MenuItem
@@ -17,6 +17,8 @@ import { useTranslation } from '@/hooks/useTranslation';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
import { useKeyDownActions } from '@/hooks/useKeyDownActions';
import { useLibraryStore } from '@/store/libraryStore';
import { useSettingsStore } from '@/store/settingsStore';
import { isReadestCloudStorageActive } from '@/services/sync/cloudSyncProvider';
import {
TransferItem,
TransferStatus,
@@ -184,6 +186,12 @@ const TransferQueuePanel: React.FC = () => {
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 = () => {
<div className='border-base-300 flex items-center justify-between border-b p-4'>
<h2 className='text-lg font-semibold'>{_('Transfer Queue')}</h2>
<div className='flex items-center gap-2'>
{booksToUpload.length > 0 && (
{readestStorageActive && booksToUpload.length > 0 && (
<button
onClick={handleUploadAll}
className='btn btn-ghost btn-sm gap-1'
@@ -9,6 +9,11 @@ import { SYNC_BOOKS_INTERVAL_SEC } from '@/services/constants';
import { throttle } from '@/utils/throttle';
import { debounce } from '@/utils/debounce';
import { eventDispatcher } from '@/utils/event';
import { useSettingsStore } from '@/store/settingsStore';
import { getCloudSyncProvider } from '@/services/sync/cloudSyncProvider';
import { runActiveFileLibrarySync } from '@/services/sync/file/runLibrarySync';
import { checkMixedFleetOnce } from '@/services/sync/fleetDetection';
import { useSyncContext } from '@/context/SyncContext';
import {
pickFresherReadingStatus,
needsCoverRefresh,
@@ -18,10 +23,11 @@ import {
export const useBooksSync = () => {
const _ = useTranslation();
const { user } = useAuth();
const { appService } = useEnv();
const { envConfig, appService } = useEnv();
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(() => {
@@ -53,6 +59,32 @@ export const useBooksSync = () => {
const pullLibrary = useCallback(
async (fullRefresh = false, verbose = false) => {
// While a third-party provider is selected, the native book channel
// is gated (syncBooks would return undefined and the toast would read
// "undefined book(s) synced"); every library-refresh surface — pull to
// refresh, the SettingsMenu sync row, BackupWindow — routes through
// here, so route them all to the file engine instead. Works logged out
// (file sync has no Readest account dependency).
const provider = getCloudSyncProvider(useSettingsStore.getState().settings);
if (provider !== 'readest') {
if (isPullingRef.current) return;
try {
isPullingRef.current = true;
const ok = await runActiveFileLibrarySync(envConfig, _);
if (verbose) {
const providerName = provider === 'gdrive' ? 'Google Drive' : 'WebDAV';
eventDispatcher.dispatch('toast', {
type: ok ? 'info' : 'error',
message: ok
? _('Synced via {{provider}}', { provider: providerName })
: _('Sync failed'),
});
}
} finally {
isPullingRef.current = false;
}
return;
}
if (!user) return;
if (isPullingRef.current) return;
try {
@@ -70,7 +102,7 @@ export const useBooksSync = () => {
isPullingRef.current = false;
}
},
[_, user, libraryLoaded, syncBooks],
[_, user, libraryLoaded, syncBooks, envConfig],
);
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -78,6 +110,15 @@ export const useBooksSync = () => {
throttle(
async () => {
if (isPullingRef.current) return;
// Third-party provider selected: 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).
const settingsNow = useSettingsStore.getState().settings;
if (getCloudSyncProvider(settingsNow) !== 'readest') {
void checkMixedFleetOnce(syncClient, settingsNow, _);
return;
}
const newBooks = getNewBooks();
if (!newBooks.lastSyncedAt) return;
isPullingRef.current = true;
@@ -159,7 +159,9 @@ export const useLibraryFileSync = () => {
},
});
await updateLastSyncedAt(Date.now());
useFileSyncStore.getState().setLastError(kind, null);
} catch (e) {
useFileSyncStore.getState().setLastError(kind, e instanceof Error ? e.message : String(e));
console.warn('library file sync failed', e);
} finally {
useFileSyncStore.getState().endSync(kind);
@@ -9,6 +9,7 @@ import { isTauriAppPlatform } from '@/services/environment';
import { tauriHandleSetAlwaysOnTop, tauriHandleToggleFullScreen } from '@/utils/window';
import { setAboutDialogVisible } from '@/components/AboutWindow';
import { saveSysSettings } from '@/helpers/settings';
import { isReadestCloudStorageActive } from '@/services/sync/cloudSyncProvider';
import { SettingsPanelType } from '@/components/settings/SettingsDialog';
import {
CommandItem,
@@ -116,10 +117,14 @@ export const CommandPaletteProvider: React.FC<CommandPaletteProviderProps> = ({
[setSettingsDialogOpen, setActiveSettingsItemId],
);
// Auto-upload targets Readest Cloud storage, which is not written to while
// a third-party provider is selected — hide the toggle then.
const readestStorageActive = isReadestCloudStorageActive(settings);
// build command registry
const commandItems = useMemo(
() =>
buildCommandRegistry({
() => {
const items = buildCommandRegistry({
_,
openSettingsPanel,
toggleTheme,
@@ -132,8 +137,12 @@ export const CommandPaletteProvider: React.FC<CommandPaletteProviderProps> = ({
showAbout,
toggleTelemetry,
isDesktop,
}),
});
return readestStorageActive ? items : items.filter((i) => i.id !== 'action.autoUpload');
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[
readestStorageActive,
_,
openSettingsPanel,
toggleTheme,
@@ -32,7 +32,7 @@ import HardcoverForm from './integrations/HardcoverForm';
import SendToReadestForm from './integrations/SendToReadestForm';
import WebDAVForm from './integrations/WebDAVForm';
import GoogleDriveForm from './integrations/GoogleDriveForm';
import { withActiveCloudProvider } from './integrations/cloudSync';
import { persistActiveCloudProvider } from './integrations/cloudSync';
import type { FileSyncBackendKind } from '@/services/sync/file/providerRegistry';
import SubPageHeader from './SubPageHeader';
import { SectionTitle, SettingLabel } from './primitives';
@@ -56,8 +56,7 @@ const IntegrationsPanel: React.FC = () => {
const router = useRouter();
const { envConfig, appService } = useEnv();
const { user } = useAuth();
const { settings, setSettings, saveSettings, requestedSubPage, setRequestedSubPage } =
useSettingsStore();
const { settings, requestedSubPage, setRequestedSubPage } = useSettingsStore();
const opdsCatalogs = useCustomOPDSStore((s) => s.catalogs);
const opdsCount = opdsCatalogs.filter((c) => !c.deletedAt).length;
// Surface a library-wide WebDAV sync that's mid-flight in the row's
@@ -234,10 +233,7 @@ const IntegrationsPanel: React.FC = () => {
: _('Not connected');
const activateCloudProvider = async (kind: FileSyncBackendKind) => {
const latest = useSettingsStore.getState().settings;
const next = withActiveCloudProvider(latest, kind);
setSettings(next);
await saveSettings(envConfig, next);
await persistActiveCloudProvider(envConfig, kind);
};
const opdsStatus =
@@ -12,7 +12,7 @@ import {
import { hasValidWebDriveToken } from '@/services/sync/providers/gdrive/auth/webTokenStore';
import { Tips } from '../primitives';
import FileSyncForm from './FileSyncForm';
import { withActiveCloudProvider } from './cloudSync';
import { persistActiveCloudProvider } from './cloudSync';
const disconnectButtonClass = clsx(
'eink-bordered',
@@ -68,14 +68,9 @@ const GoogleDriveForm: React.FC = () => {
// Make Drive the active provider (turns WebDAV off), optionally stamping a
// freshly-resolved account label.
const activate = async (accountLabel?: string) => {
const latest = useSettingsStore.getState().settings;
const withLabel =
accountLabel === undefined
? latest
: { ...latest, googleDrive: { ...latest.googleDrive, accountLabel } };
const next = withActiveCloudProvider(withLabel, 'gdrive');
setSettings(next);
await saveSettings(envConfig, next);
await persistActiveCloudProvider(envConfig, 'gdrive', (s) =>
accountLabel === undefined ? s : { ...s, googleDrive: { ...s.googleDrive, accountLabel } },
);
};
const handleConnect = async () => {
@@ -102,11 +97,10 @@ const GoogleDriveForm: React.FC = () => {
const handleDisconnect = async () => {
await runGoogleDriveDisconnect();
const latest = useSettingsStore.getState().settings;
const base = withActiveCloudProvider(latest, null);
const next = { ...base, googleDrive: { ...base.googleDrive, accountLabel: undefined } };
setSettings(next);
await saveSettings(envConfig, next);
await persistActiveCloudProvider(envConfig, null, (s) => ({
...s,
googleDrive: { ...s.googleDrive, accountLabel: undefined },
}));
eventDispatcher.dispatch('toast', { type: 'info', message: _('Disconnected') });
};
@@ -14,7 +14,7 @@ import { buildWebDAVConnectSettings } from '@/services/sync/providers/webdav/con
import { SectionTitle } from '../primitives';
import FileSyncForm from './FileSyncForm';
import WebDAVBrowsePane from './WebDAVBrowsePane';
import { withActiveCloudProvider } from './cloudSync';
import { persistActiveCloudProvider } from './cloudSync';
/**
* Translate a connection-probe failure into a user-facing string. Each branch is
@@ -75,31 +75,26 @@ const WebDAVForm: React.FC = () => {
setIsConnecting(false);
return;
}
const latest = useSettingsStore.getState().settings;
// Build the WebDAV connect settings (preserves deviceId / sub-toggles), then
// make WebDAV the single active cloud provider (turns Google Drive off).
const connected = {
...latest,
webdav: buildWebDAVConnectSettings(latest.webdav, {
// persistActiveCloudProvider owns activation, persistence, and the
// cross-window provider broadcast.
await persistActiveCloudProvider(envConfig, 'webdav', (s) => ({
...s,
webdav: buildWebDAVConnectSettings(s.webdav, {
serverUrl: url,
username,
password,
rootPath: normalizedRoot,
}),
};
const next = withActiveCloudProvider(connected, 'webdav');
setSettings(next);
await saveSettings(envConfig, next);
}));
setIsConnecting(false);
eventDispatcher.dispatch('toast', { type: 'info', message: _('Connected') });
};
const handleDisconnect = async () => {
const latest = useSettingsStore.getState().settings;
// Deactivate (keep the credentials so a later reconnect is one click).
const next = withActiveCloudProvider(latest, null);
setSettings(next);
await saveSettings(envConfig, next);
await persistActiveCloudProvider(envConfig, null);
setShowPassword(false);
eventDispatcher.dispatch('toast', { type: 'info', message: _('Disconnected') });
};
@@ -1,5 +1,8 @@
import type { SystemSettings } from '@/types/settings';
import type { EnvConfigType } from '@/services/environment';
import type { FileSyncBackendKind } from '@/services/sync/file/providerRegistry';
import { useSettingsStore } from '@/store/settingsStore';
import { broadcastGlobalSettings } from '@/utils/settingsSync';
/**
* Return settings with exactly one third-party cloud-sync provider active (or
@@ -23,11 +26,44 @@ export const withActiveCloudProvider = (
webdav: {
...settings.webdav,
enabled: active === 'webdav',
...(active === 'webdav' && !settings.webdav?.enabled ? { syncBooks: true } : {}),
...(active === 'webdav' && !settings.webdav?.enabled
? { syncBooks: true, providerSelectedAt: Date.now() }
: {}),
},
googleDrive: {
...settings.googleDrive,
enabled: active === 'gdrive',
...(active === 'gdrive' && !settings.googleDrive?.enabled ? { syncBooks: true } : {}),
...(active === 'gdrive' && !settings.googleDrive?.enabled
? { syncBooks: true, providerSelectedAt: Date.now() }
: {}),
},
});
/**
* The single write path for changing the selected cloud sync provider.
* Every activation/deactivation surface (the Integrations chooser, the
* WebDAV/Drive connect and disconnect flows, the Drive OAuth callback)
* routes through here so the change always (a) persists, (b) hydrates the
* settings store even on routes where it wasn't loaded yet (the OAuth
* callback), and (c) broadcasts the provider flags to other windows —
* a stale reader window would otherwise clobber the switch on its next
* whole-file save, silently resuming Readest Cloud uploads.
*
* `mutate` runs BEFORE activation so connect flows can apply credentials
* or account labels without pre-setting `enabled` (which would suppress
* the activation side effects).
*/
export const persistActiveCloudProvider = async (
envConfig: EnvConfigType,
active: FileSyncBackendKind | null,
mutate: (settings: SystemSettings) => SystemSettings = (s) => s,
): Promise<SystemSettings> => {
const store = useSettingsStore.getState();
const appService = await envConfig.getAppService();
const current = store.settings?.version ? store.settings : await appService.loadSettings();
const next = withActiveCloudProvider(mutate(current), active);
store.setSettings(next);
await appService.saveSettings(next);
void broadcastGlobalSettings(next, { includeCloudSyncProviders: true });
return next;
};
@@ -53,6 +53,8 @@ export const BACKUP_SETTINGS_BLACKLIST = [
'googleDrive.lastSyncedAt',
'webdav.deviceId',
'webdav.lastSyncedAt',
'webdav.providerSelectedAt',
'googleDrive.providerSelectedAt',
// Transient runtime state — book keys may not exist post-restore; screen
// brightness is live device state.
'lastOpenBooks',
@@ -0,0 +1,87 @@
import { v4 as uuidv4 } from 'uuid';
import type { EnvConfigType } from '@/services/environment';
import type { TranslationFunc } from '@/hooks/useTranslation';
import { useSettingsStore } from '@/store/settingsStore';
import { useLibraryStore } from '@/store/libraryStore';
import { useFileSyncStore } from '@/store/fileSyncStore';
import { getCloudSyncProvider } from '@/services/sync/cloudSyncProvider';
import { createFileSyncProvider } from '@/services/sync/file/providerRegistry';
import { createAppLocalStore } from '@/services/sync/file/appLocalStore';
import { FileSyncEngine } from '@/services/sync/file/engine';
/**
* Run one library-wide sync against the ACTIVE third-party provider —
* the shared execution owner for surfaces outside the auto-sync hooks
* (the SettingsMenu sync row, pull-to-refresh) so they don't each
* duplicate engine construction, deviceId minting, and error handling.
*
* Honours the same guards as `useLibraryFileSync`: never syncs a
* not-yet-loaded library (an empty index push would clobber the remote)
* and respects the global library-sync mutex. Returns true only when a
* sync actually ran to completion; failures record `lastError` on the
* fileSyncStore for the health surfaces.
*/
export const runActiveFileLibrarySync = async (
envConfig: EnvConfigType,
_: TranslationFunc,
): Promise<boolean> => {
const provider = getCloudSyncProvider(useSettingsStore.getState().settings);
if (provider === 'readest') return false;
const kind = provider;
if (!useLibraryStore.getState().libraryLoaded) return false;
const syncStore = useFileSyncStore.getState();
if (!syncStore.beginSync(kind, _('Syncing…'))) return false;
try {
const appService = await envConfig.getAppService();
const current = useSettingsStore.getState().settings;
const fileProvider = await createFileSyncProvider(kind, current);
if (!fileProvider) return false;
const key = kind === 'gdrive' ? 'googleDrive' : 'webdav';
const ps = current[key];
let deviceId = ps?.deviceId;
if (!deviceId) {
deviceId = uuidv4();
const next = { ...current, [key]: { ...current[key], deviceId } };
useSettingsStore.getState().setSettings(next);
await appService.saveSettings(next);
}
const store = createAppLocalStore({ appService, settings: current, envConfig });
const engine = new FileSyncEngine(fileProvider, store);
const strategy = ps?.strategy ?? 'silent';
await engine.syncLibrary(useLibraryStore.getState().library, {
strategy: strategy === 'prompt' ? 'silent' : strategy,
syncBooks: ps?.syncBooks ?? false,
fullSync: false,
deviceId,
onProgress: ({ index, total, action }) => {
const label = action === 'downloading' ? _('Downloading') : _('Uploading');
useFileSyncStore
.getState()
.updateProgress(
kind,
_('{{action}} {{n}} / {{total}}', { action: label, n: index + 1, total }),
);
},
});
const latest = useSettingsStore.getState().settings;
const next = { ...latest, [key]: { ...latest[key], lastSyncedAt: Date.now() } };
useSettingsStore.getState().setSettings(next);
await appService.saveSettings(next);
useFileSyncStore.getState().setLastError(kind, null);
return true;
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
useFileSyncStore.getState().setLastError(kind, message);
console.warn('[cloudSync] library file sync failed', kind, e);
return false;
} finally {
useFileSyncStore.getState().endSync(kind);
}
};
@@ -0,0 +1,57 @@
import type { SyncClient } from '@/libs/sync';
import type { SystemSettings } from '@/types/settings';
import type { TranslationFunc } from '@/hooks/useTranslation';
import { useFileSyncStore } from '@/store/fileSyncStore';
import { getCloudSyncProvider } 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".
*
* While the native book/progress/note channels are gated, probe
* `/api/sync` READ-ONLY for any book row newer than the moment this
* device selected its provider (`providerSelectedAt`). This device
* stopped writing natively at that moment, so 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<boolean> => {
const provider = getCloudSyncProvider(settings);
if (provider === 'readest') return false;
if (useFileSyncStore.getState().fleetNoticeShown) return false;
const key = provider === 'gdrive' ? 'googleDrive' : 'webdav';
const selectedAt = settings[key]?.providerSelectedAt;
if (!selectedAt) return false;
try {
const result = await syncClient.pullChanges(selectedAt, '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;
};
@@ -17,9 +17,14 @@ export interface WebDAVConnectFormValues {
* and MUST be preserved across a disconnect/reconnect cycle.
*
* Spreading `previous` first lets the form fields shadow the captured
* credentials while every bookkeeping field rides through untouched. The
* `enabled: true` flag is set last so a previously-disabled connection
* comes back online without otherwise mutating user preferences.
* credentials while every bookkeeping field rides through untouched.
*
* Deliberately does NOT touch `enabled`: activation belongs to
* `withActiveCloudProvider`, which the connect flow applies on top. If the
* builder pre-set `enabled`, activation would never see the
* disabled -> enabled transition and its side effects (the syncBooks
* auto-flip, the providerSelectedAt stamp) would silently skip the most
* common path.
*
* Pulled out as a pure helper specifically to unit-test the "reconnect
* preserves prior state" invariant: the inline version in WebDAVForm
@@ -32,7 +37,6 @@ export const buildWebDAVConnectSettings = (
): WebDAVSettings => {
return {
...(previous ?? {}),
enabled: true,
serverUrl: form.serverUrl.trim(),
username: form.username,
password: form.password,
@@ -24,6 +24,7 @@
* helper applies a cascade. See `CATEGORY_DEPENDENTS` below.
*/
import { useSettingsStore } from '@/store/settingsStore';
import { getCloudSyncProvider } from '@/services/sync/cloudSyncProvider';
import { SYNC_CATEGORIES, type SyncCategory } from '@/types/settings';
export { SYNC_CATEGORIES };
@@ -101,9 +102,34 @@ export const isSyncCategoryLocked = (category: SyncCategory): boolean => {
return false;
};
/**
* Book-data categories routed exclusively to the selected cloud sync
* provider (#4380). While WebDAV/Drive is selected, the file-sync engine
* owns these channels (library.json + per-book config.json) and the
* native rows must not be pushed or pulled — dual-running the channels
* is what let quota errors and split metadata happen. Account-level
* categories (settings, stats, dictionaries, fonts, textures, OPDS
* catalogs) have no file-based counterpart and always stay native.
*/
const PROVIDER_GATED_CATEGORIES: ReadonlySet<SyncCategory> = new Set([
'book',
'progress',
'note',
] as SyncCategory[]);
export const isSyncCategoryEnabled = (id: string): boolean => {
const category = toCategory(id);
if (!category) return true; // unknown id → always-on
if (
PROVIDER_GATED_CATEGORIES.has(category) &&
getCloudSyncProvider(useSettingsStore.getState().settings) !== 'readest'
) {
// Runtime override, deliberately not written into syncCategories:
// the user's own toggles persist untouched and govern the native
// channels again the moment Readest Cloud is re-selected. The
// Manage Sync panel surfaces this state per-row.
return false;
}
if (isSyncCategoryLocked(category)) return true; // forced by a dependent
return isCategoryRawEnabled(category);
};
@@ -47,6 +47,16 @@ interface FileSyncState {
byKind: Partial<Record<FileSyncBackendKind, ProviderSyncProgress>>;
/** The backend currently holding the library-sync lock, or null when free. */
activeKind: FileSyncBackendKind | null;
/**
* Last terminal sync error per backend, surviving `endSync` so health
* surfaces (the SettingsMenu sync row, the chooser row status) can show
* "Sync failed" after the run finished. Cleared (set to null) by the
* next successful run. Process-local like the rest of this store — the
* durable "last synced" timestamp lives in the provider settings slice.
*/
lastErrorByKind: Partial<Record<FileSyncBackendKind, string | null>>;
/** 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
@@ -56,11 +66,15 @@ interface FileSyncState {
beginSync: (kind: FileSyncBackendKind, initialLabel: string) => boolean;
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<FileSyncState>((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
@@ -99,6 +113,13 @@ export const useFileSyncStore = create<FileSyncState>((set, get) => ({
activeKind: s.activeKind === kind ? null : s.activeKind,
byKind: { ...s.byKind, [kind]: IDLE },
})),
setLastError: (kind, message) =>
set((s) => ({
lastErrorByKind: { ...s.lastErrorByKind, [kind]: message },
})),
setFleetNoticeShown: () => set({ fleetNoticeShown: true }),
}));
/** Per-backend progress, idle when the backend has never started a run. */
+7
View File
@@ -143,6 +143,11 @@ export interface WebDAVSettings {
// Wall-clock millisecond timestamp of the last successful end-to-end
// sync, surfaced in the WebDAV settings sub-page.
lastSyncedAt?: number;
// Device-local wall-clock millis of when this provider was made the
// selected cloud sync backend on THIS device. Anchors the mixed-fleet
// detection probe: any native /api/sync row newer than this means
// another device is still writing the gated channels.
providerSelectedAt?: number;
}
/**
@@ -164,6 +169,8 @@ export interface GoogleDriveSettings {
strategy?: KOSyncStrategy;
deviceId?: string;
lastSyncedAt?: number;
/** See {@link WebDAVSettings.providerSelectedAt}. */
providerSelectedAt?: number;
}
/**
+52 -7
View File
@@ -23,11 +23,31 @@ import type { SystemSettings } from '@/types/settings';
*/
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 };
}
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;
}
/**
@@ -36,18 +56,31 @@ export interface SettingsSyncPayload {
*/
export const mergeSyncedGlobalSettings = (
local: SystemSettings,
payload: Pick<SettingsSyncPayload, 'globalViewSettings' | 'globalReadSettings'>,
): SystemSettings => ({
...local,
globalViewSettings: payload.globalViewSettings,
globalReadSettings: payload.globalReadSettings,
});
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 };
}
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): Promise<void> => {
export const broadcastGlobalSettings = async (
settings: SystemSettings,
opts: { includeCloudSyncProviders?: boolean } = {},
): Promise<void> => {
if (!isTauriAppPlatform()) return;
if (!settings.globalViewSettings || !settings.globalReadSettings) return;
try {
@@ -56,6 +89,18 @@ export const broadcastGlobalSettings = async (settings: SystemSettings): Promise
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,
},
};
}
await emit(SETTINGS_SYNC_EVENT, payload);
} catch (err) {
console.warn('Failed to broadcast settings to other windows', err);