diff --git a/apps/desktop/src/main/import/apple-journal/apple-journal-importer.test.ts b/apps/desktop/src/main/import/apple-journal/apple-journal-importer.test.ts index c7074c0fb..28cb54be4 100644 --- a/apps/desktop/src/main/import/apple-journal/apple-journal-importer.test.ts +++ b/apps/desktop/src/main/import/apple-journal/apple-journal-importer.test.ts @@ -15,7 +15,9 @@ import { createNoteDerivedStateProjector } from '../../projections/projectors/no vi.mock('electron', () => { const send = vi.fn() return { - BrowserWindow: { getAllWindows: vi.fn(() => [{ webContents: { send } }]) }, + BrowserWindow: { + getAllWindows: vi.fn(() => [{ isDestroyed: () => false, webContents: { send } }]) + }, shell: { openPath: vi.fn(() => Promise.resolve('')), showItemInFolder: vi.fn() } } }) diff --git a/apps/desktop/src/main/import/apple-notes/apple-notes-importer.test.ts b/apps/desktop/src/main/import/apple-notes/apple-notes-importer.test.ts index 47c203b9e..655abc012 100644 --- a/apps/desktop/src/main/import/apple-notes/apple-notes-importer.test.ts +++ b/apps/desktop/src/main/import/apple-notes/apple-notes-importer.test.ts @@ -26,7 +26,9 @@ import { createNoteDerivedStateProjector } from '../../projections/projectors/no vi.mock('electron', () => { const send = vi.fn() return { - BrowserWindow: { getAllWindows: vi.fn(() => [{ webContents: { send } }]) }, + BrowserWindow: { + getAllWindows: vi.fn(() => [{ isDestroyed: () => false, webContents: { send } }]) + }, shell: { openPath: vi.fn(() => Promise.resolve('')), showItemInFolder: vi.fn() } } }) diff --git a/apps/desktop/src/main/import/bear/bear-importer.test.ts b/apps/desktop/src/main/import/bear/bear-importer.test.ts index abf3da8f2..5431b95dc 100644 --- a/apps/desktop/src/main/import/bear/bear-importer.test.ts +++ b/apps/desktop/src/main/import/bear/bear-importer.test.ts @@ -17,7 +17,9 @@ import { createNoteDerivedStateProjector } from '../../projections/projectors/no vi.mock('electron', () => { const send = vi.fn() return { - BrowserWindow: { getAllWindows: vi.fn(() => [{ webContents: { send } }]) }, + BrowserWindow: { + getAllWindows: vi.fn(() => [{ isDestroyed: () => false, webContents: { send } }]) + }, shell: { openPath: vi.fn(() => Promise.resolve('')), showItemInFolder: vi.fn() } } }) diff --git a/apps/desktop/src/main/import/csv/csv-importer.test.ts b/apps/desktop/src/main/import/csv/csv-importer.test.ts index 68ec041b2..64c3f4a41 100644 --- a/apps/desktop/src/main/import/csv/csv-importer.test.ts +++ b/apps/desktop/src/main/import/csv/csv-importer.test.ts @@ -15,7 +15,9 @@ import { createNoteDerivedStateProjector } from '../../projections/projectors/no vi.mock('electron', () => { const send = vi.fn() return { - BrowserWindow: { getAllWindows: vi.fn(() => [{ webContents: { send } }]) }, + BrowserWindow: { + getAllWindows: vi.fn(() => [{ isDestroyed: () => false, webContents: { send } }]) + }, shell: { openPath: vi.fn(() => Promise.resolve('')), showItemInFolder: vi.fn() } } }) diff --git a/apps/desktop/src/main/import/evernote/evernote-importer.test.ts b/apps/desktop/src/main/import/evernote/evernote-importer.test.ts index 4c0b82b6b..68f5794c8 100644 --- a/apps/desktop/src/main/import/evernote/evernote-importer.test.ts +++ b/apps/desktop/src/main/import/evernote/evernote-importer.test.ts @@ -15,7 +15,9 @@ import { createNoteDerivedStateProjector } from '../../projections/projectors/no vi.mock('electron', () => { const send = vi.fn() return { - BrowserWindow: { getAllWindows: vi.fn(() => [{ webContents: { send } }]) }, + BrowserWindow: { + getAllWindows: vi.fn(() => [{ isDestroyed: () => false, webContents: { send } }]) + }, shell: { openPath: vi.fn(() => Promise.resolve('')), showItemInFolder: vi.fn() } } }) diff --git a/apps/desktop/src/main/import/google-keep/google-keep-importer.test.ts b/apps/desktop/src/main/import/google-keep/google-keep-importer.test.ts index 2af1c99db..acf35c011 100644 --- a/apps/desktop/src/main/import/google-keep/google-keep-importer.test.ts +++ b/apps/desktop/src/main/import/google-keep/google-keep-importer.test.ts @@ -15,7 +15,9 @@ import { createNoteDerivedStateProjector } from '../../projections/projectors/no vi.mock('electron', () => { const send = vi.fn() return { - BrowserWindow: { getAllWindows: vi.fn(() => [{ webContents: { send } }]) }, + BrowserWindow: { + getAllWindows: vi.fn(() => [{ isDestroyed: () => false, webContents: { send } }]) + }, shell: { openPath: vi.fn(() => Promise.resolve('')), showItemInFolder: vi.fn() } } }) diff --git a/apps/desktop/src/main/import/html/html-importer.test.ts b/apps/desktop/src/main/import/html/html-importer.test.ts index 20071b457..b41a319bf 100644 --- a/apps/desktop/src/main/import/html/html-importer.test.ts +++ b/apps/desktop/src/main/import/html/html-importer.test.ts @@ -15,7 +15,9 @@ import { createNoteDerivedStateProjector } from '../../projections/projectors/no vi.mock('electron', () => { const send = vi.fn() return { - BrowserWindow: { getAllWindows: vi.fn(() => [{ webContents: { send } }]) }, + BrowserWindow: { + getAllWindows: vi.fn(() => [{ isDestroyed: () => false, webContents: { send } }]) + }, shell: { openPath: vi.fn(() => Promise.resolve('')), showItemInFolder: vi.fn() } } }) diff --git a/apps/desktop/src/main/import/import-context.test.ts b/apps/desktop/src/main/import/import-context.test.ts index 53ee69cd5..dfb6cb718 100644 --- a/apps/desktop/src/main/import/import-context.test.ts +++ b/apps/desktop/src/main/import/import-context.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' const send = vi.fn() vi.mock('electron', () => ({ - BrowserWindow: { getAllWindows: () => [{ webContents: { send } }] } + BrowserWindow: { getAllWindows: () => [{ isDestroyed: () => false, webContents: { send } }] } })) import { createImportContext } from './import-context' diff --git a/apps/desktop/src/main/import/import-context.ts b/apps/desktop/src/main/import/import-context.ts index 58f59267e..05f3f2179 100644 --- a/apps/desktop/src/main/import/import-context.ts +++ b/apps/desktop/src/main/import/import-context.ts @@ -1,4 +1,4 @@ -import { BrowserWindow } from 'electron' +import { broadcastToAllWindows } from '../lib/window-broadcast' import { ImportChannels } from '@memry/contracts/import-channels' import type { ImportMessage } from '@memry/contracts/import-channels' import { createLogger } from '../lib/logger' @@ -38,9 +38,7 @@ export function createImportContext(importId: string, signal: AbortSignal): Impo done, summary: done ? toSummary() : undefined } - for (const win of BrowserWindow.getAllWindows()) { - win.webContents.send(ImportChannels.events.PROGRESS, payload) - } + broadcastToAllWindows(ImportChannels.events.PROGRESS, payload) } return { diff --git a/apps/desktop/src/main/import/markdown/markdown-importer.test.ts b/apps/desktop/src/main/import/markdown/markdown-importer.test.ts index d0e4af20a..beb762fd0 100644 --- a/apps/desktop/src/main/import/markdown/markdown-importer.test.ts +++ b/apps/desktop/src/main/import/markdown/markdown-importer.test.ts @@ -16,7 +16,9 @@ import { createNoteDerivedStateProjector } from '../../projections/projectors/no vi.mock('electron', () => { const send = vi.fn() return { - BrowserWindow: { getAllWindows: vi.fn(() => [{ webContents: { send } }]) }, + BrowserWindow: { + getAllWindows: vi.fn(() => [{ isDestroyed: () => false, webContents: { send } }]) + }, shell: { openPath: vi.fn(() => Promise.resolve('')), showItemInFolder: vi.fn() } } }) diff --git a/apps/desktop/src/main/import/notion/notion-importer.test.ts b/apps/desktop/src/main/import/notion/notion-importer.test.ts index 403d9c150..d2d8ef766 100644 --- a/apps/desktop/src/main/import/notion/notion-importer.test.ts +++ b/apps/desktop/src/main/import/notion/notion-importer.test.ts @@ -15,7 +15,9 @@ import { createNoteDerivedStateProjector } from '../../projections/projectors/no vi.mock('electron', () => { const send = vi.fn() return { - BrowserWindow: { getAllWindows: vi.fn(() => [{ webContents: { send } }]) }, + BrowserWindow: { + getAllWindows: vi.fn(() => [{ isDestroyed: () => false, webContents: { send } }]) + }, shell: { openPath: vi.fn(() => Promise.resolve('')), showItemInFolder: vi.fn() } } }) diff --git a/apps/desktop/src/main/import/onenote/onenote-importer.test.ts b/apps/desktop/src/main/import/onenote/onenote-importer.test.ts index 22157fef6..1e515a793 100644 --- a/apps/desktop/src/main/import/onenote/onenote-importer.test.ts +++ b/apps/desktop/src/main/import/onenote/onenote-importer.test.ts @@ -24,7 +24,9 @@ import { createNoteDerivedStateProjector } from '../../projections/projectors/no vi.mock('electron', () => { const send = vi.fn() return { - BrowserWindow: { getAllWindows: vi.fn(() => [{ webContents: { send } }]) }, + BrowserWindow: { + getAllWindows: vi.fn(() => [{ isDestroyed: () => false, webContents: { send } }]) + }, shell: { openPath: vi.fn(() => Promise.resolve('')), showItemInFolder: vi.fn() } } }) diff --git a/apps/desktop/src/main/import/roam/roam-importer.test.ts b/apps/desktop/src/main/import/roam/roam-importer.test.ts index 6a6b98777..b82a0895a 100644 --- a/apps/desktop/src/main/import/roam/roam-importer.test.ts +++ b/apps/desktop/src/main/import/roam/roam-importer.test.ts @@ -15,7 +15,9 @@ import { createNoteDerivedStateProjector } from '../../projections/projectors/no vi.mock('electron', () => { const send = vi.fn() return { - BrowserWindow: { getAllWindows: vi.fn(() => [{ webContents: { send } }]) }, + BrowserWindow: { + getAllWindows: vi.fn(() => [{ isDestroyed: () => false, webContents: { send } }]) + }, shell: { openPath: vi.fn(() => Promise.resolve('')), showItemInFolder: vi.fn() } } }) diff --git a/apps/desktop/src/main/inbox/review-notification.test.ts b/apps/desktop/src/main/inbox/review-notification.test.ts index 1d3ff6989..dd20003da 100644 --- a/apps/desktop/src/main/inbox/review-notification.test.ts +++ b/apps/desktop/src/main/inbox/review-notification.test.ts @@ -1,19 +1,20 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' -const { show, on, NotificationMock, supportedRef } = vi.hoisted(() => { +const { show, on, NotificationMock, supportedRef, windowsRef } = vi.hoisted(() => { const show = vi.fn() const on = vi.fn() const supportedRef = { value: true } + const windowsRef: { value: unknown[] } = { value: [] } const NotificationMock = vi.fn().mockImplementation(function (this: Record) { this.on = on this.show = show }) - return { show, on, NotificationMock, supportedRef } + return { show, on, NotificationMock, supportedRef, windowsRef } }) vi.mock('electron', () => ({ Notification: Object.assign(NotificationMock, { isSupported: () => supportedRef.value }), - BrowserWindow: { getAllWindows: () => [] } + BrowserWindow: { getAllWindows: () => windowsRef.value } })) vi.mock('../lib/main-i18n', () => ({ @@ -22,14 +23,56 @@ vi.mock('../lib/main-i18n', () => ({ }) })) +import { InboxChannels } from '@memry/contracts/inbox-channels' import { sendTestReviewNotification, showReviewNotification } from './review-notification' +/** + * Real Electron throws 'Object has been destroyed' on any access to a destroyed + * window, and getAllWindows() can still list one (splash, quick capture, + * print/export). + */ +function makeDestroyedWindow(): unknown { + return { + isDestroyed: () => true, + isMinimized(): never { + throw new Error('Object has been destroyed') + }, + focus(): never { + throw new Error('Object has been destroyed') + }, + get webContents(): never { + throw new Error('Object has been destroyed') + } + } +} + +function makeLiveWindow(): { + win: unknown + send: ReturnType + focus: ReturnType +} { + const send = vi.fn() + const focus = vi.fn() + return { + win: { + isDestroyed: () => false, + isMinimized: () => false, + restore: vi.fn(), + focus, + webContents: { send } + }, + send, + focus + } +} + describe('review-notification', () => { beforeEach(() => { show.mockClear() on.mockClear() NotificationMock.mockClear() supportedRef.value = true + windowsRef.value = [] }) it('sendTestReviewNotification shows the real title with the test body', () => { @@ -63,4 +106,19 @@ describe('review-notification', () => { expect.objectContaining({ body: 'notification.inboxReview.body:4' }) ) }) + + it('routes the click past a destroyed window to the first live one', () => { + const live = makeLiveWindow() + showReviewNotification(1) + + const clickHandler = on.mock.calls.find(([event]) => event === 'click')?.[1] as () => void + expect(clickHandler).toBeTypeOf('function') + + // The window dies between the banner being shown and the user clicking it. + windowsRef.value = [makeDestroyedWindow(), live.win] + + expect(() => clickHandler()).not.toThrow() + expect(live.focus).toHaveBeenCalled() + expect(live.send).toHaveBeenCalledWith(InboxChannels.events.REVIEW_OPEN, {}) + }) }) diff --git a/apps/desktop/src/main/inbox/review-notification.ts b/apps/desktop/src/main/inbox/review-notification.ts index 10d486875..56077eb99 100644 --- a/apps/desktop/src/main/inbox/review-notification.ts +++ b/apps/desktop/src/main/inbox/review-notification.ts @@ -19,15 +19,20 @@ import { createLogger } from '../lib/logger' const logger = createLogger('InboxReview') -/** Focus the app and route it to the inbox — the review notification's click action. */ +/** + * Focus the app and route it to the inbox — the review notification's click action. + * + * Takes the first *live* window rather than `getAllWindows()[0]`: short-lived + * windows (splash, quick capture, print/export) can still be listed after + * destruction, and any access to one throws "Object has been destroyed" — which + * would kill the click handler with nothing focused and nothing navigated. + */ function openInboxOnClick(): void { - const windows = BrowserWindow.getAllWindows() - if (windows.length > 0) { - const win = windows[0] - if (win.isMinimized()) win.restore() - win.focus() - win.webContents.send(InboxChannels.events.REVIEW_OPEN, {}) - } + const win = BrowserWindow.getAllWindows().find((candidate) => !candidate.isDestroyed()) + if (!win) return + if (win.isMinimized()) win.restore() + win.focus() + win.webContents.send(InboxChannels.events.REVIEW_OPEN, {}) } /** diff --git a/apps/desktop/src/main/ipc/ai-inline-handlers.test.ts b/apps/desktop/src/main/ipc/ai-inline-handlers.test.ts index 63fdf6031..b8c11e493 100644 --- a/apps/desktop/src/main/ipc/ai-inline-handlers.test.ts +++ b/apps/desktop/src/main/ipc/ai-inline-handlers.test.ts @@ -74,7 +74,9 @@ describe('AI inline IPC handlers', () => { mocks.getSetting.mockReturnValue( JSON.stringify({ enabled: true, provider: 'openai', apiKey: 'sk-real', model: 'gpt-4o-mini' }) ) - mocks.BrowserWindow.getAllWindows.mockReturnValue([{ webContents: mocks.webContents }]) + mocks.BrowserWindow.getAllWindows.mockReturnValue([ + { isDestroyed: () => false, webContents: mocks.webContents } + ]) mocks.getServerPort.mockReturnValue(3434) mocks.startChatServer.mockResolvedValue(4545) mocks.stopChatServer.mockResolvedValue(undefined) diff --git a/apps/desktop/src/main/ipc/ai-inline-handlers.ts b/apps/desktop/src/main/ipc/ai-inline-handlers.ts index 4f3625101..5ed0a4528 100644 --- a/apps/desktop/src/main/ipc/ai-inline-handlers.ts +++ b/apps/desktop/src/main/ipc/ai-inline-handlers.ts @@ -1,4 +1,5 @@ -import { ipcMain, BrowserWindow } from 'electron' +import { ipcMain } from 'electron' +import { broadcastToAllWindows } from '../lib/window-broadcast' import { AIInlineChannels, AI_INLINE_SETTINGS_DEFAULTS } from '@memry/contracts/ai-inline-channels' import type { AIInlineSettings } from '@memry/contracts/ai-inline-channels' @@ -62,11 +63,9 @@ export function registerAIInlineHandlers(): void { const updated = { ...current, ...updates } setSetting(db, SETTINGS_KEY, JSON.stringify(updated)) - BrowserWindow.getAllWindows().forEach((win) => { - win.webContents.send(AIInlineChannels.events.SERVER_READY, { - key: SETTINGS_KEY, - value: maskApiKey(updated) - }) + broadcastToAllWindows(AIInlineChannels.events.SERVER_READY, { + key: SETTINGS_KEY, + value: maskApiKey(updated) }) return { success: true } diff --git a/apps/desktop/src/main/ipc/auth-device-handlers.test.ts b/apps/desktop/src/main/ipc/auth-device-handlers.test.ts index 3629ae798..710e07eba 100644 --- a/apps/desktop/src/main/ipc/auth-device-handlers.test.ts +++ b/apps/desktop/src/main/ipc/auth-device-handlers.test.ts @@ -340,7 +340,7 @@ describe('auth-device handlers', () => { // then clipboard polling is started - verified by advancing timers const mockWebContents = { send: vi.fn() } - const mockWindow = { webContents: mockWebContents } + const mockWindow = { isDestroyed: () => false, webContents: mockWebContents } mockGetAllWindows.mockReturnValue([mockWindow]) mockClipboardReadText.mockReturnValue('123456') @@ -369,7 +369,9 @@ describe('auth-device handlers', () => { }) const mockWebContents = { send: vi.fn() } - mockGetAllWindows.mockReturnValue([{ webContents: mockWebContents }]) + mockGetAllWindows.mockReturnValue([ + { isDestroyed: () => false, webContents: mockWebContents } + ]) mockClipboardReadText.mockReturnValue('654321') vi.advanceTimersByTime(2000) @@ -385,7 +387,9 @@ describe('auth-device handlers', () => { await invokeHandler(SYNC_CHANNELS.AUTH_REQUEST_OTP, { email: 'user@example.com' }) const mockWebContents = { send: vi.fn() } - mockGetAllWindows.mockReturnValue([{ webContents: mockWebContents }]) + mockGetAllWindows.mockReturnValue([ + { isDestroyed: () => false, webContents: mockWebContents } + ]) mockClipboardReadText.mockReturnValue('not-a-code') // #when @@ -405,7 +409,9 @@ describe('auth-device handlers', () => { vi.advanceTimersByTime(10 * 60 * 1000 + 1000) const mockWebContents = { send: vi.fn() } - mockGetAllWindows.mockReturnValue([{ webContents: mockWebContents }]) + mockGetAllWindows.mockReturnValue([ + { isDestroyed: () => false, webContents: mockWebContents } + ]) mockClipboardReadText.mockReturnValue('123456') vi.advanceTimersByTime(2000) @@ -801,7 +807,7 @@ describe('auth-device handlers', () => { it('removes remote devices locally without revoking the current renderer, and tolerates server 404s', async () => { registerAuthDeviceHandlers() const send = vi.fn() - mockGetAllWindows.mockReturnValue([{ webContents: { send } }]) + mockGetAllWindows.mockReturnValue([{ isDestroyed: () => false, webContents: { send } }]) await expect( invokeHandler(SYNC_CHANNELS.REMOVE_DEVICE, { deviceId: 'dev-remote' }) @@ -837,7 +843,7 @@ describe('auth-device handlers', () => { it('renames a device locally and broadcasts the new name', async () => { registerAuthDeviceHandlers() const send = vi.fn() - mockGetAllWindows.mockReturnValue([{ webContents: { send } }]) + mockGetAllWindows.mockReturnValue([{ isDestroyed: () => false, webContents: { send } }]) await expect( invokeHandler(SYNC_CHANNELS.RENAME_DEVICE, { diff --git a/apps/desktop/src/main/ipc/auth-device-handlers.ts b/apps/desktop/src/main/ipc/auth-device-handlers.ts index 32792f5c8..b701ef562 100644 --- a/apps/desktop/src/main/ipc/auth-device-handlers.ts +++ b/apps/desktop/src/main/ipc/auth-device-handlers.ts @@ -1,4 +1,5 @@ -import { BrowserWindow, clipboard, ipcMain } from 'electron' +import { clipboard, ipcMain } from 'electron' +import { broadcastToAllWindows } from '../lib/window-broadcast' import { syncDevices } from '@memry/db-schema/schema/sync-devices' import { KEYCHAIN_ENTRIES } from '@memry/contracts/crypto' @@ -194,10 +195,7 @@ const startOtpClipboardDetection = (): void => { lastClipboardValue = text if (OTP_PATTERN.test(text)) { - const windows = BrowserWindow.getAllWindows() - for (const win of windows) { - win.webContents.send(SYNC_EVENTS.OTP_DETECTED, { code: text }) - } + broadcastToAllWindows(SYNC_EVENTS.OTP_DETECTED, { code: text }) } }, OTP_CLIPBOARD_POLL_MS) @@ -510,12 +508,10 @@ export function registerAuthDeviceHandlers(): void { .run() } - for (const win of BrowserWindow.getAllWindows()) { - win.webContents.send(SYNC_EVENTS.DEVICE_RENAMED, { - deviceId: input.deviceId, - name: input.newName - }) - } + broadcastToAllWindows(SYNC_EVENTS.DEVICE_RENAMED, { + deviceId: input.deviceId, + name: input.newName + }) logger.info(`Device renamed: ${input.deviceId} → ${input.newName}`) return { success: true } diff --git a/apps/desktop/src/main/ipc/auth-oauth-handlers.test.ts b/apps/desktop/src/main/ipc/auth-oauth-handlers.test.ts index 214839d93..28cb61c13 100644 --- a/apps/desktop/src/main/ipc/auth-oauth-handlers.test.ts +++ b/apps/desktop/src/main/ipc/auth-oauth-handlers.test.ts @@ -201,7 +201,7 @@ describe('auth-oauth handlers', () => { describe('AUTH_INIT_OAUTH', () => { it('opens the provider URL and relays successful loopback callbacks', async () => { const send = vi.fn() - mockGetAllWindows.mockReturnValue([{ webContents: { send } }]) + mockGetAllWindows.mockReturnValue([{ isDestroyed: () => false, webContents: { send } }]) registerAuthOAuthHandlers() const result = await invokeHandler(SYNC_CHANNELS.AUTH_INIT_OAUTH, { provider: 'google' }) @@ -247,7 +247,7 @@ describe('auth-oauth handlers', () => { it('relays OAuth errors and rejects malformed provider responses', async () => { const send = vi.fn() - mockGetAllWindows.mockReturnValue([{ webContents: { send } }]) + mockGetAllWindows.mockReturnValue([{ isDestroyed: () => false, webContents: { send } }]) registerAuthOAuthHandlers() await invokeHandler(SYNC_CHANNELS.AUTH_INIT_OAUTH, { provider: 'google' }) diff --git a/apps/desktop/src/main/ipc/auth-oauth-handlers.ts b/apps/desktop/src/main/ipc/auth-oauth-handlers.ts index df9b5432f..acdf3beb9 100644 --- a/apps/desktop/src/main/ipc/auth-oauth-handlers.ts +++ b/apps/desktop/src/main/ipc/auth-oauth-handlers.ts @@ -1,4 +1,5 @@ -import { BrowserWindow, ipcMain, shell } from 'electron' +import { ipcMain, shell } from 'electron' +import { broadcastToAllWindows } from '../lib/window-broadcast' import http from 'node:http' import https from 'node:https' @@ -185,9 +186,7 @@ export function registerAuthOAuthHandlers(): void { const cbState = reqUrl.searchParams.get('state') if (cbState) oauthSessions.delete(cbState) - for (const win of BrowserWindow.getAllWindows()) { - win.webContents.send(SYNC_EVENTS.OAUTH_ERROR, { error: oauthError }) - } + broadcastToAllWindows(SYNC_EVENTS.OAUTH_ERROR, { error: oauthError }) shutdownLoopbackServer() return @@ -200,9 +199,7 @@ export function registerAuthOAuthHandlers(): void { res.end(SUCCESS_HTML) if (code && cbState) { - for (const win of BrowserWindow.getAllWindows()) { - win.webContents.send(SYNC_EVENTS.OAUTH_CALLBACK, { code, state: cbState }) - } + broadcastToAllWindows(SYNC_EVENTS.OAUTH_CALLBACK, { code, state: cbState }) } shutdownLoopbackServer() diff --git a/apps/desktop/src/main/ipc/bookmarks-handlers.test.ts b/apps/desktop/src/main/ipc/bookmarks-handlers.test.ts index ecf3eaa38..b083bf1c1 100644 --- a/apps/desktop/src/main/ipc/bookmarks-handlers.test.ts +++ b/apps/desktop/src/main/ipc/bookmarks-handlers.test.ts @@ -24,7 +24,7 @@ vi.mock('electron', () => ({ }) }, BrowserWindow: { - getAllWindows: vi.fn(() => [{ webContents: { send: mockSend } }]) + getAllWindows: vi.fn(() => [{ isDestroyed: () => false, webContents: { send: mockSend } }]) } })) diff --git a/apps/desktop/src/main/ipc/bookmarks-handlers.ts b/apps/desktop/src/main/ipc/bookmarks-handlers.ts index 5d065fdd3..379a0f8d8 100644 --- a/apps/desktop/src/main/ipc/bookmarks-handlers.ts +++ b/apps/desktop/src/main/ipc/bookmarks-handlers.ts @@ -5,7 +5,8 @@ * @module ipc/bookmarks-handlers */ -import { ipcMain, BrowserWindow } from 'electron' +import { ipcMain } from 'electron' +import { broadcastToAllWindows } from '../lib/window-broadcast' import { BookmarksChannels, BookmarkItemTypes, @@ -36,9 +37,7 @@ import { getMainI18n } from '../lib/main-i18n' * Emit bookmark event to all windows */ function emitBookmarkEvent(channel: string, data: unknown): void { - BrowserWindow.getAllWindows().forEach((win) => { - win.webContents.send(channel, data) - }) + broadcastToAllWindows(channel, data) } /** diff --git a/apps/desktop/src/main/ipc/calendar-handlers.test.ts b/apps/desktop/src/main/ipc/calendar-handlers.test.ts index 0c34d6683..7bcb765c3 100644 --- a/apps/desktop/src/main/ipc/calendar-handlers.test.ts +++ b/apps/desktop/src/main/ipc/calendar-handlers.test.ts @@ -53,7 +53,9 @@ vi.mock('electron', () => ({ }) }, BrowserWindow: { - getAllWindows: vi.fn(() => [{ webContents: { send: webContentsSend } }]) + getAllWindows: vi.fn(() => [ + { isDestroyed: () => false, webContents: { send: webContentsSend } } + ]) } })) diff --git a/apps/desktop/src/main/ipc/calendar-handlers.ts b/apps/desktop/src/main/ipc/calendar-handlers.ts index 99f6fe10a..e30b73ffe 100644 --- a/apps/desktop/src/main/ipc/calendar-handlers.ts +++ b/apps/desktop/src/main/ipc/calendar-handlers.ts @@ -1,4 +1,5 @@ -import { BrowserWindow, ipcMain } from 'electron' +import { ipcMain } from 'electron' +import { broadcastToAllWindows } from '../lib/window-broadcast' import { and, asc, eq, inArray, isNull } from 'drizzle-orm' import { CalendarChannels } from '@memry/contracts/ipc-channels' import { @@ -92,9 +93,7 @@ import { getMainI18n } from '../lib/main-i18n' const log = createLogger('IPC:Calendar') function emitCalendarChanged(event: CalendarChangedEvent): void { - for (const win of BrowserWindow.getAllWindows()) { - win.webContents.send(CalendarChannels.events.CHANGED, event) - } + broadcastToAllWindows(CalendarChannels.events.CHANGED, event) } function mapCalendarEvent(row: typeof calendarEvents.$inferSelect): CalendarEventRecord { diff --git a/apps/desktop/src/main/ipc/canvas-handlers.ts b/apps/desktop/src/main/ipc/canvas-handlers.ts index d64f1d768..fb5a67a07 100644 --- a/apps/desktop/src/main/ipc/canvas-handlers.ts +++ b/apps/desktop/src/main/ipc/canvas-handlers.ts @@ -8,6 +8,7 @@ import { z } from 'zod' import { ipcMain, BrowserWindow } from 'electron' +import { broadcastToAllWindows } from '../lib/window-broadcast' import { CanvasChannels, CanvasCreateSchema, @@ -46,9 +47,7 @@ function emitCanvasEvent( channel: string, data: CanvasCreatedEvent | CanvasUpdatedEvent | CanvasDeletedEvent | CanvasTooLargeEvent ): void { - BrowserWindow.getAllWindows().forEach((win) => { - win.webContents.send(channel, data) - }) + broadcastToAllWindows(channel, data) } /** Windows already carrying a 'closed' listener for live-canvas cleanup. */ diff --git a/apps/desktop/src/main/ipc/journal-handlers.test.ts b/apps/desktop/src/main/ipc/journal-handlers.test.ts index 5b7efd1b4..09f7491d7 100644 --- a/apps/desktop/src/main/ipc/journal-handlers.test.ts +++ b/apps/desktop/src/main/ipc/journal-handlers.test.ts @@ -24,7 +24,7 @@ vi.mock('electron', () => ({ }) }, BrowserWindow: { - getAllWindows: vi.fn(() => [{ webContents: { send: vi.fn() } }]) + getAllWindows: vi.fn(() => [{ isDestroyed: () => false, webContents: { send: vi.fn() } }]) } })) diff --git a/apps/desktop/src/main/ipc/journal-handlers.ts b/apps/desktop/src/main/ipc/journal-handlers.ts index 90e289b8d..ba426b492 100644 --- a/apps/desktop/src/main/ipc/journal-handlers.ts +++ b/apps/desktop/src/main/ipc/journal-handlers.ts @@ -5,7 +5,8 @@ * @module ipc/journal-handlers */ -import { ipcMain, BrowserWindow } from 'electron' +import { ipcMain } from 'electron' +import { broadcastToAllWindows } from '../lib/window-broadcast' import { JournalChannels } from '@memry/contracts/ipc-channels' import { GetEntryInputSchema, @@ -69,9 +70,7 @@ const logger = createLogger('IPC:Journal') * Emit journal event to all windows. */ function emitJournalEvent(channel: string, payload: unknown): void { - BrowserWindow.getAllWindows().forEach((win) => { - win.webContents.send(channel, payload) - }) + broadcastToAllWindows(channel, payload) } // ============================================================================ diff --git a/apps/desktop/src/main/ipc/locale-handler.test.ts b/apps/desktop/src/main/ipc/locale-handler.test.ts index 26d810227..863484706 100644 --- a/apps/desktop/src/main/ipc/locale-handler.test.ts +++ b/apps/desktop/src/main/ipc/locale-handler.test.ts @@ -1,7 +1,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const hoisted = vi.hoisted(() => ({ - windows: [] as Array<{ webContents: { send: ReturnType } }>, + windows: [] as Array<{ + isDestroyed: () => boolean + webContents: { send: ReturnType } + }>, getDatabase: vi.fn(() => ({})), getSetting: vi.fn(() => null), setSetting: vi.fn(), @@ -73,7 +76,7 @@ describe('locale handler', () => { const send = vi.fn() const mockI18n = { changeLanguage: vi.fn(), language: 'en' } as any const rebuildMenu = vi.fn() - hoisted.windows = [{ webContents: { send } }] + hoisted.windows = [{ isDestroyed: () => false, webContents: { send } }] hoisted.getSetting.mockReturnValue(JSON.stringify({ theme: 'dark', language: 'en' })) registerLocaleHandlers(mockI18n, rebuildMenu) @@ -105,7 +108,7 @@ describe('locale handler', () => { const send = vi.fn() const mockI18n = { changeLanguage: vi.fn(), language: 'en' } as any const rebuildMenu = vi.fn() - hoisted.windows = [{ webContents: { send } }] + hoisted.windows = [{ isDestroyed: () => false, webContents: { send } }] hoisted.getCurrentVaultPath.mockReturnValue(null) hoisted.getDatabase.mockImplementation(() => { throw new Error('Database not initialized') @@ -138,7 +141,7 @@ describe('locale handler', () => { language: 'en' } as any const rebuildMenu = vi.fn() - hoisted.windows = [{ webContents: { send } }] + hoisted.windows = [{ isDestroyed: () => false, webContents: { send } }] registerLocaleHandlers(mockI18n, rebuildMenu) const getHandler = (ipcMain.handle as any).mock.calls.find( diff --git a/apps/desktop/src/main/ipc/locale-handler.ts b/apps/desktop/src/main/ipc/locale-handler.ts index 9367fab91..7975c5c03 100644 --- a/apps/desktop/src/main/ipc/locale-handler.ts +++ b/apps/desktop/src/main/ipc/locale-handler.ts @@ -1,4 +1,5 @@ -import { BrowserWindow, ipcMain } from 'electron' +import { ipcMain } from 'electron' +import { broadcastToAllWindows } from '../lib/window-broadcast' import { LocaleChannels } from '@memry/contracts/ipc-channels' import { LocaleSchema, SUPPORTED_LOCALES, type Locale } from '@memry/contracts/locale-api' import { GENERAL_SETTINGS_DEFAULTS, type GeneralSettings } from '@memry/contracts/settings-schemas' @@ -101,9 +102,7 @@ export async function applyLocale(locale: Locale): Promise { await runtime.i18n.changeLanguage(locale) runtime.rebuildMenu(locale) - for (const win of BrowserWindow.getAllWindows()) { - win.webContents.send(LocaleChannels.Changed, locale) - } + broadcastToAllWindows(LocaleChannels.Changed, locale) activeLocale = locale persistLocale(locale) diff --git a/apps/desktop/src/main/ipc/saved-filters-handlers.test.ts b/apps/desktop/src/main/ipc/saved-filters-handlers.test.ts index 2186d239a..98f50e2a0 100644 --- a/apps/desktop/src/main/ipc/saved-filters-handlers.test.ts +++ b/apps/desktop/src/main/ipc/saved-filters-handlers.test.ts @@ -24,7 +24,7 @@ vi.mock('electron', () => ({ }) }, BrowserWindow: { - getAllWindows: vi.fn(() => [{ webContents: { send: mockSend } }]) + getAllWindows: vi.fn(() => [{ isDestroyed: () => false, webContents: { send: mockSend } }]) } })) diff --git a/apps/desktop/src/main/ipc/saved-filters-handlers.ts b/apps/desktop/src/main/ipc/saved-filters-handlers.ts index 656b6c0f8..054c9af86 100644 --- a/apps/desktop/src/main/ipc/saved-filters-handlers.ts +++ b/apps/desktop/src/main/ipc/saved-filters-handlers.ts @@ -5,7 +5,8 @@ * @module ipc/saved-filters-handlers */ -import { ipcMain, BrowserWindow } from 'electron' +import { ipcMain } from 'electron' +import { broadcastToAllWindows } from '../lib/window-broadcast' import { SavedFiltersChannels } from '@memry/contracts/ipc-channels' import { SavedFilterCreateSchema, @@ -29,9 +30,7 @@ import { getMainI18n } from '../lib/main-i18n' * Emit saved filter event to all windows */ function emitSavedFilterEvent(channel: string, data: unknown): void { - BrowserWindow.getAllWindows().forEach((win) => { - win.webContents.send(channel, data) - }) + broadcastToAllWindows(channel, data) } /** diff --git a/apps/desktop/src/main/ipc/settings-handlers.ts b/apps/desktop/src/main/ipc/settings-handlers.ts index 55c69185b..f3ab85a05 100644 --- a/apps/desktop/src/main/ipc/settings-handlers.ts +++ b/apps/desktop/src/main/ipc/settings-handlers.ts @@ -7,7 +7,8 @@ * @module main/ipc/settings-handlers */ -import { ipcMain, BrowserWindow, app, globalShortcut, systemPreferences, shell } from 'electron' +import { ipcMain, app, globalShortcut, systemPreferences, shell } from 'electron' +import { broadcastToAllWindows } from '../lib/window-broadcast' import { SettingsChannels } from '@memry/contracts/ipc-channels' import { GENERAL_SETTINGS_DEFAULTS, @@ -342,11 +343,9 @@ function writeGroupSettings>( const updated = { ...current, ...updates } setSetting(db, groupKey, JSON.stringify(updated)) - BrowserWindow.getAllWindows().forEach((win) => { - win.webContents.send(SettingsChannels.events.CHANGED, { - key: groupKey, - value: updates - }) + broadcastToAllWindows(SettingsChannels.events.CHANGED, { + key: groupKey, + value: updates }) trackGroupSettingChanges(groupKey, updates) @@ -386,9 +385,7 @@ export function registerSettingsHandlers(): void { setSetting(db, key, value) // Emit settings changed event - BrowserWindow.getAllWindows().forEach((win) => { - win.webContents.send(SettingsChannels.events.CHANGED, { key, value }) - }) + broadcastToAllWindows(SettingsChannels.events.CHANGED, { key, value }) if (SafeDimensionValueSchema.safeParse(key).success) { trackMainEvent('setting_changed', { @@ -475,11 +472,9 @@ export function registerSettingsHandlers(): void { } // Emit settings changed event - BrowserWindow.getAllWindows().forEach((win) => { - win.webContents.send(SettingsChannels.events.CHANGED, { - key: 'journal', - value: settings - }) + broadcastToAllWindows(SettingsChannels.events.CHANGED, { + key: 'journal', + value: settings }) trackGroupSettingChanges('journal', settings) @@ -521,11 +516,9 @@ export function registerSettingsHandlers(): void { } // Emit settings changed event - BrowserWindow.getAllWindows().forEach((win) => { - win.webContents.send(SettingsChannels.events.CHANGED, { - key: 'ai', - value: settings - }) + broadcastToAllWindows(SettingsChannels.events.CHANGED, { + key: 'ai', + value: settings }) trackGroupSettingChanges('ai', settings) @@ -791,11 +784,9 @@ export function registerSettingsHandlers(): void { } // Emit settings changed event - BrowserWindow.getAllWindows().forEach((win) => { - win.webContents.send(SettingsChannels.events.CHANGED, { - key: 'tabs', - value: settings - }) + broadcastToAllWindows(SettingsChannels.events.CHANGED, { + key: 'tabs', + value: settings }) trackGroupSettingChanges('tabs', settings) @@ -834,11 +825,9 @@ export function registerSettingsHandlers(): void { } // Emit settings changed event - BrowserWindow.getAllWindows().forEach((win) => { - win.webContents.send(SettingsChannels.events.CHANGED, { - key: 'noteEditor', - value: settings - }) + broadcastToAllWindows(SettingsChannels.events.CHANGED, { + key: 'noteEditor', + value: settings }) trackGroupSettingChanges('noteEditor', settings) @@ -986,11 +975,9 @@ export function registerSettingsHandlers(): void { deleteSetting(db, 'keyboard') - BrowserWindow.getAllWindows().forEach((win) => { - win.webContents.send(SettingsChannels.events.CHANGED, { - key: 'keyboard', - value: KEYBOARD_SHORTCUTS_DEFAULTS - }) + broadcastToAllWindows(SettingsChannels.events.CHANGED, { + key: 'keyboard', + value: KEYBOARD_SHORTCUTS_DEFAULTS }) return { success: true } @@ -1048,9 +1035,7 @@ export function applyGlobalCaptureShortcut(): GlobalCaptureResult { const accelerator = toElectronAccelerator(binding) const registered = globalShortcut.register(accelerator, () => { - BrowserWindow.getAllWindows().forEach((win) => { - if (!win.isDestroyed()) win.webContents.send('quick-capture:open') - }) + broadcastToAllWindows('quick-capture:open') }) if (!registered) { diff --git a/apps/desktop/src/main/ipc/sync-attachment-handlers.test.ts b/apps/desktop/src/main/ipc/sync-attachment-handlers.test.ts index 03da3bfb5..0e81fae3b 100644 --- a/apps/desktop/src/main/ipc/sync-attachment-handlers.test.ts +++ b/apps/desktop/src/main/ipc/sync-attachment-handlers.test.ts @@ -43,6 +43,7 @@ vi.mock('electron', () => ({ BrowserWindow: { getAllWindows: vi.fn(() => [ { + isDestroyed: () => false, webContents: { send: (channel: string, payload: unknown) => attachmentMocks.sent.push({ channel, payload }) diff --git a/apps/desktop/src/main/ipc/sync-attachment-handlers.ts b/apps/desktop/src/main/ipc/sync-attachment-handlers.ts index ce2a4c599..d4e7830f2 100644 --- a/apps/desktop/src/main/ipc/sync-attachment-handlers.ts +++ b/apps/desktop/src/main/ipc/sync-attachment-handlers.ts @@ -1,4 +1,5 @@ -import { BrowserWindow, ipcMain } from 'electron' +import { ipcMain } from 'electron' +import { broadcastToAllWindows } from '../lib/window-broadcast' import fs from 'node:fs' import path from 'node:path' import sodium from 'libsodium-wrappers-sumo' @@ -81,14 +82,12 @@ const broadcastUploadProgress = (progress: TransferProgress): void => { progress.totalChunks > 0 ? Math.round((progress.chunksCompleted / progress.totalChunks) * 100) : 0 - for (const win of BrowserWindow.getAllWindows()) { - win.webContents.send(SYNC_EVENTS.UPLOAD_PROGRESS, { - attachmentId: progress.attachmentId, - sessionId: '', - progress: percent, - status: progress.phase - }) - } + broadcastToAllWindows(SYNC_EVENTS.UPLOAD_PROGRESS, { + attachmentId: progress.attachmentId, + sessionId: '', + progress: percent, + status: progress.phase + }) } const getOrCreateAttachmentService = (): AttachmentSyncService | null => { @@ -234,13 +233,11 @@ export function registerAttachmentHandlers(): void { progress.totalChunks > 0 ? Math.round((progress.chunksCompleted / progress.totalChunks) * 100) : 0 - for (const win of BrowserWindow.getAllWindows()) { - win.webContents.send(SYNC_EVENTS.DOWNLOAD_PROGRESS, { - attachmentId: progress.attachmentId, - progress: percent, - status: progress.phase - }) - } + broadcastToAllWindows(SYNC_EVENTS.DOWNLOAD_PROGRESS, { + attachmentId: progress.attachmentId, + progress: percent, + status: progress.phase + }) }) try { @@ -374,14 +371,12 @@ export function registerAttachmentHandlers(): void { invalidateCachedEntitlementLimits() } - for (const win of BrowserWindow.getAllWindows()) { - win.webContents.send(SYNC_EVENTS.ATTACHMENT_UPLOAD_FAILED, { - noteId, - diskPath, - error: message, - errorCategory: category - }) - } + broadcastToAllWindows(SYNC_EVENTS.ATTACHMENT_UPLOAD_FAILED, { + noteId, + diskPath, + error: message, + errorCategory: category + }) } })() }) @@ -417,13 +412,11 @@ export function registerAttachmentHandlers(): void { // Mirrors the upload path above — a download-side outage (auth, R2, // decrypt) must not repeat the 58-day blindness the upload path had. trackMainError('sync_attachments', 'attachment_download_failed', err) - for (const win of BrowserWindow.getAllWindows()) { - win.webContents.send(SYNC_EVENTS.ATTACHMENT_UPLOAD_FAILED, { - noteId, - diskPath, - error: message - }) - } + broadcastToAllWindows(SYNC_EVENTS.ATTACHMENT_UPLOAD_FAILED, { + noteId, + diskPath, + error: message + }) } })() }) diff --git a/apps/desktop/src/main/ipc/sync-core-handlers.test.ts b/apps/desktop/src/main/ipc/sync-core-handlers.test.ts index c6a61dd9a..971de14bf 100644 --- a/apps/desktop/src/main/ipc/sync-core-handlers.test.ts +++ b/apps/desktop/src/main/ipc/sync-core-handlers.test.ts @@ -667,7 +667,7 @@ describe('sync IPC handlers', () => { mockGetDevicePublicKey.mockReturnValue(new Uint8Array(32).fill(8)) mockCheckLocalKeyAgainstAccount.mockResolvedValue('mismatch') const send = vi.fn() - mockGetAllWindows.mockReturnValue([{ webContents: { send } }]) + mockGetAllWindows.mockReturnValue([{ isDestroyed: () => false, webContents: { send } }]) await checkSyncIntegrity() diff --git a/apps/desktop/src/main/ipc/sync-core-handlers.ts b/apps/desktop/src/main/ipc/sync-core-handlers.ts index e42244cb0..4eb7ddb88 100644 --- a/apps/desktop/src/main/ipc/sync-core-handlers.ts +++ b/apps/desktop/src/main/ipc/sync-core-handlers.ts @@ -1,4 +1,5 @@ -import { BrowserWindow, ipcMain } from 'electron' +import { ipcMain } from 'electron' +import { broadcastToAllWindows } from '../lib/window-broadcast' import sodium from 'libsodium-wrappers-sumo' import { syncDevices } from '@memry/db-schema/schema/sync-devices' @@ -174,9 +175,7 @@ export async function checkSyncIntegrity(): Promise { } function emitVaultRecoveryNeededToWindows(event: VaultRecoveryNeededEvent): void { - for (const win of BrowserWindow.getAllWindows()) { - win.webContents.send(EVENT_CHANNELS.VAULT_RECOVERY_NEEDED, event) - } + broadcastToAllWindows(EVENT_CHANNELS.VAULT_RECOVERY_NEEDED, event) } async function cleanupLocalSyncState(): Promise { diff --git a/apps/desktop/src/main/ipc/tags-handlers.test.ts b/apps/desktop/src/main/ipc/tags-handlers.test.ts index c743cd424..40d525b25 100644 --- a/apps/desktop/src/main/ipc/tags-handlers.test.ts +++ b/apps/desktop/src/main/ipc/tags-handlers.test.ts @@ -41,7 +41,7 @@ vi.mock('electron', () => ({ }) }, BrowserWindow: { - getAllWindows: vi.fn(() => [{ webContents: { send: mockSend } }]) + getAllWindows: vi.fn(() => [{ isDestroyed: () => false, webContents: { send: mockSend } }]) } })) diff --git a/apps/desktop/src/main/ipc/tags-handlers.ts b/apps/desktop/src/main/ipc/tags-handlers.ts index b3579b1c0..66713828d 100644 --- a/apps/desktop/src/main/ipc/tags-handlers.ts +++ b/apps/desktop/src/main/ipc/tags-handlers.ts @@ -6,7 +6,8 @@ */ import { readFile } from 'fs/promises' -import { ipcMain, BrowserWindow } from 'electron' +import { ipcMain } from 'electron' +import { broadcastToAllWindows } from '../lib/window-broadcast' import { eq } from 'drizzle-orm' import { TagsChannels } from '@memry/contracts/ipc-channels' import { @@ -87,9 +88,7 @@ const log = createLogger('TagsHandlers') * Emit tag event to all windows */ function emitTagEvent(channel: string, data: unknown): void { - BrowserWindow.getAllWindows().forEach((win) => { - win.webContents.send(channel, data) - }) + broadcastToAllWindows(channel, data) } /** diff --git a/apps/desktop/src/main/lib/reminders.test.ts b/apps/desktop/src/main/lib/reminders.test.ts index cbb511f4f..682287239 100644 --- a/apps/desktop/src/main/lib/reminders.test.ts +++ b/apps/desktop/src/main/lib/reminders.test.ts @@ -485,6 +485,54 @@ describe('reminders service', () => { }) }) + it('routes a notification click past a destroyed window to the first live one', () => { + seedNoteCache('note-1', 'Focus Note') + seedReminder({ + id: 'rem-destroyed', + targetType: 'note', + targetId: 'note-1', + remindAt: '2000-01-01T00:00:00.000Z', + status: reminderStatus.PENDING + }) + + // getAllWindows() can still list a destroyed short-lived window (splash, + // quick capture, print/export). Real Electron throws 'Object has been + // destroyed' on any access to one, so taking windows[0] blindly kills the + // click handler and focuses nothing. + const destroyed = { + isDestroyed: () => true, + isMinimized(): never { + throw new Error('Object has been destroyed') + }, + focus(): never { + throw new Error('Object has been destroyed') + }, + get webContents(): never { + throw new Error('Object has been destroyed') + } + } + remindersService.startReminderScheduler() + remindersService.stopReminderScheduler() + + const notification = notificationInstances[0] + expect(notification).toBeDefined() + + // The window dies between the banner being shown and the user clicking it. + vi.mocked(BrowserWindow.getAllWindows).mockReturnValue([ + destroyed as unknown as import('electron').BrowserWindow, + window as unknown as import('electron').BrowserWindow + ]) + + expect(() => notification?.emit('click')).not.toThrow() + expect(window.focus).toHaveBeenCalled() + expect(window.webContents.send).toHaveBeenCalledWith( + ReminderChannels.events.CLICKED, + expect.objectContaining({ + reminder: expect.objectContaining({ id: 'rem-destroyed' }) + }) + ) + }) + it('builds notifications with a stable id/groupId and logs delivery failures without throwing', () => { seedNoteCache('note-1', 'Focus Note') seedReminder({ diff --git a/apps/desktop/src/main/lib/reminders.ts b/apps/desktop/src/main/lib/reminders.ts index d38a464ea..9cc629d3b 100644 --- a/apps/desktop/src/main/lib/reminders.ts +++ b/apps/desktop/src/main/lib/reminders.ts @@ -276,16 +276,18 @@ function showDesktopNotification(reminder: ReminderWithTarget): void { silent: false }) - // Handle click - focus window and emit event to navigate + // Handle click - focus window and emit event to navigate. + // Takes the first *live* window rather than getAllWindows()[0]: short-lived + // windows (splash, quick capture, print/export) can still be listed after + // destruction, and any access to one throws "Object has been destroyed" — + // which would kill the click with nothing focused and nothing navigated. notification.on('click', () => { - const windows = BrowserWindow.getAllWindows() - if (windows.length > 0) { - const win = windows[0] - if (win.isMinimized()) win.restore() - win.focus() - // Emit event to navigate to the reminder target - win.webContents.send(ReminderChannels.events.CLICKED, { reminder }) - } + const win = BrowserWindow.getAllWindows().find((candidate) => !candidate.isDestroyed()) + if (!win) return + if (win.isMinimized()) win.restore() + win.focus() + // Emit event to navigate to the reminder target + win.webContents.send(ReminderChannels.events.CLICKED, { reminder }) }) // Electron 42+ routes macOS notifications through UNUserNotificationCenter: diff --git a/apps/desktop/src/main/lib/window-broadcast.test.ts b/apps/desktop/src/main/lib/window-broadcast.test.ts index f40454c9f..70a5619bb 100644 --- a/apps/desktop/src/main/lib/window-broadcast.test.ts +++ b/apps/desktop/src/main/lib/window-broadcast.test.ts @@ -20,6 +20,12 @@ vi.mock('electron', () => ({ } })) +const { loggerMock } = vi.hoisted(() => ({ + loggerMock: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() } +})) + +vi.mock('./logger', () => ({ createLogger: () => loggerMock })) + import { broadcastToAllWindows } from './window-broadcast' function makeLiveWindow() { @@ -57,9 +63,27 @@ function makeDeadContentsWindow() { } } +/** + * Passes the isDestroyed() guards but blows up on the actual send — the window + * died in the gap between the check and the delivery. + */ +function makeThrowingWindow() { + const send = vi.fn(() => { + throw new Error('Object has been destroyed') + }) + return { + win: { + isDestroyed: () => false, + webContents: { isDestroyed: () => false, send } + }, + send + } +} + describe('broadcastToAllWindows', () => { beforeEach(() => { windows.length = 0 + loggerMock.warn.mockClear() }) it('sends the payload to every live window', () => { @@ -94,4 +118,43 @@ describe('broadcastToAllWindows', () => { expect(dead.send).not.toHaveBeenCalled() expect(live.send).toHaveBeenCalledWith('sync:event', { id: 1 }) }) + + // A window that dies between the isDestroyed() guard and the send must not + // take the rest of the fan-out down with it, and must not surface as a throw + // in a caller that is mid-transaction (#935, #1000). + it('keeps delivering after a send throws, and does not propagate to the caller', () => { + const before = makeLiveWindow() + const throwing = makeThrowingWindow() + const after = makeLiveWindow() + windows.push(before.win, throwing.win, after.win) + + expect(() => broadcastToAllWindows('sync:event', { id: 1 })).not.toThrow() + + expect(before.send).toHaveBeenCalledWith('sync:event', { id: 1 }) + expect(after.send).toHaveBeenCalledWith('sync:event', { id: 1 }) + }) + + it('logs a failed delivery rather than dropping it silently', () => { + const throwing = makeThrowingWindow() + windows.push(throwing.win) + + broadcastToAllWindows('sync:event', { id: 1 }) + + expect(loggerMock.warn).toHaveBeenCalledWith( + 'Failed to deliver sync:event to a window:', + expect.any(Error) + ) + }) + + // The global-capture shortcut broadcasts a bare channel with no payload. + // Passing an explicit `undefined` would change the send() arity every window + // sees, so the helper must forward exactly the arguments it was given. + it('preserves a zero-payload send arity', () => { + const live = makeLiveWindow() + windows.push(live.win) + + broadcastToAllWindows('quick-capture:open') + + expect(live.send).toHaveBeenCalledWith('quick-capture:open') + }) }) diff --git a/apps/desktop/src/main/lib/window-broadcast.ts b/apps/desktop/src/main/lib/window-broadcast.ts index eb7c68b73..bdf46fa60 100644 --- a/apps/desktop/src/main/lib/window-broadcast.ts +++ b/apps/desktop/src/main/lib/window-broadcast.ts @@ -1,4 +1,7 @@ import { BrowserWindow } from 'electron' +import { createLogger } from './logger' + +const logger = createLogger('WindowBroadcast') /** * Send an IPC payload to every live window. @@ -8,11 +11,25 @@ import { BrowserWindow } from 'electron' * "Object has been destroyed". Callers run inside db transactions (sync item * handlers), where an unguarded throw rolls back an applied item — so skip * destroyed windows, mirroring window-rpc.ts and crdt-provider.ts. + * + * A window can also die *between* the guard and the send. That race is + * contained per window rather than allowed to abort the fan-out: a window that + * cannot be reached must not stop the remaining windows from receiving the + * event, and must not surface as a throw in a caller that is mid-transaction. + * The failure is logged, never silently dropped. + * + * `args` is a rest parameter so each call site keeps its exact `send()` arity — + * a zero-payload broadcast stays zero-payload rather than gaining an explicit + * `undefined` argument. */ -export function broadcastToAllWindows(channel: string, data: unknown): void { +export function broadcastToAllWindows(channel: string, ...args: unknown[]): void { for (const win of BrowserWindow.getAllWindows()) { if (win.isDestroyed()) continue if (typeof win.webContents.isDestroyed === 'function' && win.webContents.isDestroyed()) continue - win.webContents.send(channel, data) + try { + win.webContents.send(channel, ...args) + } catch (err) { + logger.warn(`Failed to deliver ${channel} to a window:`, err) + } } } diff --git a/apps/desktop/src/main/sync/crdt-writeback.test.ts b/apps/desktop/src/main/sync/crdt-writeback.test.ts index 49308bc15..fb9802d59 100644 --- a/apps/desktop/src/main/sync/crdt-writeback.test.ts +++ b/apps/desktop/src/main/sync/crdt-writeback.test.ts @@ -42,6 +42,7 @@ vi.mock('electron', () => ({ BrowserWindow: { getAllWindows: () => [ { + isDestroyed: () => false, webContents: { send: (channel: string, payload: unknown) => mocks.sent.push({ channel, payload }) } diff --git a/apps/desktop/src/main/sync/crdt-writeback.ts b/apps/desktop/src/main/sync/crdt-writeback.ts index 51fe51e61..2fa21559c 100644 --- a/apps/desktop/src/main/sync/crdt-writeback.ts +++ b/apps/desktop/src/main/sync/crdt-writeback.ts @@ -37,7 +37,7 @@ import { createRemindersService, type RemindersServiceHooks } from '@memry/app-c import { syncNoteDateReminders, clearNoteDateReminders } from '../notes/note-date-reminders' import { deleteFile } from '../vault/file-ops' import { NotesChannels, JournalChannels } from '@memry/contracts/ipc-channels' -import { BrowserWindow } from 'electron' +import { broadcastToAllWindows } from '../lib/window-broadcast' import path from 'path' import { enqueueLocalSyncCreate, @@ -148,9 +148,7 @@ export function wasRecentNetworkUpdate(noteId: string): boolean { } function emitToRenderer(channel: string, data: unknown): void { - for (const win of BrowserWindow.getAllWindows()) { - win.webContents.send(channel, data) - } + broadcastToAllWindows(channel, data) } export function scheduleWriteback(noteId: string, doc: Y.Doc): void { diff --git a/apps/desktop/src/main/sync/item-handlers/settings-handler.test.ts b/apps/desktop/src/main/sync/item-handlers/settings-handler.test.ts index 62de31d59..dc77ecd54 100644 --- a/apps/desktop/src/main/sync/item-handlers/settings-handler.test.ts +++ b/apps/desktop/src/main/sync/item-handlers/settings-handler.test.ts @@ -59,7 +59,7 @@ vi.mock('../../store', () => ({ const mockSend = vi.fn() vi.mock('electron', () => ({ BrowserWindow: { - getAllWindows: vi.fn(() => [{ webContents: { send: mockSend } }]) + getAllWindows: vi.fn(() => [{ isDestroyed: () => false, webContents: { send: mockSend } }]) }, // locale-handler registers its IPC channels on import-time registration; the // real apply path is exercised through it, so ipcMain has to exist. diff --git a/apps/desktop/src/main/sync/item-handlers/settings-handler.ts b/apps/desktop/src/main/sync/item-handlers/settings-handler.ts index 023707db3..2ee898b5f 100644 --- a/apps/desktop/src/main/sync/item-handlers/settings-handler.ts +++ b/apps/desktop/src/main/sync/item-handlers/settings-handler.ts @@ -1,4 +1,3 @@ -import { BrowserWindow } from 'electron' import { SettingsChannels } from '@memry/contracts/ipc-channels' import { SettingsSyncPayloadSchema } from '@memry/contracts/settings-sync' import type { SettingsSyncPayload, SyncedSettings } from '@memry/contracts/settings-sync' @@ -15,6 +14,7 @@ import { getDatabase } from '../../database' import { getSetting, setSetting, deleteSetting } from '../../database/queries/settings' import { INBOX_REVIEW_LAST_NOTIFIED_KEY } from '../../inbox/review-reminder-constants' import { createLogger } from '../../lib/logger' +import { broadcastToAllWindows } from '../../lib/window-broadcast' import type { SyncItemHandler, ApplyContext, ApplyResult, DrizzleDb } from './types' const log = createLogger('SettingsHandler') @@ -157,39 +157,37 @@ function applySyncedLocale(candidate: string | undefined): void { }) } +/** + * This runs inside the sync item's DB transaction (#935, #1000): anything that + * escapes here rolls back an item that was already applied. broadcastToAllWindows + * skips destroyed windows and contains a per-window send failure, but + * BrowserWindow.getAllWindows() itself can still throw during app teardown — + * which the previous hand-rolled loop tolerated. Keep tolerating it, but log it + * rather than dropping it silently. + */ function broadcastSettingsChanged(merged: SyncedSettings): void { - let windows: Electron.BrowserWindow[] try { - windows = BrowserWindow.getAllWindows() - } catch { - return - } - if (windows.length === 0) return - - if (merged.general) { - for (const win of windows) { - win.webContents.send(SettingsChannels.events.CHANGED, { + if (merged.general) { + broadcastToAllWindows(SettingsChannels.events.CHANGED, { key: 'general', value: merged.general }) } - } - if (merged.editor) { - for (const win of windows) { - win.webContents.send(SettingsChannels.events.CHANGED, { + if (merged.editor) { + broadcastToAllWindows(SettingsChannels.events.CHANGED, { key: 'editor', value: merged.editor }) } - } - if (merged.inbox) { - for (const win of windows) { - win.webContents.send(SettingsChannels.events.CHANGED, { + if (merged.inbox) { + broadcastToAllWindows(SettingsChannels.events.CHANGED, { key: 'inbox', value: merged.inbox }) } + } catch (err) { + log.warn('Failed to broadcast synced settings change:', err) } } diff --git a/apps/desktop/src/main/sync/linking-service.test.ts b/apps/desktop/src/main/sync/linking-service.test.ts index 8d307d613..9fed902f1 100644 --- a/apps/desktop/src/main/sync/linking-service.test.ts +++ b/apps/desktop/src/main/sync/linking-service.test.ts @@ -54,7 +54,7 @@ const { vi.mock('electron', () => ({ BrowserWindow: { - getAllWindows: () => [{ webContents: { send: mockSend } }] + getAllWindows: () => [{ isDestroyed: () => false, webContents: { send: mockSend } }] } })) diff --git a/apps/desktop/src/main/sync/linking-service.ts b/apps/desktop/src/main/sync/linking-service.ts index bbe0685f5..2dd4c95c9 100644 --- a/apps/desktop/src/main/sync/linking-service.ts +++ b/apps/desktop/src/main/sync/linking-service.ts @@ -1,6 +1,6 @@ import os from 'os' import sodium from 'libsodium-wrappers-sumo' -import { BrowserWindow } from 'electron' +import { broadcastToAllWindows } from '../lib/window-broadcast' import { KEYCHAIN_ENTRIES } from '@memry/contracts/crypto' import type { @@ -542,9 +542,7 @@ function emitLinkingFinalized(payload: { error?: string warning?: string }): void { - for (const win of BrowserWindow.getAllWindows()) { - win.webContents.send('sync:linking-finalized', payload) - } + broadcastToAllWindows('sync:linking-finalized', payload) } // ============================================================================ diff --git a/apps/desktop/src/main/sync/token-manager.test.ts b/apps/desktop/src/main/sync/token-manager.test.ts index b18241f91..5cd0197f4 100644 --- a/apps/desktop/src/main/sync/token-manager.test.ts +++ b/apps/desktop/src/main/sync/token-manager.test.ts @@ -217,7 +217,7 @@ describe('token-manager', () => { const { SyncServerError } = await import('./http-client') mockPostToServer.mockRejectedValue(new SyncServerError('Unauthorized', 401)) - const mockWin = { webContents: { send: vi.fn() } } + const mockWin = { isDestroyed: () => false, webContents: { send: vi.fn() } } mockGetAllWindows.mockReturnValue([mockWin]) // #when @@ -281,7 +281,7 @@ describe('token-manager', () => { return Promise.resolve(null) }) mockDecodeJwt.mockReturnValue({ exp: nowSeconds() - 100 }) - const win = { webContents: { send: vi.fn() } } + const win = { isDestroyed: () => false, webContents: { send: vi.fn() } } mockGetAllWindows.mockReturnValue([win]) return { win } } diff --git a/apps/desktop/src/main/sync/token-manager.ts b/apps/desktop/src/main/sync/token-manager.ts index 4419e6e43..7ac025550 100644 --- a/apps/desktop/src/main/sync/token-manager.ts +++ b/apps/desktop/src/main/sync/token-manager.ts @@ -1,4 +1,4 @@ -import { BrowserWindow } from 'electron' +import { broadcastToAllWindows } from '../lib/window-broadcast' import { decodeJwt } from 'jose' import { KEYCHAIN_ENTRIES } from '@memry/contracts/crypto' @@ -104,10 +104,7 @@ export const cancelTokenRefresh = (): void => { export const emitSessionExpired = (reason: SessionExpiredReason = 'token_expired'): void => { cancelTokenRefresh() - const windows = BrowserWindow.getAllWindows() - for (const win of windows) { - win.webContents.send(SYNC_EVENTS.SESSION_EXPIRED, { reason }) - } + broadcastToAllWindows(SYNC_EVENTS.SESSION_EXPIRED, { reason }) } const clearRefreshRejections = (): void => { diff --git a/apps/desktop/src/main/test-hooks.test.ts b/apps/desktop/src/main/test-hooks.test.ts index 4124f70dc..13bb756f0 100644 --- a/apps/desktop/src/main/test-hooks.test.ts +++ b/apps/desktop/src/main/test-hooks.test.ts @@ -1,7 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const windowSendMock = vi.fn() -const browserWindows = [{ webContents: { send: windowSendMock } }] +const browserWindows = [{ isDestroyed: () => false, webContents: { send: windowSendMock } }] const storeGetMock = vi.fn(() => ({ existing: true })) const storeSetMock = vi.fn() const persistKeysAndRegisterDeviceMock = vi.fn(async () => 'device-1') diff --git a/apps/desktop/src/main/test-hooks.ts b/apps/desktop/src/main/test-hooks.ts index 45755f037..dc2d8feca 100644 --- a/apps/desktop/src/main/test-hooks.ts +++ b/apps/desktop/src/main/test-hooks.ts @@ -1,4 +1,5 @@ import { BrowserWindow } from 'electron' +import { broadcastToAllWindows } from './lib/window-broadcast' import { join } from 'node:path' import { store } from './store' import { persistKeysAndRegisterDevice } from './sync/device-registration' @@ -525,20 +526,18 @@ export function registerTestHooks(): void { `) } - BrowserWindow.getAllWindows().forEach((win) => { - win.webContents.send(CalendarChannels.events.CHANGED, { - entityType: 'calendar_external_event', - id: 'calendar-e2e-external' - }) - if (input.overlapMemryTitle) { - win.webContents.send(CalendarChannels.events.CHANGED, { - entityType: 'calendar_event', - id: 'calendar-e2e-memry-overlap' - }) - } - win.webContents.send(TasksChannels.events.CREATED, { - task: { id: 'calendar-e2e-task' } + broadcastToAllWindows(CalendarChannels.events.CHANGED, { + entityType: 'calendar_external_event', + id: 'calendar-e2e-external' + }) + if (input.overlapMemryTitle) { + broadcastToAllWindows(CalendarChannels.events.CHANGED, { + entityType: 'calendar_event', + id: 'calendar-e2e-memry-overlap' }) + } + broadcastToAllWindows(TasksChannels.events.CREATED, { + task: { id: 'calendar-e2e-task' } }) }, diff --git a/apps/desktop/src/main/vault/index.test.ts b/apps/desktop/src/main/vault/index.test.ts index bcbb735e7..55118d521 100644 --- a/apps/desktop/src/main/vault/index.test.ts +++ b/apps/desktop/src/main/vault/index.test.ts @@ -70,6 +70,7 @@ vi.mock('electron', () => ({ BrowserWindow: { getAllWindows: () => [ { + isDestroyed: () => false, webContents: { send: (channel: string, payload: unknown) => mocks.sent.push({ channel, payload }) } diff --git a/apps/desktop/src/main/vault/index.ts b/apps/desktop/src/main/vault/index.ts index a1905000c..37b97e8bc 100644 --- a/apps/desktop/src/main/vault/index.ts +++ b/apps/desktop/src/main/vault/index.ts @@ -1,4 +1,5 @@ -import { dialog, BrowserWindow } from 'electron' +import { dialog } from 'electron' +import { broadcastToAllWindows } from '../lib/window-broadcast' import type { VaultInfo, VaultStatus, @@ -197,9 +198,7 @@ function emitStatusChanged(): void { // nothing should react to it while the app is quitting/installing. if (isShuttingDown) return statusListeners.forEach((listener) => listener(currentStatus)) - BrowserWindow.getAllWindows().forEach((win) => { - win.webContents.send('vault:status-changed', currentStatus) - }) + broadcastToAllWindows('vault:status-changed', currentStatus) } /** @@ -207,9 +206,7 @@ function emitStatusChanged(): void { */ export function emitIndexProgress(progress: number): void { updateStatus({ indexProgress: progress }) - BrowserWindow.getAllWindows().forEach((win) => { - win.webContents.send('vault:index-progress', progress) - }) + broadcastToAllWindows('vault:index-progress', progress) } /** @@ -217,9 +214,7 @@ export function emitIndexProgress(progress: number): void { */ export function emitVaultError(error: string): void { updateStatus({ error }) - BrowserWindow.getAllWindows().forEach((win) => { - win.webContents.send('vault:error', error) - }) + broadcastToAllWindows('vault:error', error) } /** @@ -244,9 +239,7 @@ export function emitIndexRecovered(event: IndexRecoveredEvent): void { errorCode: event.reason, metrics: { durationMs: event.duration, itemCount: event.filesIndexed } }) - BrowserWindow.getAllWindows().forEach((win) => { - win.webContents.send(VaultChannels.events.INDEX_RECOVERED, event) - }) + broadcastToAllWindows(VaultChannels.events.INDEX_RECOVERED, event) } /** diff --git a/apps/desktop/src/main/vault/notes-io.ts b/apps/desktop/src/main/vault/notes-io.ts index e027b71ba..8bfe0ca69 100644 --- a/apps/desktop/src/main/vault/notes-io.ts +++ b/apps/desktop/src/main/vault/notes-io.ts @@ -7,7 +7,7 @@ */ import path from 'path' -import { BrowserWindow } from 'electron' +import { broadcastToAllWindows } from '../lib/window-broadcast' import { getStatus, getConfig } from './index' import { normalizeRelativePath } from '../lib/paths' import { VaultError, VaultErrorCode } from '../lib/errors' @@ -49,7 +49,5 @@ export function toRelativePath(absolutePath: string): string { // ============================================================================ export function emitNoteEvent(channel: string, payload: unknown): void { - BrowserWindow.getAllWindows().forEach((win) => { - win.webContents.send(channel, payload) - }) + broadcastToAllWindows(channel, payload) } diff --git a/apps/desktop/src/main/vault/notes.test.ts b/apps/desktop/src/main/vault/notes.test.ts index a78c40d99..2044611aa 100644 --- a/apps/desktop/src/main/vault/notes.test.ts +++ b/apps/desktop/src/main/vault/notes.test.ts @@ -22,7 +22,7 @@ import * as projections from '../projections' // Mock electron (must be at module level) vi.mock('electron', () => { const mockWebContents = { send: vi.fn() } - const mockWindow = { webContents: mockWebContents } + const mockWindow = { isDestroyed: () => false, webContents: mockWebContents } return { BrowserWindow: { @@ -238,7 +238,7 @@ describe('notes operations', () => { const { BrowserWindow } = await import('electron') const mockSend = vi.fn() vi.mocked(BrowserWindow.getAllWindows).mockReturnValue([ - { webContents: { send: mockSend } } as never + { isDestroyed: () => false, webContents: { send: mockSend } } as never ]) await notes.createNote({ @@ -523,7 +523,7 @@ describe('notes operations', () => { const { BrowserWindow } = await import('electron') const mockSend = vi.fn() vi.mocked(BrowserWindow.getAllWindows).mockReturnValue([ - { webContents: { send: mockSend } } as never + { isDestroyed: () => false, webContents: { send: mockSend } } as never ]) const created = await notes.createNote({ @@ -726,7 +726,7 @@ describe('notes operations', () => { const { BrowserWindow } = await import('electron') const mockSend = vi.fn() vi.mocked(BrowserWindow.getAllWindows).mockReturnValue([ - { webContents: { send: mockSend } } as never + { isDestroyed: () => false, webContents: { send: mockSend } } as never ]) const created = await notes.createNote({ @@ -879,7 +879,7 @@ describe('notes operations', () => { const { BrowserWindow } = await import('electron') const mockSend = vi.fn() vi.mocked(BrowserWindow.getAllWindows).mockReturnValue([ - { webContents: { send: mockSend } } as never + { isDestroyed: () => false, webContents: { send: mockSend } } as never ]) const created = await notes.createNote({ @@ -982,7 +982,7 @@ describe('notes operations', () => { const { BrowserWindow } = await import('electron') const mockSend = vi.fn() vi.mocked(BrowserWindow.getAllWindows).mockReturnValue([ - { webContents: { send: mockSend } } as never + { isDestroyed: () => false, webContents: { send: mockSend } } as never ]) const created = await notes.createNote({ diff --git a/apps/desktop/src/main/vault/rename-tracker.ts b/apps/desktop/src/main/vault/rename-tracker.ts index fcc7835cc..6035ff9a0 100644 --- a/apps/desktop/src/main/vault/rename-tracker.ts +++ b/apps/desktop/src/main/vault/rename-tracker.ts @@ -11,7 +11,7 @@ */ import path from 'path' -import { BrowserWindow } from 'electron' +import { broadcastToAllWindows } from '../lib/window-broadcast' import { NotesChannels } from '@memry/contracts/ipc-channels' import type { NoteRenamedEvent } from '@memry/contracts/notes-api' import { createLogger } from '../lib/logger' @@ -77,11 +77,9 @@ export function unregisterRenameSyncCallback(): void { * Emit note renamed event to all renderer windows. */ function emitNoteRenamed(event: NoteRenamedEvent): void { - BrowserWindow.getAllWindows().forEach((win) => { - win.webContents.send(NotesChannels.events.RENAMED, { - ...event, - source: 'external' - }) + broadcastToAllWindows(NotesChannels.events.RENAMED, { + ...event, + source: 'external' }) } diff --git a/apps/desktop/src/main/vault/watcher.ts b/apps/desktop/src/main/vault/watcher.ts index 1768ba4bd..b7b5b8417 100644 --- a/apps/desktop/src/main/vault/watcher.ts +++ b/apps/desktop/src/main/vault/watcher.ts @@ -11,7 +11,7 @@ import path from 'path' import fs from 'fs/promises' import chokidar from 'chokidar' import type { FSWatcher } from 'chokidar' -import { BrowserWindow } from 'electron' +import { broadcastToAllWindows } from '../lib/window-broadcast' import { getConfig } from './index' import { parseNote, generateContentHash, extractProperties } from './frontmatter' import { safeRead } from './file-ops' @@ -114,9 +114,7 @@ function createPathDebouncer( * Emit event to all renderer windows. */ function emitEvent(channel: string, payload: unknown): void { - BrowserWindow.getAllWindows().forEach((win) => { - win.webContents.send(channel, payload) - }) + broadcastToAllWindows(channel, payload) } /** diff --git a/apps/docs/src/architecture/ipc.md b/apps/docs/src/architecture/ipc.md index dd9a94744..50bd797c1 100644 --- a/apps/docs/src/architecture/ipc.md +++ b/apps/docs/src/architecture/ipc.md @@ -84,3 +84,9 @@ log.info('created note', { id }) ## Main → Renderer Broadcasts Main-process code that fans an event out to every open window — sync status, task and calendar change events, inbox capture/filing/snooze/transcription events, search and embedding progress, updater state, reminders, agent events, and FTS rebuild progress — goes through `broadcastToAllWindows(channel, data)` in `src/main/lib/window-broadcast.ts`. The helper skips destroyed windows: short-lived windows (splash, quick capture, print/export) can still appear in `BrowserWindow.getAllWindows()` after destruction, and an unguarded `webContents.send()` throws — inside a sync item handler that throw escapes `ctx.emit` within the item's DB transaction and rolls it back. Use the helper instead of hand-rolling a `getAllWindows()` loop. + +The helper also contains a per-window delivery failure: a window that dies between the guard and the send is logged and skipped, so the remaining windows still receive the event and the throw never reaches a caller that is mid-transaction. Payload arity is forwarded as given, so a zero-payload broadcast such as `broadcastToAllWindows('quick-capture:open')` reaches the renderer with no payload argument. + +An ESLint `no-restricted-syntax` rule over `apps/desktop/src/main/**` rejects `for...of` and `.forEach` fan-out loops written directly against `BrowserWindow.getAllWindows()`, so the hand-rolled pattern cannot come back. Loops over a deliberate _subset_ of windows are a different thing and stay allowed — for example `crdt-provider` iterates a doc's own `windowIds` and must skip the source window to avoid an IPC echo, which a fan-out would break. + +Picking a single window (focusing the app from a notification click, targeting the sender) is also not a fan-out. Those sites take the first _live_ window — `BrowserWindow.getAllWindows().find((w) => !w.isDestroyed())` — rather than `getAllWindows()[0]`, which throws when the window at index 0 has been destroyed. diff --git a/eslint.config.mjs b/eslint.config.mjs index e183b189d..a40b4d541 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -141,6 +141,43 @@ export default defineConfig( 'max-lines': ['error', { max: 800, skipBlankLines: true, skipComments: true }] } }, + { + // Guard against a fourth regression of #935/#1000: hand-rolled fan-out loops + // that call webContents.send() on every window. getAllWindows() can list a + // destroyed short-lived window (splash, quick capture, print/export), and the + // send then throws "Object has been destroyed" — inside a sync item handler + // that throw escapes ctx.emit within the item's DB transaction and rolls back + // an already-applied item. broadcastToAllWindows() skips destroyed windows and + // contains a per-window failure so the rest of the fan-out still lands. + // + // Scope note: the selectors are anchored on getAllWindows() itself, so they + // stay free of false positives — a loop over a deliberate *subset* of windows + // (e.g. crdt-provider's per-doc windowIds, which must skip the source window) + // is a different thing and must not be rewritten to a fan-out. The two-step + // form (`const windows = getAllWindows()` then iterating `windows`) is not + // statically distinguishable from legitimate window selection and is not + // caught. This is a regression tripwire for the shape that has actually + // recurred, not a proof of absence. + files: ['apps/desktop/src/main/**/*.ts'], + ignores: ['apps/desktop/src/main/lib/window-broadcast.ts'], + rules: { + 'no-restricted-syntax': [ + 'error', + { + selector: + "ForOfStatement[right.callee.property.name='getAllWindows']:has(MemberExpression[object.property.name='webContents'][property.name='send'])", + message: + 'Do not hand-roll a window fan-out loop. Use broadcastToAllWindows() from main/lib/window-broadcast — it skips destroyed windows, whose webContents.send() throws and can roll back a sync transaction (#935, #1000).' + }, + { + selector: + "CallExpression[callee.object.callee.property.name='getAllWindows'][callee.property.name='forEach']:has(MemberExpression[object.property.name='webContents'][property.name='send'])", + message: + 'Do not hand-roll a window fan-out loop. Use broadcastToAllWindows() from main/lib/window-broadcast — it skips destroyed windows, whose webContents.send() throws and can roll back a sync transaction (#935, #1000).' + } + ] + } + }, { files: ['apps/desktop/src/main/ipc/sync-handlers.ts'], rules: {