Skip to content

Commit a447a2c

Browse files
authored
fix(desktop): complete the update restart flow (#126)
## Related Issue No issue. This bug was reported directly with macOS screenshots and a reproducible update failure. ## Problem The update restart closed Electron windows before the normal quit event. The tray close handler hid the window, and the bundled local server remained active. macOS ShipIt then found two running Pythinker processes and canceled the installation. The remaining app could show a main-process connection error. ## What changed - Start the shared desktop shutdown path on Electron update quits, before windows close. - Use one **Update** toast action on Windows and macOS. It downloads when needed, then closes, installs, and restarts automatically. - Keep **Skip** as the alternative and document the flow. - Add updater and toast regression coverage plus a patch changeset. ## Checklist - [x] I have read the [CONTRIBUTING](https://github.com/PyModel/pythinker-code/blob/main/CONTRIBUTING.md) document. - [x] I have linked a related issue, or explained the problem above. - [x] I have added tests that prove my feature works. - [x] Ran `gen-changesets` skill, or this PR needs no changeset. - [x] Ran `gen-docs` skill, or this PR needs no doc update. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Bug Fixes** * Desktop updates now download, install, close, and restart the app through a single **Update** action. * Update installation waits for the download to finish before restarting. * **Documentation** * Added guidance for the desktop update prompt, including **Update** and **Skip** actions. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent 0e9ae30 commit a447a2c

8 files changed

Lines changed: 70 additions & 13 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@pymodel/pythinker-code": patch
3+
---
4+
5+
Fix desktop update prompts so one action downloads, closes, installs, and restarts the app.

apps/desktop/src/main.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { existsSync, readFileSync, writeFileSync } from 'node:fs'
55
import { isAbsolute, join, resolve } from 'node:path'
66
import {
77
app,
8+
autoUpdater,
89
BrowserWindow,
910
dialog,
1011
ipcMain,
@@ -401,6 +402,8 @@ async function boot(): Promise<void> {
401402
if (!app.requestSingleInstanceLock()) {
402403
app.quit()
403404
} else {
405+
// Update-triggered quits close windows before app.before-quit, so start teardown here.
406+
autoUpdater.on('before-quit-for-update', () => { void requestAppQuit() })
404407
app.on('second-instance', showWindowSafely)
405408
app.on('activate', showWindowSafely)
406409
app.on('window-all-closed', () => {

apps/desktop/src/updater.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ let initialCheckTimer: ReturnType<typeof setTimeout> | undefined
5555
let checkInterval: ReturnType<typeof setInterval> | undefined
5656
let listenersWired = false
5757
let initialized = false
58+
let installWhenDownloaded = false
5859
let updateTelemetryTrack: UpdateTelemetryTrack = () => {}
5960

6061
export function trackUpdateTransition(
@@ -92,6 +93,7 @@ function emitUpdateTelemetry(previous: UpdateState, next: UpdateState): void {
9293
}
9394

9495
function stateError(error: unknown): void {
96+
installWhenDownloaded = false
9597
updateState({
9698
status: 'error',
9799
message: error instanceof Error ? error.message : String(error),
@@ -166,6 +168,7 @@ function wireUpdaterEvents(): void {
166168
})
167169
autoUpdater.on('update-downloaded', (info) => {
168170
updateState({ status: 'downloaded', version: info.version, percent: 100, message: undefined })
171+
if (installWhenDownloaded) installDownloadedUpdate()
169172
})
170173
autoUpdater.on('error', stateError)
171174
listenersWired = true
@@ -278,10 +281,26 @@ export function quitAndInstallNow(): UpdateState {
278281
} catch {
279282
// Telemetry must never delay update installation.
280283
}
284+
if (state.status === 'available') {
285+
installWhenDownloaded = true
286+
updateState({ status: 'downloading', percent: 0, message: undefined })
287+
try {
288+
void autoUpdater.downloadUpdate().catch(stateError)
289+
} catch (error) {
290+
stateError(error)
291+
}
292+
return state
293+
}
294+
if (state.status !== 'downloaded') return state
295+
installDownloadedUpdate()
296+
return state
297+
}
298+
299+
function installDownloadedUpdate(): void {
300+
installWhenDownloaded = false
281301
try {
282302
autoUpdater.quitAndInstall()
283303
} catch (error) {
284304
stateError(error)
285305
}
286-
return state
287306
}

apps/desktop/tests/updater.spec.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ vi.mock('electron-updater', () => ({
1616
autoUpdater: {
1717
on: vi.fn(),
1818
checkForUpdates: vi.fn(),
19+
downloadUpdate: vi.fn(() => Promise.resolve([])),
1920
quitAndInstall: vi.fn(),
2021
},
2122
},
@@ -118,3 +119,33 @@ describe('packaged builds without update metadata', () => {
118119
expect(app.once).not.toHaveBeenCalled()
119120
})
120121
})
122+
123+
describe('installing an update', () => {
124+
it('downloads an available update and installs it when the download completes', async () => {
125+
vi.resetModules()
126+
const directory = temporaryDirectory()
127+
writeFileSync(join(directory, 'app-update.yml'), '', 'utf8')
128+
const { app: localApp } = await import('electron')
129+
const { default: localElectronUpdater } = await import('electron-updater')
130+
const {
131+
initUpdater: initLocalUpdater,
132+
quitAndInstallNow,
133+
} = await import('../src/updater')
134+
const localAutoUpdater = localElectronUpdater.autoUpdater
135+
vi.mocked(localApp.getPath).mockReturnValue(directory)
136+
Object.defineProperty(localApp, 'isPackaged', { configurable: true, value: true })
137+
Object.defineProperty(process, 'resourcesPath', { configurable: true, value: directory })
138+
139+
initLocalUpdater(() => undefined)
140+
const available = vi.mocked(localAutoUpdater.on).mock.calls.find(([event]) => event === 'update-available')?.[1] as ((info: { version: string }) => void) | undefined
141+
const downloaded = vi.mocked(localAutoUpdater.on).mock.calls.find(([event]) => event === 'update-downloaded')?.[1] as ((info: { version: string }) => void) | undefined
142+
available?.({ version: '1.2.3' })
143+
144+
expect(quitAndInstallNow()).toMatchObject({ status: 'downloading' })
145+
expect(localAutoUpdater.downloadUpdate).toHaveBeenCalledOnce()
146+
expect(localAutoUpdater.quitAndInstall).not.toHaveBeenCalled()
147+
148+
downloaded?.({ version: '1.2.3' })
149+
expect(localAutoUpdater.quitAndInstall).toHaveBeenCalledOnce()
150+
})
151+
})

apps/pythinker-web/src/components/UpdateToast.vue

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -41,16 +41,11 @@ const title = computed(() =>
4141
: t('update.available'),
4242
);
4343
44-
const primaryLabel = computed(() =>
45-
state.value?.status === 'downloaded' ? t('settings.desktop.restartToUpdate') : t('update.download'),
46-
);
47-
4844
async function primary(): Promise<void> {
4945
if (bridge === undefined || busy.value) return;
5046
busy.value = true;
5147
try {
52-
state.value =
53-
state.value?.status === 'downloaded' ? await bridge.quitAndInstall() : await bridge.checkForUpdates();
48+
state.value = await bridge.quitAndInstall();
5449
} finally {
5550
busy.value = false;
5651
}
@@ -97,7 +92,7 @@ onUnmounted(() => {
9792
</div>
9893
<div class="acts">
9994
<button type="button" class="skip" @click="skip">{{ t('update.skip') }}</button>
100-
<button type="button" class="go" :disabled="busy" @click="void primary()">{{ primaryLabel }}</button>
95+
<button type="button" class="go" :disabled="busy" @click="void primary()">{{ t('update.install') }}</button>
10196
</div>
10297
</div>
10398
</template>

apps/pythinker-web/src/i18n/locales/en/update.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,6 @@ export default {
22
available: 'A new version is available',
33
availableVersion: 'Version {version} is available',
44
prompt: 'Install it now, or skip this version.',
5-
download: 'Download update',
5+
install: 'Update',
66
skip: 'Skip',
77
} as const;

apps/pythinker-web/test/update-toast.test.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -62,17 +62,17 @@ describe('UpdateToast', () => {
6262
const bridge = installBridge({ status: 'downloaded', version: '1.2.3', autoUpdate: true });
6363
const wrapper = await mountToast();
6464
expect(wrapper.get('.title').text()).toContain('1.2.3');
65-
expect(wrapper.get('.go').text()).toBe(enSettings.desktop.restartToUpdate);
65+
expect(wrapper.get('.go').text()).toBe(enUpdate.install);
6666
await wrapper.get('.go').trigger('click');
6767
expect(bridge.quitAndInstall).toHaveBeenCalledTimes(1);
6868
});
6969

70-
it('offers a manual download when automatic updates are off', async () => {
70+
it('starts the complete update flow when automatic downloads are off', async () => {
7171
const bridge = installBridge({ status: 'available', version: '1.2.3', autoUpdate: false });
7272
const wrapper = await mountToast();
73-
expect(wrapper.get('.go').text()).toBe(enUpdate.download);
73+
expect(wrapper.get('.go').text()).toBe(enUpdate.install);
7474
await wrapper.get('.go').trigger('click');
75-
expect(bridge.checkForUpdates).toHaveBeenCalledTimes(1);
75+
expect(bridge.quitAndInstall).toHaveBeenCalledTimes(1);
7676
});
7777

7878
it('hides the prompt and remembers the skipped version', async () => {

docs/guides/desktop.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,10 @@ The desktop application updates itself. Open **Settings** in the application to
5959
- **Check for updates** — check immediately.
6060
- **Restart to update** — appears when an update is downloaded and ready to install.
6161

62+
When a new version is available, the application also shows an update prompt. Choose **Update** to
63+
download the version if needed, close the application, install it, and restart. Choose **Skip** to
64+
ignore that version.
65+
6266
The update controls apply to installed builds only. A development build shows them as unavailable.
6367

6468
## The local Host port

0 commit comments

Comments
 (0)