diff --git a/apps/readest-app/.claude/memory/MEMORY.md b/apps/readest-app/.claude/memory/MEMORY.md
index d01c3eb5..f0b2c313 100644
--- a/apps/readest-app/.claude/memory/MEMORY.md
+++ b/apps/readest-app/.claude/memory/MEMORY.md
@@ -37,6 +37,7 @@
- [Pull cursor via synced_at (#4678)](sync-synced-at-cursor-4678.md) — books `synced_at` + BEFORE trigger
- [WebDAV metadata sync (#4756)](webdav-metadata-sync-4756.md) — PR #4776; LWW on `book.updatedAt` + `updateBookMetadata`
- [File-sync refactor (#4784)](webdav-filesync-refactor-plan.md) — `FileSyncEngine`/`FileSyncProvider`/`merge.ts`; incremental + Full Sync
+- [Third-party library auto-sync (#4835)](third-party-library-autosync-4835.md) — `useLibraryFileSync` parity w/ `useBooksSync`; `library.json` on import/delete/close; delete propagation NEEDS full library (incl. deletedAt) passed to `syncLibrary`
- [WebDAV connect nullified (#4780)](webdav-connect-nullified-4780.md) — catch+finally saved STALE closure; `getState().settings`
- [WebDAV credential sync (#4810)](webdav-credential-sync-4810.md) — `webdav.*` missing from `SETTINGS_WHITELIST`/`ENCRYPTED_FIELDS`; new nested whitelist group NEEDS matching `mergeSettings` deep-merge or shallow merge clobbers per-device siblings
- [Multi-window settings clobber (#4580)](multiwindow-settings-clobber-4580.md) — stale window overwrites shared settings.json; cross-window broadcast `settingsSync.ts`
@@ -72,6 +73,7 @@
- [Image zoom trackpad flicker (#4742)](image-zoom-trackpad-flicker-4742.md) — macOS pinch=`ctrl+wheel`; `isWheelZooming` debounce
- [Instant Highlight ate tap/swipe](instant-highlight-tap-paginate.md) — preventDefault killed tap-paginate; `INSTANT_HOLD_MS=300`
- [Keyboard selection adjust (#4728)](keyboard-selection-adjust-4728.md) — Shift+←/→ char, Ctrl/Alt+Shift word; `onAdjustTextSelection`
+- [Double-click word select](iframe-double-click-word-select.md) — orphaned `iframe-double-click` → `handleDoubleClick` selects word via `getWordRangeFromPoint`/`Intl.Segmenter`, routes through `makeSelection`; `pointerDownTimeRef=0` bypasses hold gate; `isValidSelection` guard avoids desktop double-fire
- [Cross-page selection auto-turn (#4741)](cross-page-selection-autoturn-4741.md) — `useAutoPageTurn` dwell; `onAfterTurn` re-emit
- [Annotator onLoad listener leak (#4735)](annotator-onload-listener-leak-paragraph-mode.md) — `useRendererInputListeners` once-per-view
- [Paragraph mode toggle/resume (#4717)](paragraph-mode-toggle-resume-4717.md) — snapshot live Set; resume from fresh `lastLocation.cfi`
@@ -93,6 +95,7 @@
- [Instant highlight delete orphan (#4773)](instant-highlight-delete-orphan-4773.md) — stale memoized index + in-place `deletedAt`; re-check at READ
- [Empty highlight leak on annotate cancel (#4791)](empty-highlight-leak-on-annotate-cancel-4791.md) — Annotate eagerly creates placeholder; track `notebookNewHighlightId`, presentation-driven cleanup effect
- [Customize Toolbar global (#4760)](customize-toolbar-global-serializeconfig.md) — `serializeConfig` ref-compare → stale override; value compare
+- [Customize Toolbar e-ink black bar (#4839)](customize-toolbar-eink-black-bar-4839.md) — preview Zone copied `bg-gray-600` w/o `.popup-container`; add `eink-bordered` (+ `eink:text-base-content` hint)
- Native TTS: [iOS #4676](native-ios-tts-4676.md) AVSpeechSynthesizer pause==stop rate `pow^(1/2.5)`; [offline halt #4613](native-tts-offline-autoadvance-4613.md) advance only on `end`, SKIP-on-error via `forward()`
- Edge TTS: [word highlight #4017](edge-tts-word-highlighting-4017.md) `audio.metadata` WordBoundary by rAF gate on UA; [drift](tts-word-highlight-singletextnode-drift.md) TEXT_NODE fast path ignored offsets, slice `[start,end]`
- [TTS highlight granularity setting](tts-highlight-granularity-setting.md) — Word/Sentence; gate `prepareSpeakWords` on granularity (NOT supportsWordBoundaries); suppress = wordBoundaries && word
diff --git a/apps/readest-app/.claude/memory/customize-toolbar-eink-black-bar-4839.md b/apps/readest-app/.claude/memory/customize-toolbar-eink-black-bar-4839.md
new file mode 100644
index 00000000..b2e9b430
--- /dev/null
+++ b/apps/readest-app/.claude/memory/customize-toolbar-eink-black-bar-4839.md
@@ -0,0 +1,14 @@
+---
+name: customize-toolbar-eink-black-bar-4839
+description: Customize Toolbar preview rendered as a solid black bar in e-ink; preview surfaces copying bg-gray-600 need eink-bordered
+metadata:
+ type: project
+---
+
+#4839: the Customize Toolbar sub-page (`AnnotationToolbarCustomizer.tsx`) toolbar **preview** Zone copied the live popup's `selection-popup bg-gray-600 text-white` but rendered as an unreadable solid black bar under `[data-eink='true']`.
+
+**Why:** the real reader popup earns its e-ink chrome from `.popup-container` (globals.css `[data-eink] .popup-container` → `bg base-100` + 1px `base-content` border). The preview Zone is a plain `
` with NO `popup-container`, so the dark `bg-gray-600` survived in e-ink; the base-content (inverted via `[data-eink] button`) chip icons then sat black-on-black.
+
+**How to apply:** any e-ink "preview" surface that mimics the live popup must scope the dark fill to non-e-ink (`not-eink:bg-gray-600 not-eink:text-white`) and add `eink-bordered` so e-ink renders it as `bg-base-100` + 1px `base-content` border (don't just rely on `eink-bordered`'s `!important` to override the gray — drop the gray in e-ink outright). Also fix copied white hint text (`text-white/70` → `not-eink:text-white/70 eink:text-base-content`) since the surface turns base-100. Chip icons need no change — they are `
`s, already inverted to base-content by the global `[data-eink] button` rule. Guard: render test asserts `.selection-popup` element carries `eink-bordered`. Verify rendered colors via `getComputedStyle` under `[data-eink]` (set `data-theme='default-light'` first or theme vars are unresolved → transparent); note daisyUI returns **oklch** not rgb — e-ink correct = bg `oklch(1 0 0)`, border/icon `oklch(0.2 0 0)`. PR #4841.
+
+Same feature as [[customize-toolbar-global-serializeconfig]]; e-ink conventions in [[feedback_design_system_doc]].
diff --git a/apps/readest-app/.claude/memory/gdrive-provider-multipr-status.md b/apps/readest-app/.claude/memory/gdrive-provider-multipr-status.md
index 9bfa5ef8..789b4226 100644
--- a/apps/readest-app/.claude/memory/gdrive-provider-multipr-status.md
+++ b/apps/readest-app/.claude/memory/gdrive-provider-multipr-status.md
@@ -66,6 +66,12 @@ Adding **Google Drive as a second `FileSyncProvider`** for the merged file-sync
**CODE COMPLETE + MERGED.** REMAINING (human/ops-only): (1) on-device re-verify with #4827 in the build — Android sync should no longer stall ~3-4 min / spam `failed to inspect hash dir`; iOS OAuth sign-in; desktop streaming book-sync re-check; (2) Google consent screen → Production (testing caps 100 users). NOTE: Android build auto-generates a deep-link intent-filter for the gdrive reverse-DNS scheme in `gen/android/.../AndroidManifest.xml` (duplicates the manual `gdrive-oauth` filter) — benign build drift, don't commit.
+**Google Drive on WEB via FULL-PAGE REDIRECT OAuth — DONE on branch `feat/gdrive-web-oauth` (was `feat/gdrive-web-gis`; local/unpushed; suite 6516 green).**
+- **GIS popup ABANDONED:** `src/middleware.ts:55` sets `Cross-Origin-Opener-Policy: same-origin` on every web doc (Turso WASM/SharedArrayBuffer needs `crossOriginIsolated`). COOP same-origin SEVERS a cross-origin popup's opener handle → GIS's `popup.closed` poll reads true instantly → `popup_closed` fires while the popup is still open (diagnosed live). Can't relax COOP (breaks Turso); can't scope it (connect happens over Turso routes). So no popup OAuth on web.
+- **Web flow:** full-page redirect (no `window.opener`, works under COOP). `auth/webRedirectFlow.ts` (implicit `response_type=token` — secretless Web client can't code-exchange; CSRF state+returnPath in sessionStorage; parse token from callback fragment) + `auth/webTokenStore.ts` (sessionStorage access token, no refresh token) + `WebDriveAuth.ts` (reads stored token, expired→AUTH_FAILED, `accountLabel` via about.get) + `app/gdrive-callback/page.tsx` (validates state, stores token, `withActiveCloudProvider(settings,'gdrive')`+label via `appService.load/saveSettings`, routes back). `buildGoogleDriveProvider` web branch: `new WebDriveAuth(globalThis.fetch)` (Drive REST CORS-ok; streaming Tauri-only→web buffered). `googleDriveConnect` web: Connect=`beginWebDriveRedirect` (navigates away, never resolves), Disconnect=`clearWebDriveToken`.
+- **Official Web client id BAKED** `209390247301-585tc3dohg4c02588uvah5d32hg6dneq` (`getGoogleWebClientId`, env `NEXT_PUBLIC_GOOGLE_WEB_CLIENT_ID` overrides). **NO auto-refresh** (secretless browser client → no refresh token; Google blocks hidden-iframe silent renewal) → user reconnects per session; true auto-refresh needs a server-side token broker (Worker holds secret+refresh token) — deferred ("A for now").
+- **OPS REMAINING:** add `https://web.readest.com/gdrive-callback` + `http://localhost:3000/gdrive-callback` to the Web client's **Authorized redirect URIs** (JS origins already set). Then live-verify `pnpm dev-web`.
+
**PR3 REMAINING:**
- **LIVE VERIFICATION (needs the user — real Google sign-in):** `pnpm tauri dev` → add own Google account as a Test user in the consent screen (Testing mode caps + gates) → Settings → Integrations → Google Drive → Connect → browser → grant → "Connected as " → add book / Sync now → confirm `Readest/books//{config.json,cover.png}` in Drive. Windows cold-browser fallback.
- **Reader-hook auto-sync (deferred):** generalize `useWebDAVSync`→`useFileSync` (per-provider state maps, async Drive provider build in the hook) so Drive auto-syncs per-book while reading like WebDAV. Manual Sync-now already works without it; do after live-verifying the base.
diff --git a/apps/readest-app/.claude/memory/iframe-double-click-word-select.md b/apps/readest-app/.claude/memory/iframe-double-click-word-select.md
new file mode 100644
index 00000000..d0b12784
--- /dev/null
+++ b/apps/readest-app/.claude/memory/iframe-double-click-word-select.md
@@ -0,0 +1,51 @@
+---
+name: iframe-double-click-word-select
+description: Double-click / touch double-tap on a word selects it and fires the instant action or annotation toolbar
+metadata:
+ node_type: memory
+ type: project
+ originSessionId: bac4ae5d-047f-4b4f-8a04-b239beb4d7d7
+---
+
+Double-tap (touch) / double-click (mouse) on a word now selects that word — like
+a long-press — then runs the configured instant quick action, or raises the
+annotation toolbar if none is set. Verified live on Xiaomi 12 (Android).
+
+**The gap:** `iframe-double-click` was posted by `handleClick`
+(`src/app/reader/utils/iframeEventHandlers.ts`, gated on `!doubleClickDisabled`)
+but had **no consumer** — a touch double-tap did nothing (Android has no native
+double-tap word-select; desktop double-click already selects natively via the
+`handlePointerUp` path).
+
+**Impl (3 files):**
+- `src/utils/sel.ts`: `getWordRangeAt(node, offset)` expands a caret to the
+ word-like segment via `Intl.Segmenter` (CJK + Latin), `[start,end]` inclusive
+ so a boundary caret still selects the adjacent word; `getWordRangeFromPoint(doc,x,y)`
+ resolves the caret (`caretPositionFromPoint`/`caretRangeFromPoint`) then delegates.
+- `useTextSelector.ts`: `handleDoubleClick(doc, index, x, y)` selects the word and
+ routes through the existing `makeSelection` (guarded so the programmatic
+ `selectionchange` echo is ignored). **Guard `if (isValidSelection(sel)) return`**
+ — on desktop the browser already selected the word natively (flows through
+ `handlePointerUp`), so synthesize ONLY when nothing is selected (touch double-tap).
+ No `isUpToPopup` latch: a double-tap is two taps both consumed by double-click
+ detection, so no trailing single-click follows that would dismiss the popup.
+- `Annotator.tsx`: window `message` listener for `iframe-double-click` resolves the
+ visible section doc/index like `handleNativeTouch` (`renderer.getContents()` +
+ `primaryIndex`), then sets **`pointerDownTimeRef.current = 0`** before calling
+ `handleDoubleClick` so the deliberate double-tap bypasses `handleQuickAction`'s
+ `quickActionMinHoldMs` (300ms) long-press gate (mouse already uses 0). Coords:
+ `clientX/clientY` from the iframe click are already section-doc-relative, exactly
+ what caretFromPoint wants — no window↔frame mapping (unlike `rangeFromAnchorToPoint`).
+
+The branch decision (instant action vs toolbar) reuses the existing Annotator
+`selection` effect: `enableAnnotationQuickActions && annotationQuickAction &&
+isTextSelected.current ? handleQuickAction() : handleShowAnnotPopup()`. Default
+config has `annotationQuickAction: null` → toolbar.
+
+**Tests:** unit `sel.test.ts` (getWordRangeAt/FromPoint), `useTextSelector-doubleClick.test.ts`
+(selection routing + desktop guard); e2e `double-click.android.test.ts` + `doubleTap`
+helper in `helpers/adb.ts` (two `input tap` in one shell, < 250ms apart). Live CDP
+verify: toolbar branch (`.popup-container.selection-popup`) and instant-action
+branch (set quick action to Dictionary via header dropdown → `.popup-container.select-text`,
+toolbar absent). See [[dblclick-drag-pageturn-4524]], [[instant-highlight-tap-paginate]],
+[[tap-to-open-image-table-4600]].
diff --git a/apps/readest-app/.claude/memory/third-party-library-autosync-4835.md b/apps/readest-app/.claude/memory/third-party-library-autosync-4835.md
new file mode 100644
index 00000000..01ea857e
--- /dev/null
+++ b/apps/readest-app/.claude/memory/third-party-library-autosync-4835.md
@@ -0,0 +1,27 @@
+---
+name: third-party-library-autosync-4835
+description: Third-party cloud sync (WebDAV/Drive) library.json auto-sync on import/delete/close — parity with useBooksSync; delete propagation needs full library
+metadata:
+ node_type: memory
+ type: project
+ originSessionId: 50e2c2b8-ca61-4c33-acae-cd5d2c9aa93f
+---
+
+PR #4835 (`feat/third-party-library-autosync`). Adds library-scoped auto-sync for the active third-party file-sync provider so `library.json` stays current without a manual "Sync now".
+
+**Architecture split (important):**
+- `library.json` (the remote index) is written ONLY by `engine.syncLibrary` (`src/services/sync/file/engine.ts`). Before this PR that was called from exactly ONE place: the Settings → "Sync now" button (`FileSyncForm.tsx`).
+- The reader's `useFileSync` (`app/reader/hooks/`) is PER-BOOK (progress/notes/cover/file) and NEVER touches `library.json` — it's the analogue of `useProgressSync`, not `useBooksSync`.
+- So nothing auto-updated `library.json` on import/delete/book-close. Native sync didn't have this gap because `useBooksSync` is library-scoped.
+
+**Fix:** `useLibraryFileSync()` (`app/library/hooks/useLibraryFileSync.ts`), mounted once on the library page next to `useBooksSync()`. Parity counterpart of `useBooksSync`:
+- Single `useEffect([library])` → debounced (5s) `engine.syncLibrary`. import (adds row), delete (sets `deletedAt`), book-close (bumps `updatedAt`) all mutate `library`, so one effect covers all three + initial-load pull.
+- Builds engine async (Drive keychain probe), keyed on connection-relevant settings (NOT lastSyncedAt). Stable debounced trigger via `runSyncRef` so it isn't lost on re-creation.
+- Gated on global file-sync mutex (`fileSyncStore.beginSync` — skip if a manual Sync now holds it), Sync Strategy, Upload Book Files, and `isCloudSyncAllowed`.
+- MUST gate on `libraryLoaded` — syncing a transient empty pre-load library would push an empty index and clobber remote.
+
+**Delete propagation gotcha (the key insight):** `engine.syncLibrary` tombstones a deleted book in `library.json` ONLY if the deleted book (with `deletedAt`) is in the `books` arg → it stays in `allBooksMap` → final index carries the tombstone. If filtered out (the old `FileSyncForm` passed `eligibleBooks = filter(!deletedAt)`), then (1) no tombstone AND (2) the discovery books-dir scan (`!allBooksMap.has(hash)`) RE-DOWNLOADS the just-deleted book (its remote hash dir lingers until the separate GC sweep). So BOTH the hook and `FileSyncForm` now pass the FULL library incl. soft-deleted. Engine tests in `engine-metadata-sync.test.ts`.
+
+**Scope:** pushes the deletion tombstone to the index (peers won't re-pull it). Does NOT auto-remove the book from a peer's LOCAL library — engine reconcile skips `rb.deletedAt` entries (`engine.ts` ~line 430). Peer-side local deletion is a future, riskier change.
+
+See [[gdrive-provider-multipr-status]] · [[webdav-metadata-sync-4756]] · [[webdav-filesync-refactor-plan]].
diff --git a/apps/readest-app/src-tauri/gen/apple/ReadestWidget/Info.plist b/apps/readest-app/src-tauri/gen/apple/ReadestWidget/Info.plist
index 20b67d35..baca7e54 100644
--- a/apps/readest-app/src-tauri/gen/apple/ReadestWidget/Info.plist
+++ b/apps/readest-app/src-tauri/gen/apple/ReadestWidget/Info.plist
@@ -2,12 +2,28 @@
- CFBundleDisplayName Readest
- CFBundleIdentifier $(PRODUCT_BUNDLE_IDENTIFIER)
- CFBundlePackageType $(PRODUCT_BUNDLE_PACKAGE_TYPE)
- NSExtension
-
- NSExtensionPointIdentifier com.apple.widgetkit-extension
-
+ CFBundleDevelopmentRegion
+ $(DEVELOPMENT_LANGUAGE)
+ CFBundleDisplayName
+ Readest
+ CFBundleExecutable
+ $(EXECUTABLE_NAME)
+ CFBundleIdentifier
+ $(PRODUCT_BUNDLE_IDENTIFIER)
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundleName
+ $(PRODUCT_NAME)
+ CFBundlePackageType
+ XPC!
+ CFBundleShortVersionString
+ 1.0
+ CFBundleVersion
+ 0.11.12
+ NSExtension
+
+ NSExtensionPointIdentifier
+ com.apple.widgetkit-extension
+
diff --git a/apps/readest-app/src/__tests__/services/sync/providers/gdrive/auth/webTokenStore.test.ts b/apps/readest-app/src/__tests__/services/sync/providers/gdrive/auth/webTokenStore.test.ts
index 4d11aa44..9efd7314 100644
--- a/apps/readest-app/src/__tests__/services/sync/providers/gdrive/auth/webTokenStore.test.ts
+++ b/apps/readest-app/src/__tests__/services/sync/providers/gdrive/auth/webTokenStore.test.ts
@@ -1,6 +1,7 @@
import { afterEach, describe, expect, test } from 'vitest';
import {
clearWebDriveToken,
+ hasValidWebDriveToken,
loadWebDriveToken,
saveWebDriveToken,
} from '@/services/sync/providers/gdrive/auth/webTokenStore';
@@ -26,4 +27,11 @@ describe('webTokenStore', () => {
window.sessionStorage.setItem('gdrive_web_token', 'not json');
expect(loadWebDriveToken()).toBeNull();
});
+
+ test('hasValidWebDriveToken reflects presence + expiry', () => {
+ expect(hasValidWebDriveToken(1_000)).toBe(false); // none stored
+ saveWebDriveToken({ accessToken: 'AT', expiresAt: 5_000 });
+ expect(hasValidWebDriveToken(1_000)).toBe(true); // not yet expired
+ expect(hasValidWebDriveToken(9_000)).toBe(false); // expired
+ });
});
diff --git a/apps/readest-app/src/app/reader/hooks/useFileSync.ts b/apps/readest-app/src/app/reader/hooks/useFileSync.ts
index f2b84a58..bcb423c3 100644
--- a/apps/readest-app/src/app/reader/hooks/useFileSync.ts
+++ b/apps/readest-app/src/app/reader/hooks/useFileSync.ts
@@ -5,8 +5,8 @@ import { useBookDataStore } from '@/store/bookDataStore';
import { useReaderStore } from '@/store/readerStore';
import { useBookProgress } from '@/store/readerProgressStore';
import { useSettingsStore } from '@/store/settingsStore';
-import { useTranslation } from '@/hooks/useTranslation';
import { useQuotaStats } from '@/hooks/useQuotaStats';
+import { useTranslation } from '@/hooks/useTranslation';
import { isCloudSyncAllowed } from '@/utils/access';
import { debounce } from '@/utils/debounce';
import { eventDispatcher } from '@/utils/event';
@@ -95,6 +95,8 @@ export const useFileSync = (bookKey: string) => {
const fileSyncedRef = useRef(false);
/** Per-instance lock for the cover uploader (gated differently than files). */
const coverSyncedRef = useRef(false);
+ /** One-shot guard so an expired session toasts once, not on every page-turn. */
+ const authNotifiedRef = useRef(false);
// Switching the active provider mid-session resets the per-book locks so the
// newly-active backend does a fresh pull-on-open and re-checks file/cover.
@@ -104,6 +106,7 @@ export const useFileSync = (bookKey: string) => {
coverSyncedRef.current = false;
lastPulledAtRef.current = 0;
dirtyRef.current = false;
+ authNotifiedRef.current = false;
}, [activeKind]);
// Read latest settings from the store (not the closure) when patching the
@@ -187,12 +190,33 @@ export const useFileSync = (bookKey: string) => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [engineKey, isReady, activeKind, appService, envConfig]);
- const authFailedToast = useCallback(() => {
- eventDispatcher.dispatch('toast', {
- type: 'error',
- message: _('Cloud sync authentication failed. Reconnect in Settings.'),
+ /**
+ * Notify (once) that the active provider's session expired so the user knows
+ * to reconnect — a single top-right reader `hint` (same affordance as the
+ * native "Reading Progress Synced" hint), NOT a per-failure error toast. Reset
+ * on a successful sync / provider switch.
+ */
+ const notifyAuthExpiredOnce = useCallback(() => {
+ if (authNotifiedRef.current) return;
+ authNotifiedRef.current = true;
+ eventDispatcher.dispatch('hint', {
+ bookKey,
+ timeout: 5000,
+ message:
+ activeKind === 'gdrive'
+ ? _('Google Drive session expired. Reconnect in Settings.')
+ : _('Cloud sync session expired. Reconnect in Settings.'),
});
- }, [_]);
+ }, [bookKey, activeKind, _]);
+
+ /** Map a sync error: surface an expired session once, log everything. */
+ const handleSyncError = useCallback(
+ (label: string, e: unknown) => {
+ if (e instanceof FileSyncError && e.code === 'AUTH_FAILED') notifyAuthExpiredOnce();
+ console.warn(label, e);
+ },
+ [notifyAuthExpiredOnce],
+ );
/**
* Push the latest config (progress + booknotes) to the remote. Skips while the
@@ -214,10 +238,10 @@ export const useFileSync = (bookKey: string) => {
const deviceId = ensureDeviceId();
await engine.pushBookConfig(book, config, deviceId);
dirtyRef.current = false;
+ authNotifiedRef.current = false;
await updateLastSyncedAt(Date.now());
} catch (e) {
- if (e instanceof FileSyncError && e.code === 'AUTH_FAILED') authFailedToast();
- else console.warn('file sync push failed', e);
+ handleSyncError('file sync push failed', e);
}
}, [
allowPush,
@@ -228,7 +252,7 @@ export const useFileSync = (bookKey: string) => {
engine,
providerSettings,
updateLastSyncedAt,
- authFailedToast,
+ handleSyncError,
]);
/**
@@ -251,8 +275,7 @@ export const useFileSync = (bookKey: string) => {
} catch (e) {
// Reset the lock on failure so a later trigger retries.
fileSyncedRef.current = false;
- if (e instanceof FileSyncError && e.code === 'AUTH_FAILED') authFailedToast();
- else console.warn('file sync book push failed', e);
+ handleSyncError('file sync book push failed', e);
}
}, [
allowPush,
@@ -261,7 +284,7 @@ export const useFileSync = (bookKey: string) => {
bookKey,
engine,
updateLastSyncedAt,
- authFailedToast,
+ handleSyncError,
]);
/**
@@ -281,10 +304,9 @@ export const useFileSync = (bookKey: string) => {
await engine.pushBookCover(book);
} catch (e) {
coverSyncedRef.current = false;
- if (e instanceof FileSyncError && e.code === 'AUTH_FAILED') authFailedToast();
- else console.warn('file sync cover push failed', e);
+ handleSyncError('file sync cover push failed', e);
}
- }, [allowPush, getBookData, bookKey, engine, authFailedToast]);
+ }, [allowPush, getBookData, bookKey, engine, handleSyncError]);
/**
* Pull, merge, and persist, using the same per-config / per-note merge as the
@@ -304,6 +326,8 @@ export const useFileSync = (bookKey: string) => {
try {
const result = await engine.pullBookConfig(book, config);
lastPulledAtRef.current = Date.now();
+ // The pull's getAccessToken succeeded — clear any expired-session notice.
+ authNotifiedRef.current = false;
if (!result.applied || !result.mergedConfig) return false;
// Surface merged notes through the live view so highlights re-appear /
@@ -342,8 +366,7 @@ export const useFileSync = (bookKey: string) => {
await updateLastSyncedAt(Date.now());
return true;
} catch (e) {
- if (e instanceof FileSyncError && e.code === 'AUTH_FAILED') authFailedToast();
- else console.warn('file sync pull failed', e);
+ handleSyncError('file sync pull failed', e);
return false;
}
}, [
@@ -360,7 +383,7 @@ export const useFileSync = (bookKey: string) => {
settings,
providerSettings,
updateLastSyncedAt,
- authFailedToast,
+ handleSyncError,
]);
// Stash the latest callbacks in a ref so the event-bridge effect doesn't
diff --git a/apps/readest-app/src/components/settings/integrations/FileSyncForm.tsx b/apps/readest-app/src/components/settings/integrations/FileSyncForm.tsx
index 56cebe56..2c3897f3 100644
--- a/apps/readest-app/src/components/settings/integrations/FileSyncForm.tsx
+++ b/apps/readest-app/src/components/settings/integrations/FileSyncForm.tsx
@@ -35,6 +35,12 @@ interface FileSyncFormProps {
stored: FileSyncFormSettings;
/** Persist a patch into this backend's settings slice (must merge store-latest). */
persist: (patch: Partial) => Promise;
+ /**
+ * Disable the "Sync now" button — set when the connection needs attention
+ * (e.g. an expired web Google Drive session) so a manual sync that would just
+ * fail isn't offered. The parent panel shows the reconnect affordance.
+ */
+ syncNowDisabled?: boolean;
}
/**
@@ -67,7 +73,12 @@ const formatSyncError = (_: TranslationFunc, e: unknown): string => {
* form; everything below the connect line is identical across backends, so it
* lives here once and is parameterised by {@link FileSyncFormProps.kind}.
*/
-const FileSyncForm: React.FC = ({ kind, stored, persist }) => {
+const FileSyncForm: React.FC = ({
+ kind,
+ stored,
+ persist,
+ syncNowDisabled = false,
+}) => {
const _ = useTranslation();
const { settings } = useSettingsStore();
const { envConfig } = useEnv();
@@ -92,6 +103,7 @@ const FileSyncForm: React.FC = ({ kind, stored, persist }) =>
* provider is built by kind through the registry so this stays backend-neutral.
*/
const handleSyncNow = async () => {
+ if (syncNowDisabled) return;
if (useFileSyncStore.getState().byKind[kind]?.isSyncing) return;
if (!stored.enabled) return;
@@ -213,8 +225,11 @@ const FileSyncForm: React.FC = ({ kind, stored, persist }) =>
diff --git a/apps/readest-app/src/components/settings/integrations/GoogleDriveForm.tsx b/apps/readest-app/src/components/settings/integrations/GoogleDriveForm.tsx
index 8f9fe700..455d9c9f 100644
--- a/apps/readest-app/src/components/settings/integrations/GoogleDriveForm.tsx
+++ b/apps/readest-app/src/components/settings/integrations/GoogleDriveForm.tsx
@@ -2,12 +2,14 @@ import clsx from 'clsx';
import React, { useState } from 'react';
import { useEnv } from '@/context/EnvContext';
import { useTranslation } from '@/hooks/useTranslation';
+import { isWebAppPlatform } from '@/services/environment';
import { useSettingsStore } from '@/store/settingsStore';
import { eventDispatcher } from '@/utils/event';
import {
runGoogleDriveConnect,
runGoogleDriveDisconnect,
} from '@/services/sync/providers/gdrive/googleDriveConnect';
+import { hasValidWebDriveToken } from '@/services/sync/providers/gdrive/auth/webTokenStore';
import { Tips } from '../primitives';
import FileSyncForm from './FileSyncForm';
import { withActiveCloudProvider } from './cloudSync';
@@ -49,6 +51,13 @@ const GoogleDriveForm: React.FC = () => {
const isConfigured = !!stored?.accountLabel;
const [isConnecting, setIsConnecting] = useState(false);
+ // On web the access token is short-lived (no refresh token) and lives in
+ // sessionStorage, so an active connection can sit with an expired/absent
+ // token. When that happens, swap Disconnect → Reconnect and disable Sync now;
+ // the user-facing "session expired" notice is a one-time toast in the reader
+ // (see useFileSync), not a hint here.
+ const sessionExpired = isActive && isWebAppPlatform() && !hasValidWebDriveToken();
+
const persistGDrive = async (patch: Partial) => {
const latest = useSettingsStore.getState().settings;
const next = { ...latest, googleDrive: { ...latest.googleDrive, ...patch } };
@@ -104,11 +113,34 @@ const GoogleDriveForm: React.FC = () => {
if (isActive) {
return (
-
+
-
- {_('Disconnect')}
-
+ {sessionExpired ? (
+
+ {isConnecting ? (
+ <>
+
+ {_('Waiting for sign-in…')}
+ >
+ ) : (
+ _('Reconnect')
+ )}
+
+ ) : (
+
+ {_('Disconnect')}
+
+ )}
);
diff --git a/apps/readest-app/src/services/sync/providers/gdrive/auth/webTokenStore.ts b/apps/readest-app/src/services/sync/providers/gdrive/auth/webTokenStore.ts
index d088086a..b90f30f1 100644
--- a/apps/readest-app/src/services/sync/providers/gdrive/auth/webTokenStore.ts
+++ b/apps/readest-app/src/services/sync/providers/gdrive/auth/webTokenStore.ts
@@ -41,3 +41,13 @@ export const loadWebDriveToken = (): TokenSet | null => {
export const clearWebDriveToken = (): void => {
getStorage()?.removeItem(WEB_TOKEN_KEY);
};
+
+/**
+ * Whether a usable (non-expired) access token is stored. Lets the settings panel
+ * show a "session expired" hint + Reconnect when the web token is gone/expired
+ * (the connection stays `enabled`, but the short-lived token must be re-minted).
+ */
+export const hasValidWebDriveToken = (now: number = Date.now()): boolean => {
+ const tokens = loadWebDriveToken();
+ return !!tokens && now < tokens.expiresAt;
+};