fix(sync): silence third-party cloud-sync error toasts (#4845)

* fix(sync): never toast third-party cloud-sync errors; log to console only

The reader's per-book auto-sync surfaced an "Cloud sync authentication failed.
Reconnect in Settings." toast on any AUTH_FAILED (e.g. an expired web Google
Drive token), interrupting reading. Background sync failures shouldn't pop a
toast — drop it and console.warn every sync error instead (the AUTH_FAILED
branch only chose toast-vs-console, so it collapses to a plain log). Removes the
now-unused authFailedToast + useTranslation/FileSyncError imports.

Manual "Sync now" (FileSyncForm) still reports its result — it's a deliberate,
foreground action. Native cloud sync (useBooksSync) is unaffected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(sync): surface an expired cloud-sync session in the reader + Settings

With sync-error toasts silenced, an expired third-party session (e.g. the
short-lived web Google Drive token) had no UI indicator. Surface it without the
old per-failure error toast:

- Reader: a single top-right `hint` ("Google Drive session expired. Reconnect in
  Settings.") — the same affordance as the native "Reading Progress Synced"
  hint. De-duplicated via a per-instance ref so it shows once, not on every
  page-turn sync; reset on a successful sync / provider switch (web reconnect
  reloads anyway).
- Settings → Google Drive: Disconnect swaps to Reconnect when the session is
  expired, and "Sync now" is disabled (FileSyncForm gains a `syncNowDisabled`
  prop) so a sync that would just fail isn't offered. No hint text in Settings.
- webTokenStore.hasValidWebDriveToken() backs the web detection (the token lives
  in sessionStorage; native auto-refreshes so it doesn't apply there).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Huang Xin
2026-06-29 02:29:14 +08:00
committed by GitHub
parent 70bad93ebf
commit ea99106677
11 changed files with 237 additions and 32 deletions
@@ -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
@@ -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 `<div>` 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 `<button>`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]].
@@ -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 <email>" → add book / Sync now → confirm `Readest/books/<hash>/{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.
@@ -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]].
@@ -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]].
@@ -2,12 +2,28 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDisplayName</key><string>Readest</string>
<key>CFBundleIdentifier</key><string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundlePackageType</key><string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key><string>com.apple.widgetkit-extension</string>
</dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Readest</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>XPC!</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>0.11.12</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.widgetkit-extension</string>
</dict>
</dict>
</plist>
@@ -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
});
});
@@ -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
@@ -35,6 +35,12 @@ interface FileSyncFormProps {
stored: FileSyncFormSettings;
/** Persist a patch into this backend's settings slice (must merge store-latest). */
persist: (patch: Partial<FileSyncFormSettings>) => Promise<void>;
/**
* 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<FileSyncFormProps> = ({ kind, stored, persist }) => {
const FileSyncForm: React.FC<FileSyncFormProps> = ({
kind,
stored,
persist,
syncNowDisabled = false,
}) => {
const _ = useTranslation();
const { settings } = useSettingsStore();
const { envConfig } = useEnv();
@@ -92,6 +103,7 @@ const FileSyncForm: React.FC<FileSyncFormProps> = ({ 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<FileSyncFormProps> = ({ kind, stored, persist }) =>
<button
type='button'
onClick={handleSyncNow}
disabled={isSyncing}
className={clsx('btn btn-ghost btn-sm h-8 min-h-8 gap-1 px-2', isSyncing && 'opacity-60')}
disabled={isSyncing || syncNowDisabled}
className={clsx(
'btn btn-ghost btn-sm h-8 min-h-8 gap-1 px-2',
(isSyncing || syncNowDisabled) && 'opacity-60',
)}
title={_('Sync now')}
aria-label={_('Sync now')}
>
@@ -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<typeof stored>) => {
const latest = useSettingsStore.getState().settings;
const next = { ...latest, googleDrive: { ...latest.googleDrive, ...patch } };
@@ -104,11 +113,34 @@ const GoogleDriveForm: React.FC = () => {
if (isActive) {
return (
<div className='space-y-5'>
<FileSyncForm kind='gdrive' stored={stored} persist={persistGDrive} />
<FileSyncForm
kind='gdrive'
stored={stored}
persist={persistGDrive}
syncNowDisabled={sessionExpired}
/>
<div className='flex justify-end'>
<button type='button' onClick={handleDisconnect} className={disconnectButtonClass}>
{_('Disconnect')}
</button>
{sessionExpired ? (
<button
type='button'
onClick={handleConnect}
disabled={isConnecting}
className={clsx(primaryButtonClass, isConnecting && 'opacity-60')}
>
{isConnecting ? (
<>
<span className='loading loading-spinner loading-sm' />
{_('Waiting for sign-in…')}
</>
) : (
_('Reconnect')
)}
</button>
) : (
<button type='button' onClick={handleDisconnect} className={disconnectButtonClass}>
{_('Disconnect')}
</button>
)}
</div>
</div>
);
@@ -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;
};