From 7b92511f232fcb35597f59fddafa9e825722a545 Mon Sep 17 00:00:00 2001 From: sudacode Date: Mon, 26 Oct 2026 22:08:35 -0700 Subject: [PATCH] fix(plugins): ignore stale plugin panel close notices The overlay's modal-closed notice now carries the panel id, and the runtime only finishes the active panel when the ids match. A late notice for a replaced panel no longer aborts the newer one. --- src/core/services/ipc.test.ts | 8 ++++--- src/core/services/ipc.ts | 23 +++++++++++++------ src/main.ts | 6 ++--- src/main/main-wiring.test.ts | 2 +- src/main/runtime/plugin-panel-runtime.test.ts | 13 ++++++++++- src/main/runtime/plugin-panel-runtime.ts | 6 ++--- src/preload.ts | 4 ++-- src/renderer/modals/plugin-panel.ts | 3 ++- src/types/runtime.ts | 2 ++ 9 files changed, 46 insertions(+), 21 deletions(-) diff --git a/src/core/services/ipc.test.ts b/src/core/services/ipc.test.ts index f6d8192e..88381106 100644 --- a/src/core/services/ipc.test.ts +++ b/src/core/services/ipc.test.ts @@ -575,8 +575,8 @@ test('registerIpcHandlers ignores malformed fire-and-forget payloads', () => { const openedModals: unknown[] = []; registerIpcHandlers( { - onOverlayModalClosed: (modal) => { - closedModals.push(modal); + onOverlayModalClosed: (modal, _senderWindow, instanceId) => { + closedModals.push(instanceId === undefined ? modal : [modal, instanceId]); }, onOverlayModalOpened: (modal) => { openedModals.push(modal); @@ -637,7 +637,9 @@ test('registerIpcHandlers ignores malformed fire-and-forget payloads', () => { handlers.on.get(IPC_CHANNELS.command.overlayModalClosed)!({}, 'not-a-modal'); handlers.on.get(IPC_CHANNELS.command.overlayModalClosed)!({}, 'subsync'); handlers.on.get(IPC_CHANNELS.command.overlayModalClosed)!({}, 'kiku'); - assert.deepEqual(closedModals, ['subsync', 'kiku']); + handlers.on.get(IPC_CHANNELS.command.overlayModalClosed)!({}, 'plugin-panel', 4); + handlers.on.get(IPC_CHANNELS.command.overlayModalClosed)!({}, 'plugin-panel', 'bad'); + assert.deepEqual(closedModals, ['subsync', 'kiku', ['plugin-panel', 4], 'plugin-panel']); handlers.on.get(IPC_CHANNELS.command.overlayModalOpened)!({}, 'bad'); handlers.on.get(IPC_CHANNELS.command.overlayModalOpened)!({}, 'subsync'); diff --git a/src/core/services/ipc.ts b/src/core/services/ipc.ts index ae9cc6f8..4e22d4cd 100644 --- a/src/core/services/ipc.ts +++ b/src/core/services/ipc.ts @@ -50,6 +50,7 @@ export interface IpcServiceDeps { onOverlayModalClosed: ( modal: OverlayHostedModal, senderWindow: ElectronBrowserWindow | null, + instanceId?: number, ) => void; onOverlayModalOpened?: ( modal: OverlayHostedModal, @@ -317,6 +318,7 @@ export interface IpcDepsRuntimeOptions { onOverlayModalClosed: ( modal: OverlayHostedModal, senderWindow: ElectronBrowserWindow | null, + instanceId?: number, ) => void; onOverlayModalOpened?: ( modal: OverlayHostedModal, @@ -527,13 +529,20 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar }, ); - ipc.on(IPC_CHANNELS.command.overlayModalClosed, (event: unknown, modal: unknown) => { - const parsedModal = parseOverlayHostedModal(modal); - if (!parsedModal) return; - const senderWindow = - electron.BrowserWindow?.fromWebContents((event as IpcMainEvent).sender) ?? null; - deps.onOverlayModalClosed(parsedModal, senderWindow); - }); + ipc.on( + IPC_CHANNELS.command.overlayModalClosed, + (event: unknown, modal: unknown, instanceId: unknown) => { + const parsedModal = parseOverlayHostedModal(modal); + if (!parsedModal) return; + const senderWindow = + electron.BrowserWindow?.fromWebContents((event as IpcMainEvent).sender) ?? null; + deps.onOverlayModalClosed( + parsedModal, + senderWindow, + Number.isInteger(instanceId) ? (instanceId as number) : undefined, + ); + }, + ); ipc.on(IPC_CHANNELS.command.overlayModalOpened, (event: unknown, modal: unknown) => { const parsedModal = parseOverlayHostedModal(modal); if (!parsedModal) return; diff --git a/src/main.ts b/src/main.ts index 0088a5f3..149fc663 100644 --- a/src/main.ts +++ b/src/main.ts @@ -6024,12 +6024,12 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({ return false; } }, - onOverlayModalClosed: (modal, senderWindow) => { + onOverlayModalClosed: (modal, senderWindow, instanceId) => { if (modal === 'media-timing-review') { void mediaTimingReviewRuntime.dispose(); } - if (modal === 'plugin-panel') { - pluginPanelRuntime.handleModalClosed(); + if (modal === 'plugin-panel' && instanceId !== undefined) { + pluginPanelRuntime.handleModalClosed(instanceId); } if (modal === 'subtitle-sidebar' && senderWindow === overlayManager.getMainWindow()) { subtitleSidebarRequestedOpen = false; diff --git a/src/main/main-wiring.test.ts b/src/main/main-wiring.test.ts index 51953089..9de71900 100644 --- a/src/main/main-wiring.test.ts +++ b/src/main/main-wiring.test.ts @@ -303,7 +303,7 @@ test('subtitle sidebar open state is restored for replacement visible overlay wi /onOverlayModalOpened:\s*\(modal,\s*senderWindow\)\s*=>\s*\{(?[\s\S]*?)\n \},/, )?.groups?.body; const closedBlock = source.match( - /onOverlayModalClosed:\s*\(modal,\s*senderWindow\)\s*=>\s*\{(?[\s\S]*?)\n \},/, + /onOverlayModalClosed:\s*\(modal,\s*senderWindow(?:,\s*instanceId)?\)\s*=>\s*\{(?[\s\S]*?)\n \},/, )?.groups?.body; const depsBlock = source.match(/getSubtitleSidebarOpen:\s*\(\)\s*=>\s*(?[^\n,]+)/)?.groups ?.body; diff --git a/src/main/runtime/plugin-panel-runtime.test.ts b/src/main/runtime/plugin-panel-runtime.test.ts index 3e3f0629..9076d344 100644 --- a/src/main/runtime/plugin-panel-runtime.test.ts +++ b/src/main/runtime/plugin-panel-runtime.test.ts @@ -44,7 +44,7 @@ test('closing from the overlay aborts the handle and ignores later updates', asy await new Promise((resolve) => setImmediate(resolve)); const sentBefore = h.sent.length; - h.runtime.handleModalClosed(); + h.runtime.handleModalClosed(1); panel.update({ markdown: 'too late' }); assert.equal(panel.signal.aborted, true); @@ -52,6 +52,17 @@ test('closing from the overlay aborts the handle and ignores later updates', asy assert.equal(h.sent.length, sentBefore); }); +test('a late close acknowledgement for a replaced panel leaves the new panel open', () => { + const h = createHarness(Promise.resolve(true)); + const first = h.runtime.open({ title: 'One', markdown: '' }); + first.close(); + const second = h.runtime.open({ title: 'Two', markdown: '' }); + + h.runtime.handleModalClosed(1); + + assert.equal(second.signal.aborted, false); +}); + test('opening a new panel aborts the previous one', () => { const h = createHarness(Promise.resolve(true)); const first = h.runtime.open({ title: 'One', markdown: '' }); diff --git a/src/main/runtime/plugin-panel-runtime.ts b/src/main/runtime/plugin-panel-runtime.ts index 4994608f..36894ece 100644 --- a/src/main/runtime/plugin-panel-runtime.ts +++ b/src/main/runtime/plugin-panel-runtime.ts @@ -103,9 +103,9 @@ export function createPluginPanelRuntime(deps: { }; }, - /** The user closed the panel in the overlay. */ - handleModalClosed(): void { - if (active) finish(active); + /** The overlay closed `panelId`; a late notice for an already replaced panel is ignored. */ + handleModalClosed(panelId: number): void { + if (active?.panelId === panelId) finish(active); }, }; } diff --git a/src/preload.ts b/src/preload.ts index 45e0f6e9..38473fe5 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -532,8 +532,8 @@ const electronAPI: ElectronAPI = { mediaId, direction, ), - notifyOverlayModalClosed: (modal) => { - ipcRenderer.send(IPC_CHANNELS.command.overlayModalClosed, modal); + notifyOverlayModalClosed: (modal, instanceId) => { + ipcRenderer.send(IPC_CHANNELS.command.overlayModalClosed, modal, instanceId); }, notifyOverlayModalOpened: (modal) => { ipcRenderer.send(IPC_CHANNELS.command.overlayModalOpened, modal); diff --git a/src/renderer/modals/plugin-panel.ts b/src/renderer/modals/plugin-panel.ts index 53a255fc..66f3d696 100644 --- a/src/renderer/modals/plugin-panel.ts +++ b/src/renderer/modals/plugin-panel.ts @@ -60,12 +60,13 @@ export function createPluginPanelModal( function closePluginPanelModal(): void { if (!ctx.state.pluginPanelModalOpen) return; + const closedPanelId = panelId ?? undefined; ctx.state.pluginPanelModalOpen = false; panelId = null; options.syncSettingsModalSubtitleSuppression(); ctx.dom.pluginPanelModal.classList.add('hidden'); ctx.dom.pluginPanelModal.setAttribute('aria-hidden', 'true'); - window.electronAPI.notifyOverlayModalClosed('plugin-panel'); + window.electronAPI.notifyOverlayModalClosed('plugin-panel', closedPanelId); if (!ctx.state.isOverSubtitle && !options.modalStateReader.isAnyModalOpen()) { ctx.dom.overlay.classList.remove('interactive'); } diff --git a/src/types/runtime.ts b/src/types/runtime.ts index 425037dc..599237bc 100644 --- a/src/types/runtime.ts +++ b/src/types/runtime.ts @@ -625,6 +625,8 @@ export interface ElectronAPI { | 'character-dictionary' | 'changelog' | 'plugin-panel', + /** Which instance closed, for modals that swap content in place (the plugin panel's id). */ + instanceId?: number, ) => void; notifyOverlayModalOpened: ( modal: