diff --git a/electron/main.js b/electron/main.js index ea28615..d2dcd59 100644 --- a/electron/main.js +++ b/electron/main.js @@ -370,6 +370,15 @@ function registerIpcHandlers() { openPetdexWindow(); }); + ipcMain.on('pet:state-changed', (event) => { + if (settingsWindow === undefined + || settingsWindow.isDestroyed() + || event.sender.id !== settingsWindow.webContents.id) return; + const currentPetWindow = getCurrentPetWindow(); + if (currentPetWindow === undefined) return; + currentPetWindow.webContents.send('pet:state-changed'); + }); + ipcMain.handle('pet:manual-dock', () => { if (!EDGE_DOCKING_ENABLED) return; const currentPetWindow = getCurrentPetWindow(); diff --git a/electron/main.test.js b/electron/main.test.js index 2aa49f9..68ba0e5 100644 --- a/electron/main.test.js +++ b/electron/main.test.js @@ -227,3 +227,26 @@ describe('Electron process lifecycle', () => { expect(settingsWindows).toHaveLength(2); }); }); + +describe('renderer state synchronization', () => { + it('forwards state changes from settings to the current pet window only', async () => { + const openSettings = electron.ipcMain.handle.mock.calls + .find(([channel]) => channel === 'pet:open-settings')[1]; + await openSettings(); + const settings = electron.windows + .filter(({ options }) => options.title === 'Codex Pet Pause 设置') + .at(-1); + const currentPet = electron.windows + .filter(({ options, destroyed }) => options.transparent === true && !destroyed) + .at(-1); + const forwardState = electron.ipcMain.on.mock.calls + .find(([channel]) => channel === 'pet:state-changed')[1]; + + forwardState({ sender: settings.webContents }); + expect(currentPet.webContents.send).toHaveBeenCalledWith('pet:state-changed'); + + currentPet.webContents.send.mockClear(); + forwardState({ sender: currentPet.webContents }); + expect(currentPet.webContents.send).not.toHaveBeenCalled(); + }); +}); diff --git a/electron/preload.js b/electron/preload.js index 6575769..41096c3 100644 --- a/electron/preload.js +++ b/electron/preload.js @@ -8,6 +8,12 @@ contextBridge.exposeInMainWorld('petShell', { ipcRenderer.on('pet:petdex-import', listener); return () => ipcRenderer.removeListener('pet:petdex-import', listener); }, + notifyStateChanged: () => ipcRenderer.send('pet:state-changed'), + onStateChanged: (callback) => { + const listener = () => callback(); + ipcRenderer.on('pet:state-changed', listener); + return () => ipcRenderer.removeListener('pet:state-changed', listener); + }, dockNow: () => ipcRenderer.invoke('pet:manual-dock'), dragWindowTo: (x, y) => ipcRenderer.send('pet:drag-window', { x, y }), showContextMenu: (x, y) => ipcRenderer.send('pet:show-context-menu', { x, y }), diff --git a/package-lock.json b/package-lock.json index 7487a2f..d17b5c0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "codex-pet-pause", - "version": "0.2.5", + "version": "0.2.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "codex-pet-pause", - "version": "0.2.5", + "version": "0.2.6", "license": "MIT", "dependencies": { "@zip.js/zip.js": "^2.8.34", diff --git a/package.json b/package.json index dd1385f..765a22d 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "codex-pet-pause", "private": true, - "version": "0.2.5", + "version": "0.2.6", "description": "A playful, local-first break reminder PWA with interactive and Codex-compatible pets.", "license": "MIT", "repository": { diff --git a/src/infrastructure/rendererSynchronization.test.ts b/src/infrastructure/rendererSynchronization.test.ts new file mode 100644 index 0000000..2e9a220 --- /dev/null +++ b/src/infrastructure/rendererSynchronization.test.ts @@ -0,0 +1,32 @@ +import { afterEach, describe, expect, test, vi } from 'vitest'; +import { createRendererSynchronization } from './rendererSynchronization'; + +describe('renderer synchronization', () => { + afterEach(() => { + delete window.petShell; + }); + + test('uses native Electron IPC when browser channels are unreliable', () => { + const notifyStateChanged = vi.fn(); + const unsubscribe = vi.fn(); + let receiveNative: (() => void) | undefined; + window.petShell = { + notifyStateChanged, + onStateChanged(callback) { + receiveNative = callback; + return unsubscribe; + }, + }; + const synchronization = createRendererSynchronization(); + const listener = vi.fn(); + synchronization.subscribe(listener); + + synchronization.notify(); + expect(notifyStateChanged).toHaveBeenCalledOnce(); + receiveNative?.(); + expect(listener).toHaveBeenCalledOnce(); + + synchronization.close(); + expect(unsubscribe).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/infrastructure/rendererSynchronization.ts b/src/infrastructure/rendererSynchronization.ts index b50bebc..7d49052 100644 --- a/src/infrastructure/rendererSynchronization.ts +++ b/src/infrastructure/rendererSynchronization.ts @@ -31,6 +31,7 @@ export function createRendererSynchronization(): RendererSynchronization { let closed = false; let lastToken: string | undefined; let channel: BroadcastChannel | undefined; + let unsubscribeNative = (): void => undefined; try { if (typeof globalThis.BroadcastChannel === 'function') { @@ -57,6 +58,12 @@ export function createRendererSynchronization(): RendererSynchronization { if (isSyncMessage(event.data)) receive(event.data.token); }; } + try { + unsubscribeNative = window.petShell?.onStateChanged?.(() => receive()) + ?? (() => undefined); + } catch { + unsubscribeNative = () => undefined; + } return { notify(): void { @@ -73,6 +80,11 @@ export function createRendererSynchronization(): RendererSynchronization { } catch { // Storage events remain available when BroadcastChannel delivery fails. } + try { + window.petShell?.notifyStateChanged?.(); + } catch { + // Browser synchronization remains available when native IPC delivery fails. + } }, subscribe(listener): () => void { @@ -86,6 +98,7 @@ export function createRendererSynchronization(): RendererSynchronization { closed = true; listeners.clear(); window.removeEventListener('storage', onStorage); + unsubscribeNative(); if (channel !== undefined) { channel.onmessage = null; channel.close(); diff --git a/src/types/electron-api.d.ts b/src/types/electron-api.d.ts index 47f9f7f..5733d6b 100644 --- a/src/types/electron-api.d.ts +++ b/src/types/electron-api.d.ts @@ -7,6 +7,8 @@ declare global { | { type: 'archive'; name: string; bytes: ArrayBuffer } | { type: 'error' } ) => void) => () => void; + notifyStateChanged?: () => void; + onStateChanged?: (callback: () => void) => () => void; dockNow?: () => void; dragWindowTo?: (x: number, y: number) => void; showContextMenu?: (x?: number, y?: number) => void;