Skip to content

Commit f556484

Browse files
committed
fix(desktop): install Windows updates without the installer UI
`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.
1 parent ad27021 commit f556484

5 files changed

Lines changed: 99 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-desktop": patch
3+
---
4+
5+
Install Windows updates in the background instead of opening the installer wizard, and report an update that did not take effect.

apps/desktop/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@
103103
]
104104
},
105105
"nsis": {
106-
"allowElevation": true,
106+
"allowElevation": false,
107107
"allowToChangeInstallationDirectory": true,
108108
"artifactName": "Pythinker-${version}-${arch}-Setup.${ext}",
109109
"createDesktopShortcut": true,

apps/desktop/src/updater.ts

Lines changed: 36 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ export type UpdateState = {
5959
notifiedVersion?: string
6060
skippedVersion?: string
6161
completedVersion?: string
62+
failedInstallVersion?: string
6263
}
6364

6465
export type UpdateTelemetryTrack = (
@@ -129,16 +130,31 @@ function isVerifiedUpgrade(currentVersion: string, previousVersion: string | und
129130
&& gt(currentVersion, previousVersion)
130131
}
131132

132-
function reconcileStartupReceipt(value: UpdateSettings, currentVersion: string): UpdateSettings {
133+
/**
134+
* A silent installer reports nothing back: the app quits, the installer runs
135+
* hidden, and the only evidence either way is the version that comes back up.
136+
* A pending receipt that does not match this launch therefore means the install
137+
* did not take effect, and it has to become a visible error rather than silence.
138+
*/
139+
function reconcileStartupReceipt(
140+
value: UpdateSettings,
141+
currentVersion: string,
142+
): { settings: UpdateSettings, failedInstallVersion?: string } {
133143
let completedVersion = value.completedVersion === currentVersion ? value.completedVersion : undefined
144+
let failedInstallVersion: string | undefined
134145
if (value.pendingInstallVersion === currentVersion) {
135146
if (isVerifiedUpgrade(currentVersion, value.lastRunVersion)) completedVersion = currentVersion
147+
} else if (value.pendingInstallVersion !== undefined) {
148+
failedInstallVersion = value.pendingInstallVersion
136149
}
137150
return {
138-
...value,
139-
pendingInstallVersion: undefined,
140-
completedVersion,
141-
lastRunVersion: currentVersion,
151+
settings: {
152+
...value,
153+
pendingInstallVersion: undefined,
154+
completedVersion,
155+
lastRunVersion: currentVersion,
156+
},
157+
failedInstallVersion,
142158
}
143159
}
144160

@@ -295,7 +311,7 @@ function scheduleChecks(): void {
295311
async function runCheck(): Promise<UpdateState> {
296312
if (checkPromise !== undefined) return checkPromise
297313
checkPromise = (async () => {
298-
updateState({ status: 'checking', message: undefined })
314+
updateState({ status: 'checking', message: undefined, failedInstallVersion: undefined })
299315
try {
300316
configureExplicitConsent()
301317
await autoUpdater.checkForUpdates()
@@ -329,7 +345,7 @@ function wireUpdaterEvents(): void {
329345
if (listenersWired) return
330346
try {
331347
autoUpdater.on('checking-for-update', () => {
332-
updateState({ status: 'checking', message: undefined })
348+
updateState({ status: 'checking', message: undefined, failedInstallVersion: undefined })
333349
})
334350
autoUpdater.on('update-available', applyAvailableUpdate)
335351
autoUpdater.on('update-not-available', () => {
@@ -389,12 +405,19 @@ export function initUpdater(
389405
updateTelemetryTrack = track
390406
const userData = app.getPath('userData')
391407
settings = readUpdateSettings(userData)
408+
let failedInstallVersion: string | undefined
392409
if (app.isPackaged) {
393-
settings = reconcileStartupReceipt(settings, app.getVersion())
410+
const receipt = reconcileStartupReceipt(settings, app.getVersion())
411+
settings = receipt.settings
412+
failedInstallVersion = receipt.failedInstallVersion
394413
writeUpdateSettings(userData, settings)
395414
}
396415
state = {
397-
status: app.isPackaged ? 'idle' : 'disabled',
416+
status: app.isPackaged ? (failedInstallVersion === undefined ? 'idle' : 'error') : 'disabled',
417+
message: failedInstallVersion === undefined
418+
? undefined
419+
: `Update to v${failedInstallVersion} did not complete. Pythinker is still on v${app.getVersion()}.`,
420+
failedInstallVersion,
398421
installedVersion: app.getVersion(),
399422
autoUpdate: settings.autoUpdate,
400423
channel: settings.channel,
@@ -619,7 +642,10 @@ export function installDownloadedUpdateNow(): UpdateState {
619642
} catch {
620643
// Telemetry must never delay an explicit installation.
621644
}
622-
autoUpdater.quitAndInstall()
645+
// Silent NSIS install: `--updated /S --force-run`. Without `isSilent` the
646+
// assisted installer opens its wizard, and relaunch falls to
647+
// `autoRunAppAfterInstall` instead of `isForceRunAfter`.
648+
autoUpdater.quitAndInstall(true, true)
623649
} catch (error) {
624650
installRequestedVersion = undefined
625651
if (settings.pendingInstallVersion === version) {

apps/desktop/tests/packaging-config.spec.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,7 @@ describe('desktop packaging configuration', () => {
143143
it('configures the Windows x64 NSIS installer', () => {
144144
expect(desktopPackage.build.win.target).toEqual([{ target: 'nsis', arch: ['x64'] }])
145145
expect(desktopPackage.build.nsis).toEqual({
146-
allowElevation: true,
146+
allowElevation: false,
147147
allowToChangeInstallationDirectory: true,
148148
artifactName: 'Pythinker-${version}-${arch}-Setup.${ext}',
149149
createDesktopShortcut: true,
@@ -160,7 +160,7 @@ describe('desktop packaging configuration', () => {
160160
it('offers an assisted installer that defaults to a per-user install', () => {
161161
expect(desktopPackage.build.nsis.oneClick).toBe(false)
162162
expect(desktopPackage.build.nsis.perMachine).toBe(false)
163-
expect(desktopPackage.build.nsis.allowElevation).toBe(true)
163+
expect(desktopPackage.build.nsis.allowElevation).toBe(false)
164164
})
165165

166166
it('exposes desktop commands at the repository root', () => {

apps/desktop/tests/updater.spec.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -454,6 +454,7 @@ describe('strict update consent', () => {
454454
expect(installLocalUpdate()).toMatchObject({ status: 'downloaded' })
455455
expect(installLocalUpdate()).toMatchObject({ status: 'downloaded' })
456456
expect(localAutoUpdater.quitAndInstall).toHaveBeenCalledOnce()
457+
expect(localAutoUpdater.quitAndInstall).toHaveBeenCalledWith(true, true)
457458
expect(readUpdateSettings(directory)).toMatchObject({ pendingInstallVersion: '1.2.3' })
458459
})
459460

@@ -886,4 +887,58 @@ describe('update prompt receipts', () => {
886887
expect(readLocalUpdateSettings(directory)).toMatchObject({ lastRunVersion: currentVersion })
887888
expect(readLocalUpdateSettings(directory).pendingInstallVersion).toBeUndefined()
888889
})
890+
891+
it('reports a pending install that did not take effect as an error', async () => {
892+
vi.resetModules()
893+
const directory = temporaryDirectory()
894+
writeFileSync(join(directory, 'app-update.yml'), '', 'utf8')
895+
writeFileSync(
896+
join(directory, 'update-settings.json'),
897+
'{"autoUpdate":false,"lastRunVersion":"1.1.0","pendingInstallVersion":"1.2.0"}\n',
898+
'utf8',
899+
)
900+
const { app: localApp } = await import('electron')
901+
const {
902+
getUpdateState: getLocalUpdateState,
903+
initUpdater: initLocalUpdater,
904+
} = await import('../src/updater')
905+
vi.mocked(localApp.getPath).mockReturnValue(directory)
906+
vi.mocked(localApp.getVersion).mockReturnValue('1.1.0')
907+
Object.defineProperty(localApp, 'isPackaged', { configurable: true, value: true })
908+
Object.defineProperty(process, 'resourcesPath', { configurable: true, value: directory })
909+
910+
initLocalUpdater(() => undefined)
911+
912+
expect(getLocalUpdateState()).toMatchObject({
913+
status: 'error',
914+
failedInstallVersion: '1.2.0',
915+
installedVersion: '1.1.0',
916+
})
917+
expect(getLocalUpdateState().message).toContain('1.2.0')
918+
})
919+
920+
it('leaves a completed install without a failure receipt', async () => {
921+
vi.resetModules()
922+
const directory = temporaryDirectory()
923+
writeFileSync(join(directory, 'app-update.yml'), '', 'utf8')
924+
writeFileSync(
925+
join(directory, 'update-settings.json'),
926+
'{"autoUpdate":false,"lastRunVersion":"1.1.0","pendingInstallVersion":"1.2.0"}\n',
927+
'utf8',
928+
)
929+
const { app: localApp } = await import('electron')
930+
const {
931+
getUpdateState: getLocalUpdateState,
932+
initUpdater: initLocalUpdater,
933+
} = await import('../src/updater')
934+
vi.mocked(localApp.getPath).mockReturnValue(directory)
935+
vi.mocked(localApp.getVersion).mockReturnValue('1.2.0')
936+
Object.defineProperty(localApp, 'isPackaged', { configurable: true, value: true })
937+
Object.defineProperty(process, 'resourcesPath', { configurable: true, value: directory })
938+
939+
initLocalUpdater(() => undefined)
940+
941+
expect(getLocalUpdateState()).toMatchObject({ status: 'idle', completedVersion: '1.2.0' })
942+
expect(getLocalUpdateState().failedInstallVersion).toBeUndefined()
943+
})
889944
})

0 commit comments

Comments
 (0)