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:
parent
8ef3139529
commit
f9e803082c
|
|
@ -79,6 +79,7 @@ describe('registerPtyHandlers', () => {
|
|||
}
|
||||
|
||||
beforeEach(() => {
|
||||
delete process.env.OPENCODE_CONFIG_DIR
|
||||
handlers.clear()
|
||||
handleMock.mockReset()
|
||||
onMock.mockReset()
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -62,6 +62,7 @@ vi.mock('../updater', () => ({
|
|||
checkForUpdates: vi.fn(),
|
||||
getUpdateStatus: vi.fn(),
|
||||
quitAndInstall: vi.fn(),
|
||||
dismissNudge: vi.fn(),
|
||||
setupAutoUpdater: setupAutoUpdaterMock
|
||||
}))
|
||||
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -153,7 +153,8 @@ describe('useIpcEvents updater integration', () => {
|
|||
onStatus: (listener: (status: unknown) => void) => {
|
||||
updaterStatusListenerRef.current = listener
|
||||
return () => {}
|
||||
}
|
||||
},
|
||||
onClearDismissal: () => () => {}
|
||||
},
|
||||
browser: {
|
||||
onGuestLoadFailed: () => () => {}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
Loading…
Reference in New Issue