Skip to content

Commit 9b35e7b

Browse files
committed
feat(desktop): drive updates from a sidebar button and one overlay
An Update button appears at the top of the sidebar as soon as a version the user has not skipped is waiting. It opens a centered overlay that carries the whole flow: the version and release date, a download that reports transferred bytes against the total, a cancel that aborts the transfer mid-flight, and the restart that applies it. Cancel is new capability, not just UI. The main process now passes a cancellation token to the downloader and keeps it for the lifetime of that download, so an abort stops the transfer instead of letting it finish unwatched. A cancelled download reports through the same error path as a real failure, so the guard reads the token that owns the rejection rather than the current one, and a retry started after a cancel cannot inherit the cancelled download's failure. Download and install remain two deliberate actions; the overlay shows a restart step between them rather than chaining them. The bottom-right update toast is removed. It duplicated the same progress in a second place and could surface over the open overlay.
1 parent cfb4f19 commit 9b35e7b

112 files changed

Lines changed: 1244 additions & 876 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@pymodel/pythinker-code": minor
3+
---
4+
5+
Add an Update button to the top of the desktop sidebar that opens a single overlay for the whole update: download it, watch the progress, cancel it mid-transfer, then restart to apply it.

apps/desktop/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
"@pymodel/pythinker-telemetry": "workspace:*",
2020
"@types/node": "^26.1.2",
2121
"@types/semver": "^7.7.0",
22+
"builder-util-runtime": "9.7.0",
2223
"electron": "43.4.0",
2324
"electron-builder": "26.15.3",
2425
"electron-updater": "6.8.9",

