From 1558078391a451a56aaffe46927534518f301fd4 Mon Sep 17 00:00:00 2001 From: Huang Xin Date: Fri, 26 Jun 2026 15:43:31 +0800 Subject: [PATCH] fix(settings): keep global settings in sync across windows (#4580) (#4803) On desktop the app runs multiple windows (one library plus one per open book), and each keeps its own in-memory settings loaded once at window open. Global settings persist to a single shared settings.json, and every window writes the whole object on save. A window opened before the user customized a global view setting therefore clobbers that change with its own stale (often default) value on its next save, most visibly a reader window reverting Click to Paginate back to the default on close. Broadcast the global view and read settings after every save and have all other windows adopt them, preserving each window's device-local fields (paths, lastOpenBooks, sync cursors, brightness). The receive path only updates the in-memory store, so there is no save or broadcast loop. No-op off Tauri. Co-authored-by: Claude Opus 4.8 (1M context) --- .../__tests__/store/settings-store.test.ts | 22 ++++++ .../src/__tests__/utils/settings-sync.test.ts | 65 +++++++++++++++ apps/readest-app/src/components/Providers.tsx | 2 + apps/readest-app/src/hooks/useSettingsSync.ts | 22 ++++++ apps/readest-app/src/store/settingsStore.ts | 4 + apps/readest-app/src/utils/settingsSync.ts | 79 +++++++++++++++++++ 6 files changed, 194 insertions(+) create mode 100644 apps/readest-app/src/__tests__/utils/settings-sync.test.ts create mode 100644 apps/readest-app/src/hooks/useSettingsSync.ts create mode 100644 apps/readest-app/src/utils/settingsSync.ts diff --git a/apps/readest-app/src/__tests__/store/settings-store.test.ts b/apps/readest-app/src/__tests__/store/settings-store.test.ts index 16a5bec3..93260341 100644 --- a/apps/readest-app/src/__tests__/store/settings-store.test.ts +++ b/apps/readest-app/src/__tests__/store/settings-store.test.ts @@ -10,13 +10,20 @@ vi.mock('@/utils/time', () => ({ initDayjs: vi.fn(), })); +vi.mock('@/utils/settingsSync', () => ({ + broadcastGlobalSettings: vi.fn(), +})); + import i18n from '@/i18n/i18n'; import { initDayjs } from '@/utils/time'; +import { broadcastGlobalSettings } from '@/utils/settingsSync'; import { useSettingsStore } from '@/store/settingsStore'; +import type { EnvConfigType } from '@/services/environment'; import type { SystemSettings } from '@/types/settings'; const mockChangeLanguage = vi.mocked(i18n.changeLanguage); const mockInitDayjs = vi.mocked(initDayjs); +const mockBroadcastGlobalSettings = vi.mocked(broadcastGlobalSettings); function makeSettings(overrides: Partial = {}): SystemSettings { return { @@ -91,6 +98,21 @@ describe('settingsStore', () => { }); }); + describe('saveSettings', () => { + test('persists settings and broadcasts globals to other windows', async () => { + const settings = makeSettings({ version: 7 }); + const saveSettings = vi.fn().mockResolvedValue(undefined); + const envConfig = { + getAppService: vi.fn().mockResolvedValue({ saveSettings }), + } as unknown as EnvConfigType; + + await useSettingsStore.getState().saveSettings(envConfig, settings); + + expect(saveSettings).toHaveBeenCalledWith(settings); + expect(mockBroadcastGlobalSettings).toHaveBeenCalledWith(settings); + }); + }); + describe('setSettingsDialogBookKey', () => { test('sets the dialog book key', () => { useSettingsStore.getState().setSettingsDialogBookKey('book-key-123'); diff --git a/apps/readest-app/src/__tests__/utils/settings-sync.test.ts b/apps/readest-app/src/__tests__/utils/settings-sync.test.ts new file mode 100644 index 00000000..0385da44 --- /dev/null +++ b/apps/readest-app/src/__tests__/utils/settings-sync.test.ts @@ -0,0 +1,65 @@ +import { describe, test, expect } from 'vitest'; +import type { SystemSettings } from '@/types/settings'; +import type { ReadSettings } from '@/types/settings'; +import type { ViewSettings } from '@/types/book'; +import { mergeSyncedGlobalSettings } from '@/utils/settingsSync'; + +const makeLocal = (overrides: Partial = {}): SystemSettings => + ({ + localBooksDir: '/device-local/books', + customRootDir: '/device-local/root', + lastOpenBooks: ['book-only-open-in-this-window'], + screenBrightness: 0.42, + lastSyncedAtBooks: 1234, + globalViewSettings: { disableClick: false, disableSwipe: false } as ViewSettings, + globalReadSettings: { sideBarWidth: '15%' } as ReadSettings, + ...overrides, + }) as SystemSettings; + +describe('mergeSyncedGlobalSettings', () => { + test('adopts the broadcasting window global view settings', () => { + const local = makeLocal(); + const merged = mergeSyncedGlobalSettings(local, { + globalViewSettings: { disableClick: true, disableSwipe: true } as ViewSettings, + globalReadSettings: { sideBarWidth: '15%' } as ReadSettings, + }); + + expect(merged.globalViewSettings.disableClick).toBe(true); + expect(merged.globalViewSettings.disableSwipe).toBe(true); + }); + + test('adopts the broadcasting window global read settings', () => { + const local = makeLocal(); + const merged = mergeSyncedGlobalSettings(local, { + globalViewSettings: local.globalViewSettings, + globalReadSettings: { sideBarWidth: '30%' } as ReadSettings, + }); + + expect(merged.globalReadSettings.sideBarWidth).toBe('30%'); + }); + + test('preserves device/window-local fields from the local copy', () => { + const local = makeLocal(); + const merged = mergeSyncedGlobalSettings(local, { + globalViewSettings: { disableClick: true } as ViewSettings, + globalReadSettings: { sideBarWidth: '30%' } as ReadSettings, + }); + + expect(merged.localBooksDir).toBe('/device-local/books'); + expect(merged.customRootDir).toBe('/device-local/root'); + expect(merged.lastOpenBooks).toEqual(['book-only-open-in-this-window']); + expect(merged.screenBrightness).toBe(0.42); + expect(merged.lastSyncedAtBooks).toBe(1234); + }); + + test('returns a new object without mutating the local settings', () => { + const local = makeLocal(); + const merged = mergeSyncedGlobalSettings(local, { + globalViewSettings: { disableClick: true } as ViewSettings, + globalReadSettings: { sideBarWidth: '30%' } as ReadSettings, + }); + + expect(merged).not.toBe(local); + expect(local.globalViewSettings.disableClick).toBe(false); + }); +}); diff --git a/apps/readest-app/src/components/Providers.tsx b/apps/readest-app/src/components/Providers.tsx index bdb03aa2..ec69e9a1 100644 --- a/apps/readest-app/src/components/Providers.tsx +++ b/apps/readest-app/src/components/Providers.tsx @@ -13,6 +13,7 @@ import { initSystemThemeListener, loadDataTheme } from '@/store/themeStore'; import { useSettingsStore } from '@/store/settingsStore'; import { useCustomTextureStore } from '@/store/customTextureStore'; import { useSafeAreaInsets } from '@/hooks/useSafeAreaInsets'; +import { useSettingsSync } from '@/hooks/useSettingsSync'; import { useDefaultIconSize } from '@/hooks/useResponsiveSize'; import { useBackgroundTexture } from '@/hooks/useBackgroundTexture'; import { useEinkMode } from '@/hooks/useEinkMode'; @@ -113,6 +114,7 @@ const Providers = ({ children }: { children: React.ReactNode }) => { const iconSize = useDefaultIconSize(); const [showTelemetryConsent, setShowTelemetryConsent] = useState(false); useSafeAreaInsets(); // Initialize safe area insets + useSettingsSync(); // Adopt global settings broadcast by other windows (#4580) useEffect(() => { const handlerLanguageChanged = (lng: string) => { diff --git a/apps/readest-app/src/hooks/useSettingsSync.ts b/apps/readest-app/src/hooks/useSettingsSync.ts new file mode 100644 index 00000000..5e3f5fec --- /dev/null +++ b/apps/readest-app/src/hooks/useSettingsSync.ts @@ -0,0 +1,22 @@ +import { useEffect } from 'react'; +import { useSettingsStore } from '@/store/settingsStore'; +import { mergeSyncedGlobalSettings, subscribeSettingsSync } from '@/utils/settingsSync'; + +/** + * Adopt global settings broadcast by other app windows (issue #4580). Without + * this, a window that loaded before another window changed a global setting + * would clobber that change with its own stale copy on its next save. + */ +export const useSettingsSync = () => { + useEffect(() => { + const unlistenPromise = subscribeSettingsSync((payload) => { + const { settings, setSettings } = useSettingsStore.getState(); + // Settings may not be loaded yet on this window; skip until they are. + if (!settings.globalViewSettings) return; + setSettings(mergeSyncedGlobalSettings(settings, payload)); + }); + return () => { + unlistenPromise.then((unlisten) => unlisten()); + }; + }, []); +}; diff --git a/apps/readest-app/src/store/settingsStore.ts b/apps/readest-app/src/store/settingsStore.ts index e9cf3c7c..77ea18c6 100644 --- a/apps/readest-app/src/store/settingsStore.ts +++ b/apps/readest-app/src/store/settingsStore.ts @@ -3,6 +3,7 @@ import { create } from 'zustand'; import { SystemSettings } from '@/types/settings'; import { EnvConfigType } from '@/services/environment'; import { initDayjs } from '@/utils/time'; +import { broadcastGlobalSettings } from '@/utils/settingsSync'; export type FontPanelView = 'main-fonts' | 'custom-fonts'; @@ -50,6 +51,9 @@ export const useSettingsStore = create((set) => ({ saveSettings: async (envConfig: EnvConfigType, settings: SystemSettings) => { const appService = await envConfig.getAppService(); await appService.saveSettings(settings); + // Keep other open windows' in-memory global settings in sync so a stale + // window doesn't clobber this write on its next save (issue #4580). + void broadcastGlobalSettings(settings); }, setSettingsDialogBookKey: (bookKey) => set({ settingsDialogBookKey: bookKey }), setSettingsDialogOpen: (open) => set({ isSettingsDialogOpen: open }), diff --git a/apps/readest-app/src/utils/settingsSync.ts b/apps/readest-app/src/utils/settingsSync.ts new file mode 100644 index 00000000..aa3d4023 --- /dev/null +++ b/apps/readest-app/src/utils/settingsSync.ts @@ -0,0 +1,79 @@ +import { emit, listen, type UnlistenFn } from '@tauri-apps/api/event'; +import { getCurrentWindow } from '@tauri-apps/api/window'; +import { isTauriAppPlatform } from '@/services/environment'; +import type { SystemSettings } from '@/types/settings'; + +/** + * Cross-window global-settings sync. + * + * On desktop the app runs multiple windows (one library + one per open book), + * and each keeps its own in-memory settings loaded once at window open. Global + * settings persist to a single shared `settings.json`, and every window writes + * the whole object on save. A window that loaded before the user customized a + * global setting therefore clobbers that change with its own stale (often + * default) value the next time it saves — e.g. a reader window reverting + * "Click to Paginate" back to the default on close (issue #4580). + * + * To keep windows consistent, the persisting window broadcasts its global + * setting blobs and every other window adopts them, so a later save no longer + * carries stale globals. Only `globalViewSettings` / `globalReadSettings` — + * the truly-global objects edited in the Settings dialog — are synced; every + * device/window-local field (filesystem paths, `lastOpenBooks`, sync cursors, + * screen brightness, ...) is left untouched on the receiving window. + */ +export const SETTINGS_SYNC_EVENT = 'global-settings-window-sync'; + +export interface SettingsSyncPayload { + /** Label of the window that persisted the change, so receivers ignore their own echo. */ + sourceLabel: string; + globalViewSettings: SystemSettings['globalViewSettings']; + globalReadSettings: SystemSettings['globalReadSettings']; +} + +/** + * Merge the global setting blobs broadcast by another window into this window's + * settings, preserving every device/window-local field on the local copy. + */ +export const mergeSyncedGlobalSettings = ( + local: SystemSettings, + payload: Pick, +): SystemSettings => ({ + ...local, + globalViewSettings: payload.globalViewSettings, + globalReadSettings: payload.globalReadSettings, +}); + +/** + * Broadcast this window's global settings to all other windows after a + * settings write. Fire-and-forget and a no-op off Tauri. + */ +export const broadcastGlobalSettings = async (settings: SystemSettings): Promise => { + if (!isTauriAppPlatform()) return; + if (!settings.globalViewSettings || !settings.globalReadSettings) return; + try { + const payload: SettingsSyncPayload = { + sourceLabel: getCurrentWindow().label, + globalViewSettings: settings.globalViewSettings, + globalReadSettings: settings.globalReadSettings, + }; + await emit(SETTINGS_SYNC_EVENT, payload); + } catch (err) { + console.warn('Failed to broadcast settings to other windows', err); + } +}; + +/** + * Subscribe to global-settings broadcasts from other windows. The callback is + * invoked only for events emitted by a different window. Returns an unlisten + * function (a no-op resolver off Tauri). + */ +export const subscribeSettingsSync = async ( + onReceive: (payload: SettingsSyncPayload) => void, +): Promise => { + if (!isTauriAppPlatform()) return () => {}; + const currentLabel = getCurrentWindow().label; + return listen(SETTINGS_SYNC_EVENT, ({ payload }) => { + if (!payload || payload.sourceLabel === currentLabel) return; + onReceive(payload); + }); +};