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..6cb2a605b 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,11 @@ 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: { isDestroyed: () => false, 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..39b3f2775 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,11 @@ 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: { isDestroyed: () => false, 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..0f71f37ce 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,11 @@ 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: { isDestroyed: () => false, 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 9280e287c..9d4401a85 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,11 @@ 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: { isDestroyed: () => false, 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..e0299aff3 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,11 @@ 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: { isDestroyed: () => false, 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..639979230 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,11 @@ 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: { isDestroyed: () => false, 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..9283d71b9 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,11 @@ 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: { isDestroyed: () => false, 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..f83c94e7a 100644 --- a/apps/desktop/src/main/import/import-context.test.ts +++ b/apps/desktop/src/main/import/import-context.test.ts @@ -2,7 +2,11 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' const send = vi.fn() vi.mock('electron', () => ({ - BrowserWindow: { getAllWindows: () => [{ webContents: { send } }] } + BrowserWindow: { + getAllWindows: () => [ + { isDestroyed: () => false, webContents: { isDestroyed: () => false, 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 c791a2b62..a5a2b314b 100644 --- a/apps/desktop/src/main/import/import-context.ts +++ b/apps/desktop/src/main/import/import-context.ts @@ -1,6 +1,6 @@ -import { BrowserWindow } from 'electron' import { ImportChannels } from '@memry/contracts/import-channels' import { createLogger } from '../lib/logger' +import { broadcastToAllWindows } from '../lib/window-broadcast' import type { ImportContext, ImportProgress, ImportSummary } from './types' const logger = createLogger('Import') @@ -37,9 +37,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..6f529f9e7 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,11 @@ 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: { isDestroyed: () => false, 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..afa7b575f 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,11 @@ 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: { isDestroyed: () => false, 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..76195795b 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,11 @@ 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: { isDestroyed: () => false, send } } + ]) + }, shell: { openPath: vi.fn(() => Promise.resolve('')), showItemInFolder: vi.fn() } } }) 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 04e55725a..bb288704b 100644 --- a/apps/desktop/src/main/ipc/ai-inline-handlers.test.ts +++ b/apps/desktop/src/main/ipc/ai-inline-handlers.test.ts @@ -16,7 +16,7 @@ const mocks = vi.hoisted(() => { BrowserWindow: { getAllWindows: vi.fn() }, - webContents: { send: vi.fn() }, + webContents: { isDestroyed: () => false, send: vi.fn() }, startChatServer: vi.fn(), stopChatServer: vi.fn(), getServerPort: vi.fn(), @@ -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 c7a5944f5..d397aaabf 100644 --- a/apps/desktop/src/main/ipc/ai-inline-handlers.ts +++ b/apps/desktop/src/main/ipc/ai-inline-handlers.ts @@ -1,10 +1,11 @@ -import { ipcMain, BrowserWindow } from 'electron' +import { ipcMain } from 'electron' import { AIInlineChannels, AI_INLINE_SETTINGS_DEFAULTS } from '@memry/contracts/ai-inline-channels' import type { AIInlineSettings } from '@memry/contracts/ai-inline-channels' import { startChatServer, stopChatServer, getServerPort } from '../ai-inline/ai-chat-server' import { getDatabase } from '../database' import { createLogger } from '../lib/logger' +import { broadcastToAllWindows } from '../lib/window-broadcast' import { getSetting, setSetting } from '../settings/settings-store' import { isConnectionRefusedError, markExpectedCondition } from '../telemetry/expected-conditions' import { withErrorHandler } from './validate' @@ -61,11 +62,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..9194855c0 100644 --- a/apps/desktop/src/main/ipc/auth-device-handlers.test.ts +++ b/apps/desktop/src/main/ipc/auth-device-handlers.test.ts @@ -339,8 +339,8 @@ describe('auth-device handlers', () => { await invokeHandler(SYNC_CHANNELS.AUTH_REQUEST_OTP, { email: 'user@example.com' }) // then clipboard polling is started - verified by advancing timers - const mockWebContents = { send: vi.fn() } - const mockWindow = { webContents: mockWebContents } + const mockWebContents = { isDestroyed: () => false, send: vi.fn() } + const mockWindow = { isDestroyed: () => false, webContents: mockWebContents } mockGetAllWindows.mockReturnValue([mockWindow]) mockClipboardReadText.mockReturnValue('123456') @@ -368,8 +368,10 @@ describe('auth-device handlers', () => { code: '123456' }) - const mockWebContents = { send: vi.fn() } - mockGetAllWindows.mockReturnValue([{ webContents: mockWebContents }]) + const mockWebContents = { isDestroyed: () => false, send: vi.fn() } + mockGetAllWindows.mockReturnValue([ + { isDestroyed: () => false, webContents: mockWebContents } + ]) mockClipboardReadText.mockReturnValue('654321') vi.advanceTimersByTime(2000) @@ -384,8 +386,10 @@ describe('auth-device handlers', () => { mockPostToServer.mockResolvedValue({ success: true }) await invokeHandler(SYNC_CHANNELS.AUTH_REQUEST_OTP, { email: 'user@example.com' }) - const mockWebContents = { send: vi.fn() } - mockGetAllWindows.mockReturnValue([{ webContents: mockWebContents }]) + const mockWebContents = { isDestroyed: () => false, send: vi.fn() } + mockGetAllWindows.mockReturnValue([ + { isDestroyed: () => false, webContents: mockWebContents } + ]) mockClipboardReadText.mockReturnValue('not-a-code') // #when @@ -404,8 +408,10 @@ describe('auth-device handlers', () => { // #when - advance past 10-minute timeout vi.advanceTimersByTime(10 * 60 * 1000 + 1000) - const mockWebContents = { send: vi.fn() } - mockGetAllWindows.mockReturnValue([{ webContents: mockWebContents }]) + const mockWebContents = { isDestroyed: () => false, send: vi.fn() } + mockGetAllWindows.mockReturnValue([ + { isDestroyed: () => false, webContents: mockWebContents } + ]) mockClipboardReadText.mockReturnValue('123456') vi.advanceTimersByTime(2000) @@ -801,7 +807,9 @@ 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: { isDestroyed: () => false, send } } + ]) await expect( invokeHandler(SYNC_CHANNELS.REMOVE_DEVICE, { deviceId: 'dev-remote' }) @@ -837,7 +845,9 @@ 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: { isDestroyed: () => false, 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 5d83305ae..6915d07c7 100644 --- a/apps/desktop/src/main/ipc/auth-device-handlers.ts +++ b/apps/desktop/src/main/ipc/auth-device-handlers.ts @@ -1,4 +1,4 @@ -import { BrowserWindow, clipboard, ipcMain } from 'electron' +import { clipboard, ipcMain } from 'electron' import { syncDevices } from '@memry/db-schema/schema/sync-devices' import { KEYCHAIN_ENTRIES } from '@memry/contracts/crypto' @@ -43,6 +43,7 @@ import { } from '../sync/linking-service' import { getValidAccessToken, retrieveToken, storeToken } from '../sync/token-manager' import { createLogger } from '../lib/logger' +import { broadcastToAllWindows } from '../lib/window-broadcast' import { registerCommand } from './lib/register-command' const logger = createLogger('IPC:Sync:Device') @@ -193,10 +194,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) @@ -507,12 +505,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..6f99e64c7 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,9 @@ 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: { isDestroyed: () => false, send } } + ]) registerAuthOAuthHandlers() const result = await invokeHandler(SYNC_CHANNELS.AUTH_INIT_OAUTH, { provider: 'google' }) @@ -247,7 +249,9 @@ 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: { isDestroyed: () => false, 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 da0ebfeb2..b639f3b45 100644 --- a/apps/desktop/src/main/ipc/auth-oauth-handlers.ts +++ b/apps/desktop/src/main/ipc/auth-oauth-handlers.ts @@ -1,4 +1,4 @@ -import { BrowserWindow, ipcMain, shell } from 'electron' +import { ipcMain, shell } from 'electron' import http from 'node:http' import https from 'node:https' @@ -19,6 +19,7 @@ import { startGoogleCalendarSyncRunner } from '../calendar/google/sync-service' import { teardownSession } from '../sync/session-teardown' import { refreshAccessToken, storeToken } from '../sync/token-manager' import { createLogger } from '../lib/logger' +import { broadcastToAllWindows } from '../lib/window-broadcast' import { registerCommand } from './lib/register-command' import { clearPendingRecoveryPhrase, @@ -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..4369f7e2f 100644 --- a/apps/desktop/src/main/ipc/bookmarks-handlers.test.ts +++ b/apps/desktop/src/main/ipc/bookmarks-handlers.test.ts @@ -24,7 +24,9 @@ vi.mock('electron', () => ({ }) }, BrowserWindow: { - getAllWindows: vi.fn(() => [{ webContents: { send: mockSend } }]) + getAllWindows: vi.fn(() => [ + { isDestroyed: () => false, webContents: { isDestroyed: () => false, send: mockSend } } + ]) } })) diff --git a/apps/desktop/src/main/ipc/bookmarks-handlers.ts b/apps/desktop/src/main/ipc/bookmarks-handlers.ts index 8db828b17..ccfe20c42 100644 --- a/apps/desktop/src/main/ipc/bookmarks-handlers.ts +++ b/apps/desktop/src/main/ipc/bookmarks-handlers.ts @@ -5,7 +5,7 @@ * @module ipc/bookmarks-handlers */ -import { ipcMain, BrowserWindow } from 'electron' +import { ipcMain } from 'electron' import { BookmarksChannels, BookmarkItemTypes, @@ -23,6 +23,7 @@ import { } from '@memry/contracts/bookmarks-api' import { bookmarkSyncId } from '@memry/contracts/bookmark-types' import { createValidatedHandler, createStringHandler } from './validate' +import { broadcastToAllWindows } from '../lib/window-broadcast' import { requireDatabase, getIndexDatabase } from '../database' import { bookmarkQueries, notesQueries, tasksQueries } from '../bookmarks/store' import { @@ -35,9 +36,7 @@ import { * 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..81310b112 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: { isDestroyed: () => false, send: webContentsSend } } + ]) } })) diff --git a/apps/desktop/src/main/ipc/calendar-handlers.ts b/apps/desktop/src/main/ipc/calendar-handlers.ts index 0e6ef6915..ec82c8345 100644 --- a/apps/desktop/src/main/ipc/calendar-handlers.ts +++ b/apps/desktop/src/main/ipc/calendar-handlers.ts @@ -1,4 +1,4 @@ -import { BrowserWindow, ipcMain } from 'electron' +import { ipcMain } from 'electron' import { and, asc, eq, inArray, isNull } from 'drizzle-orm' import { CalendarChannels } from '@memry/contracts/ipc-channels' import { @@ -39,6 +39,7 @@ import { calendarExternalEvents } from '@memry/db-schema/schema/calendar-externa import { calendarSources } from '@memry/db-schema/schema/calendar-sources' import { calendarBindings } from '@memry/db-schema/schema/calendar-bindings' import { createLogger } from '../lib/logger' +import { broadcastToAllWindows } from '../lib/window-broadcast' import { trackCalendar } from './calendar-telemetry' import { requireDatabase, getIndexDatabase, type DataDb } from '../database' import { generateId } from '../lib/id' @@ -89,9 +90,7 @@ import { 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 063c59cef..626d287b6 100644 --- a/apps/desktop/src/main/ipc/canvas-handlers.ts +++ b/apps/desktop/src/main/ipc/canvas-handlers.ts @@ -27,6 +27,7 @@ import { type CanvasUpdateResponse } from '@memry/contracts/canvas-api' import { createValidatedHandler, createHandler, createStringHandler } from './validate' +import { broadcastToAllWindows } from '../lib/window-broadcast' import { getCanvasContext, disposeCanvasVaultKey } from '../canvas/vault-key' import { forgetWindow, markCanvasClosed, markCanvasOpen } from '../canvas/live-registry' import { createCanvas, deleteCanvas, getCanvas, listCanvases, updateCanvas } from '../canvas/store' @@ -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..617c07a49 100644 --- a/apps/desktop/src/main/ipc/journal-handlers.test.ts +++ b/apps/desktop/src/main/ipc/journal-handlers.test.ts @@ -24,7 +24,9 @@ vi.mock('electron', () => ({ }) }, BrowserWindow: { - getAllWindows: vi.fn(() => [{ webContents: { send: vi.fn() } }]) + getAllWindows: vi.fn(() => [ + { isDestroyed: () => false, webContents: { isDestroyed: () => false, 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..5e10ac144 100644 --- a/apps/desktop/src/main/ipc/journal-handlers.ts +++ b/apps/desktop/src/main/ipc/journal-handlers.ts @@ -5,7 +5,7 @@ * @module ipc/journal-handlers */ -import { ipcMain, BrowserWindow } from 'electron' +import { ipcMain } from 'electron' import { JournalChannels } from '@memry/contracts/ipc-channels' import { GetEntryInputSchema, @@ -24,6 +24,7 @@ import { type GetAllTagsOutput } from '@memry/contracts/journal-api' import { createLogger } from '../lib/logger' +import { broadcastToAllWindows } from '../lib/window-broadcast' import { createValidatedHandler, createHandler } from './validate' import { readJournalEntry, @@ -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 f75a88cb0..44a59cdfc 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: { isDestroyed: () => boolean; send: ReturnType } + }>, getDatabase: vi.fn(() => ({})), getSetting: vi.fn(() => null), setSetting: vi.fn(), @@ -73,7 +76,9 @@ 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: { isDestroyed: () => false, send } } + ] hoisted.getSetting.mockReturnValue(JSON.stringify({ theme: 'dark', language: 'en' })) registerLocaleHandlers(mockI18n, rebuildMenu) @@ -105,7 +110,9 @@ 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: { isDestroyed: () => false, send } } + ] hoisted.getCurrentVaultPath.mockReturnValue(null) hoisted.getDatabase.mockImplementation(() => { throw new Error('Database not initialized') diff --git a/apps/desktop/src/main/ipc/locale-handler.ts b/apps/desktop/src/main/ipc/locale-handler.ts index 7e27ed414..a83f087e7 100644 --- a/apps/desktop/src/main/ipc/locale-handler.ts +++ b/apps/desktop/src/main/ipc/locale-handler.ts @@ -1,10 +1,11 @@ -import { BrowserWindow, ipcMain } from 'electron' +import { ipcMain } from 'electron' 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' import type { I18nInstance } from '@memry/i18n/main' import { getDatabase, type DataDb } from '../database' import { createLogger } from '../lib/logger' +import { broadcastToAllWindows } from '../lib/window-broadcast' import { getSetting, setSetting } from '../settings/settings-store' import { getCurrentVaultPath, setStoredLocale } from '../store' import { writePreferences } from '../vault/vault-preferences' @@ -81,9 +82,7 @@ export function registerLocaleHandlers(i18n: I18nInstance, rebuildMenu: RebuildM await i18n.changeLanguage(locale) rebuildMenu(locale) - for (const win of BrowserWindow.getAllWindows()) { - win.webContents.send(LocaleChannels.Changed, locale) - } + broadcastToAllWindows(LocaleChannels.Changed, locale) activeLocale = locale logger.info('Locale changed', { 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..bf382d6e2 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,9 @@ vi.mock('electron', () => ({ }) }, BrowserWindow: { - getAllWindows: vi.fn(() => [{ webContents: { send: mockSend } }]) + getAllWindows: vi.fn(() => [ + { isDestroyed: () => false, webContents: { isDestroyed: () => false, 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 431080c17..27ac33b78 100644 --- a/apps/desktop/src/main/ipc/saved-filters-handlers.ts +++ b/apps/desktop/src/main/ipc/saved-filters-handlers.ts @@ -5,7 +5,7 @@ * @module ipc/saved-filters-handlers */ -import { ipcMain, BrowserWindow } from 'electron' +import { ipcMain } from 'electron' import { SavedFiltersChannels } from '@memry/contracts/ipc-channels' import { SavedFilterCreateSchema, @@ -17,6 +17,7 @@ import { import { createValidatedHandler, createHandler } from './validate' import { requireDatabase } from '../database' import { generateId } from '../lib/id' +import { broadcastToAllWindows } from '../lib/window-broadcast' import * as savedFiltersStore from '../settings/saved-filters-store' import { syncFilterCreate, @@ -28,9 +29,7 @@ import { * 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.test.ts b/apps/desktop/src/main/ipc/settings-handlers.test.ts index 44e2771e0..7a7b3f4f4 100644 --- a/apps/desktop/src/main/ipc/settings-handlers.test.ts +++ b/apps/desktop/src/main/ipc/settings-handlers.test.ts @@ -940,7 +940,7 @@ describe('settings-handlers', () => { callback?.() expect(mockGlobalShortcutRegister).toHaveBeenCalledWith('Control+K', expect.any(Function)) - expect(mockSend).toHaveBeenCalledWith('quick-capture:open') + expect(mockSend).toHaveBeenCalledWith('quick-capture:open', undefined) }) }) diff --git a/apps/desktop/src/main/ipc/settings-handlers.ts b/apps/desktop/src/main/ipc/settings-handlers.ts index d73dde8d2..46bbaf546 100644 --- a/apps/desktop/src/main/ipc/settings-handlers.ts +++ b/apps/desktop/src/main/ipc/settings-handlers.ts @@ -7,7 +7,7 @@ * @module main/ipc/settings-handlers */ -import { ipcMain, BrowserWindow, app, globalShortcut, systemPreferences, shell } from 'electron' +import { ipcMain, app, globalShortcut, systemPreferences, shell } from 'electron' import { SettingsChannels } from '@memry/contracts/ipc-channels' import { GENERAL_SETTINGS_DEFAULTS, @@ -38,6 +38,7 @@ import type { import { GRAPH_SETTINGS_DEFAULTS } from '@memry/contracts/graph-api' import type { GraphSettings } from '@memry/contracts/graph-api' import { createLogger } from '../lib/logger' +import { broadcastToAllWindows } from '../lib/window-broadcast' import { getDatabase } from '../database' import { getSetting, setSetting, deleteSetting } from '../settings/settings-store' import { withErrorHandler } from './validate' @@ -316,11 +317,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 }) return { success: true } @@ -358,9 +357,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', { @@ -447,11 +444,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 }) return { success: true } @@ -491,11 +486,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 }) return { success: true } @@ -737,11 +730,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 }) return { success: true } @@ -778,11 +769,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 }) return { success: true } @@ -928,11 +917,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 } @@ -990,9 +977,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..7f4b73cb2 100644 --- a/apps/desktop/src/main/ipc/sync-attachment-handlers.test.ts +++ b/apps/desktop/src/main/ipc/sync-attachment-handlers.test.ts @@ -43,7 +43,9 @@ vi.mock('electron', () => ({ BrowserWindow: { getAllWindows: vi.fn(() => [ { + isDestroyed: () => false, webContents: { + isDestroyed: () => false, 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 e24f9cc3d..b28f36b2e 100644 --- a/apps/desktop/src/main/ipc/sync-attachment-handlers.ts +++ b/apps/desktop/src/main/ipc/sync-attachment-handlers.ts @@ -1,4 +1,4 @@ -import { BrowserWindow, ipcMain } from 'electron' +import { ipcMain } from 'electron' import fs from 'node:fs' import path from 'node:path' import sodium from 'libsodium-wrappers-sumo' @@ -45,6 +45,7 @@ import { } from '../crypto' import { getDatabase, isDatabaseInitialized } from '../database/client' import { createLogger } from '../lib/logger' +import { broadcastToAllWindows } from '../lib/window-broadcast' import { recordDownloadedFileSize, recordUploadedAttachment @@ -80,14 +81,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 => { @@ -231,13 +230,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 { @@ -370,14 +367,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 + }) } })() }) @@ -410,13 +405,11 @@ export function registerAttachmentHandlers(): void { diskPath, error: message }) - 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..ab0c35498 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,9 @@ 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: { isDestroyed: () => false, 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 b9bed72bb..12a12e441 100644 --- a/apps/desktop/src/main/ipc/sync-core-handlers.ts +++ b/apps/desktop/src/main/ipc/sync-core-handlers.ts @@ -1,4 +1,4 @@ -import { BrowserWindow, ipcMain } from 'electron' +import { ipcMain } from 'electron' import sodium from 'libsodium-wrappers-sumo' import { syncDevices } from '@memry/db-schema/schema/sync-devices' @@ -22,6 +22,7 @@ import { getDatabase, isDatabaseInitialized } from '../database/client' import { getFromServer } from '../sync/http-client' import { createLogger } from '../lib/logger' +import { broadcastToAllWindows } from '../lib/window-broadcast' import { withErrorHandler } from './validate' import { registerCommand } from './lib/register-command' import { getSyncEngine, startSyncRuntime } from '../sync/runtime' @@ -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 18c38766b..cf47d826b 100644 --- a/apps/desktop/src/main/ipc/tags-handlers.test.ts +++ b/apps/desktop/src/main/ipc/tags-handlers.test.ts @@ -41,7 +41,9 @@ vi.mock('electron', () => ({ }) }, BrowserWindow: { - getAllWindows: vi.fn(() => [{ webContents: { send: mockSend } }]) + getAllWindows: vi.fn(() => [ + { isDestroyed: () => false, webContents: { isDestroyed: () => false, send: mockSend } } + ]) } })) diff --git a/apps/desktop/src/main/ipc/tags-handlers.ts b/apps/desktop/src/main/ipc/tags-handlers.ts index 86bead3bd..7cdcdf8eb 100644 --- a/apps/desktop/src/main/ipc/tags-handlers.ts +++ b/apps/desktop/src/main/ipc/tags-handlers.ts @@ -6,7 +6,7 @@ */ import { readFile } from 'fs/promises' -import { ipcMain, BrowserWindow } from 'electron' +import { ipcMain } from 'electron' import { eq } from 'drizzle-orm' import { TagsChannels } from '@memry/contracts/ipc-channels' import { @@ -63,6 +63,7 @@ import { type TagAssignment } from '../tags/store' import { createLogger } from '../lib/logger' +import { broadcastToAllWindows } from '../lib/window-broadcast' import { toAbsolutePath } from '../vault/notes' import { parseNote, serializeParsedNote } from '../vault/frontmatter' import { atomicWrite } from '../vault/file-ops' @@ -84,9 +85,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/window-broadcast.ts b/apps/desktop/src/main/lib/window-broadcast.ts index eb7c68b73..8d7551e1a 100644 --- a/apps/desktop/src/main/lib/window-broadcast.ts +++ b/apps/desktop/src/main/lib/window-broadcast.ts @@ -9,7 +9,7 @@ import { BrowserWindow } from 'electron' * handlers), where an unguarded throw rolls back an applied item — so skip * destroyed windows, mirroring window-rpc.ts and crdt-provider.ts. */ -export function broadcastToAllWindows(channel: string, data: unknown): void { +export function broadcastToAllWindows(channel: string, data?: unknown): void { for (const win of BrowserWindow.getAllWindows()) { if (win.isDestroyed()) continue if (typeof win.webContents.isDestroyed === 'function' && win.webContents.isDestroyed()) continue diff --git a/apps/desktop/src/main/sync/crdt-writeback.test.ts b/apps/desktop/src/main/sync/crdt-writeback.test.ts index 3d3f2e995..a379a8688 100644 --- a/apps/desktop/src/main/sync/crdt-writeback.test.ts +++ b/apps/desktop/src/main/sync/crdt-writeback.test.ts @@ -42,7 +42,9 @@ vi.mock('electron', () => ({ BrowserWindow: { getAllWindows: () => [ { + isDestroyed: () => false, webContents: { + isDestroyed: () => false, 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 fbc21dbb2..94af77087 100644 --- a/apps/desktop/src/main/sync/crdt-writeback.ts +++ b/apps/desktop/src/main/sync/crdt-writeback.ts @@ -1,5 +1,6 @@ import * as Y from 'yjs' import { createLogger } from '../lib/logger' +import { broadcastToAllWindows } from '../lib/window-broadcast' import { getCrdtProvider } from './crdt-provider' import { yDocToMarkdown } from './blocknote-converter' import { readCriticMarkupMarksFromYDoc, serializeCriticMarkup } from '@memry/shared' @@ -34,7 +35,6 @@ 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 path from 'path' import { enqueueLocalSyncCreate, @@ -134,9 +134,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 23d42f4b4..ea30569e8 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 @@ -46,7 +46,9 @@ vi.mock('../../store', () => ({ const mockSend = vi.fn() vi.mock('electron', () => ({ BrowserWindow: { - getAllWindows: vi.fn(() => [{ webContents: { send: mockSend } }]) + getAllWindows: vi.fn(() => [ + { isDestroyed: () => false, webContents: { isDestroyed: () => false, send: mockSend } } + ]) } })) 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 2f66fe4b0..044100a36 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' @@ -13,6 +12,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') @@ -135,38 +135,26 @@ function propagateMergedSettings(merged: SyncedSettings): void { } 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 { + return } } diff --git a/apps/desktop/src/main/sync/linking-service.test.ts b/apps/desktop/src/main/sync/linking-service.test.ts index be9b0a0b6..8aacd3d8f 100644 --- a/apps/desktop/src/main/sync/linking-service.test.ts +++ b/apps/desktop/src/main/sync/linking-service.test.ts @@ -50,7 +50,9 @@ const { vi.mock('electron', () => ({ BrowserWindow: { - getAllWindows: () => [{ webContents: { send: mockSend } }] + getAllWindows: () => [ + { isDestroyed: () => false, webContents: { isDestroyed: () => false, send: mockSend } } + ] } })) diff --git a/apps/desktop/src/main/sync/linking-service.ts b/apps/desktop/src/main/sync/linking-service.ts index 7d8afe8e2..771d618fd 100644 --- a/apps/desktop/src/main/sync/linking-service.ts +++ b/apps/desktop/src/main/sync/linking-service.ts @@ -1,6 +1,5 @@ import os from 'os' import sodium from 'libsodium-wrappers-sumo' -import { BrowserWindow } from 'electron' import { KEYCHAIN_ENTRIES } from '@memry/contracts/crypto' import type { @@ -35,6 +34,7 @@ import { persistImportedGoogleProviderAuth } from '../calendar/google/provider-auth-transfer' import { createLogger } from '../lib/logger' +import { broadcastToAllWindows } from '../lib/window-broadcast' import { getFromServer, postToServer, RateLimitError, SyncServerError } from './http-client' import { withRetry } from './retry' @@ -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..49cbe8432 100644 --- a/apps/desktop/src/main/sync/token-manager.test.ts +++ b/apps/desktop/src/main/sync/token-manager.test.ts @@ -217,7 +217,10 @@ 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: { isDestroyed: () => false, send: vi.fn() } + } mockGetAllWindows.mockReturnValue([mockWin]) // #when @@ -281,7 +284,10 @@ describe('token-manager', () => { return Promise.resolve(null) }) mockDecodeJwt.mockReturnValue({ exp: nowSeconds() - 100 }) - const win = { webContents: { send: vi.fn() } } + const win = { + isDestroyed: () => false, + webContents: { isDestroyed: () => false, 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..931383f25 100644 --- a/apps/desktop/src/main/sync/token-manager.ts +++ b/apps/desktop/src/main/sync/token-manager.ts @@ -1,4 +1,3 @@ -import { BrowserWindow } from 'electron' import { decodeJwt } from 'jose' import { KEYCHAIN_ENTRIES } from '@memry/contracts/crypto' @@ -8,6 +7,7 @@ import { RefreshTokenResponseSchema } from '@memry/contracts/auth-api' import { storeKey, retrieveKey } from '../crypto' import { postToServer, SyncServerError } from './http-client' import { createLogger } from '../lib/logger' +import { broadcastToAllWindows } from '../lib/window-broadcast' const log = createLogger('TokenManager') @@ -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 c8dd2efdf..36aa6d6fb 100644 --- a/apps/desktop/src/main/test-hooks.test.ts +++ b/apps/desktop/src/main/test-hooks.test.ts @@ -1,7 +1,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const windowSendMock = vi.fn() -const browserWindows = [{ webContents: { send: windowSendMock } }] +const browserWindows = [ + { isDestroyed: () => false, webContents: { isDestroyed: () => false, 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 db8be0ced..4a0221cb0 100644 --- a/apps/desktop/src/main/test-hooks.ts +++ b/apps/desktop/src/main/test-hooks.ts @@ -21,6 +21,7 @@ import { import { listCalendarExternalEventsBySource } from './calendar/repositories/calendar-external-events-repository' import { calendarEvents } from '@memry/db-schema/schema/calendar-events' import { getMainI18n } from './lib/main-i18n' +import { broadcastToAllWindows } from './lib/window-broadcast' import { getOrInitializeLocalVaultKey, VAULT_KEY_VERIFIER_SETTING } from './crypto/vault-key-state' import { getOrCreateVaultUuid } from './agent/storage/vault-id' import { inboxItems, inboxItemType } from '@memry/db-schema/schema/inbox' @@ -517,20 +518,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 42ee2ea8e..19c68dd2b 100644 --- a/apps/desktop/src/main/vault/index.test.ts +++ b/apps/desktop/src/main/vault/index.test.ts @@ -67,7 +67,9 @@ vi.mock('electron', () => ({ BrowserWindow: { getAllWindows: () => [ { + isDestroyed: () => false, webContents: { + isDestroyed: () => false, 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 5271b8cf8..41a8d6b61 100644 --- a/apps/desktop/src/main/vault/index.ts +++ b/apps/desktop/src/main/vault/index.ts @@ -1,4 +1,4 @@ -import { dialog, BrowserWindow } from 'electron' +import { dialog } from 'electron' import type { VaultInfo, VaultStatus, @@ -46,6 +46,7 @@ import { import { ensureDefaultTaskProject } from '../database/defaults' import { VaultChannels } from '@memry/contracts/ipc-channels' import { VaultError, VaultErrorCode } from '../lib/errors' +import { broadcastToAllWindows } from '../lib/window-broadcast' import { startWatcher, stopWatcher } from './watcher' import { indexVault, rebuildIndex } from './indexer' import { createLogger } from '../lib/logger' @@ -188,9 +189,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) } /** @@ -198,9 +197,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) } /** @@ -208,9 +205,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) } /** @@ -227,9 +222,7 @@ export interface IndexRecoveredEvent { * Sent after automatic recovery from corrupt or missing index. */ export function emitIndexRecovered(event: IndexRecoveredEvent): void { - 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..ee08cf5ae 100644 --- a/apps/desktop/src/main/vault/notes-io.ts +++ b/apps/desktop/src/main/vault/notes-io.ts @@ -7,10 +7,10 @@ */ import path from 'path' -import { BrowserWindow } from 'electron' import { getStatus, getConfig } from './index' import { normalizeRelativePath } from '../lib/paths' import { VaultError, VaultErrorCode } from '../lib/errors' +import { broadcastToAllWindows } from '../lib/window-broadcast' // ============================================================================ // Helpers @@ -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 960c08833..fcc86257c 100644 --- a/apps/desktop/src/main/vault/notes.test.ts +++ b/apps/desktop/src/main/vault/notes.test.ts @@ -21,8 +21,8 @@ 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 mockWebContents = { isDestroyed: () => false, send: vi.fn() } + const mockWindow = { isDestroyed: () => false, webContents: mockWebContents } return { BrowserWindow: { @@ -238,7 +238,10 @@ 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: { isDestroyed: () => false, send: mockSend } + } as never ]) await notes.createNote({ @@ -523,7 +526,10 @@ 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: { isDestroyed: () => false, send: mockSend } + } as never ]) const created = await notes.createNote({ @@ -726,7 +732,10 @@ 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: { isDestroyed: () => false, send: mockSend } + } as never ]) const created = await notes.createNote({ @@ -879,7 +888,10 @@ 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: { isDestroyed: () => false, send: mockSend } + } as never ]) const created = await notes.createNote({ @@ -982,7 +994,10 @@ 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: { isDestroyed: () => false, 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..283c4e212 100644 --- a/apps/desktop/src/main/vault/rename-tracker.ts +++ b/apps/desktop/src/main/vault/rename-tracker.ts @@ -11,10 +11,10 @@ */ import path from 'path' -import { BrowserWindow } from 'electron' import { NotesChannels } from '@memry/contracts/ipc-channels' import type { NoteRenamedEvent } from '@memry/contracts/notes-api' import { createLogger } from '../lib/logger' +import { broadcastToAllWindows } from '../lib/window-broadcast' const logger = createLogger('RenameTracker') @@ -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/templates.ts b/apps/desktop/src/main/vault/templates.ts index cf1f30cd9..841902ea1 100644 --- a/apps/desktop/src/main/vault/templates.ts +++ b/apps/desktop/src/main/vault/templates.ts @@ -8,7 +8,6 @@ import path from 'path' import fs from 'fs/promises' import { existsSync, mkdirSync } from 'fs' -import { BrowserWindow } from 'electron' import matter from 'gray-matter' import { getStatus } from './index' import { getMemryDir } from './init' @@ -23,6 +22,7 @@ import type { TemplateProperty } from '@memry/contracts/templates-api' import { createLogger } from '../lib/logger' +import { broadcastToAllWindows } from '../lib/window-broadcast' const logger = createLogger('Templates') @@ -330,9 +330,7 @@ async function seedBuiltInTemplates(): Promise { * Emit template event to all windows. */ function emitTemplateEvent(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/watcher.ts b/apps/desktop/src/main/vault/watcher.ts index a6bbd1764..56f31c95c 100644 --- a/apps/desktop/src/main/vault/watcher.ts +++ b/apps/desktop/src/main/vault/watcher.ts @@ -11,7 +11,6 @@ import path from 'path' import fs from 'fs/promises' import chokidar from 'chokidar' import type { FSWatcher } from 'chokidar' -import { BrowserWindow } from 'electron' import { getConfig } from './index' import { parseNote, generateContentHash, extractProperties } from './frontmatter' import { safeRead } from './file-ops' @@ -46,6 +45,7 @@ import { } from '../journal/runtime-effects' import { syncNoteCreate, syncNoteDelete, syncNoteUpdate } from '../notes/runtime-effects' import { normalizeRelativePath } from '../lib/paths' +import { broadcastToAllWindows } from '../lib/window-broadcast' const logger = createLogger('Watcher') @@ -112,9 +112,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..daac6b56d 100644 --- a/apps/docs/src/architecture/ipc.md +++ b/apps/docs/src/architecture/ipc.md @@ -83,4 +83,17 @@ 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. +Main-process code that fans an event out to every open window 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. + +```ts +import { broadcastToAllWindows } from '../lib/window-broadcast' + +broadcastToAllWindows(SettingsChannels.events.CHANGED, { key, value }) +broadcastToAllWindows('quick-capture:open') // payload is optional +``` + +Every fan-out site in main uses it: sync status, session expiry, OAuth and device pairing events, settings changes (local writes and merged remote settings), attachment upload/download progress, vault status, index progress and index recovery, watcher and note-rename events, import progress, template events, inbox capture/filing/snooze/transcription events, search and embedding progress, updater state, reminders, agent events, FTS rebuild progress, and calendar, task, tag, bookmark, saved-filter, journal, canvas, locale, and AI inline events. + +The helper is for fan-out only. Code that targets one specific window — a `find()` / `filter()` lookup, `getAllWindows()[0]`, or `BrowserWindow.fromWebContents()` — still uses `BrowserWindow` directly. + +Main-process tests that mock `electron` must give each mocked window an `isDestroyed()` that returns `false`, and the same on its `webContents`; otherwise the guard filters the window out and the broadcast assertion never fires.