apps/desktop/src/main.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import {
3838
import { createSplashWindow } from './splash'
3939
import {
4040
acknowledgeCompletedUpdate,
41+
cancelUpdateDownload,
4142
checkForUpdatesNow,
4243
getUpdateState,
4344
initUpdater,
@@ -274,6 +275,10 @@ ipcMain.handle('pythinker:update:download', (event) => {
274275
assertTrustedSender(event)
275276
return startUpdateDownload()
276277
})
278+
ipcMain.handle('pythinker:update:cancel', (event) => {
279+
assertTrustedSender(event)
280+
return cancelUpdateDownload()
281+
})
277282
ipcMain.handle('pythinker:update:skip', (event, version: unknown) => {
278283
assertTrustedSender(event)
279284
return skipUpdate(updateVersion(version))

apps/desktop/src/preload.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ contextBridge.exposeInMainWorld('pythinkerDesktop', {
66
setAutoUpdate: (enabled: boolean) => ipcRenderer.invoke('pythinker:update:set-auto', enabled),
77
checkForUpdates: () => ipcRenderer.invoke('pythinker:update:check'),
88
downloadUpdate: () => ipcRenderer.invoke('pythinker:update:download'),
9+
cancelUpdateDownload: () => ipcRenderer.invoke('pythinker:update:cancel'),
910
skipUpdate: (version: string) => ipcRenderer.invoke('pythinker:update:skip', version),
1011
undoSkippedUpdate: () => ipcRenderer.invoke('pythinker:update:undo-skip'),
1112
markUpdateNotified: (version: string) => ipcRenderer.invoke('pythinker:update:notified', version),

apps/desktop/src/updater.ts

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
22
import { join } from 'node:path'
33
import { app, type BrowserWindow } from 'electron'
4+
import { CancellationToken } from 'builder-util-runtime'
45
import electronUpdater, { type ProgressInfo, type UpdateInfo } from 'electron-updater'
56
import { gt, valid } from 'semver'
67

@@ -130,6 +131,7 @@ let initialCheckTimer: ReturnType<typeof setTimeout> | undefined
130131
let checkInterval: ReturnType<typeof setInterval> | undefined
131132
let checkPromise: Promise<UpdateState> | undefined
132133
let installRequestedVersion: string | undefined
134+
let activeDownloadToken: CancellationToken | undefined
133135
let listenersWired = false
134136
let initialized = false
135137
let updateTelemetryTrack: UpdateTelemetryTrack = () => {}
@@ -207,6 +209,19 @@ function stateError(error: unknown): void {
207209
})
208210
}
209211

212+
/**
213+
* electron-updater reports a cancelled download through the same `error` path
214+
* as a genuine failure, and `CancellationError` carries no distinguishing
215+
* `name`, so the token itself is the discriminator: `cancel()` flips
216+
* `cancelled` synchronously, before the rejection reaches us. The guard reads
217+
* the token that owns this rejection rather than the current one, so a retry
218+
* started after a cancel cannot inherit the cancelled download's failure.
219+
*/
220+
function downloadError(token: CancellationToken, error: unknown): void {
221+
if (token.cancelled) return
222+
stateError(error)
223+
}
224+
210225
function clearTimers(): void {
211226
if (initialCheckTimer !== undefined) clearTimeout(initialCheckTimer)
212227
if (checkInterval !== undefined) clearInterval(checkInterval)
@@ -457,13 +472,41 @@ export function startUpdateDownload(): UpdateState {
457472
bytesPerSecond: undefined,
458473
message: undefined,
459474
})
460-
void autoUpdater.downloadUpdate().catch(stateError)
475+
const token = new CancellationToken()
476+
activeDownloadToken = token
477+
void autoUpdater
478+
.downloadUpdate(token)
479+
.catch((error: unknown) => downloadError(token, error))
480+
.finally(() => {
481+
if (activeDownloadToken === token) activeDownloadToken = undefined
482+
token.dispose()
483+
})
461484
} catch (error) {
485+
activeDownloadToken = undefined
462486
stateError(error)
463487
}
464488
return state
465489
}
466490

491+
/**
492+
* Aborts an in-flight download and returns the update to the state it had
493+
* before the user consented, so the same version can be downloaded again.
494+
*/
495+
export function cancelUpdateDownload(): UpdateState {
496+
const token = activeDownloadToken
497+
if (state.status !== 'downloading' || token === undefined) return state
498+
token.cancel()
499+
updateState({
500+
status: 'available',
501+
percent: undefined,
502+
transferred: undefined,
503+
total: undefined,
504+
bytesPerSecond: undefined,
505+
message: undefined,
506+
})
507+
return state
508+
}
509+
467510
export function installDownloadedUpdateNow(): UpdateState {
468511
const version = state.availableVersion
469512
if (!app.isPackaged || !hasUpdateConfig() || state.status !== 'downloaded' || version === undefined) {

apps/desktop/tests/updater.spec.ts

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -388,6 +388,102 @@ describe('strict update consent', () => {
388388
expect(localAutoUpdater.downloadUpdate).toHaveBeenCalledTimes(2)
389389
expect(localAutoUpdater.autoDownload).toBe(false)
390390
})
391+
392+
it('cancelling an in-flight download aborts the transfer and restores the available state', async () => {
393+
vi.resetModules()
394+
const directory = temporaryDirectory()
395+
writeFileSync(join(directory, 'app-update.yml'), '', 'utf8')
396+
writeFileSync(join(directory, 'update-settings.json'), '{"autoUpdate":false}\n', 'utf8')
397+
const { app: localApp } = await import('electron')
398+
const { default: localElectronUpdater } = await import('electron-updater')
399+
const {
400+
cancelUpdateDownload: cancelLocalUpdateDownload,
401+
getUpdateState: getLocalUpdateState,
402+
initUpdater: initLocalUpdater,
403+
startUpdateDownload: startLocalUpdateDownload,
404+
} = await import('../src/updater')
405+
const localAutoUpdater = localElectronUpdater.autoUpdater
406+
vi.mocked(localApp.getPath).mockReturnValue(directory)
407+
Object.defineProperty(localApp, 'isPackaged', { configurable: true, value: true })
408+
Object.defineProperty(process, 'resourcesPath', { configurable: true, value: directory })
409+
410+
initLocalUpdater(() => undefined)
411+
const available = vi.mocked(localAutoUpdater.on).mock.calls.find(
412+
([event]) => event === 'update-available',
413+
)?.[1] as ((info: { version: string }) => void) | undefined
414+
const progress = vi.mocked(localAutoUpdater.on).mock.calls.find(
415+
([event]) => event === 'download-progress',
416+
)?.[1] as ((info: {
417+
percent: number
418+
transferred: number
419+
total: number
420+
bytesPerSecond: number
421+
}) => void) | undefined
422+
available?.({ version: '1.2.3' })
423+
424+
startLocalUpdateDownload()
425+
progress?.({ percent: 40, transferred: 400, total: 1_000, bytesPerSecond: 80 })
426+
expect(getLocalUpdateState()).toMatchObject({ status: 'downloading', percent: 40 })
427+
428+
const token = vi.mocked(localAutoUpdater.downloadUpdate).mock.calls[0]?.[0] as
429+
| { cancelled: boolean }
430+
| undefined
431+
expect(token).toBeDefined()
432+
expect(token?.cancelled).toBe(false)
433+
434+
expect(cancelLocalUpdateDownload()).toMatchObject({
435+
status: 'available',
436+
availableVersion: '1.2.3',
437+
percent: undefined,
438+
transferred: undefined,
439+
total: undefined,
440+
bytesPerSecond: undefined,
441+
})
442+
expect(token?.cancelled).toBe(true)
443+
expect(localAutoUpdater.autoDownload).toBe(false)
444+
expect(localAutoUpdater.quitAndInstall).not.toHaveBeenCalled()
445+
})
446+
447+
it('does not report an error when the cancelled download rejects', async () => {
448+
vi.resetModules()
449+
const directory = temporaryDirectory()
450+
writeFileSync(join(directory, 'app-update.yml'), '', 'utf8')
451+
writeFileSync(join(directory, 'update-settings.json'), '{"autoUpdate":false}\n', 'utf8')
452+
const { app: localApp } = await import('electron')
453+
const { default: localElectronUpdater } = await import('electron-updater')
454+
const {
455+
cancelUpdateDownload: cancelLocalUpdateDownload,
456+
getUpdateState: getLocalUpdateState,
457+
initUpdater: initLocalUpdater,
458+
startUpdateDownload: startLocalUpdateDownload,
459+
} = await import('../src/updater')
460+
const localAutoUpdater = localElectronUpdater.autoUpdater
461+
vi.mocked(localApp.getPath).mockReturnValue(directory)
462+
Object.defineProperty(localApp, 'isPackaged', { configurable: true, value: true })
463+
Object.defineProperty(process, 'resourcesPath', { configurable: true, value: directory })
464+
465+
let rejectDownload: ((error: Error) => void) | undefined
466+
vi.mocked(localAutoUpdater.downloadUpdate).mockReturnValueOnce(
467+
new Promise<string[]>((_resolve, reject) => {
468+
rejectDownload = reject
469+
}),
470+
)
471+
472+
initLocalUpdater(() => undefined)
473+
const available = vi.mocked(localAutoUpdater.on).mock.calls.find(
474+
([event]) => event === 'update-available',
475+
)?.[1] as ((info: { version: string }) => void) | undefined
476+
available?.({ version: '1.2.3' })
477+
478+
startLocalUpdateDownload()
479+
cancelLocalUpdateDownload()
480+
rejectDownload?.(new Error('cancelled'))
481+
await Promise.resolve()
482+
await Promise.resolve()
483+
484+
expect(getLocalUpdateState()).toMatchObject({ status: 'available', availableVersion: '1.2.3' })
485+
expect(getLocalUpdateState().message).toBeUndefined()
486+
})
391487
})
392488

393489
describe('update prompt receipts', () => {
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
{
2-
"sourceHash": "88d1ce5b6a9e547d95f58e7c12da9767e9741da5b9af3e0663d0ba66c9783432",
3-
"sourceFileCount": 390
2+
"sourceHash": "601145dfae3f453f1f25a8e4ab9bf2734da59a3032fde032a6e2c8c7b779d93e",
3+
"sourceFileCount": 391
44
}

0 commit comments

Comments
 (0)