diff --git a/.changeset/fix-desktop-update-restart.md b/.changeset/fix-desktop-update-restart.md new file mode 100644 index 00000000..694eae52 --- /dev/null +++ b/.changeset/fix-desktop-update-restart.md @@ -0,0 +1,5 @@ +--- +"@pymodel/pythinker-code": patch +--- + +Fix desktop update prompts so one action downloads, closes, installs, and restarts the app. diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index e5d8ccd1..c3439123 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -5,6 +5,7 @@ import { existsSync, readFileSync, writeFileSync } from 'node:fs' import { isAbsolute, join, resolve } from 'node:path' import { app, + autoUpdater, BrowserWindow, dialog, ipcMain, @@ -401,6 +402,8 @@ async function boot(): Promise { if (!app.requestSingleInstanceLock()) { app.quit() } else { + // Update-triggered quits close windows before app.before-quit, so start teardown here. + autoUpdater.on('before-quit-for-update', () => { void requestAppQuit() }) app.on('second-instance', showWindowSafely) app.on('activate', showWindowSafely) app.on('window-all-closed', () => { diff --git a/apps/desktop/src/updater.ts b/apps/desktop/src/updater.ts index 803f625e..afe4913a 100644 --- a/apps/desktop/src/updater.ts +++ b/apps/desktop/src/updater.ts @@ -55,6 +55,7 @@ let initialCheckTimer: ReturnType | undefined let checkInterval: ReturnType | undefined let listenersWired = false let initialized = false +let installWhenDownloaded = false let updateTelemetryTrack: UpdateTelemetryTrack = () => {} export function trackUpdateTransition( @@ -92,6 +93,7 @@ function emitUpdateTelemetry(previous: UpdateState, next: UpdateState): void { } function stateError(error: unknown): void { + installWhenDownloaded = false updateState({ status: 'error', message: error instanceof Error ? error.message : String(error), @@ -166,6 +168,7 @@ function wireUpdaterEvents(): void { }) autoUpdater.on('update-downloaded', (info) => { updateState({ status: 'downloaded', version: info.version, percent: 100, message: undefined }) + if (installWhenDownloaded) installDownloadedUpdate() }) autoUpdater.on('error', stateError) listenersWired = true @@ -278,10 +281,26 @@ export function quitAndInstallNow(): UpdateState { } catch { // Telemetry must never delay update installation. } + if (state.status === 'available') { + installWhenDownloaded = true + updateState({ status: 'downloading', percent: 0, message: undefined }) + try { + void autoUpdater.downloadUpdate().catch(stateError) + } catch (error) { + stateError(error) + } + return state + } + if (state.status !== 'downloaded') return state + installDownloadedUpdate() + return state +} + +function installDownloadedUpdate(): void { + installWhenDownloaded = false try { autoUpdater.quitAndInstall() } catch (error) { stateError(error) } - return state } diff --git a/apps/desktop/tests/updater.spec.ts b/apps/desktop/tests/updater.spec.ts index c26899d9..59e1ebc6 100644 --- a/apps/desktop/tests/updater.spec.ts +++ b/apps/desktop/tests/updater.spec.ts @@ -16,6 +16,7 @@ vi.mock('electron-updater', () => ({ autoUpdater: { on: vi.fn(), checkForUpdates: vi.fn(), + downloadUpdate: vi.fn(() => Promise.resolve([])), quitAndInstall: vi.fn(), }, }, @@ -118,3 +119,33 @@ describe('packaged builds without update metadata', () => { expect(app.once).not.toHaveBeenCalled() }) }) + +describe('installing an update', () => { + it('downloads an available update and installs it when the download completes', async () => { + vi.resetModules() + const directory = temporaryDirectory() + writeFileSync(join(directory, 'app-update.yml'), '', 'utf8') + const { app: localApp } = await import('electron') + const { default: localElectronUpdater } = await import('electron-updater') + const { + initUpdater: initLocalUpdater, + quitAndInstallNow, + } = await import('../src/updater') + const localAutoUpdater = localElectronUpdater.autoUpdater + vi.mocked(localApp.getPath).mockReturnValue(directory) + Object.defineProperty(localApp, 'isPackaged', { configurable: true, value: true }) + Object.defineProperty(process, 'resourcesPath', { configurable: true, value: directory }) + + initLocalUpdater(() => undefined) + const available = vi.mocked(localAutoUpdater.on).mock.calls.find(([event]) => event === 'update-available')?.[1] as ((info: { version: string }) => void) | undefined + const downloaded = vi.mocked(localAutoUpdater.on).mock.calls.find(([event]) => event === 'update-downloaded')?.[1] as ((info: { version: string }) => void) | undefined + available?.({ version: '1.2.3' }) + + expect(quitAndInstallNow()).toMatchObject({ status: 'downloading' }) + expect(localAutoUpdater.downloadUpdate).toHaveBeenCalledOnce() + expect(localAutoUpdater.quitAndInstall).not.toHaveBeenCalled() + + downloaded?.({ version: '1.2.3' }) + expect(localAutoUpdater.quitAndInstall).toHaveBeenCalledOnce() + }) +}) diff --git a/apps/pythinker-web/src/components/UpdateToast.vue b/apps/pythinker-web/src/components/UpdateToast.vue index a2eb03ef..79423396 100644 --- a/apps/pythinker-web/src/components/UpdateToast.vue +++ b/apps/pythinker-web/src/components/UpdateToast.vue @@ -41,16 +41,11 @@ const title = computed(() => : t('update.available'), ); -const primaryLabel = computed(() => - state.value?.status === 'downloaded' ? t('settings.desktop.restartToUpdate') : t('update.download'), -); - async function primary(): Promise { if (bridge === undefined || busy.value) return; busy.value = true; try { - state.value = - state.value?.status === 'downloaded' ? await bridge.quitAndInstall() : await bridge.checkForUpdates(); + state.value = await bridge.quitAndInstall(); } finally { busy.value = false; } @@ -97,7 +92,7 @@ onUnmounted(() => {
- +
diff --git a/apps/pythinker-web/src/i18n/locales/en/update.ts b/apps/pythinker-web/src/i18n/locales/en/update.ts index db5f6f22..28a33411 100644 --- a/apps/pythinker-web/src/i18n/locales/en/update.ts +++ b/apps/pythinker-web/src/i18n/locales/en/update.ts @@ -2,6 +2,6 @@ export default { available: 'A new version is available', availableVersion: 'Version {version} is available', prompt: 'Install it now, or skip this version.', - download: 'Download update', + install: 'Update', skip: 'Skip', } as const; diff --git a/apps/pythinker-web/test/update-toast.test.ts b/apps/pythinker-web/test/update-toast.test.ts index 89a4f684..9c087524 100644 --- a/apps/pythinker-web/test/update-toast.test.ts +++ b/apps/pythinker-web/test/update-toast.test.ts @@ -62,17 +62,17 @@ describe('UpdateToast', () => { const bridge = installBridge({ status: 'downloaded', version: '1.2.3', autoUpdate: true }); const wrapper = await mountToast(); expect(wrapper.get('.title').text()).toContain('1.2.3'); - expect(wrapper.get('.go').text()).toBe(enSettings.desktop.restartToUpdate); + expect(wrapper.get('.go').text()).toBe(enUpdate.install); await wrapper.get('.go').trigger('click'); expect(bridge.quitAndInstall).toHaveBeenCalledTimes(1); }); - it('offers a manual download when automatic updates are off', async () => { + it('starts the complete update flow when automatic downloads are off', async () => { const bridge = installBridge({ status: 'available', version: '1.2.3', autoUpdate: false }); const wrapper = await mountToast(); - expect(wrapper.get('.go').text()).toBe(enUpdate.download); + expect(wrapper.get('.go').text()).toBe(enUpdate.install); await wrapper.get('.go').trigger('click'); - expect(bridge.checkForUpdates).toHaveBeenCalledTimes(1); + expect(bridge.quitAndInstall).toHaveBeenCalledTimes(1); }); it('hides the prompt and remembers the skipped version', async () => { diff --git a/docs/guides/desktop.md b/docs/guides/desktop.md index 225496fe..3aaff455 100644 --- a/docs/guides/desktop.md +++ b/docs/guides/desktop.md @@ -59,6 +59,10 @@ The desktop application updates itself. Open **Settings** in the application to - **Check for updates** — check immediately. - **Restart to update** — appears when an update is downloaded and ready to install. +When a new version is available, the application also shows an update prompt. Choose **Update** to +download the version if needed, close the application, install it, and restart. Choose **Skip** to +ignore that version. + The update controls apply to installed builds only. A development build shows them as unavailable. ## The local Host port