fix(sync): decouple Readest Cloud storage quota from third-party cloud sync (#4959) (#4971)

When a third-party provider (WebDAV or Google Drive) is the selected
cloud sync backend, Readest Cloud storage is no longer written to:

- New src/services/sync/cloudSyncProvider.ts policy module: the selected
  provider is derived from the existing per-device enabled flags
  (webdav wins deterministically if both are ever set); the premium
  guard resolves to a PAUSED state instead of silently falling back to
  Readest Cloud, with the user plan cached for non-React modules.
- transferManager gates book uploads on the selected provider: queueUpload
  returns null when gated; pending book uploads from before a provider
  switch are visibly cancelled (cancelReason policy) and pruned on the
  next restore; downloads and replica transfers are never gated.
- Book uploads are deferred until settings hydrate (settings.version
  barrier) so a persisted queue cannot be mis-processed at startup;
  replica transfers are not stalled.
- Quota-exceeded uploads fail fast with zero retries, and a batch import
  produces one summary toast instead of one toast per book.
- Policy cancellations are a distinct bucket via a shared predicate:
  excluded from failed stats, Retry All, and the per-item Retry button.
- Auto-upload call sites (ingest, OPDS, subscriptions) check the provider
  gate; the explicit Upload Book action explains the gate with a toast
  instead of silently doing nothing.
- Activating a provider auto-enables its syncBooks so books keep backing
  up somewhere; a one-time migration (20260706) applies the same flip for
  users who already had a provider enabled.
- webdav.deviceId and webdav.lastSyncedAt are excluded from backups,
  matching the existing googleDrive entries.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Huang Xin
2026-07-07 00:31:15 +09:00
committed by GitHub
parent a02b236e97
commit 942c062d35
22 changed files with 1042 additions and 45 deletions
@@ -32,6 +32,46 @@ describe('withActiveCloudProvider', () => {
expect(next.webdav.serverUrl).toBe('https://dav');
expect(next.googleDrive.accountLabel).toBe('a@b.com');
});
// Selecting a third-party provider hands it the book-file channel:
// native Readest Cloud uploads gate off, so without syncBooks the books
// would back up nowhere. Activation therefore turns syncBooks on.
describe('syncBooks auto-enable on activation', () => {
const inactive = {
webdav: { enabled: false, serverUrl: 'https://dav', syncBooks: false },
googleDrive: { enabled: false, syncBooks: false },
} as unknown as SystemSettings;
test('activating a disabled provider turns its syncBooks on', () => {
const next = withActiveCloudProvider(inactive, 'webdav');
expect(next.webdav.syncBooks).toBe(true);
expect(next.googleDrive.syncBooks).toBe(false);
});
test('activating gdrive turns only gdrive syncBooks on', () => {
const next = withActiveCloudProvider(inactive, 'gdrive');
expect(next.googleDrive.syncBooks).toBe(true);
expect(next.webdav.syncBooks).toBe(false);
});
test('re-activating an already-active provider respects an explicit syncBooks opt-out', () => {
const active = {
webdav: { enabled: true, syncBooks: false },
googleDrive: { enabled: false },
} as unknown as SystemSettings;
const next = withActiveCloudProvider(active, 'webdav');
expect(next.webdav.syncBooks).toBe(false);
});
test('deactivating a provider leaves its syncBooks untouched', () => {
const active = {
webdav: { enabled: true, syncBooks: true },
googleDrive: { enabled: false, syncBooks: false },
} as unknown as SystemSettings;
const next = withActiveCloudProvider(active, null);
expect(next.webdav.syncBooks).toBe(true);
});
});
});
describe('isCloudSyncInPlan', () => {
@@ -59,6 +59,15 @@ function makeSettings(overrides: Partial<SystemSettings> = {}): SystemSettings {
checksumMethod: 'binary',
strategy: 'prompt',
},
webdav: {
enabled: true,
serverUrl: 'https://dav.example',
username: 'wuser',
password: 'wpass',
rootPath: '/',
deviceId: 'webdav-device-id',
lastSyncedAt: 666,
},
readwise: { enabled: true, accessToken: 'rw-token', lastSyncedAt: 999 },
hardcover: { enabled: false, accessToken: 'hc-token', lastSyncedAt: 888 },
googleDrive: {
@@ -104,6 +113,11 @@ describe('sanitizeSettingsForBackup - blacklist', () => {
expect(rec(out['googleDrive'])['deviceId']).toBeUndefined();
// Non-identity Drive settings still travel with the backup.
expect(rec(out['googleDrive'])['enabled']).toBe(true);
// WebDAV device identity and cursor stay on the device; restoring
// them onto a second device would duplicate WebDAV sync identity.
expect(rec(out['webdav'])['deviceId']).toBeUndefined();
expect(rec(out['webdav'])['lastSyncedAt']).toBeUndefined();
expect(rec(out['webdav'])['serverUrl']).toBe('https://dav.example');
});
it('strips sync cursors', () => {
@@ -0,0 +1,158 @@
import { describe, test, expect, beforeEach, vi } from 'vitest';
import type { SystemSettings } from '@/types/settings';
vi.mock('@/utils/access', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/utils/access')>();
return {
...actual,
isCloudSyncAllowed: vi.fn(actual.isCloudSyncAllowed),
};
});
import {
applySyncBooksAutoEnable,
getCloudSyncProvider,
isReadestCloudStorageActive,
resolveCloudSyncGate,
setCachedUserPlan,
} from '@/services/sync/cloudSyncProvider';
import { isCloudSyncAllowed } from '@/utils/access';
const makeSettings = (overrides: Partial<SystemSettings> = {}): SystemSettings =>
({
webdav: { enabled: false },
googleDrive: { enabled: false },
...overrides,
}) as SystemSettings;
beforeEach(() => {
vi.mocked(isCloudSyncAllowed).mockReturnValue(true);
setCachedUserPlan('free');
});
describe('getCloudSyncProvider', () => {
test('derives readest when no third-party provider is enabled', () => {
expect(getCloudSyncProvider(makeSettings())).toBe('readest');
});
test('derives webdav when webdav is enabled', () => {
const settings = makeSettings({ webdav: { enabled: true } } as Partial<SystemSettings>);
expect(getCloudSyncProvider(settings)).toBe('webdav');
});
test('derives gdrive when google drive is enabled', () => {
const settings = makeSettings({ googleDrive: { enabled: true } } as Partial<SystemSettings>);
expect(getCloudSyncProvider(settings)).toBe('gdrive');
});
test('webdav wins deterministically when both flags are enabled (corrupt state)', () => {
const settings = makeSettings({
webdav: { enabled: true },
googleDrive: { enabled: true },
} as Partial<SystemSettings>);
expect(getCloudSyncProvider(settings)).toBe('webdav');
});
test('defaults to readest for missing slices and missing settings', () => {
expect(getCloudSyncProvider({} as SystemSettings)).toBe('readest');
expect(getCloudSyncProvider(null)).toBe('readest');
expect(getCloudSyncProvider(undefined)).toBe('readest');
});
});
describe('resolveCloudSyncGate', () => {
test('readest provider is never paused, even when cloud sync is disallowed', () => {
vi.mocked(isCloudSyncAllowed).mockReturnValue(false);
expect(resolveCloudSyncGate(makeSettings(), 'free')).toEqual({
provider: 'readest',
paused: false,
});
});
test('third-party provider stays selected but paused when disallowed (no silent readest fallback)', () => {
vi.mocked(isCloudSyncAllowed).mockReturnValue(false);
const settings = makeSettings({ webdav: { enabled: true } } as Partial<SystemSettings>);
expect(resolveCloudSyncGate(settings, 'free')).toEqual({ provider: 'webdav', paused: true });
});
test('third-party provider is active when allowed', () => {
const settings = makeSettings({ googleDrive: { enabled: true } } as Partial<SystemSettings>);
expect(resolveCloudSyncGate(settings, 'plus')).toEqual({ provider: 'gdrive', paused: false });
});
test('falls back to the cached user plan when no plan argument is given', () => {
vi.mocked(isCloudSyncAllowed).mockImplementation((plan) => plan !== 'free');
const settings = makeSettings({ webdav: { enabled: true } } as Partial<SystemSettings>);
setCachedUserPlan('free');
expect(resolveCloudSyncGate(settings).paused).toBe(true);
setCachedUserPlan('pro');
expect(resolveCloudSyncGate(settings).paused).toBe(false);
});
test('undefined cached plan is treated as free', () => {
vi.mocked(isCloudSyncAllowed).mockImplementation((plan) => plan !== 'free');
const settings = makeSettings({ webdav: { enabled: true } } as Partial<SystemSettings>);
setCachedUserPlan(undefined);
expect(resolveCloudSyncGate(settings).paused).toBe(true);
});
});
describe('applySyncBooksAutoEnable (upgrade migration for already-enabled providers)', () => {
test('flips syncBooks on for an enabled webdav provider, mutating the given settings', () => {
const settings = makeSettings({
webdav: { enabled: true, syncBooks: false },
} as Partial<SystemSettings>);
expect(applySyncBooksAutoEnable(settings)).toBe(true);
expect(settings.webdav?.syncBooks).toBe(true);
});
test('flips syncBooks on for an enabled gdrive provider', () => {
const settings = makeSettings({
googleDrive: { enabled: true, syncBooks: false },
} as Partial<SystemSettings>);
expect(applySyncBooksAutoEnable(settings)).toBe(true);
expect(settings.googleDrive?.syncBooks).toBe(true);
});
test('no-op when readest is the provider', () => {
const settings = makeSettings();
expect(applySyncBooksAutoEnable(settings)).toBe(false);
expect(settings.webdav?.syncBooks).toBeUndefined();
});
test('no-op when syncBooks is already on', () => {
const settings = makeSettings({
webdav: { enabled: true, syncBooks: true },
} as Partial<SystemSettings>);
expect(applySyncBooksAutoEnable(settings)).toBe(false);
});
test('only the selected provider is flipped when both are enabled', () => {
const settings = makeSettings({
webdav: { enabled: true, syncBooks: false },
googleDrive: { enabled: true, syncBooks: false },
} as Partial<SystemSettings>);
expect(applySyncBooksAutoEnable(settings)).toBe(true);
expect(settings.webdav?.syncBooks).toBe(true);
expect(settings.googleDrive?.syncBooks).toBe(false);
});
});
describe('isReadestCloudStorageActive', () => {
test('true when readest is the derived provider', () => {
expect(isReadestCloudStorageActive(makeSettings())).toBe(true);
});
test('false when a third-party provider is selected', () => {
const settings = makeSettings({ webdav: { enabled: true } } as Partial<SystemSettings>);
expect(isReadestCloudStorageActive(settings)).toBe(false);
});
test('false while paused: uploads must not silently resume to Readest Cloud', () => {
vi.mocked(isCloudSyncAllowed).mockReturnValue(false);
const settings = makeSettings({ googleDrive: { enabled: true } } as Partial<SystemSettings>);
expect(isReadestCloudStorageActive(settings, 'free')).toBe(false);
});
});
@@ -0,0 +1,445 @@
import { describe, test, expect, beforeEach, afterEach, vi } from 'vitest';
import { useTransferStore, TransferItem } from '@/store/transferStore';
import { useSettingsStore } from '@/store/settingsStore';
import type { SystemSettings } from '@/types/settings';
vi.mock('@/utils/event', () => ({
eventDispatcher: {
dispatch: vi.fn(),
dispatchSync: vi.fn(),
},
}));
import { transferManager } from '@/services/transferManager';
import { eventDispatcher } from '@/utils/event';
import type { Book } from '@/types/book';
function makeBook(overrides: Partial<Book> = {}): Book {
return {
hash: 'hash1',
format: 'EPUB',
title: 'Test Book',
author: 'Author',
createdAt: 1000,
updatedAt: 2000,
...overrides,
};
}
function makeTransferItem(overrides: Partial<TransferItem> = {}): TransferItem {
return {
id: 't1',
kind: 'book',
bookHash: 'hash1',
bookTitle: 'Test Book',
type: 'upload',
status: 'pending',
progress: 0,
totalBytes: 0,
transferredBytes: 0,
transferSpeed: 0,
retryCount: 0,
maxRetries: 3,
createdAt: Date.now(),
priority: 10,
isBackground: false,
...overrides,
};
}
const resetTransferManager = () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- test-only introspection
const mgr = transferManager as unknown as Record<string, unknown>;
mgr['isInitialized'] = false;
mgr['isProcessing'] = false;
mgr['appService'] = null;
mgr['getLibrary'] = null;
mgr['updateBook'] = null;
mgr['_'] = null;
(mgr['abortControllers'] as Map<string, AbortController>).clear();
let resolveReady: () => void = () => {};
mgr['readyPromise'] = new Promise<void>((res) => {
resolveReady = res;
});
mgr['readyResolve'] = resolveReady;
};
const resetTransferStore = () => {
useTransferStore.setState({
transfers: {},
isQueuePaused: false,
isTransferQueueOpen: false,
maxConcurrent: 2,
activeCount: 0,
});
};
const settingsLoaded = (overrides: Partial<SystemSettings> = {}): void => {
useSettingsStore.setState({
settings: {
version: 1,
webdav: { enabled: false },
googleDrive: { enabled: false },
...overrides,
} as SystemSettings,
});
};
const settingsNotLoaded = (): void => {
useSettingsStore.setState({ settings: {} as SystemSettings });
};
const webdavSelected = (): void =>
settingsLoaded({ webdav: { enabled: true } } as Partial<SystemSettings>);
function makeAppService(overrides: Record<string, unknown> = {}) {
return {
uploadBook: vi.fn().mockResolvedValue(undefined),
downloadBook: vi.fn().mockResolvedValue(undefined),
deleteBook: vi.fn().mockResolvedValue(undefined),
uploadReplicaFile: vi.fn().mockResolvedValue(undefined),
downloadReplicaFile: vi.fn().mockResolvedValue(undefined),
deleteReplicaBundle: vi.fn().mockResolvedValue(undefined),
isMacOSApp: false,
...overrides,
} as Record<string, unknown>;
}
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 initManager = async (appService = makeAppService(), library: Book[] = []) => {
await transferManager.initialize(appService as never, () => library, vi.fn(), translationFn);
return appService;
};
const flushAsync = async (ms = 5000) => {
await vi.advanceTimersByTimeAsync(ms);
};
beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: true });
resetTransferStore();
resetTransferManager();
vi.clearAllMocks();
localStorage.clear();
settingsLoaded();
vi.spyOn(console, 'warn').mockImplementation(() => {});
vi.spyOn(console, 'error').mockImplementation(() => {});
vi.spyOn(console, 'info').mockImplementation(() => {});
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('provider gating of book uploads', () => {
test('queueUpload returns null and queues nothing when a third-party provider is selected', async () => {
webdavSelected();
await initManager();
const id = transferManager.queueUpload(makeBook());
expect(id).toBeNull();
expect(Object.keys(useTransferStore.getState().transfers)).toHaveLength(0);
});
test('queueUpload works when Readest Cloud is the provider', async () => {
await initManager();
const id = transferManager.queueUpload(makeBook());
expect(id).toBeTruthy();
});
test('queueBatchUploads returns empty when gated', async () => {
webdavSelected();
await initManager();
const ids = transferManager.queueBatchUploads([makeBook(), makeBook({ hash: 'hash2' })]);
expect(ids).toEqual([]);
});
test('downloads and replica transfers are never gated', async () => {
webdavSelected();
await initManager();
const downloadId = transferManager.queueDownload(makeBook());
expect(downloadId).toBeTruthy();
const replicaId = transferManager.queueReplicaUpload(
'font',
'font-1',
'A Font',
[{ logical: 'font.ttf', lfp: '/tmp/font.ttf', byteSize: 10 }],
'Data' as never,
);
expect(replicaId).toBeTruthy();
});
});
describe('settings-loaded barrier', () => {
test('a pending book upload does not execute before settings hydration', async () => {
settingsNotLoaded();
const persisted = {
transfers: { t1: makeTransferItem() },
isQueuePaused: false,
};
localStorage.setItem('readest_transfer_queue', JSON.stringify(persisted));
const appService = makeAppService();
await initManager(appService, [makeBook()]);
await flushAsync();
expect(appService['uploadBook']).not.toHaveBeenCalled();
expect(useTransferStore.getState().transfers['t1']?.status).toBe('pending');
});
test('the deferred upload executes once settings hydrate with readest selected', async () => {
settingsNotLoaded();
localStorage.setItem(
'readest_transfer_queue',
JSON.stringify({ transfers: { t1: makeTransferItem() }, isQueuePaused: false }),
);
const appService = makeAppService();
await initManager(appService, [makeBook()]);
await flushAsync();
expect(appService['uploadBook']).not.toHaveBeenCalled();
settingsLoaded();
await flushAsync();
expect(appService['uploadBook']).toHaveBeenCalledTimes(1);
});
test('replica transfers are not stalled by the barrier', async () => {
settingsNotLoaded();
const appService = makeAppService();
await initManager(appService);
transferManager.queueReplicaDownload(
'font',
'font-1',
'A Font',
[{ logical: 'font.ttf', lfp: '/tmp/font.ttf', byteSize: 10 }],
'Data' as never,
);
await flushAsync();
expect(appService['downloadReplicaFile']).toHaveBeenCalled();
});
});
describe('policy cancellation on restore/reconcile', () => {
test('pre-switch pending book uploads are policy-cancelled on restore; downloads and replicas survive', async () => {
webdavSelected();
const persisted = {
transfers: {
up1: makeTransferItem({ id: 'up1' }),
down1: makeTransferItem({ id: 'down1', type: 'download', bookHash: 'hash2' }),
rep1: makeTransferItem({
id: 'rep1',
kind: 'replica',
type: 'upload',
bookHash: '',
replicaKind: 'font',
replicaId: 'font-1',
replicaFiles: [{ logical: 'font.ttf', lfp: '/tmp/font.ttf', byteSize: 10 }],
replicaBase: 'Data' as never,
}),
},
isQueuePaused: true,
};
localStorage.setItem('readest_transfer_queue', JSON.stringify(persisted));
await initManager();
await flushAsync();
const transfers = useTransferStore.getState().transfers;
expect(transfers['up1']?.status).toBe('cancelled');
expect(transfers['up1']?.cancelReason).toBe('policy');
expect(transfers['down1']?.status).toBe('pending');
expect(transfers['rep1']?.status).toBe('pending');
});
test('switching providers after init policy-cancels pending book uploads', async () => {
await initManager();
useTransferStore.getState().pauseQueue();
const id = transferManager.queueUpload(makeBook())!;
webdavSelected();
await flushAsync();
const transfer = useTransferStore.getState().transfers[id];
expect(transfer?.status).toBe('cancelled');
expect(transfer?.cancelReason).toBe('policy');
});
test('policy-cancelled rows from a previous session are pruned on restore', async () => {
const persisted = {
transfers: {
old1: makeTransferItem({ id: 'old1', status: 'cancelled', cancelReason: 'policy' }),
user1: makeTransferItem({ id: 'user1', status: 'cancelled' }),
},
isQueuePaused: false,
};
localStorage.setItem('readest_transfer_queue', JSON.stringify(persisted));
await initManager();
const transfers = useTransferStore.getState().transfers;
expect(transfers['old1']).toBeUndefined();
expect(transfers['user1']).toBeDefined();
});
test('legacy persisted entries without kind or cancelReason restore cleanly', async () => {
const legacy = makeTransferItem({ id: 'l1', status: 'completed' }) as Partial<TransferItem>;
delete legacy.kind;
localStorage.setItem(
'readest_transfer_queue',
JSON.stringify({ transfers: { l1: legacy }, isQueuePaused: false }),
);
await initManager();
const restored = useTransferStore.getState().transfers['l1'];
expect(restored?.kind).toBe('book');
expect(restored?.status).toBe('completed');
});
});
describe('cancelled bucket accounting', () => {
test('policy-cancelled rows are excluded from failed stats and getFailedTransfers; user-cancelled stay', () => {
useTransferStore.setState({
transfers: {
p1: makeTransferItem({ id: 'p1', status: 'cancelled', cancelReason: 'policy' }),
u1: makeTransferItem({ id: 'u1', status: 'cancelled', cancelReason: 'user' }),
f1: makeTransferItem({ id: 'f1', status: 'failed' }),
},
});
const store = useTransferStore.getState();
const failedIds = store.getFailedTransfers().map((t) => t.id);
expect(failedIds).toContain('u1');
expect(failedIds).toContain('f1');
expect(failedIds).not.toContain('p1');
expect(store.getQueueStats().failed).toBe(2);
});
test('retryAllFailed does not resurrect policy-cancelled rows', async () => {
webdavSelected();
await initManager();
useTransferStore.setState({
transfers: {
p1: makeTransferItem({ id: 'p1', status: 'cancelled', cancelReason: 'policy' }),
},
});
transferManager.retryAllFailed();
await flushAsync();
expect(useTransferStore.getState().transfers['p1']?.status).toBe('cancelled');
});
test('retryTransfer no-ops on a policy-cancelled row', async () => {
webdavSelected();
await initManager();
useTransferStore.setState({
transfers: {
p1: makeTransferItem({ id: 'p1', status: 'cancelled', cancelReason: 'policy' }),
},
});
transferManager.retryTransfer('p1');
await flushAsync();
expect(useTransferStore.getState().transfers['p1']?.status).toBe('cancelled');
});
test('user cancelTransfer records cancelReason user', async () => {
await initManager();
useTransferStore.getState().pauseQueue();
const id = transferManager.queueUpload(makeBook())!;
transferManager.cancelTransfer(id);
const transfer = useTransferStore.getState().transfers[id];
expect(transfer?.status).toBe('cancelled');
expect(transfer?.cancelReason).toBe('user');
});
test('gate-off reconcile settles: a force-pended policy row is re-cancelled without looping', async () => {
webdavSelected();
await initManager();
useTransferStore.setState({
transfers: {
p1: makeTransferItem({ id: 'p1', status: 'pending', cancelReason: 'policy' }),
},
});
await (transferManager as unknown as { processQueue: () => Promise<void> }).processQueue();
await flushAsync();
const transfer = useTransferStore.getState().transfers['p1'];
expect(transfer?.status).toBe('cancelled');
expect(useTransferStore.getState().getPendingTransfers()).toHaveLength(0);
});
});
describe('quota failure handling', () => {
test('quota 403 fails immediately with zero retries', async () => {
const appService = makeAppService({
uploadBook: vi.fn().mockRejectedValue(new Error('Insufficient storage quota')),
});
const book = makeBook();
await initManager(appService, [book]);
transferManager.queueUpload(book);
await flushAsync();
const transfers = Object.values(useTransferStore.getState().transfers);
expect(transfers).toHaveLength(1);
expect(transfers[0]?.status).toBe('failed');
expect(transfers[0]?.retryCount).toBe(0);
expect(appService['uploadBook']).toHaveBeenCalledTimes(1);
});
test('a batch of quota failures produces one summary toast, not one per book', async () => {
const appService = makeAppService({
uploadBook: vi.fn().mockRejectedValue(new Error('Insufficient storage quota')),
});
const books = [makeBook(), makeBook({ hash: 'hash2' }), makeBook({ hash: 'hash3' })];
await initManager(appService, books);
transferManager.queueBatchUploads(books);
await flushAsync(10000);
const dispatched = vi.mocked(eventDispatcher.dispatch).mock.calls.filter(
([event, payload]) =>
event === 'toast' &&
String((payload as { message?: string })?.message ?? '')
.toLowerCase()
.includes('quota'),
);
expect(dispatched).toHaveLength(1);
expect(String((dispatched[0]![1] as { message: string }).message)).toContain('3');
});
test('non-quota errors keep the existing retry behavior', async () => {
const appService = makeAppService({
uploadBook: vi.fn().mockRejectedValue(new Error('network boom')),
});
const book = makeBook();
await initManager(appService, [book]);
transferManager.queueUpload(book);
await flushAsync(60000);
const transfer = Object.values(useTransferStore.getState().transfers)[0];
expect(transfer?.status).toBe('failed');
expect(transfer?.retryCount).toBe(3);
});
});
@@ -1,5 +1,7 @@
import { describe, test, expect, beforeEach, afterEach, vi, type Mock } from 'vitest';
import { useTransferStore, TransferItem } from '@/store/transferStore';
import { useSettingsStore } from '@/store/settingsStore';
import type { SystemSettings } from '@/types/settings';
// ── Mocks ────────────────────────────────────────────────────────────
// The transferManager module is a singleton, so we need to mock its
@@ -105,6 +107,16 @@ beforeEach(() => {
resetTransferManager();
vi.clearAllMocks();
localStorage.clear();
// Book uploads are gated on the selected cloud sync provider and
// deferred until settings hydrate; hydrate with Readest Cloud selected
// so the pre-gating behavior under test is preserved.
useSettingsStore.setState({
settings: {
version: 1,
webdav: { enabled: false },
googleDrive: { enabled: false },
} as SystemSettings,
});
vi.spyOn(console, 'warn').mockImplementation(() => {});
vi.spyOn(console, 'error').mockImplementation(() => {});
});
@@ -17,7 +17,12 @@ import { useTranslation } from '@/hooks/useTranslation';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
import { useKeyDownActions } from '@/hooks/useKeyDownActions';
import { useLibraryStore } from '@/store/libraryStore';
import { TransferItem, TransferStatus, useTransferStore } from '@/store/transferStore';
import {
TransferItem,
TransferStatus,
isFailedLikeTransfer,
useTransferStore,
} from '@/store/transferStore';
const formatBytes = (bytes: number): string => {
if (bytes === 0) return '0 B';
@@ -109,7 +114,10 @@ const TransferItemRow: React.FC<{
<span className='text-error'>{transfer.error || _('Failed')}</span>
)}
{transfer.status === 'completed' && (completedLabel[transfer.type] || _('Completed'))}
{transfer.status === 'cancelled' && _('Cancelled')}
{transfer.status === 'cancelled' &&
(transfer.cancelReason === 'policy'
? `${_('Cancelled')} · ${_('Cloud provider switched')}`
: _('Cancelled'))}
{' · '}
{formatDateTime(transfer.completedAt || transfer.startedAt || transfer.createdAt)}
</div>
@@ -125,7 +133,7 @@ const TransferItemRow: React.FC<{
</div>
<div className='flex items-center gap-1'>
{(transfer.status === 'failed' || transfer.status === 'cancelled') && (
{isFailedLikeTransfer(transfer) && (
<button
onClick={() => onRetry(transfer.id)}
className='btn btn-ghost btn-sm btn-circle'
@@ -197,7 +205,7 @@ const TransferQueuePanel: React.FC = () => {
case 'completed':
return t.status === 'completed';
case 'failed':
return t.status === 'failed' || t.status === 'cancelled';
return isFailedLikeTransfer(t) || t.status === 'cancelled';
default:
return true;
}
@@ -14,6 +14,7 @@ import {
createFileSyncProvider,
type FileSyncBackendKind,
} from '@/services/sync/file/providerRegistry';
import { getCloudSyncProvider } from '@/services/sync/cloudSyncProvider';
/**
* Library-scoped auto-sync for the active third-party cloud provider (WebDAV /
@@ -55,11 +56,8 @@ export const useLibraryFileSync = () => {
const { userProfilePlan } = useQuotaStats();
// The single active cloud provider (WebDAV and Google Drive are exclusive).
const activeKind: FileSyncBackendKind | null = settings.webdav?.enabled
? 'webdav'
: settings.googleDrive?.enabled
? 'gdrive'
: null;
const provider = getCloudSyncProvider(settings);
const activeKind: FileSyncBackendKind | null = provider === 'readest' ? null : provider;
const isAllowed = isCloudSyncAllowed(userProfilePlan ?? 'free');
const isReady = useMemo(() => {
+17
View File
@@ -22,6 +22,10 @@ import { eventDispatcher } from '@/utils/event';
import { ProgressPayload } from '@/utils/transfer';
import { throttle } from '@/utils/throttle';
import { transferManager } from '@/services/transferManager';
import {
getCloudSyncProvider,
isReadestCloudStorageActive,
} from '@/services/sync/cloudSyncProvider';
import { getDirPath, getFilename, joinPaths } from '@/utils/path';
import { parseOpenWithFiles } from '@/helpers/openWith';
import { isTauriAppPlatform, isWebAppPlatform } from '@/services/environment';
@@ -954,6 +958,19 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
});
return true;
}
// An explicit Upload action must never silently no-op: explain the
// provider gate when it is the reason the queue refused the book.
const currentSettings = useSettingsStore.getState().settings;
if (!isReadestCloudStorageActive(currentSettings)) {
const provider = getCloudSyncProvider(currentSettings);
eventDispatcher.dispatch('toast', {
type: 'info',
timeout: 5000,
message: _('Uploads to Readest Cloud are paused while {{provider}} sync is selected', {
provider: provider === 'gdrive' ? 'Google Drive' : 'WebDAV',
}),
});
}
return false;
},
// eslint-disable-next-line react-hooks/exhaustive-deps
+8 -1
View File
@@ -18,6 +18,7 @@ import { useLibraryStore } from '@/store/libraryStore';
import { useSettingsStore } from '@/store/settingsStore';
import { useCustomOPDSStore } from '@/store/customOPDSStore';
import { transferManager } from '@/services/transferManager';
import { isReadestCloudStorageActive } from '@/services/sync/cloudSyncProvider';
import { useTransferQueue } from '@/hooks/useTransferQueue';
import { useTheme } from '@/hooks/useTheme';
import { useLibrary } from '@/hooks/useLibrary';
@@ -625,7 +626,13 @@ export default function BrowserPage() {
console.error('OPDS: failed to update source map:', sourceMapError);
}
}
if (user && book && !book.uploadedAt && settings.autoUpload) {
if (
user &&
book &&
!book.uploadedAt &&
settings.autoUpload &&
isReadestCloudStorageActive(settings)
) {
setTimeout(() => {
transferManager.queueUpload(book);
}, 3000);
@@ -17,6 +17,7 @@ import {
createFileSyncProvider,
type FileSyncBackendKind,
} from '@/services/sync/file/providerRegistry';
import { getCloudSyncProvider } from '@/services/sync/cloudSyncProvider';
import { removeBookNoteOverlays } from '../utils/annotatorUtil';
import { useWindowActiveChanged } from './useWindowActiveChanged';
@@ -79,11 +80,8 @@ export const useFileSync = (bookKey: string) => {
const progress = useBookProgress(bookKey);
// The single active cloud provider (WebDAV and Google Drive are exclusive).
const activeKind: FileSyncBackendKind | null = settings.webdav?.enabled
? 'webdav'
: settings.googleDrive?.enabled
? 'gdrive'
: null;
const provider = getCloudSyncProvider(settings);
const activeKind: FileSyncBackendKind | null = provider === 'readest' ? null : provider;
const providerSettings = activeKind === 'gdrive' ? settings.googleDrive : settings.webdav;
/** Flips true on the first local change after a push, false right before each push. */
@@ -8,12 +8,26 @@ import type { FileSyncBackendKind } from '@/services/sync/file/providerRegistry'
* (WebDAV creds, the Drive keychain token) is left untouched, so switching back
* to a previously-configured provider needs no re-entry; only an explicit
* Disconnect tears a provider's config down.
*
* Activating a provider (disabled -> enabled) also turns its `syncBooks` on:
* the selected provider owns the book-file channel — native Readest Cloud
* uploads gate off — so leaving syncBooks at its `false` default would back
* books up nowhere. An explicit opt-out while the provider stays active is
* respected (re-activation of an already-active provider changes nothing).
*/
export const withActiveCloudProvider = (
settings: SystemSettings,
active: FileSyncBackendKind | null,
): SystemSettings => ({
...settings,
webdav: { ...settings.webdav, enabled: active === 'webdav' },
googleDrive: { ...settings.googleDrive, enabled: active === 'gdrive' },
webdav: {
...settings.webdav,
enabled: active === 'webdav',
...(active === 'webdav' && !settings.webdav?.enabled ? { syncBooks: true } : {}),
},
googleDrive: {
...settings.googleDrive,
enabled: active === 'gdrive',
...(active === 'gdrive' && !settings.googleDrive?.enabled ? { syncBooks: true } : {}),
},
});
@@ -3,6 +3,7 @@ import { useAuth } from '@/context/AuthContext';
import { useEnv } from '@/context/EnvContext';
import { useLibraryStore } from '@/store/libraryStore';
import { useSettingsStore } from '@/store/settingsStore';
import { isReadestCloudStorageActive } from '@/services/sync/cloudSyncProvider';
import { useTranslation } from '@/hooks/useTranslation';
import { syncSubscribedCatalogs } from '@/services/opds';
import { AUTO_CHECK_INTERVAL_MS } from '@/services/opds/types';
@@ -51,7 +52,12 @@ export function useOPDSSubscriptions() {
// autoUpload setting on. Delay so the transfer manager has a chance
// to finish initializing if this fires right after libraryLoaded.
const { settings: currentSettings } = useSettingsStore.getState();
if (user && currentSettings.autoUpload && uniqueNewBooks.length > 0) {
if (
user &&
currentSettings.autoUpload &&
isReadestCloudStorageActive(currentSettings) &&
uniqueNewBooks.length > 0
) {
const booksToUpload = uniqueNewBooks.filter((b) => !b.uploadedAt);
if (booksToUpload.length > 0) {
setTimeout(() => {
+7 -1
View File
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
import { useAuth } from '@/context/AuthContext';
import { QuotaType, UserPlan } from '@/types/quota';
import { getStoragePlanData, getTranslationPlanData, getUserProfilePlan } from '@/utils/access';
import { setCachedUserPlan } from '@/services/sync/cloudSyncProvider';
import { useTranslation } from './useTranslation';
export const useQuotaStats = (briefName = false) => {
@@ -41,7 +42,12 @@ export const useQuotaStats = (briefName = false) => {
unit: 'K',
resetAt: translationResetAt,
};
setUserProfilePlan(getUserProfilePlan(token));
const profilePlan = getUserProfilePlan(token);
setUserProfilePlan(profilePlan);
// Non-React modules (transferManager, syncCategories) need the plan
// synchronously for the cloud-sync provider gate; cache it here, the
// one place the plan is resolved from the JWT.
setCachedUserPlan(profilePlan);
setQuotas([storageQuota, translationQuota]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [token]);
@@ -2,10 +2,15 @@ import { useEffect, useCallback, useMemo } from 'react';
import { useEnv } from '@/context/EnvContext';
import { useTranslation } from './useTranslation';
import { useLibraryStore } from '@/store/libraryStore';
import { useTransferStore, TransferType } from '@/store/transferStore';
import { useTransferStore, TransferType, isFailedLikeTransfer } from '@/store/transferStore';
import { transferManager } from '@/services/transferManager';
import { Book } from '@/types/book';
// The `libraryLoaded = true` default lets surfaces like SettingsMenu and
// TransferQueuePanel initialize the manager on mount, before settings
// hydrate. That is safe: the manager defers book uploads until
// settings.version is truthy and reconciles them against the selected
// cloud sync provider once it is (see transferManager.isSettingsLoaded).
export function useTransferQueue(libraryLoaded = true, delayInit = 0) {
const { envConfig, appService } = useEnv();
const _ = useTranslation();
@@ -92,7 +97,7 @@ export function useTransferQueue(libraryLoaded = true, delayInit = 0) {
pending: transferList.filter((t) => t.status === 'pending').length,
active: transferList.filter((t) => t.status === 'in_progress').length,
completed: transferList.filter((t) => t.status === 'completed').length,
failed: transferList.filter((t) => t.status === 'failed' || t.status === 'cancelled').length,
failed: transferList.filter(isFailedLikeTransfer).length,
total: transferList.length,
};
}, [transfers]);
@@ -106,9 +111,7 @@ export function useTransferQueue(libraryLoaded = true, delayInit = 0) {
}, [transfers]);
const failedTransfers = useMemo(() => {
return Object.values(transfers).filter(
(t) => t.status === 'failed' || t.status === 'cancelled',
);
return Object.values(transfers).filter(isFailedLikeTransfer);
}, [transfers]);
const completedTransfers = useMemo(() => {
+26 -2
View File
@@ -1,4 +1,5 @@
import { SystemSettings } from '@/types/settings';
import { applySyncBooksAutoEnable } from '@/services/sync/cloudSyncProvider';
import {
AppPlatform,
AppService,
@@ -68,7 +69,7 @@ export abstract class BaseAppService implements AppService {
storefrontRegionCode: string | null = null;
isOnlineCatalogsAccessible = true;
protected CURRENT_MIGRATION_VERSION = 20251124;
protected CURRENT_MIGRATION_VERSION = 20260706;
protected abstract fs: FileSystem;
protected abstract resolvePath(fp: string, base: BaseDir): ResolvedPath;
@@ -100,7 +101,10 @@ export abstract class BaseAppService implements AppService {
opts?: DatabaseOpts,
): Promise<DatabaseService>;
protected async runMigrations(lastMigrationVersion: number): Promise<void> {
protected async runMigrations(
lastMigrationVersion: number,
settings?: SystemSettings,
): Promise<void> {
if (lastMigrationVersion < 20251124) {
try {
await this.migrate20251124();
@@ -108,6 +112,26 @@ export abstract class BaseAppService implements AppService {
console.error('Error migrating to version 20251124:', error);
}
}
if (lastMigrationVersion < 20260706 && settings) {
try {
this.migrate20260706(settings);
} catch (error) {
console.error('Error migrating to version 20260706:', error);
}
}
}
/**
* Users with WebDAV/Drive already enabled become "third-party selected"
* when cloud sync provider selection ships, gating native Readest Cloud
* uploads off; flip the selected provider's syncBooks on once so their
* books keep backing up somewhere. Mutates the caller's settings
* snapshot, which the caller persists together with migrationVersion.
*/
private migrate20260706(settings: SystemSettings): void {
if (applySyncBooksAutoEnable(settings)) {
console.log('Migration 20260706: enabled syncBooks for the selected cloud sync provider.');
}
}
private async migrate20251124(): Promise<void> {
@@ -51,6 +51,8 @@ export const BACKUP_SETTINGS_BLACKLIST = [
'hardcover.lastSyncedAt',
'googleDrive.deviceId',
'googleDrive.lastSyncedAt',
'webdav.deviceId',
'webdav.lastSyncedAt',
// Transient runtime state — book keys may not exist post-restore; screen
// brightness is live device state.
'lastOpenBooks',
@@ -2,6 +2,7 @@ import type { Book, BookLookupIndex } from '@/types/book';
import type { AppService, OsPlatform } from '@/types/system';
import type { SystemSettings } from '@/types/settings';
import { transferManager } from '@/services/transferManager';
import { isReadestCloudStorageActive } from '@/services/sync/cloudSyncProvider';
import { normalizeFilePathForIndex } from '@/services/bookService';
import { isContentURI, isValidURL } from '@/utils/misc';
import { isPseStreamFileName } from '@/services/opds/pseStream';
@@ -237,11 +238,16 @@ export async function ingestFile(
// they are equivalent to a hash-copy book — only the local storage
// location differs. uploadBook reads straight from book.filePath in that
// case; downloads on other devices land in Books/<hash>/ as a normal copy.
// When a third-party provider (WebDAV/Drive) is the selected cloud sync
// backend, Readest Cloud storage is not written to at all — the file-sync
// engine mirrors the import instead (including Sent books, which then reach
// other devices via that provider when its syncBooks toggle is on).
if (
!opts.transient &&
isLoggedIn &&
!book.uploadedAt &&
(opts.forceUpload || settings.autoUpload)
(opts.forceUpload || settings.autoUpload) &&
isReadestCloudStorageActive(settings)
) {
transferManager.queueUpload(book);
}
@@ -658,7 +658,7 @@ export class NativeAppService extends BaseAppService {
const settings = await this.loadSettings();
const lastMigrationVersion = settings.migrationVersion || 0;
await super.runMigrations(lastMigrationVersion);
await super.runMigrations(lastMigrationVersion, settings);
if (lastMigrationVersion < 20251029) {
try {
@@ -0,0 +1,98 @@
import type { SystemSettings } from '@/types/settings';
import type { UserPlan } from '@/types/quota';
import { isCloudSyncAllowed } from '@/utils/access';
import type { FileSyncBackendKind } from '@/services/sync/file/providerRegistry';
/**
* The user's selected cloud sync provider for library data (book files,
* book rows, progress, notes). 'readest' is the native Readest Cloud;
* the others are the third-party file-sync backends. Account-level data
* (settings replicas, reading stats, dictionaries/fonts, translations)
* always syncs via Readest Cloud regardless of this selection.
*
* The selection is DERIVED from the existing per-device enabled flags —
* there is no separate persisted field, so it inherits the device-local
* semantics of `webdav.enabled` / `googleDrive.enabled` and needs no
* migration. `withActiveCloudProvider` keeps the flags mutually
* exclusive; if both are ever enabled (hand-edited or restored
* settings), WebDAV wins deterministically.
*/
export type CloudSyncProviderKind = 'readest' | FileSyncBackendKind;
export interface CloudSyncGate {
provider: CloudSyncProviderKind;
/**
* True when a third-party provider is selected but cloud sync is not
* allowed for the user's plan. Paused means paused: Readest Cloud
* uploads do NOT silently resume (that would push a possibly-private
* library to Readest servers without consent and reintroduce the
* #4959 quota path); the UI surfaces the paused state instead.
*/
paused: boolean;
}
export const getCloudSyncProvider = (
settings: SystemSettings | null | undefined,
): CloudSyncProviderKind =>
settings?.webdav?.enabled ? 'webdav' : settings?.googleDrive?.enabled ? 'gdrive' : 'readest';
/**
* `isCloudSyncAllowed` needs the UserPlan, which comes from the async
* auth JWT — non-React modules (transferManager, syncCategories) cannot
* resolve it synchronously. The plan-resolution flow (auth / quota
* refresh) writes the latest plan here; gate checks read it back.
* Defaults to 'free', the most restrictive plan, so a gate evaluated
* before the first auth resolution can only be too cautious, never too
* permissive.
*/
let cachedUserPlan: UserPlan = 'free';
export const setCachedUserPlan = (plan: UserPlan | undefined): void => {
cachedUserPlan = plan ?? 'free';
};
export const getCachedUserPlan = (): UserPlan => cachedUserPlan;
export const resolveCloudSyncGate = (
settings: SystemSettings | null | undefined,
plan: UserPlan = cachedUserPlan,
): CloudSyncGate => {
const provider = getCloudSyncProvider(settings);
if (provider !== 'readest' && !isCloudSyncAllowed(plan)) {
return { provider, paused: true };
}
return { provider, paused: false };
};
/**
* One-time upgrade migration helper (appService migrate20260706): users
* who already had WebDAV/Drive enabled before provider selection shipped
* become "third-party selected" on upgrade, which gates native Readest
* Cloud uploads off — with syncBooks at its old `false` default their
* books would back up nowhere. Flip syncBooks on for the SELECTED
* provider only. Mutates `settings` in place (the migration runner saves
* the same snapshot afterwards) and returns whether anything changed.
*/
export const applySyncBooksAutoEnable = (settings: SystemSettings): boolean => {
const provider = getCloudSyncProvider(settings);
if (provider === 'webdav' && settings.webdav && !settings.webdav.syncBooks) {
settings.webdav = { ...settings.webdav, syncBooks: true };
return true;
}
if (provider === 'gdrive' && settings.googleDrive && !settings.googleDrive.syncBooks) {
settings.googleDrive = { ...settings.googleDrive, syncBooks: true };
return true;
}
return false;
};
/**
* Whether Readest Cloud storage may be written to (book file uploads).
* Strictly: only when Readest Cloud is the selected provider. A selected
* third-party provider — active or paused — means no Readest Cloud
* uploads.
*/
export const isReadestCloudStorageActive = (
settings: SystemSettings | null | undefined,
plan?: UserPlan,
): boolean => resolveCloudSyncGate(settings, plan).provider === 'readest';
+116 -10
View File
@@ -1,6 +1,8 @@
import { Book } from '@/types/book';
import { AppService, BaseDir } from '@/types/system';
import { useTransferStore, TransferItem, ReplicaTransferFile } from '@/store/transferStore';
import { useSettingsStore } from '@/store/settingsStore';
import { isReadestCloudStorageActive } from '@/services/sync/cloudSyncProvider';
import { TranslationFunc } from '@/hooks/useTranslation';
import { ProgressHandler, ProgressPayload } from '@/utils/transfer';
import { eventDispatcher } from '@/utils/event';
@@ -8,12 +10,18 @@ import { getTransferMessages } from './transferMessages';
const TRANSFER_QUEUE_KEY = 'readest_transfer_queue';
const RETRY_DELAY_BASE_MS = 2000;
// Quota failures in a batch import arrive one per book as transfers drain;
// collapse them into one summary toast per burst instead of N identical toasts.
const QUOTA_TOAST_FLUSH_MS = 1500;
interface PersistedQueueData {
schemaVersion?: number;
transfers: Record<string, TransferItem>;
isQueuePaused: boolean;
}
const QUEUE_SCHEMA_VERSION = 1;
class TransferManager {
private static instance: TransferManager;
private appService: AppService | null = null;
@@ -23,6 +31,9 @@ class TransferManager {
private getLibrary: (() => Book[]) | null = null;
private updateBook: ((book: Book) => Promise<void>) | null = null;
private _: TranslationFunc | null = null;
private settingsUnsub: (() => void) | null = null;
private quotaFailureCount = 0;
private quotaToastTimer: ReturnType<typeof setTimeout> | null = null;
private readyResolve: () => void = () => {};
private readyPromise: Promise<void> = new Promise<void>((resolve) => {
this.readyResolve = resolve;
@@ -30,6 +41,52 @@ class TransferManager {
private constructor() {}
/**
* Settings hydrate asynchronously at app start (`settings` begins as
* `{}`); `settings.version` truthiness is the loaded signal (same
* convention as useSync). Before hydration the provider is unknown, so
* book uploads are deferred rather than judged — acting on unknown
* settings could both mis-cancel and mis-allow. Replica transfers and
* downloads are never deferred: they are not provider-gated and the
* boot-time replica pull relies on waitUntilReady().
*/
private isSettingsLoaded(): boolean {
return !!useSettingsStore.getState().settings?.version;
}
private isBookUploadAllowed(): boolean {
return isReadestCloudStorageActive(useSettingsStore.getState().settings);
}
private isDeferredBookUpload(t: TransferItem): boolean {
return t.kind === 'book' && t.type === 'upload' && !this.isSettingsLoaded();
}
/**
* Cancel pending book uploads when Readest Cloud is not the selected
* provider. Idempotent (acts on pending rows only) — safe to run on
* every processQueue pass, which also re-settles rows another window
* or a rogue retry re-pended. Cancellation is visible ('cancelled'
* with cancelReason 'policy'), never a silent drop.
*/
private reconcileUploadsWithProvider(): void {
if (!this.isSettingsLoaded() || this.isBookUploadAllowed()) return;
const store = useTransferStore.getState();
const gated = store
.getPendingTransfers()
.filter((t) => t.kind === 'book' && t.type === 'upload');
if (gated.length === 0) return;
gated.forEach((t) => {
store.setTransferStatus(t.id, 'cancelled', undefined, 'policy');
});
console.info(
`[cloudSync] cancelled ${gated.length} pending Readest Cloud upload(s): third-party provider selected`,
);
this.persistQueue();
}
static getInstance(): TransferManager {
if (!TransferManager.instance) {
TransferManager.instance = new TransferManager();
@@ -50,9 +107,18 @@ class TransferManager {
this.updateBook = updateBook;
this._ = translationFn;
await this.loadPersistedQueue();
this.reconcileUploadsWithProvider();
this.isInitialized = true;
this.readyResolve();
// Re-gate when settings hydrate or the selected provider changes.
this.settingsUnsub?.();
this.settingsUnsub = useSettingsStore.subscribe((state, prev) => {
if (state.settings === prev.settings) return;
this.reconcileUploadsWithProvider();
this.processQueue();
});
// Start processing queue
this.processQueue();
}
@@ -77,6 +143,13 @@ class TransferManager {
return null;
}
// Readest Cloud storage is not written to while a third-party
// provider is selected. Before settings hydrate the entry is queued
// and deferred; the reconcile on hydration decides its fate.
if (this.isSettingsLoaded() && !this.isBookUploadAllowed()) {
return null;
}
const store = useTransferStore.getState();
// Check if already queued or in progress
@@ -222,7 +295,7 @@ class TransferManager {
this.abortControllers.delete(transferId);
}
useTransferStore.getState().setTransferStatus(transferId, 'cancelled');
useTransferStore.getState().setTransferStatus(transferId, 'cancelled', undefined, 'user');
this.persistQueue();
}
@@ -287,11 +360,13 @@ class TransferManager {
}
private async _processQueueInternal(): Promise<void> {
this.reconcileUploadsWithProvider();
const store = useTransferStore.getState();
if (store.isQueuePaused) return;
const pending = store.getPendingTransfers();
const pending = store.getPendingTransfers().filter((t) => !this.isDeferredBookUpload(t));
const activeCount = store.getActiveTransfers().length;
const maxConcurrent = store.maxConcurrent;
@@ -308,9 +383,12 @@ class TransferManager {
await Promise.all(toProcess.map((transfer) => this.executeTransfer(transfer)));
// Check if more items to process
// Check if more items to process. Deferred book uploads (settings
// not yet hydrated) don't count — re-looping on them every 100ms
// would busy-wait; the settings subscription wakes them instead.
const newStore = useTransferStore.getState();
if (newStore.getPendingTransfers().length > 0 && !newStore.isQueuePaused) {
const processable = newStore.getPendingTransfers().filter((t) => !this.isDeferredBookUpload(t));
if (processable.length > 0 && !newStore.isQueuePaused) {
setTimeout(() => this.processQueue(), 100);
}
}
@@ -373,7 +451,15 @@ class TransferManager {
const currentStore = useTransferStore.getState();
const currentTransfer = currentStore.transfers[transfer.id];
if (currentTransfer && currentTransfer.retryCount < currentTransfer.maxRetries) {
// Quota exhaustion is permanent for this account state; retrying
// burns three backoff rounds per book for the same 403.
const isQuotaError = errorMessage.includes('Insufficient storage quota');
if (
!isQuotaError &&
currentTransfer &&
currentTransfer.retryCount < currentTransfer.maxRetries
) {
// Schedule retry with exponential backoff
const delay = RETRY_DELAY_BASE_MS * Math.pow(2, currentTransfer.retryCount);
currentStore.incrementRetryCount(transfer.id);
@@ -392,11 +478,8 @@ class TransferManager {
type: 'error',
message: _('Please log in to continue'),
});
} else if (errorMessage.includes('Insufficient storage quota')) {
eventDispatcher.dispatch('toast', {
type: 'error',
message: _('Insufficient storage quota'),
});
} else if (isQuotaError) {
this.recordQuotaFailure();
} else {
const errorMessages = getTransferMessages(transfer, _).failure;
@@ -420,6 +503,28 @@ class TransferManager {
}
}
private recordQuotaFailure(): void {
this.quotaFailureCount += 1;
if (this.quotaToastTimer) clearTimeout(this.quotaToastTimer);
this.quotaToastTimer = setTimeout(() => this.flushQuotaToast(), QUOTA_TOAST_FLUSH_MS);
}
private flushQuotaToast(): void {
const _ = this._;
const count = this.quotaFailureCount;
this.quotaFailureCount = 0;
this.quotaToastTimer = null;
if (!count || !_) return;
eventDispatcher.dispatch('toast', {
type: 'error',
message:
count === 1
? _('Insufficient storage quota')
: _('{{count}} uploads failed: insufficient storage quota', { count }),
});
}
private async executeBookTransfer(
transfer: TransferItem,
progressHandler: (p: ProgressPayload) => void,
@@ -561,6 +666,7 @@ class TransferManager {
// Persist all transfers including completed (for history)
const data: PersistedQueueData = {
schemaVersion: QUEUE_SCHEMA_VERSION,
transfers: store.transfers,
isQueuePaused: store.isQueuePaused,
};
@@ -302,7 +302,7 @@ export class WebAppService extends BaseAppService {
const settings = await this.loadSettings();
const lastMigrationVersion = settings.migrationVersion || 0;
await super.runMigrations(lastMigrationVersion);
await super.runMigrations(lastMigrationVersion, settings);
if (lastMigrationVersion < this.CURRENT_MIGRATION_VERSION) {
await this.saveSettings({
+41 -6
View File
@@ -4,6 +4,15 @@ import type { BaseDir } from '@/types/system';
export type TransferType = 'upload' | 'download' | 'delete';
export type TransferStatus = 'pending' | 'in_progress' | 'completed' | 'failed' | 'cancelled';
export type TransferKind = 'book' | 'replica';
/**
* Why a transfer was cancelled. 'user' = an explicit cancel action;
* 'policy' = the app cancelled it because Readest Cloud is not the
* selected sync provider. Policy cancellations are not failures: they
* are excluded from the failed bucket, Retry All, and per-item retry
* (retrying would either no-op against the provider gate or loop
* cancel-retry-cancel), and they are pruned on the next restore.
*/
export type TransferCancelReason = 'user' | 'policy';
export interface ReplicaTransferFile {
logical: string;
@@ -28,6 +37,7 @@ export interface TransferItem {
transferredBytes: number;
transferSpeed: number; // bytes per second
error?: string;
cancelReason?: TransferCancelReason;
retryCount: number;
maxRetries: number;
createdAt: number;
@@ -76,7 +86,12 @@ interface TransferState {
total: number,
speed: number,
) => void;
setTransferStatus: (transferId: string, status: TransferStatus, error?: string) => void;
setTransferStatus: (
transferId: string,
status: TransferStatus,
error?: string,
cancelReason?: TransferCancelReason,
) => void;
retryTransfer: (transferId: string) => void;
incrementRetryCount: (transferId: string) => void;
@@ -118,6 +133,16 @@ const generateTransferId = (): string => {
return `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
};
/**
* The single failed-bucket predicate. Every surface that shows, counts,
* or retries "failed" transfers (store getters, useTransferQueue stats,
* TransferQueuePanel filter/retry) must use this instead of matching
* statuses inline, so policy cancellations stay out of the bucket
* everywhere at once.
*/
export const isFailedLikeTransfer = (t: TransferItem): boolean =>
t.status === 'failed' || (t.status === 'cancelled' && t.cancelReason !== 'policy');
export const useTransferStore = create<TransferState>((set, get) => ({
transfers: {},
isQueuePaused: false,
@@ -225,12 +250,15 @@ export const useTransferStore = create<TransferState>((set, get) => ({
});
},
setTransferStatus: (transferId, status, error) => {
setTransferStatus: (transferId, status, error, cancelReason) => {
set((state) => {
const transfer = state.transfers[transferId];
if (!transfer) return state;
const updates: Partial<TransferItem> = { status, error };
if (status === 'cancelled') {
updates.cancelReason = cancelReason ?? transfer.cancelReason ?? 'user';
}
if (status === 'in_progress' && !transfer.startedAt) {
updates.startedAt = Date.now();
@@ -253,6 +281,9 @@ export const useTransferStore = create<TransferState>((set, get) => ({
set((state) => {
const transfer = state.transfers[transferId];
if (!transfer) return state;
// Policy cancellations are not retryable: the provider gate would
// re-cancel the row immediately (cancel-retry-cancel loop).
if (transfer.status === 'cancelled' && transfer.cancelReason === 'policy') return state;
return {
transfers: {
@@ -264,6 +295,7 @@ export const useTransferStore = create<TransferState>((set, get) => ({
transferredBytes: 0,
transferSpeed: 0,
error: undefined,
cancelReason: undefined,
startedAt: undefined,
completedAt: undefined,
},
@@ -339,9 +371,7 @@ export const useTransferStore = create<TransferState>((set, get) => ({
},
getFailedTransfers: () => {
return Object.values(get().transfers).filter(
(t) => t.status === 'failed' || t.status === 'cancelled',
);
return Object.values(get().transfers).filter(isFailedLikeTransfer);
},
getCompletedTransfers: () => {
@@ -375,7 +405,7 @@ export const useTransferStore = create<TransferState>((set, get) => ({
pending: transfers.filter((t) => t.status === 'pending').length,
active: transfers.filter((t) => t.status === 'in_progress').length,
completed: transfers.filter((t) => t.status === 'completed').length,
failed: transfers.filter((t) => t.status === 'failed' || t.status === 'cancelled').length,
failed: transfers.filter(isFailedLikeTransfer).length,
total: transfers.length,
};
},
@@ -386,6 +416,11 @@ export const useTransferStore = create<TransferState>((set, get) => ({
// Legacy rows persisted before the kind discriminator default to 'book'.
const restoredTransfers: Record<string, TransferItem> = {};
Object.entries(transfers).forEach(([id, transfer]) => {
// Policy-cancelled rows are session-scoped history: a large
// pre-switch queue must not leave hundreds of permanent
// "Cancelled" rows in the panel and localStorage. Prune on
// restore.
if (transfer.status === 'cancelled' && transfer.cancelReason === 'policy') return;
const withKind: TransferItem = { ...transfer, kind: transfer.kind ?? 'book' };
if (withKind.status === 'in_progress') {
restoredTransfers[id] = {