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); + }); +};