feat: implement on-demand update notification nudge (#570)

* wip

* feat: implement on-demand update notification nudge

* fix: address CI lint failures
This commit is contained in:
Jinjing 2026-04-12 21:30:15 -07:00 committed by GitHub
parent 8ef3139529
commit f9e803082c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 948 additions and 90 deletions

View File

@ -79,6 +79,7 @@ describe('registerPtyHandlers', () => {
}
beforeEach(() => {
delete process.env.OPENCODE_CONFIG_DIR
handlers.clear()
handleMock.mockReset()
onMock.mockReset()

View File

@ -130,7 +130,7 @@ export function registerAutoUpdaterHandlers({
setAvailableReleaseUrl(null)
recordCompletedUpdateCheck()
if (!wasUserInitiated) {
scheduleAutomaticUpdateCheck(36 * 60 * 60 * 1000)
scheduleAutomaticUpdateCheck(24 * 60 * 60 * 1000)
}
sendStatus({ state: 'available', version: info.version, changelog })
@ -144,7 +144,7 @@ export function registerAutoUpdaterHandlers({
clearAvailableUpdateContext()
recordCompletedUpdateCheck()
if (!wasUserInitiated) {
scheduleAutomaticUpdateCheck(36 * 60 * 60 * 1000)
scheduleAutomaticUpdateCheck(24 * 60 * 60 * 1000)
}
sendStatus({ state: 'not-available', userInitiated: wasUserInitiated || undefined })
})

View File

@ -12,6 +12,7 @@ export function statusesEqual(left: UpdateStatus, right: UpdateStatus): boolean
return (
right.state === 'available' &&
left.version === right.version &&
left.activeNudgeId === right.activeNudgeId &&
left.releaseUrl === right.releaseUrl &&
// Why: fetchChangelog creates a fresh object each time, so reference
// equality is always false. Compare by presence — since update-available
@ -22,19 +23,22 @@ export function statusesEqual(left: UpdateStatus, right: UpdateStatus): boolean
return (
right.state === 'downloading' &&
left.version === right.version &&
left.activeNudgeId === right.activeNudgeId &&
left.percent === right.percent
)
case 'downloaded':
return (
right.state === 'downloaded' &&
left.version === right.version &&
left.activeNudgeId === right.activeNudgeId &&
left.releaseUrl === right.releaseUrl
)
case 'error':
return (
right.state === 'error' &&
left.message === right.message &&
left.userInitiated === right.userInitiated
left.userInitiated === right.userInitiated &&
left.activeNudgeId === right.activeNudgeId
)
}
}
@ -89,6 +93,10 @@ function parseVersion(value: string): ParsedVersion | null {
}
}
export function isValidVersion(value: string): boolean {
return parseVersion(value) !== null
}
function compareIdentifiers(left: string, right: string): number {
const leftNumeric = /^\d+$/.test(left)
const rightNumeric = /^\d+$/.test(right)

View File

@ -0,0 +1,196 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { netFetchMock } = vi.hoisted(() => ({
netFetchMock: vi.fn()
}))
vi.mock('electron', () => ({
net: { fetch: netFetchMock }
}))
import { fetchNudge, versionMatchesRange, shouldApplyNudge } from './updater-nudge'
describe('updater-nudge', () => {
beforeEach(() => {
netFetchMock.mockReset()
})
describe('fetchNudge', () => {
it('returns a valid config for a well-formed response', async () => {
netFetchMock.mockResolvedValue({
ok: true,
json: async () => ({ id: 'campaign-1', minVersion: '1.1.0', maxVersion: '1.1.19' })
})
const result = await fetchNudge()
expect(result).toEqual({ id: 'campaign-1', minVersion: '1.1.0', maxVersion: '1.1.19' })
})
it('returns a valid config with only maxVersion', async () => {
netFetchMock.mockResolvedValue({
ok: true,
json: async () => ({ id: 'campaign-2', maxVersion: '1.1.19' })
})
const result = await fetchNudge()
expect(result).toEqual({ id: 'campaign-2', maxVersion: '1.1.19' })
})
it('returns null for an empty response', async () => {
netFetchMock.mockResolvedValue({
ok: true,
json: async () => ({})
})
await expect(fetchNudge()).resolves.toBeNull()
})
it('returns null for a null response', async () => {
netFetchMock.mockResolvedValue({
ok: true,
json: async () => null
})
await expect(fetchNudge()).resolves.toBeNull()
})
it('returns null on non-ok HTTP response', async () => {
netFetchMock.mockResolvedValue({ ok: false })
await expect(fetchNudge()).resolves.toBeNull()
})
it('returns null on network error', async () => {
netFetchMock.mockRejectedValue(new Error('network down'))
await expect(fetchNudge()).resolves.toBeNull()
})
it('trims whitespace from the campaign id', async () => {
netFetchMock.mockResolvedValue({
ok: true,
json: async () => ({ id: ' campaign-1 ', minVersion: '1.0.0' })
})
const result = await fetchNudge()
expect(result?.id).toBe('campaign-1')
})
it('returns null when id is missing', async () => {
netFetchMock.mockResolvedValue({
ok: true,
json: async () => ({ minVersion: '1.0.0' })
})
await expect(fetchNudge()).resolves.toBeNull()
})
it('returns null when neither version endpoint is present', async () => {
netFetchMock.mockResolvedValue({
ok: true,
json: async () => ({ id: 'campaign-1' })
})
await expect(fetchNudge()).resolves.toBeNull()
})
it('returns null when minVersion is invalid', async () => {
netFetchMock.mockResolvedValue({
ok: true,
json: async () => ({ id: 'campaign-1', minVersion: 'not-a-version' })
})
await expect(fetchNudge()).resolves.toBeNull()
})
it('returns null when maxVersion is invalid', async () => {
netFetchMock.mockResolvedValue({
ok: true,
json: async () => ({ id: 'campaign-1', maxVersion: 'wat' })
})
await expect(fetchNudge()).resolves.toBeNull()
})
it('returns null when the configured range is inverted', async () => {
netFetchMock.mockResolvedValue({
ok: true,
json: async () => ({
id: 'campaign-1',
minVersion: '1.2.0',
maxVersion: '1.1.0'
})
})
await expect(fetchNudge()).resolves.toBeNull()
})
})
describe('versionMatchesRange', () => {
it('bounded range match', () => {
expect(versionMatchesRange('1.1.5', { minVersion: '1.1.0', maxVersion: '1.1.19' })).toBe(true)
expect(versionMatchesRange('1.1.0', { minVersion: '1.1.0', maxVersion: '1.1.19' })).toBe(true)
expect(versionMatchesRange('1.1.19', { minVersion: '1.1.0', maxVersion: '1.1.19' })).toBe(true)
expect(versionMatchesRange('1.0.9', { minVersion: '1.1.0', maxVersion: '1.1.19' })).toBe(false)
expect(versionMatchesRange('1.2.0', { minVersion: '1.1.0', maxVersion: '1.1.19' })).toBe(false)
})
it('upper-only range match', () => {
expect(versionMatchesRange('1.1.5', { maxVersion: '1.1.19' })).toBe(true)
expect(versionMatchesRange('1.2.0', { maxVersion: '1.1.19' })).toBe(false)
})
it('lower-only range match', () => {
expect(versionMatchesRange('1.1.5', { minVersion: '1.1.0' })).toBe(true)
expect(versionMatchesRange('1.0.0', { minVersion: '1.1.0' })).toBe(false)
})
})
describe('shouldApplyNudge', () => {
const nudge = { id: 'campaign-1', minVersion: '1.0.0' }
it('returns true when version matches and not dismissed/pending', () => {
expect(
shouldApplyNudge({
nudge,
appVersion: '1.5.0',
pendingUpdateNudgeId: null,
dismissedUpdateNudgeId: null
})
).toBe(true)
})
it('returns false when campaign already dismissed', () => {
expect(
shouldApplyNudge({
nudge,
appVersion: '1.5.0',
pendingUpdateNudgeId: null,
dismissedUpdateNudgeId: 'campaign-1'
})
).toBe(false)
})
it('returns false when campaign is already pending', () => {
expect(
shouldApplyNudge({
nudge,
appVersion: '1.5.0',
pendingUpdateNudgeId: 'campaign-1',
dismissedUpdateNudgeId: null
})
).toBe(false)
})
it('returns false when version does not match', () => {
expect(
shouldApplyNudge({
nudge,
appVersion: '0.9.0',
pendingUpdateNudgeId: null,
dismissedUpdateNudgeId: null
})
).toBe(false)
})
})
})

86
src/main/updater-nudge.ts Normal file
View File

@ -0,0 +1,86 @@
import { net } from 'electron'
import { compareVersions, isValidVersion } from './updater-fallback'
export type NudgeConfig = {
id: string
minVersion?: string
maxVersion?: string
}
export async function fetchNudge(): Promise<NudgeConfig | null> {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 5000)
try {
const res = await net.fetch('https://onorca.dev/whats-new/nudge.json', {
signal: controller.signal
})
if (!res.ok) {
return null
}
const json: unknown = await res.json()
if (!json || typeof json !== 'object' || Array.isArray(json)) {
return null
}
const { id, minVersion, maxVersion } = json as Record<string, unknown>
if (typeof id !== 'string' || !id.trim()) {
return null
}
if (minVersion === undefined && maxVersion === undefined) {
return null
}
if (minVersion !== undefined && typeof minVersion !== 'string') {return null}
if (maxVersion !== undefined && typeof maxVersion !== 'string') {return null}
if (minVersion !== undefined && !isValidVersion(minVersion)) {return null}
if (maxVersion !== undefined && !isValidVersion(maxVersion)) {return null}
if (
minVersion !== undefined &&
maxVersion !== undefined &&
compareVersions(minVersion, maxVersion) > 0
) {
return null
}
return {
id: id.trim(),
minVersion,
maxVersion
}
} catch {
return null
} finally {
clearTimeout(timeout)
}
}
export function versionMatchesRange(
appVersion: string,
range: { minVersion?: string; maxVersion?: string }
): boolean {
if (range.minVersion !== undefined && compareVersions(appVersion, range.minVersion) < 0) {
return false
}
if (range.maxVersion !== undefined && compareVersions(appVersion, range.maxVersion) > 0) {
return false
}
return true
}
export function shouldApplyNudge(args: {
nudge: NudgeConfig
appVersion: string
pendingUpdateNudgeId: string | null
dismissedUpdateNudgeId: string | null
}): boolean {
const { nudge, appVersion, pendingUpdateNudgeId, dismissedUpdateNudgeId } = args
if (nudge.id === pendingUpdateNudgeId || nudge.id === dismissedUpdateNudgeId) {
return false
}
return versionMatchesRange(appVersion, nudge)
}

View File

@ -70,6 +70,7 @@ vi.mock('electron', () => ({
app: appMock,
BrowserWindow: browserWindowMock,
autoUpdater: nativeUpdaterMock,
powerMonitor: { on: vi.fn() },
net: { fetch: vi.fn() }
}))
@ -85,6 +86,11 @@ vi.mock('./ipc/pty', () => ({
killAllPty: killAllPtyMock
}))
vi.mock('./updater-nudge', () => ({
fetchNudge: vi.fn().mockResolvedValue(null),
shouldApplyNudge: vi.fn().mockReturnValue(false)
}))
describe('updater check failure handling', () => {
beforeEach(() => {
vi.resetModules()

View File

@ -87,6 +87,7 @@ vi.mock('electron', () => ({
app: appMock,
BrowserWindow: browserWindowMock,
autoUpdater: nativeUpdaterMock,
powerMonitor: { on: vi.fn() },
shell: shellMock,
net: { fetch: vi.fn() }
}))
@ -107,6 +108,11 @@ vi.mock('./updater-changelog', () => ({
fetchChangelog: vi.fn().mockResolvedValue(null)
}))
vi.mock('./updater-nudge', () => ({
fetchNudge: vi.fn().mockResolvedValue(null),
shouldApplyNudge: vi.fn().mockReturnValue(false)
}))
describe('updater mac install handoff', () => {
beforeEach(() => {
vi.resetModules()

View File

@ -1,82 +1,93 @@
/* eslint-disable max-lines */
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { appMock, browserWindowMock, nativeUpdaterMock, autoUpdaterMock, isMock, killAllPtyMock } =
vi.hoisted(() => {
const appEventHandlers = new Map<string, ((...args: unknown[]) => void)[]>()
const eventHandlers = new Map<string, ((...args: unknown[]) => void)[]>()
const {
appMock,
browserWindowMock,
nativeUpdaterMock,
autoUpdaterMock,
isMock,
killAllPtyMock,
powerMonitorOnMock
} = vi.hoisted(() => {
const appEventHandlers = new Map<string, ((...args: unknown[]) => void)[]>()
const eventHandlers = new Map<string, ((...args: unknown[]) => void)[]>()
const appOn = vi.fn((event: string, handler: (...args: unknown[]) => void) => {
const handlers = appEventHandlers.get(event) ?? []
handlers.push(handler)
appEventHandlers.set(event, handlers)
return appMock
})
const appEmit = (event: string, ...args: unknown[]) => {
for (const handler of appEventHandlers.get(event) ?? []) {
handler(...args)
}
}
const on = vi.fn((event: string, handler: (...args: unknown[]) => void) => {
const handlers = eventHandlers.get(event) ?? []
handlers.push(handler)
eventHandlers.set(event, handlers)
return autoUpdaterMock
})
const emit = (event: string, ...args: unknown[]) => {
for (const handler of eventHandlers.get(event) ?? []) {
handler(...args)
}
}
const reset = () => {
appEventHandlers.clear()
appOn.mockClear()
eventHandlers.clear()
on.mockClear()
autoUpdaterMock.checkForUpdates.mockReset().mockResolvedValue(null)
autoUpdaterMock.downloadUpdate.mockReset()
autoUpdaterMock.quitAndInstall.mockReset()
}
const autoUpdaterMock = {
autoDownload: false,
autoInstallOnAppQuit: false,
on,
checkForUpdates: vi.fn(),
downloadUpdate: vi.fn(),
quitAndInstall: vi.fn(),
setFeedURL: vi.fn(),
emit,
reset
}
return {
appMock: {
isPackaged: true,
getVersion: vi.fn(() => '1.0.51'),
on: appOn,
emit: appEmit,
quit: vi.fn()
},
browserWindowMock: {
getAllWindows: vi.fn(() => [])
},
nativeUpdaterMock: {
on: vi.fn()
},
autoUpdaterMock,
isMock: { dev: false },
killAllPtyMock: vi.fn()
}
const appOn = vi.fn((event: string, handler: (...args: unknown[]) => void) => {
const handlers = appEventHandlers.get(event) ?? []
handlers.push(handler)
appEventHandlers.set(event, handlers)
return appMock
})
const appEmit = (event: string, ...args: unknown[]) => {
for (const handler of appEventHandlers.get(event) ?? []) {
handler(...args)
}
}
const on = vi.fn((event: string, handler: (...args: unknown[]) => void) => {
const handlers = eventHandlers.get(event) ?? []
handlers.push(handler)
eventHandlers.set(event, handlers)
return autoUpdaterMock
})
const emit = (event: string, ...args: unknown[]) => {
for (const handler of eventHandlers.get(event) ?? []) {
handler(...args)
}
}
const reset = () => {
appEventHandlers.clear()
appOn.mockClear()
eventHandlers.clear()
on.mockClear()
autoUpdaterMock.checkForUpdates.mockReset().mockResolvedValue(null)
autoUpdaterMock.downloadUpdate.mockReset()
autoUpdaterMock.quitAndInstall.mockReset()
autoUpdaterMock.setFeedURL.mockClear()
}
const autoUpdaterMock = {
autoDownload: false,
autoInstallOnAppQuit: false,
on,
checkForUpdates: vi.fn(),
downloadUpdate: vi.fn(),
quitAndInstall: vi.fn(),
setFeedURL: vi.fn(),
emit,
reset
}
return {
appMock: {
isPackaged: true,
getVersion: vi.fn(() => '1.0.51'),
on: appOn,
emit: appEmit,
quit: vi.fn()
},
browserWindowMock: {
getAllWindows: vi.fn(() => [])
},
nativeUpdaterMock: {
on: vi.fn()
},
autoUpdaterMock,
isMock: { dev: false },
killAllPtyMock: vi.fn(),
powerMonitorOnMock: vi.fn()
}
})
vi.mock('electron', () => ({
app: appMock,
BrowserWindow: browserWindowMock,
autoUpdater: nativeUpdaterMock,
powerMonitor: { on: powerMonitorOnMock },
net: { fetch: vi.fn() }
}))
@ -96,6 +107,16 @@ vi.mock('./updater-changelog', () => ({
fetchChangelog: vi.fn().mockResolvedValue(null)
}))
const { fetchNudgeMock, shouldApplyNudgeMock } = vi.hoisted(() => ({
fetchNudgeMock: vi.fn(),
shouldApplyNudgeMock: vi.fn()
}))
vi.mock('./updater-nudge', () => ({
fetchNudge: fetchNudgeMock,
shouldApplyNudge: shouldApplyNudgeMock
}))
describe('updater', () => {
beforeEach(() => {
vi.resetModules()
@ -109,6 +130,9 @@ describe('updater', () => {
appMock.isPackaged = true
isMock.dev = false
killAllPtyMock.mockReset()
powerMonitorOnMock.mockReset()
fetchNudgeMock.mockReset().mockResolvedValue(null)
shouldApplyNudgeMock.mockReset().mockReturnValue(false)
vi.unstubAllGlobals()
vi.useRealTimers()
})
@ -219,7 +243,7 @@ describe('updater', () => {
const { setupAutoUpdater } = await import('./updater')
setupAutoUpdater(mainWindow as never, {
getLastUpdateCheckAt: () => Date.now() - 37 * 60 * 60 * 1000,
getLastUpdateCheckAt: () => Date.now() - 25 * 60 * 60 * 1000,
setLastUpdateCheckAt
})
@ -227,6 +251,26 @@ describe('updater', () => {
expect(setLastUpdateCheckAt).not.toHaveBeenCalled()
})
it('starts nudge polling only after updater initialization is complete', async () => {
const mainWindow = { webContents: { send: vi.fn() } }
fetchNudgeMock.mockResolvedValue({ id: 'campaign-1', minVersion: '1.0.0' })
shouldApplyNudgeMock.mockReturnValue(true)
const { setupAutoUpdater } = await import('./updater')
setupAutoUpdater(mainWindow as never)
expect(autoUpdaterMock.setFeedURL).toHaveBeenCalledTimes(1)
expect(autoUpdaterMock.on).toHaveBeenCalled()
expect(fetchNudgeMock).toHaveBeenCalledTimes(1)
expect(autoUpdaterMock.setFeedURL.mock.invocationCallOrder[0]).toBeLessThan(
fetchNudgeMock.mock.invocationCallOrder[0]
)
expect(autoUpdaterMock.on.mock.invocationCallOrder[0]).toBeLessThan(
fetchNudgeMock.mock.invocationCallOrder[0]
)
})
it('waits until the remaining interval before the next background check', async () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-04-03T12:00:00Z'))
@ -237,7 +281,7 @@ describe('updater', () => {
const { setupAutoUpdater } = await import('./updater')
setupAutoUpdater(mainWindow as never, {
getLastUpdateCheckAt: () => Date.now() - 35 * 60 * 60 * 1000,
getLastUpdateCheckAt: () => Date.now() - 23 * 60 * 60 * 1000,
setLastUpdateCheckAt
})
@ -283,7 +327,7 @@ describe('updater', () => {
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(2)
})
it('reschedules the next automatic check 36 hours after finding an available update', async () => {
it('reschedules the next automatic check 24 hours after finding an available update', async () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-04-03T12:00:00Z'))
@ -317,10 +361,262 @@ describe('updater', () => {
changelog: null
})
vi.advanceTimersByTime(35 * 60 * 60 * 1000 + 59 * 60 * 1000)
vi.advanceTimersByTime(23 * 60 * 60 * 1000 + 59 * 60 * 1000)
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1)
vi.advanceTimersByTime(60 * 1000)
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(2)
})
it('does not leak a nudge marker into a later ordinary update cycle', async () => {
const sendMock = vi.fn()
const mainWindow = { webContents: { send: sendMock } }
fetchNudgeMock.mockResolvedValue({ id: 'campaign-1', minVersion: '1.0.0' })
shouldApplyNudgeMock.mockReturnValue(true)
autoUpdaterMock.checkForUpdates.mockImplementation(() => {
autoUpdaterMock.emit('checking-for-update')
return Promise.resolve(undefined)
})
const { setupAutoUpdater, checkForUpdatesFromMenu } = await import('./updater')
// Why: pass a recent timestamp so the normal startup check is deferred,
// letting the nudge check run without hitting the 'checking' guard.
setupAutoUpdater(mainWindow as never, {
getLastUpdateCheckAt: () => Date.now()
})
await new Promise((resolve) => setTimeout(resolve, 0))
autoUpdaterMock.emit('update-available', { version: '1.0.61' })
await new Promise((resolve) => setTimeout(resolve, 0))
expect(sendMock).toHaveBeenCalledWith('updater:status', {
state: 'available',
version: '1.0.61',
changelog: null,
activeNudgeId: 'campaign-1'
})
sendMock.mockClear()
checkForUpdatesFromMenu()
const statusCalls = sendMock.mock.calls
.filter(([channel]) => channel === 'updater:status')
.map(([, status]) => status)
expect(statusCalls).toContainEqual({ state: 'checking', userInitiated: true })
autoUpdaterMock.emit('update-available', { version: '1.0.62' })
await new Promise((resolve) => setTimeout(resolve, 0))
expect(sendMock).toHaveBeenCalledWith('updater:status', {
state: 'available',
version: '1.0.62',
changelog: null
})
expect(sendMock).not.toHaveBeenCalledWith(
'updater:status',
expect.objectContaining({ version: '1.0.62', activeNudgeId: 'campaign-1' })
)
})
it('preserves the pending nudge marker across a later background check', async () => {
const sendMock = vi.fn()
const mainWindow = { webContents: { send: sendMock } }
autoUpdaterMock.checkForUpdates.mockImplementation(() => {
autoUpdaterMock.emit('checking-for-update')
return Promise.resolve(undefined)
})
const { setupAutoUpdater } = await import('./updater')
setupAutoUpdater(mainWindow as never, {
getLastUpdateCheckAt: () => null,
getPendingUpdateNudgeId: () => 'campaign-1',
getDismissedUpdateNudgeId: () => null
})
autoUpdaterMock.emit('update-available', { version: '1.0.61' })
await new Promise((resolve) => setTimeout(resolve, 0))
expect(sendMock).toHaveBeenCalledWith('updater:status', {
state: 'available',
version: '1.0.61',
changelog: null,
activeNudgeId: 'campaign-1'
})
})
it('does not trigger a nudge check while an updater check is already in progress', async () => {
const sendMock = vi.fn()
const mainWindow = { webContents: { send: sendMock } }
fetchNudgeMock.mockResolvedValue({ id: 'campaign-1', minVersion: '1.0.0' })
shouldApplyNudgeMock.mockReturnValue(true)
autoUpdaterMock.checkForUpdates.mockImplementation(() => {
autoUpdaterMock.emit('checking-for-update')
// Stay in 'checking' state — don't resolve
return new Promise(() => {})
})
const { setupAutoUpdater } = await import('./updater')
setupAutoUpdater(mainWindow as never, {
getLastUpdateCheckAt: () => null,
setLastUpdateCheckAt: vi.fn()
})
// Wait for the startup nudge check to run
await new Promise((resolve) => setTimeout(resolve, 0))
// The normal startup check is in progress (status is 'checking').
// The nudge fetch completed but the guard should have prevented
// calling runBackgroundUpdateCheck because currentStatus is 'checking'.
// Only the startup check should have called checkForUpdates.
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1)
})
it('respects the activation/resume cooldown for nudge checks', async () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-04-12T12:00:00Z'))
const sendMock = vi.fn()
const mainWindow = { webContents: { send: sendMock } }
fetchNudgeMock.mockResolvedValue({ id: 'campaign-1', minVersion: '1.0.0' })
shouldApplyNudgeMock.mockReturnValue(true)
autoUpdaterMock.checkForUpdates.mockResolvedValue(undefined)
const { setupAutoUpdater } = await import('./updater')
setupAutoUpdater(mainWindow as never)
// Wait for the startup nudge check to complete
await vi.advanceTimersByTimeAsync(0)
// The startup check already set lastNudgeCheckAt. Triggering
// browser-window-focus should be blocked by the 5-minute cooldown.
fetchNudgeMock.mockClear()
appMock.emit('browser-window-focus')
await vi.advanceTimersByTimeAsync(0)
// fetchNudge should NOT have been called again — cooldown blocks it
expect(fetchNudgeMock).not.toHaveBeenCalled()
// Advance past the cooldown
vi.advanceTimersByTime(5 * 60 * 1000 + 1)
appMock.emit('browser-window-focus')
await vi.advanceTimersByTimeAsync(0)
expect(fetchNudgeMock).toHaveBeenCalledTimes(1)
})
it('clears pending nudge campaign when the follow-up check ends in not-available', async () => {
const sendMock = vi.fn()
const mainWindow = { webContents: { send: sendMock } }
const setPendingUpdateNudgeId = vi.fn()
const setDismissedUpdateNudgeId = vi.fn()
fetchNudgeMock.mockResolvedValue({ id: 'campaign-1', minVersion: '1.0.0' })
shouldApplyNudgeMock.mockReturnValue(true)
autoUpdaterMock.checkForUpdates.mockImplementation(() => {
autoUpdaterMock.emit('checking-for-update')
return Promise.resolve(undefined)
})
const { setupAutoUpdater } = await import('./updater')
setupAutoUpdater(mainWindow as never, {
getLastUpdateCheckAt: () => Date.now(),
setPendingUpdateNudgeId,
getPendingUpdateNudgeId: () => null,
getDismissedUpdateNudgeId: () => null,
setDismissedUpdateNudgeId
})
await new Promise((resolve) => setTimeout(resolve, 0))
// Nudge was applied — pending id was set
expect(setPendingUpdateNudgeId).toHaveBeenCalledWith('campaign-1')
// Now simulate the updater finding no update
autoUpdaterMock.emit('update-not-available')
// Pending should be cleared and campaign should be auto-dismissed
// so it doesn't re-fire on the next poll cycle
expect(setPendingUpdateNudgeId).toHaveBeenCalledWith(null)
expect(setDismissedUpdateNudgeId).toHaveBeenCalledWith('campaign-1')
})
it('auto-dismisses nudge campaign when the follow-up check errors out', async () => {
const sendMock = vi.fn()
const mainWindow = { webContents: { send: sendMock } }
const setPendingUpdateNudgeId = vi.fn()
const setDismissedUpdateNudgeId = vi.fn()
fetchNudgeMock.mockResolvedValue({ id: 'campaign-1', minVersion: '1.0.0' })
shouldApplyNudgeMock.mockReturnValue(true)
autoUpdaterMock.checkForUpdates.mockImplementation(() => {
autoUpdaterMock.emit('checking-for-update')
return Promise.resolve(undefined)
})
const { setupAutoUpdater } = await import('./updater')
setupAutoUpdater(mainWindow as never, {
getLastUpdateCheckAt: () => Date.now(),
setPendingUpdateNudgeId,
getPendingUpdateNudgeId: () => null,
getDismissedUpdateNudgeId: () => null,
setDismissedUpdateNudgeId
})
await new Promise((resolve) => setTimeout(resolve, 0))
expect(setPendingUpdateNudgeId).toHaveBeenCalledWith('campaign-1')
// Simulate an error during the nudge-triggered check
autoUpdaterMock.emit('error', new Error('network timeout'))
// Campaign should be auto-dismissed to prevent re-fire loop
expect(setDismissedUpdateNudgeId).toHaveBeenCalledWith('campaign-1')
expect(setPendingUpdateNudgeId).toHaveBeenCalledWith(null)
})
it('moves pending nudge to dismissed when dismissNudge is called', async () => {
const sendMock = vi.fn()
const mainWindow = { webContents: { send: sendMock } }
const setPendingUpdateNudgeId = vi.fn()
const setDismissedUpdateNudgeId = vi.fn()
fetchNudgeMock.mockResolvedValue({ id: 'campaign-1', minVersion: '1.0.0' })
shouldApplyNudgeMock.mockReturnValue(true)
autoUpdaterMock.checkForUpdates.mockImplementation(() => {
autoUpdaterMock.emit('checking-for-update')
return Promise.resolve(undefined)
})
const { setupAutoUpdater, dismissNudge } = await import('./updater')
setupAutoUpdater(mainWindow as never, {
getLastUpdateCheckAt: () => Date.now(),
setPendingUpdateNudgeId,
getPendingUpdateNudgeId: () => 'campaign-1',
getDismissedUpdateNudgeId: () => null,
setDismissedUpdateNudgeId
})
await new Promise((resolve) => setTimeout(resolve, 0))
// Simulate update found, then user dismisses
autoUpdaterMock.emit('update-available', { version: '1.0.61' })
await new Promise((resolve) => setTimeout(resolve, 0))
dismissNudge()
expect(setDismissedUpdateNudgeId).toHaveBeenCalledWith('campaign-1')
expect(setPendingUpdateNudgeId).toHaveBeenCalledWith(null)
})
})

View File

@ -1,4 +1,5 @@
import { app, BrowserWindow } from 'electron'
/* eslint-disable max-lines */
import { app, BrowserWindow, powerMonitor } from 'electron'
import { autoUpdater } from 'electron-updater'
import { is } from '@electron-toolkit/utils'
import type { UpdateStatus } from '../shared/types'
@ -10,9 +11,12 @@ import {
} from './updater-mac-install'
import { registerAutoUpdaterHandlers } from './updater-events'
import { compareVersions, isBenignCheckFailure, statusesEqual } from './updater-fallback'
import { fetchNudge, shouldApplyNudge } from './updater-nudge'
const AUTO_UPDATE_CHECK_INTERVAL_MS = 36 * 60 * 60 * 1000
const AUTO_UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000
const AUTO_UPDATE_RETRY_INTERVAL_MS = 60 * 60 * 1000
const NUDGE_POLL_INTERVAL_MS = 30 * 60 * 1000
const NUDGE_ACTIVATION_COOLDOWN_MS = 5 * 60 * 1000
const QUIT_AND_INSTALL_DELAY_MS = 100
let mainWindowRef: BrowserWindow | null = null
@ -25,8 +29,18 @@ let availableReleaseUrl: string | null = null
let pendingCheckFailureKey: string | null = null
let pendingCheckFailurePromise: Promise<void> | null = null
let autoUpdateCheckTimer: ReturnType<typeof setTimeout> | null = null
let nudgeCheckTimer: ReturnType<typeof setTimeout> | null = null
let pendingQuitAndInstallTimer: ReturnType<typeof setTimeout> | null = null
let persistLastUpdateCheckAt: ((timestamp: number) => void) | null = null
let activeUpdateNudgeId: string | null = null
let awaitingNudgeCheckOutcome = false
let nudgeCheckInFlight = false
let lastNudgeCheckAt = 0
let _getPendingUpdateNudgeId: (() => string | null) | null = null
let _getDismissedUpdateNudgeId: (() => string | null) | null = null
let _setPendingUpdateNudgeId: ((id: string | null) => void) | null = null
let _setDismissedUpdateNudgeId: ((id: string | null) => void) | null = null
// Why: guards against duplicate download() calls when both the card and
// Settings trigger a download before the first download-progress event
// flips the status to 'downloading'.
@ -40,17 +54,71 @@ function clearAvailableUpdateContext(): void {
availableReleaseUrl = null
}
function clearPendingUpdateNudge(): void {
activeUpdateNudgeId = null
awaitingNudgeCheckOutcome = false
_setPendingUpdateNudgeId?.(null)
}
function getPersistedPendingUpdateNudgeId(): string | null {
return _getPendingUpdateNudgeId?.() ?? null
}
function decorateStatusWithActiveNudge(status: UpdateStatus): UpdateStatus {
// Why: only actionable/error states carry the nudge marker so the renderer
// can tell whether a dismiss should also acknowledge the campaign. Cycle-
// boundary states (idle, checking, not-available) never need it.
if (!activeUpdateNudgeId) {
return status
}
if (
status.state === 'idle' ||
status.state === 'checking' ||
status.state === 'not-available'
) {
return status
}
return { ...status, activeNudgeId: activeUpdateNudgeId }
}
function sendStatus(status: UpdateStatus): void {
if (awaitingNudgeCheckOutcome) {
if (status.state === 'available') {
awaitingNudgeCheckOutcome = false
} else if (
status.state === 'idle' ||
status.state === 'not-available' ||
status.state === 'error'
) {
// Why: when a nudge-triggered check finds no update (or errors out),
// move the campaign to dismissed so it doesn't re-fire on the next
// poll cycle. Without this, a nudge whose version range includes
// already-up-to-date users would loop every 30 minutes, each time
// triggering a redundant checkForUpdates() and clearing the persisted
// dismissedUpdateVersion.
if (activeUpdateNudgeId) {
_setDismissedUpdateNudgeId?.(activeUpdateNudgeId)
}
clearPendingUpdateNudge()
}
}
const decoratedStatus = decorateStatusWithActiveNudge(status)
// Why: reset the in-flight guard when the status moves past the
// window where duplicate download() calls are possible.
if (status.state === 'downloading' || status.state === 'error' || status.state === 'idle') {
if (
decoratedStatus.state === 'downloading' ||
decoratedStatus.state === 'error' ||
decoratedStatus.state === 'idle'
) {
downloadInFlight = false
}
if (statusesEqual(currentStatus, status)) {
if (statusesEqual(currentStatus, decoratedStatus)) {
return
}
currentStatus = status
mainWindowRef?.webContents.send('updater:status', status)
currentStatus = decoratedStatus
mainWindowRef?.webContents.send('updater:status', decoratedStatus)
}
function sendErrorStatus(message: string, userInitiated?: boolean): void {
@ -178,11 +246,19 @@ function recordCompletedUpdateCheck(): void {
persistLastUpdateCheckAt?.(Date.now())
}
function runBackgroundUpdateCheck(): void {
function runBackgroundUpdateCheck(
nudgeId: string | null = getPersistedPendingUpdateNudgeId()
): void {
if (!app.isPackaged || is.dev) {
sendStatus({ state: 'not-available' })
return
}
// Why: scope the nudge marker to the updater cycle being launched right now.
// Setting it here, before any updater events or rejected promises can arrive,
// prevents later ordinary checks from inheriting an older campaign id. Use
// the persisted pending id for ordinary background checks so a nudge-driven
// card can still be dismissed correctly after relaunch or a later 24h check.
activeUpdateNudgeId = nudgeId
// Don't send 'checking' here — the 'checking-for-update' event handler does it,
// and sending it from both places causes duplicate notifications (issue #35).
autoUpdater.checkForUpdates().catch((err) => {
@ -202,6 +278,10 @@ export function checkForUpdatesFromMenu(): void {
}
userInitiatedCheck = true
// Why: a manual check is independent of any active nudge campaign. Reset the
// nudge marker so the resulting status is not decorated with activeNudgeId,
// which would cause a later dismiss to consume the campaign by accident.
activeUpdateNudgeId = null
// Don't send 'checking' here — the 'checking-for-update' event handler does it,
// and sending it from both places causes duplicate notifications (issue #35).
@ -240,17 +320,90 @@ export function quitAndInstall(): void {
}, QUIT_AND_INSTALL_DELAY_MS)
}
async function checkForUpdateNudge(): Promise<void> {
if (!app.isPackaged || is.dev) {
return
}
if (nudgeCheckInFlight) {
return
}
const now = Date.now()
if (now - lastNudgeCheckAt < NUDGE_ACTIVATION_COOLDOWN_MS) {
return
}
lastNudgeCheckAt = now
nudgeCheckInFlight = true
try {
const nudge = await fetchNudge()
if (!nudge) {
return
}
if (currentStatus.state === 'checking' || currentStatus.state === 'downloading') {
return
}
const appVersion = app.getVersion()
const pendingUpdateNudgeId = _getPendingUpdateNudgeId?.() ?? null
const dismissedUpdateNudgeId = _getDismissedUpdateNudgeId?.() ?? null
if (
shouldApplyNudge({
nudge,
appVersion,
pendingUpdateNudgeId,
dismissedUpdateNudgeId
})
) {
awaitingNudgeCheckOutcome = true
_setPendingUpdateNudgeId?.(nudge.id)
mainWindowRef?.webContents.send('updater:clearDismissal')
runBackgroundUpdateCheck(nudge.id)
}
} finally {
nudgeCheckInFlight = false
}
}
function scheduleUpdateNudgeCheck(): void {
if (nudgeCheckTimer) {
clearTimeout(nudgeCheckTimer)
}
nudgeCheckTimer = setTimeout(() => {
void checkForUpdateNudge()
scheduleUpdateNudgeCheck()
}, NUDGE_POLL_INTERVAL_MS)
}
export function dismissNudge(): void {
const pendingId = activeUpdateNudgeId ?? _getPendingUpdateNudgeId?.() ?? null
if (pendingId) {
_setDismissedUpdateNudgeId?.(pendingId)
clearPendingUpdateNudge()
}
}
export function setupAutoUpdater(
mainWindow: BrowserWindow,
opts?: {
getLastUpdateCheckAt?: () => number | null
onBeforeQuit?: () => void
setLastUpdateCheckAt?: (timestamp: number) => void
getPendingUpdateNudgeId?: () => string | null
getDismissedUpdateNudgeId?: () => string | null
setPendingUpdateNudgeId?: (id: string | null) => void
setDismissedUpdateNudgeId?: (id: string | null) => void
}
): void {
mainWindowRef = mainWindow
onBeforeQuitCleanup = opts?.onBeforeQuit ?? null
persistLastUpdateCheckAt = opts?.setLastUpdateCheckAt ?? null
_getPendingUpdateNudgeId = opts?.getPendingUpdateNudgeId ?? null
_getDismissedUpdateNudgeId = opts?.getDismissedUpdateNudgeId ?? null
_setPendingUpdateNudgeId = opts?.setPendingUpdateNudgeId ?? null
_setDismissedUpdateNudgeId = opts?.setDismissedUpdateNudgeId ?? null
if (!app.isPackaged && !is.dev) {
return
@ -301,6 +454,16 @@ export function setupAutoUpdater(
}
})
void checkForUpdateNudge()
scheduleUpdateNudgeCheck()
powerMonitor.on('resume', () => {
void checkForUpdateNudge()
})
app.on('browser-window-focus', () => {
void checkForUpdateNudge()
})
const lastUpdateCheckAt = opts?.getLastUpdateCheckAt?.() ?? null
const msSinceLastCheck =
lastUpdateCheckAt === null ? Number.POSITIVE_INFINITY : Date.now() - lastUpdateCheckAt

View File

@ -62,6 +62,7 @@ vi.mock('../updater', () => ({
checkForUpdates: vi.fn(),
getUpdateStatus: vi.fn(),
quitAndInstall: vi.fn(),
dismissNudge: vi.fn(),
setupAutoUpdater: setupAutoUpdaterMock
}))

View File

@ -16,7 +16,8 @@ import {
downloadUpdate,
getUpdateStatus,
quitAndInstall,
setupAutoUpdater
setupAutoUpdater,
dismissNudge
} from '../updater'
export function attachMainWindowServices(
@ -34,6 +35,24 @@ export function attachMainWindowServices(
onBeforeQuit: () => store.flush(),
setLastUpdateCheckAt: (timestamp) => {
store.updateUI({ lastUpdateCheckAt: timestamp })
},
getPendingUpdateNudgeId: () => store.getUI().pendingUpdateNudgeId ?? null,
getDismissedUpdateNudgeId: () => store.getUI().dismissedUpdateNudgeId ?? null,
setPendingUpdateNudgeId: (id) => {
// Why: the nudge lifecycle is owned by the main process. When applying a
// new campaign, persist the pending id AND clear the version dismissal
// together so relaunches cannot resurrect the old hidden-card state
// between nudge apply and renderer sync. When clearing (id is null),
// only touch pendingUpdateNudgeId — clearing dismissedUpdateVersion here
// would silently un-dismiss an update if the flow ever changes.
if (id) {
store.updateUI({ pendingUpdateNudgeId: id, dismissedUpdateVersion: null })
} else {
store.updateUI({ pendingUpdateNudgeId: null })
}
},
setDismissedUpdateNudgeId: (id) => {
store.updateUI({ dismissedUpdateNudgeId: id })
}
})
registerRuntimeWindowLifecycle(mainWindow, runtime)
@ -175,10 +194,12 @@ export function registerUpdaterHandlers(_store: Store): void {
ipcMain.removeHandler('updater:check')
ipcMain.removeHandler('updater:download')
ipcMain.removeHandler('updater:quitAndInstall')
ipcMain.removeHandler('updater:dismissNudge')
ipcMain.handle('updater:getStatus', () => getUpdateStatus())
ipcMain.handle('updater:getVersion', () => app.getVersion())
ipcMain.handle('updater:check', () => checkForUpdatesFromMenu())
ipcMain.handle('updater:download', () => downloadUpdate())
ipcMain.handle('updater:quitAndInstall', () => quitAndInstall())
ipcMain.handle('updater:dismissNudge', () => dismissNudge())
}

View File

@ -295,7 +295,9 @@ export type PreloadApi = {
check: () => Promise<void>
download: () => Promise<void>
quitAndInstall: () => Promise<void>
dismissNudge: () => Promise<void>
onStatus: (callback: (status: UpdateStatus) => void) => () => void
onClearDismissal: (callback: () => void) => () => void
}
stats: StatsApi
claudeUsage: ClaudeUsageApi

View File

@ -442,6 +442,7 @@ const api = {
getVersion: (): Promise<string> => ipcRenderer.invoke('updater:getVersion'),
check: (): Promise<void> => ipcRenderer.invoke('updater:check'),
download: (): Promise<void> => ipcRenderer.invoke('updater:download'),
dismissNudge: (): Promise<void> => ipcRenderer.invoke('updater:dismissNudge'),
quitAndInstall: async (): Promise<void> => {
// Why: quitAndInstall closes the BrowserWindow directly from the main
// process. Renderer beforeunload guards treat that like a normal window
@ -501,6 +502,11 @@ const api = {
const listener = (_event: Electron.IpcRendererEvent, status: unknown) => callback(status)
ipcRenderer.on('updater:status', listener)
return () => ipcRenderer.removeListener('updater:status', listener)
},
onClearDismissal: (callback: () => void): (() => void) => {
const listener = (_event: Electron.IpcRendererEvent) => callback()
ipcRenderer.on('updater:clearDismissal', listener)
return () => ipcRenderer.removeListener('updater:clearDismissal', listener)
}
},

View File

@ -41,7 +41,8 @@ beforeEach(() => {
shell: { openUrl: vi.fn() },
updater: {
download: vi.fn().mockResolvedValue(undefined),
quitAndInstall: vi.fn().mockResolvedValue(undefined)
quitAndInstall: vi.fn().mockResolvedValue(undefined),
dismissNudge: vi.fn().mockResolvedValue(undefined)
}
},
matchMedia: vi.fn().mockReturnValue({
@ -140,6 +141,49 @@ describe('dismissUpdate', () => {
})
})
// ── dismissUpdate nudge-aware path ───────────────────────────────────
describe('dismissUpdate nudge-aware', () => {
it('calls dismissNudge when the current status has an activeNudgeId', () => {
const store = createTestStore()
setState(store, {
state: 'available',
version: '1.2.0',
changelog: null,
activeNudgeId: 'campaign-1'
})
store.getState().dismissUpdate()
expect(store.getState().dismissedUpdateVersion).toBe('1.2.0')
expect(window.api.updater.dismissNudge).toHaveBeenCalledTimes(1)
})
it('does not call dismissNudge when the status has no activeNudgeId', () => {
const store = createTestStore()
setState(store, { state: 'available', version: '1.2.0', changelog: null })
store.getState().dismissUpdate()
expect(store.getState().dismissedUpdateVersion).toBe('1.2.0')
expect(window.api.updater.dismissNudge).not.toHaveBeenCalled()
})
it('calls dismissNudge when dismissing during a nudge-driven download', () => {
const store = createTestStore()
setState(store, {
state: 'downloading',
percent: 50,
version: '1.2.0',
activeNudgeId: 'campaign-1'
})
store.getState().dismissUpdate()
expect(window.api.updater.dismissNudge).toHaveBeenCalledTimes(1)
})
})
// ── markUpdateReassuranceSeen ────────────────────────────────────────
describe('markUpdateReassuranceSeen', () => {

View File

@ -153,7 +153,8 @@ describe('useIpcEvents updater integration', () => {
onStatus: (listener: (status: unknown) => void) => {
updaterStatusListenerRef.current = listener
return () => {}
}
},
onClearDismissal: () => () => {}
},
browser: {
onGuestLoadFailed: () => () => {}

View File

@ -156,6 +156,12 @@ export function useIpcEvents(): void {
})
)
unsubs.push(
window.api.updater.onClearDismissal(() => {
useAppStore.getState().clearDismissedUpdateVersion()
})
)
unsubs.push(
window.api.ui.onFullscreenChanged((isFullScreen) => {
useAppStore.getState().setIsFullScreen(isFullScreen)

View File

@ -85,6 +85,7 @@ export type UISlice = {
updateChangelog: ChangelogData | null
dismissedUpdateVersion: string | null
dismissUpdate: (versionOverride?: string) => void
clearDismissedUpdateVersion: () => void
updateReassuranceSeen: boolean
markUpdateReassuranceSeen: () => void
isFullScreen: boolean
@ -216,16 +217,27 @@ export const createUISlice: StateCreator<AppState, [], [], UISlice> = (set) => (
},
updateChangelog: null,
dismissedUpdateVersion: null,
clearDismissedUpdateVersion: () => {
set({ dismissedUpdateVersion: null })
},
dismissUpdate: (versionOverride?: string) =>
set((s) => {
// Why: the 'error' variant has no version field, so the card passes
// the cached version explicitly via versionOverride.
const dismissedUpdateVersion =
versionOverride ?? ('version' in s.updateStatus ? (s.updateStatus.version ?? null) : null)
const activeNudgeId =
'activeNudgeId' in s.updateStatus ? (s.updateStatus.activeNudgeId ?? null) : null
// Why: dismissing an update is user intent, not transient view state. Persist
// the dismissed version so relaunching the app does not immediately re-show
// the same reminder card until a newer release appears.
void window.api.ui.set({ dismissedUpdateVersion }).catch(console.error)
// Why: only dismiss the main-process nudge campaign when the visible card
// actually came from a nudge-driven update cycle. Ordinary update dismissals
// must not consume the active campaign state.
if (activeNudgeId) {
void window.api.updater.dismissNudge().catch(console.error)
}
return { dismissedUpdateVersion }
}),
updateReassuranceSeen: false,

View File

@ -320,6 +320,7 @@ export type UpdateStatus =
| {
state: 'available'
version: string
activeNudgeId?: string
// Why: releaseUrl is not currently populated by the update-available handler
// (it always sends undefined). Kept on the type for the Settings page's
// release-notes link fallback and for potential future use if the main
@ -333,9 +334,9 @@ export type UpdateStatus =
changelog: ChangelogData | null
}
| { state: 'not-available'; userInitiated?: boolean }
| { state: 'downloading'; percent: number; version: string }
| { state: 'downloaded'; version: string; releaseUrl?: string }
| { state: 'error'; message: string; userInitiated?: boolean }
| { state: 'downloading'; percent: number; version: string; activeNudgeId?: string }
| { state: 'downloaded'; version: string; releaseUrl?: string; activeNudgeId?: string }
| { state: 'error'; message: string; userInitiated?: boolean; activeNudgeId?: string }
// ─── Settings ────────────────────────────────────────────────────────
export type NotificationSettings = {
@ -468,6 +469,8 @@ export type PersistedUIState = {
statusBarVisible: boolean
dismissedUpdateVersion: string | null
lastUpdateCheckAt: number | null
pendingUpdateNudgeId?: string | null
dismissedUpdateNudgeId?: string | null
/** Whether Orca has already attempted to trigger the macOS notification
* permission dialog via a startup notification. Prevents re-firing on
* every launch. */