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) <noreply@anthropic.com>
This commit is contained in:
Huang Xin
2026-06-26 15:43:31 +08:00
committed by GitHub
parent 7544835fb8
commit 1558078391
6 changed files with 194 additions and 0 deletions
@@ -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> = {}): 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');
@@ -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> = {}): 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);
});
});
@@ -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) => {
@@ -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());
};
}, []);
};
@@ -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<SettingsState>((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 }),
@@ -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<SettingsSyncPayload, 'globalViewSettings' | 'globalReadSettings'>,
): 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<void> => {
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<UnlistenFn> => {
if (!isTauriAppPlatform()) return () => {};
const currentLabel = getCurrentWindow().label;
return listen<SettingsSyncPayload>(SETTINGS_SYNC_EVENT, ({ payload }) => {
if (!payload || payload.sourceLabel === currentLabel) return;
onReceive(payload);
});
};