From f556484fb5d7288db19cf132dbff599bfe1015fd Mon Sep 17 00:00:00 2001 From: elkaix Date: Thu, 27 Aug 2026 15:05:06 -0400 Subject: [PATCH] fix(desktop): install Windows updates without the installer UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `installDownloadedUpdateNow()` called `quitAndInstall()` with no arguments, so `isSilent` defaulted to false and Restart to update launched the assisted NSIS installer UI instead of applying the downloaded update. Passing silent and force-run spawns the setup as `--updated /S --force-run`: no window, and the app relaunches itself. The NSIS templates make this safe for a per-user install: the assisted installer honours `/S`, reads the chosen directory back from the HKCU InstallLocation, and only elevates when a per-machine installation exists. `allowElevation: false` stops a normally launched installer from offering that per-machine path — it is soft hardening, not a guarantee, since an installer started as Administrator still offers both modes. A silent installer reports nothing back, so the startup receipt now also proves failure: a pending install version that does not match the running version opens the app in an error state naming both versions. --- .changeset/desktop-silent-windows-update.md | 5 ++ apps/desktop/package.json | 2 +- apps/desktop/src/updater.ts | 46 +++++++++++++---- apps/desktop/tests/packaging-config.spec.ts | 4 +- apps/desktop/tests/updater.spec.ts | 55 +++++++++++++++++++++ 5 files changed, 99 insertions(+), 13 deletions(-) create mode 100644 .changeset/desktop-silent-windows-update.md diff --git a/.changeset/desktop-silent-windows-update.md b/.changeset/desktop-silent-windows-update.md new file mode 100644 index 000000000..f8feb1ce0 --- /dev/null +++ b/.changeset/desktop-silent-windows-update.md @@ -0,0 +1,5 @@ +--- +"@pymodel/pythinker-desktop": patch +--- + +Install Windows updates in the background instead of opening the installer wizard, and report an update that did not take effect. diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 3ffae924b..82e21888b 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -103,7 +103,7 @@ ] }, "nsis": { - "allowElevation": true, + "allowElevation": false, "allowToChangeInstallationDirectory": true, "artifactName": "Pythinker-${version}-${arch}-Setup.${ext}", "createDesktopShortcut": true, diff --git a/apps/desktop/src/updater.ts b/apps/desktop/src/updater.ts index 460bd9c4e..477a5b98f 100644 --- a/apps/desktop/src/updater.ts +++ b/apps/desktop/src/updater.ts @@ -59,6 +59,7 @@ export type UpdateState = { notifiedVersion?: string skippedVersion?: string completedVersion?: string + failedInstallVersion?: string } export type UpdateTelemetryTrack = ( @@ -129,16 +130,31 @@ function isVerifiedUpgrade(currentVersion: string, previousVersion: string | und && gt(currentVersion, previousVersion) } -function reconcileStartupReceipt(value: UpdateSettings, currentVersion: string): UpdateSettings { +/** + * A silent installer reports nothing back: the app quits, the installer runs + * hidden, and the only evidence either way is the version that comes back up. + * A pending receipt that does not match this launch therefore means the install + * did not take effect, and it has to become a visible error rather than silence. + */ +function reconcileStartupReceipt( + value: UpdateSettings, + currentVersion: string, +): { settings: UpdateSettings, failedInstallVersion?: string } { let completedVersion = value.completedVersion === currentVersion ? value.completedVersion : undefined + let failedInstallVersion: string | undefined if (value.pendingInstallVersion === currentVersion) { if (isVerifiedUpgrade(currentVersion, value.lastRunVersion)) completedVersion = currentVersion + } else if (value.pendingInstallVersion !== undefined) { + failedInstallVersion = value.pendingInstallVersion } return { - ...value, - pendingInstallVersion: undefined, - completedVersion, - lastRunVersion: currentVersion, + settings: { + ...value, + pendingInstallVersion: undefined, + completedVersion, + lastRunVersion: currentVersion, + }, + failedInstallVersion, } } @@ -295,7 +311,7 @@ function scheduleChecks(): void { async function runCheck(): Promise { if (checkPromise !== undefined) return checkPromise checkPromise = (async () => { - updateState({ status: 'checking', message: undefined }) + updateState({ status: 'checking', message: undefined, failedInstallVersion: undefined }) try { configureExplicitConsent() await autoUpdater.checkForUpdates() @@ -329,7 +345,7 @@ function wireUpdaterEvents(): void { if (listenersWired) return try { autoUpdater.on('checking-for-update', () => { - updateState({ status: 'checking', message: undefined }) + updateState({ status: 'checking', message: undefined, failedInstallVersion: undefined }) }) autoUpdater.on('update-available', applyAvailableUpdate) autoUpdater.on('update-not-available', () => { @@ -389,12 +405,19 @@ export function initUpdater( updateTelemetryTrack = track const userData = app.getPath('userData') settings = readUpdateSettings(userData) + let failedInstallVersion: string | undefined if (app.isPackaged) { - settings = reconcileStartupReceipt(settings, app.getVersion()) + const receipt = reconcileStartupReceipt(settings, app.getVersion()) + settings = receipt.settings + failedInstallVersion = receipt.failedInstallVersion writeUpdateSettings(userData, settings) } state = { - status: app.isPackaged ? 'idle' : 'disabled', + status: app.isPackaged ? (failedInstallVersion === undefined ? 'idle' : 'error') : 'disabled', + message: failedInstallVersion === undefined + ? undefined + : `Update to v${failedInstallVersion} did not complete. Pythinker is still on v${app.getVersion()}.`, + failedInstallVersion, installedVersion: app.getVersion(), autoUpdate: settings.autoUpdate, channel: settings.channel, @@ -619,7 +642,10 @@ export function installDownloadedUpdateNow(): UpdateState { } catch { // Telemetry must never delay an explicit installation. } - autoUpdater.quitAndInstall() + // Silent NSIS install: `--updated /S --force-run`. Without `isSilent` the + // assisted installer opens its wizard, and relaunch falls to + // `autoRunAppAfterInstall` instead of `isForceRunAfter`. + autoUpdater.quitAndInstall(true, true) } catch (error) { installRequestedVersion = undefined if (settings.pendingInstallVersion === version) { diff --git a/apps/desktop/tests/packaging-config.spec.ts b/apps/desktop/tests/packaging-config.spec.ts index 9f66beb08..8d39d69e5 100644 --- a/apps/desktop/tests/packaging-config.spec.ts +++ b/apps/desktop/tests/packaging-config.spec.ts @@ -143,7 +143,7 @@ describe('desktop packaging configuration', () => { it('configures the Windows x64 NSIS installer', () => { expect(desktopPackage.build.win.target).toEqual([{ target: 'nsis', arch: ['x64'] }]) expect(desktopPackage.build.nsis).toEqual({ - allowElevation: true, + allowElevation: false, allowToChangeInstallationDirectory: true, artifactName: 'Pythinker-${version}-${arch}-Setup.${ext}', createDesktopShortcut: true, @@ -160,7 +160,7 @@ describe('desktop packaging configuration', () => { it('offers an assisted installer that defaults to a per-user install', () => { expect(desktopPackage.build.nsis.oneClick).toBe(false) expect(desktopPackage.build.nsis.perMachine).toBe(false) - expect(desktopPackage.build.nsis.allowElevation).toBe(true) + expect(desktopPackage.build.nsis.allowElevation).toBe(false) }) it('exposes desktop commands at the repository root', () => { diff --git a/apps/desktop/tests/updater.spec.ts b/apps/desktop/tests/updater.spec.ts index 130a744ea..ebf594a2e 100644 --- a/apps/desktop/tests/updater.spec.ts +++ b/apps/desktop/tests/updater.spec.ts @@ -454,6 +454,7 @@ describe('strict update consent', () => { expect(installLocalUpdate()).toMatchObject({ status: 'downloaded' }) expect(installLocalUpdate()).toMatchObject({ status: 'downloaded' }) expect(localAutoUpdater.quitAndInstall).toHaveBeenCalledOnce() + expect(localAutoUpdater.quitAndInstall).toHaveBeenCalledWith(true, true) expect(readUpdateSettings(directory)).toMatchObject({ pendingInstallVersion: '1.2.3' }) }) @@ -886,4 +887,58 @@ describe('update prompt receipts', () => { expect(readLocalUpdateSettings(directory)).toMatchObject({ lastRunVersion: currentVersion }) expect(readLocalUpdateSettings(directory).pendingInstallVersion).toBeUndefined() }) + + it('reports a pending install that did not take effect as an error', async () => { + vi.resetModules() + const directory = temporaryDirectory() + writeFileSync(join(directory, 'app-update.yml'), '', 'utf8') + writeFileSync( + join(directory, 'update-settings.json'), + '{"autoUpdate":false,"lastRunVersion":"1.1.0","pendingInstallVersion":"1.2.0"}\n', + 'utf8', + ) + const { app: localApp } = await import('electron') + const { + getUpdateState: getLocalUpdateState, + initUpdater: initLocalUpdater, + } = await import('../src/updater') + vi.mocked(localApp.getPath).mockReturnValue(directory) + vi.mocked(localApp.getVersion).mockReturnValue('1.1.0') + Object.defineProperty(localApp, 'isPackaged', { configurable: true, value: true }) + Object.defineProperty(process, 'resourcesPath', { configurable: true, value: directory }) + + initLocalUpdater(() => undefined) + + expect(getLocalUpdateState()).toMatchObject({ + status: 'error', + failedInstallVersion: '1.2.0', + installedVersion: '1.1.0', + }) + expect(getLocalUpdateState().message).toContain('1.2.0') + }) + + it('leaves a completed install without a failure receipt', async () => { + vi.resetModules() + const directory = temporaryDirectory() + writeFileSync(join(directory, 'app-update.yml'), '', 'utf8') + writeFileSync( + join(directory, 'update-settings.json'), + '{"autoUpdate":false,"lastRunVersion":"1.1.0","pendingInstallVersion":"1.2.0"}\n', + 'utf8', + ) + const { app: localApp } = await import('electron') + const { + getUpdateState: getLocalUpdateState, + initUpdater: initLocalUpdater, + } = await import('../src/updater') + vi.mocked(localApp.getPath).mockReturnValue(directory) + vi.mocked(localApp.getVersion).mockReturnValue('1.2.0') + Object.defineProperty(localApp, 'isPackaged', { configurable: true, value: true }) + Object.defineProperty(process, 'resourcesPath', { configurable: true, value: directory }) + + initLocalUpdater(() => undefined) + + expect(getLocalUpdateState()).toMatchObject({ status: 'idle', completedVersion: '1.2.0' }) + expect(getLocalUpdateState().failedInstallVersion).toBeUndefined() + }) })