Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/desktop-silent-windows-update.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion apps/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@
]
},
"nsis": {
"allowElevation": true,
"allowElevation": false,
"allowToChangeInstallationDirectory": true,
"artifactName": "Pythinker-${version}-${arch}-Setup.${ext}",
"createDesktopShortcut": true,
Expand Down
46 changes: 36 additions & 10 deletions apps/desktop/src/updater.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ export type UpdateState = {
notifiedVersion?: string
skippedVersion?: string
completedVersion?: string
failedInstallVersion?: string
}

export type UpdateTelemetryTrack = (
Expand Down Expand Up @@ -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,
}
}

Expand Down Expand Up @@ -295,7 +311,7 @@ function scheduleChecks(): void {
async function runCheck(): Promise<UpdateState> {
if (checkPromise !== undefined) return checkPromise
checkPromise = (async () => {
updateState({ status: 'checking', message: undefined })
updateState({ status: 'checking', message: undefined, failedInstallVersion: undefined })
try {
configureExplicitConsent()
await autoUpdater.checkForUpdates()
Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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) {
Expand Down
4 changes: 2 additions & 2 deletions apps/desktop/tests/packaging-config.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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', () => {
Expand Down
55 changes: 55 additions & 0 deletions apps/desktop/tests/updater.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' })
})

Expand Down Expand Up @@ -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()
})
})
Loading