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
2 changes: 2 additions & 0 deletions apps/desktop/src/main/ipc/generated-ipc-invoke-map.ts
Original file line number Diff line number Diff line change
Expand Up @@ -376,6 +376,8 @@ export interface MainIpcInvokeHandlers {
"updater:download-update": (...args: []) => Awaited<Promise<import("../../../../../packages/contracts/src/ipc-updater").AppUpdateState>>
"updater:get-state": (...args: []) => Awaited<import("../../../../../packages/contracts/src/ipc-updater").AppUpdateState>
"updater:quit-and-install": (...args: []) => Awaited<void>
"updater:set-auto-download": (...args: [boolean]) => Awaited<import("../../../../../packages/contracts/src/ipc-updater").AppUpdateState>
"updater:skip-version": (...args: [string]) => Awaited<import("../../../../../packages/contracts/src/ipc-updater").AppUpdateState>
"vault:close": (...args: []) => Awaited<Promise<void>>
"vault:download-remote": (...args: [{ vaultUuid: string; parentPath?: string | undefined; }]) => Awaited<Promise<import("../../../../../packages/contracts/src/vault-api").SelectVaultResponse>>
"vault:get-all": (...args: []) => Awaited<Promise<import("../../../../../packages/contracts/src/vault-api").GetVaultsResponse>>
Expand Down
27 changes: 24 additions & 3 deletions apps/desktop/src/main/ipc/updater-handlers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@ const mocks = vi.hoisted(() => ({
checkForUpdates: vi.fn(),
downloadUpdate: vi.fn(),
getUpdateState: vi.fn(),
quitAndInstall: vi.fn()
quitAndInstall: vi.fn(),
skipVersion: vi.fn(),
setAutoDownloadEnabled: vi.fn()
}))

vi.mock('electron', () => ({
Expand All @@ -25,7 +27,9 @@ vi.mock('../updater', () => ({
checkForUpdates: mocks.checkForUpdates,
downloadUpdate: mocks.downloadUpdate,
getUpdateState: mocks.getUpdateState,
quitAndInstall: mocks.quitAndInstall
quitAndInstall: mocks.quitAndInstall,
skipVersion: mocks.skipVersion,
setAutoDownloadEnabled: mocks.setAutoDownloadEnabled
}))

import { UpdaterChannels } from '@memry/contracts/ipc-updater'
Expand All @@ -38,22 +42,37 @@ describe('updater ipc handlers', () => {
mocks.getUpdateState.mockReturnValue({ status: 'idle' })
mocks.checkForUpdates.mockResolvedValue({ status: 'checking' })
mocks.downloadUpdate.mockResolvedValue({ status: 'downloading' })
mocks.skipVersion.mockReturnValue({ status: 'up-to-date' })
mocks.setAutoDownloadEnabled.mockReturnValue({ status: 'idle', autoDownloadEnabled: true })
})

it('registers every updater invoke channel and forwards calls to updater services', async () => {
registerUpdaterHandlers()

expect(mocks.handle).toHaveBeenCalledTimes(4)
expect(mocks.handle).toHaveBeenCalledTimes(6)
expect(mocks.handlers.get(UpdaterChannels.invoke.GET_STATE)?.()).toEqual({ status: 'idle' })
await expect(mocks.handlers.get(UpdaterChannels.invoke.CHECK_FOR_UPDATES)?.()).resolves.toEqual(
{ status: 'checking' }
)
// Manual checks clear a skipped version.
expect(mocks.checkForUpdates).toHaveBeenCalledWith({ clearSkip: true })
await expect(mocks.handlers.get(UpdaterChannels.invoke.DOWNLOAD_UPDATE)?.()).resolves.toEqual({
status: 'downloading'
})

expect(mocks.handlers.get(UpdaterChannels.invoke.QUIT_AND_INSTALL)?.()).toBeUndefined()
expect(mocks.quitAndInstall).toHaveBeenCalledTimes(1)

expect(mocks.handlers.get(UpdaterChannels.invoke.SKIP_VERSION)?.({}, 'v1.2.4')).toEqual({
status: 'up-to-date'
})
expect(mocks.skipVersion).toHaveBeenCalledWith('v1.2.4')

expect(mocks.handlers.get(UpdaterChannels.invoke.SET_AUTO_DOWNLOAD)?.({}, true)).toEqual({
status: 'idle',
autoDownloadEnabled: true
})
expect(mocks.setAutoDownloadEnabled).toHaveBeenCalledWith(true)
})

it('unregisters every updater invoke channel', () => {
Expand All @@ -64,6 +83,8 @@ describe('updater ipc handlers', () => {
expect(mocks.removeHandler).toHaveBeenCalledWith(UpdaterChannels.invoke.CHECK_FOR_UPDATES)
expect(mocks.removeHandler).toHaveBeenCalledWith(UpdaterChannels.invoke.DOWNLOAD_UPDATE)
expect(mocks.removeHandler).toHaveBeenCalledWith(UpdaterChannels.invoke.QUIT_AND_INSTALL)
expect(mocks.removeHandler).toHaveBeenCalledWith(UpdaterChannels.invoke.SKIP_VERSION)
expect(mocks.removeHandler).toHaveBeenCalledWith(UpdaterChannels.invoke.SET_AUTO_DOWNLOAD)
expect(mocks.handlers.size).toBe(0)
})
})
22 changes: 20 additions & 2 deletions apps/desktop/src/main/ipc/updater-handlers.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,37 @@
import { ipcMain } from 'electron'
import { UpdaterChannels } from '@memry/contracts/ipc-updater'
import { checkForUpdates, downloadUpdate, getUpdateState, quitAndInstall } from '../updater'
import {
checkForUpdates,
downloadUpdate,
getUpdateState,
quitAndInstall,
setAutoDownloadEnabled,
skipVersion
} from '../updater'

export function registerUpdaterHandlers(): void {
ipcMain.handle(UpdaterChannels.invoke.GET_STATE, () => getUpdateState())
ipcMain.handle(UpdaterChannels.invoke.CHECK_FOR_UPDATES, () => checkForUpdates())
// Manual checks clear a skipped version so the user can see it again.
ipcMain.handle(UpdaterChannels.invoke.CHECK_FOR_UPDATES, () =>
checkForUpdates({ clearSkip: true })
)
ipcMain.handle(UpdaterChannels.invoke.DOWNLOAD_UPDATE, () => downloadUpdate())
ipcMain.handle(UpdaterChannels.invoke.QUIT_AND_INSTALL, () => {
quitAndInstall()
})
ipcMain.handle(UpdaterChannels.invoke.SKIP_VERSION, (_event, version: string) =>
skipVersion(version)
)
ipcMain.handle(UpdaterChannels.invoke.SET_AUTO_DOWNLOAD, (_event, enabled: boolean) =>
setAutoDownloadEnabled(enabled)
)
}

export function unregisterUpdaterHandlers(): void {
ipcMain.removeHandler(UpdaterChannels.invoke.GET_STATE)
ipcMain.removeHandler(UpdaterChannels.invoke.CHECK_FOR_UPDATES)
ipcMain.removeHandler(UpdaterChannels.invoke.DOWNLOAD_UPDATE)
ipcMain.removeHandler(UpdaterChannels.invoke.QUIT_AND_INSTALL)
ipcMain.removeHandler(UpdaterChannels.invoke.SKIP_VERSION)
ipcMain.removeHandler(UpdaterChannels.invoke.SET_AUTO_DOWNLOAD)
}
37 changes: 36 additions & 1 deletion apps/desktop/src/main/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,17 @@ export interface AgentStoreData {
}
}

/**
* Auto-updater preferences that must survive restarts and be readable by the
* main process before any renderer loads (so the startup update check honors them).
*/
export interface UpdaterStoreData {
/** Display version the user chose to skip; suppresses automatic prompts for it. */
skippedVersion?: string
/** When true, updates download + install without prompting. */
autoDownload?: boolean
}

/**
* Application store schema
*/
Expand All @@ -78,6 +89,8 @@ interface StoreSchema {
agent: AgentStoreData
/** Localhost capture: origins that have completed the pairing handshake */
captureAllowedOrigins: string[]
/** Auto-updater preferences */
updater: UpdaterStoreData
}

const CONFIG_FILE = 'memry-config.json'
Expand All @@ -88,7 +101,8 @@ const defaultData: StoreSchema = {
vaults: [],
sync: {},
agent: {},
captureAllowedOrigins: []
captureAllowedOrigins: [],
updater: {}
}

/** In-memory cache — populated on first read, updated on every write. */
Expand Down Expand Up @@ -275,6 +289,27 @@ export function setDefaultVaultPath(vaultPath: string): StoredVaultInfo | null {
return updated.find((vault) => vault.path === vaultPath) ?? null
}

/**
* Get the persisted auto-updater preferences.
*/
export function getUpdaterPrefs(): UpdaterStoreData {
return store.get('updater')
}

/**
* Persist the display version the user skipped (or clear it with null).
*/
export function setSkippedVersion(version: string | null): void {
store.set('updater', { ...store.get('updater'), skippedVersion: version ?? undefined })
}

/**
* Persist whether updates download + install automatically.
*/
export function setAutoDownloadPref(enabled: boolean): void {
store.set('updater', { ...store.get('updater'), autoDownload: enabled })
}

/**
* Update the lastOpened timestamp for a vault
*/
Expand Down
99 changes: 84 additions & 15 deletions apps/desktop/src/main/updater.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,18 @@ const mocks = vi.hoisted(() => {
}

const emitter = new MockEmitter()
const storeState: { prefs: { skippedVersion?: string; autoDownload?: boolean } } = { prefs: {} }
return {
storeState,
store: {
getUpdaterPrefs: vi.fn(() => storeState.prefs),
setSkippedVersion: vi.fn((version: string | null) => {
storeState.prefs = { ...storeState.prefs, skippedVersion: version ?? undefined }
}),
setAutoDownloadPref: vi.fn((enabled: boolean) => {
storeState.prefs = { ...storeState.prefs, autoDownload: enabled }
})
},
app: {
isPackaged: true,
getVersion: vi.fn(() => '1.2.3'),
Expand Down Expand Up @@ -63,6 +74,12 @@ vi.mock('electron-updater', () => ({
autoUpdater: mocks.autoUpdater
}))

vi.mock('./store', () => ({
getUpdaterPrefs: mocks.store.getUpdaterPrefs,
setSkippedVersion: mocks.store.setSkippedVersion,
setAutoDownloadPref: mocks.store.setAutoDownloadPref
}))

vi.mock('./lib/logger', () => ({
createLogger: () => ({
info: vi.fn(),
Expand Down Expand Up @@ -99,6 +116,7 @@ async function flushAsyncWork(): Promise<void> {
describe('updater', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.storeState.prefs = {}
mocks.autoUpdater.removeAllListeners()
mocks.autoUpdater.autoDownload = true
mocks.autoUpdater.autoInstallOnAppQuit = false
Expand Down Expand Up @@ -161,9 +179,8 @@ describe('updater', () => {
expect(mocks.autoUpdater.autoInstallOnAppQuit).toBe(true)
})

it('shows available-update prompts with release notes and starts downloads on demand', async () => {
it('exposes available-update state without a native prompt or auto-download', async () => {
const updater = await loadUpdater()
mocks.dialog.showMessageBox.mockResolvedValueOnce({ response: 0 })

updater.initializeUpdater()
mocks.autoUpdater.emit('update-available', {
Expand All @@ -174,22 +191,22 @@ describe('updater', () => {
})
await flushAsyncWork()

expect(mocks.dialog.showMessageBox).toHaveBeenCalledWith(
expect.objectContaining({
buttons: ['dialog.update.buttonDownload', 'dialog.update.buttonLater'],
detail: expect.stringContaining('Desktop sync fixes')
})
)
expect(mocks.autoUpdater.downloadUpdate).toHaveBeenCalledTimes(1)
// The in-app modal renders from this state — no native OS dialog, and the
// download waits for the user (auto-download off by default).
expect(mocks.dialog.showMessageBox).not.toHaveBeenCalled()
expect(mocks.autoUpdater.downloadUpdate).not.toHaveBeenCalled()
expect(updater.getUpdateState()).toMatchObject({
status: 'available',
availableVersion: 'v1.2.4',
releaseName: 'Memrynote 1.2.4',
releaseDate: '2026-05-10',
autoDownloadEnabled: false,
error: null
})
expect(updater.getUpdateState().releaseNotes).toContain('Desktop sync fixes')
})

it('strips HTML from release notes before showing them in the dialog', async () => {
it('strips HTML from release notes exposed in the update state', async () => {
const updater = await loadUpdater()

updater.initializeUpdater()
Expand All @@ -199,11 +216,11 @@ describe('updater', () => {
})
await flushAsyncWork()

const { detail } = mocks.dialog.showMessageBox.mock.calls[0][0]
expect(detail).toContain('Sync fix')
expect(detail).toContain('• Calendar fix')
expect(detail).not.toMatch(/<[^>]+>/)
expect(updater.getUpdateState().releaseNotes).toBe('Fixes\n• Sync fix\n• Calendar fix')
const { releaseNotes } = updater.getUpdateState()
expect(releaseNotes).toContain('Sync fix')
expect(releaseNotes).toContain('• Calendar fix')
expect(releaseNotes).not.toMatch(/<[^>]+>/)
expect(releaseNotes).toBe('Fixes\n• Sync fix\n• Calendar fix')
})

it('strips the developer changelog from string release notes', async () => {
Expand Down Expand Up @@ -239,6 +256,58 @@ describe('updater', () => {
expect(updater.getUpdateState().releaseNotes).toBe('New Features\n• Calendar sync')
})

it('skips a version: clears the current prompt and suppresses it on re-check', async () => {
const updater = await loadUpdater()
updater.initializeUpdater()

mocks.autoUpdater.emit('update-available', { version: '1.2.4', releaseNotes: 'notes' })
expect(updater.getUpdateState().status).toBe('available')

const next = updater.skipVersion('v1.2.4')
expect(mocks.store.setSkippedVersion).toHaveBeenCalledWith('v1.2.4')
expect(next.status).toBe('up-to-date')
expect(next.availableVersion).toBeNull()

// The same version re-emitted stays suppressed (no 'available').
mocks.autoUpdater.emit('update-available', { version: '1.2.4', releaseNotes: 'notes' })
expect(updater.getUpdateState().status).toBe('up-to-date')

// A different version still surfaces.
mocks.autoUpdater.emit('update-available', { version: '1.2.5', releaseNotes: 'notes' })
expect(updater.getUpdateState()).toMatchObject({
status: 'available',
availableVersion: 'v1.2.5'
})
})

it('clears a skipped version on a manual (clearSkip) check', async () => {
const updater = await loadUpdater()
updater.initializeUpdater()
updater.skipVersion('v1.2.4')

await updater.checkForUpdates({ clearSkip: true })
expect(mocks.store.setSkippedVersion).toHaveBeenCalledWith(null)
})

it('persists and applies the auto-download preference', async () => {
const updater = await loadUpdater()
updater.initializeUpdater()

const next = updater.setAutoDownloadEnabled(true)
expect(mocks.store.setAutoDownloadPref).toHaveBeenCalledWith(true)
expect(mocks.autoUpdater.autoDownload).toBe(true)
expect(next.autoDownloadEnabled).toBe(true)
})

it('honors a persisted auto-download preference at startup', async () => {
mocks.storeState.prefs = { autoDownload: true }
const updater = await loadUpdater()
updater.initializeUpdater()

expect(mocks.autoUpdater.autoDownload).toBe(true)
expect(updater.getUpdateState().autoDownloadEnabled).toBe(true)
})

it('coalesces manual checks and downloads, then installs downloaded updates', async () => {
const updater = await loadUpdater()
updater.initializeUpdater()
Expand Down
Loading
Loading