Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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() }
}
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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() }
}
})
Expand Down
4 changes: 3 additions & 1 deletion apps/desktop/src/main/import/bear/bear-importer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() }
}
})
Expand Down
4 changes: 3 additions & 1 deletion apps/desktop/src/main/import/csv/csv-importer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() }
}
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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() }
}
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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() }
}
})
Expand Down
4 changes: 3 additions & 1 deletion apps/desktop/src/main/import/html/html-importer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() }
}
})
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src/main/import/import-context.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
6 changes: 2 additions & 4 deletions apps/desktop/src/main/import/import-context.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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() }
}
})
Expand Down
4 changes: 3 additions & 1 deletion apps/desktop/src/main/import/notion/notion-importer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() }
}
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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() }
}
})
Expand Down
4 changes: 3 additions & 1 deletion apps/desktop/src/main/import/roam/roam-importer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() }
}
})
Expand Down
64 changes: 61 additions & 3 deletions apps/desktop/src/main/inbox/review-notification.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>) {
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', () => ({
Expand All @@ -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<typeof vi.fn>
focus: ReturnType<typeof vi.fn>
} {
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', () => {
Expand Down Expand Up @@ -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, {})
})
})
21 changes: 13 additions & 8 deletions apps/desktop/src/main/inbox/review-notification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, {})
}

/**
Expand Down
4 changes: 3 additions & 1 deletion apps/desktop/src/main/ipc/ai-inline-handlers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
11 changes: 5 additions & 6 deletions apps/desktop/src/main/ipc/ai-inline-handlers.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -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 }
Expand Down
18 changes: 12 additions & 6 deletions apps/desktop/src/main/ipc/auth-device-handlers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')

Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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' })
Expand Down Expand Up @@ -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, {
Expand Down
Loading
Loading