Skip to content

Commit 92df21c

Browse files
committed
feat(desktop): add update progress and release validation
Complete the explicit-consent update flow in Settings and notifications. Verify platform signatures and update manifests before publishing releases. Task: tasks/todo.md desktop updater consent and UX
1 parent 1062dae commit 92df21c

244 files changed

Lines changed: 6341 additions & 5529 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
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+
Add explicit download and restart controls with live progress for desktop updates.

.github/workflows/desktop-release.yml

Lines changed: 151 additions & 76 deletions
Large diffs are not rendered by default.

apps/desktop/README.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -66,17 +66,19 @@ rmdir "$MOUNT_POINT"
6666

6767
### Windows
6868

69-
Run `pnpm run dist:win` on a native Windows x64 host; cross-building from macOS is not possible because the staged Host closure contains platform-gated native packages. The output is `dist/Pythinker-<version>-x64-Setup.exe`, an assisted NSIS installer that defaults to a per-user install, offers a per-machine option that requires elevation, and lets you select the installation directory. The existing certificate-file signing path uses `WIN_CSC_LINK` and `WIN_CSC_KEY_PASSWORD`.
69+
Run `pnpm run dist:win` on a native Windows x64 host; cross-building from macOS is not possible because the staged Host closure contains platform-gated native packages. The output is `dist/Pythinker-<version>-x64-Setup.exe`, an assisted NSIS installer that defaults to a per-user install, offers a per-machine option that requires elevation, and lets you select the installation directory. The certificate-file signing path uses `WIN_CSC_LINK`, `WIN_CSC_KEY_PASSWORD`, and `WINDOWS_SIGNING_PUBLISHER_NAME`. The full publisher name is stored in the packaged updater configuration so electron-updater verifies future installers against it.
7070

7171
#### Azure Artifact Signing
7272

73-
Windows artifacts are signed through Azure Artifact Signing when `AZURE_TENANT_ID`, `AZURE_CLIENT_ID`, `AZURE_CLIENT_SECRET`, `AZURE_SIGNING_ENDPOINT`, `AZURE_SIGNING_ACCOUNT`, `AZURE_SIGNING_CERT_PROFILE`, and `AZURE_SIGNING_PUBLISHER_NAME` are all set; they are unsigned when none are set. The credential variables are read from the environment; the four `AZURE_SIGNING_*` variables map to `azureSignOptions.endpoint`, `azureSignOptions.codeSigningAccountName`, `azureSignOptions.certificateProfileName`, and `azureSignOptions.publisherName`, respectively. Setting only some of the seven variables is a hard error by design.
73+
Windows artifacts are signed through Azure Artifact Signing when `AZURE_TENANT_ID`, `AZURE_CLIENT_ID`, `AZURE_CLIENT_SECRET`, `AZURE_SIGNING_ENDPOINT`, `AZURE_SIGNING_ACCOUNT`, `AZURE_SIGNING_CERT_PROFILE`, and `AZURE_SIGNING_PUBLISHER_NAME` are all set; they are unsigned when neither signing method is set. The credential variables are read from the environment; the four `AZURE_SIGNING_*` variables map to `azureSignOptions.endpoint`, `azureSignOptions.codeSigningAccountName`, `azureSignOptions.certificateProfileName`, and `azureSignOptions.publisherName`, respectively. Setting only part of either signing method, or setting both methods, is a hard error.
74+
75+
Tagged releases require one complete Windows signing method. CI verifies the installer and packaged app with electron-updater's Authenticode verifier before upload. Both platform jobs also recompute every size and SHA-512 value in `latest.yml` or `latest-mac.yml`. The final job downloads the draft assets and repeats both manifest checks before publication. Manual workflow runs remain private workflow artifacts and cannot publish an unsigned build.
7476

7577
## Known limitations
7678

7779
The first desktop assembly uses a loopback HTTP Host. The renderer and Host protocol remain unchanged so the application can replace the transport with the IPC carrier reserved by the GUI architecture without changing product features.
7880

79-
The signed installer path currently targets macOS. Linux packaging creates an unpacked application; its installer format and distribution signing remain release work.
81+
Linux packaging creates an unpacked application; its installer format and distribution signing remain release work.
8082

8183
## Model Experience
8284

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
/** Fail a tagged desktop release unless Windows signing is complete. */
2+
3+
import { requireWindowsReleaseSigning } from './package-win'
4+
5+
try {
6+
console.log(`Windows release signing is configured for ${requireWindowsReleaseSigning(process.env)}`)
7+
} catch (error) {
8+
console.error(error instanceof Error ? error.message : String(error))
9+
process.exit(1)
10+
}

apps/desktop/scripts/package-win.ts

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,16 +46,49 @@ export function windowsSigningArgs(env: NodeJS.ProcessEnv): readonly string[] {
4646
}
4747
missing.sort()
4848

49-
if (missing.length === values.length) return []
50-
if (missing.length > 0) {
49+
const certificateValues: readonly (readonly [string, string | undefined])[] = [
50+
['WIN_CSC_LINK', trimmedValue(env['WIN_CSC_LINK'])],
51+
['WIN_CSC_KEY_PASSWORD', trimmedValue(env['WIN_CSC_KEY_PASSWORD'])],
52+
['WINDOWS_SIGNING_PUBLISHER_NAME', trimmedValue(env['WINDOWS_SIGNING_PUBLISHER_NAME'])],
53+
]
54+
const missingCertificateValues = certificateValues
55+
.filter(([, value]) => value === undefined)
56+
.map(([name]) => name)
57+
const hasCertificateValue = missingCertificateValues.length < certificateValues.length
58+
const hasAzureValue = missing.length < values.length
59+
60+
if (hasCertificateValue && missingCertificateValues.length > 0) {
61+
throw new Error(
62+
`Windows certificate signing is partially configured; missing: ${missingCertificateValues.join(', ')}. Set all three signing variables or none.`,
63+
)
64+
}
65+
if (hasAzureValue && hasCertificateValue) {
66+
throw new Error('Choose one Windows signing method; Azure and certificate signing are both configured.')
67+
}
68+
69+
if (!hasAzureValue && !hasCertificateValue) return []
70+
if (hasAzureValue && missing.length > 0) {
5171
throw new Error(
5272
`Windows signing is partially configured; missing: ${missing.join(', ')}. Set all seven signing variables or none.`,
5373
)
5474
}
5575

76+
const publisherName = hasAzureValue
77+
? trimmedValue(env['AZURE_SIGNING_PUBLISHER_NAME'])!
78+
: trimmedValue(env['WINDOWS_SIGNING_PUBLISHER_NAME'])!
79+
if (!hasAzureValue) args.length = 0
80+
args.push('--config.win.publisherName', publisherName)
5681
return args
5782
}
5883

84+
/** Require one complete signing method for a tagged Windows release. */
85+
export function requireWindowsReleaseSigning(env: NodeJS.ProcessEnv): string {
86+
const args = windowsSigningArgs(env)
87+
if (args.length === 0) throw new Error('Windows release signing is not configured')
88+
return trimmedValue(env['AZURE_SIGNING_PUBLISHER_NAME'])
89+
?? trimmedValue(env['WINDOWS_SIGNING_PUBLISHER_NAME'])!
90+
}
91+
5992
/** Return the package-manager invocation for a Windows installer build. */
6093
export function windowsPackageInvocation(platform: string, env: NodeJS.ProcessEnv, publish: string): {
6194
readonly command: string
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
/** Verify every file referenced by an electron-updater release manifest. */
2+
3+
import { createHash } from 'node:crypto'
4+
import { createReadStream } from 'node:fs'
5+
import { lstat, readFile } from 'node:fs/promises'
6+
import { basename, join, resolve } from 'node:path'
7+
import { pathToFileURL, fileURLToPath } from 'node:url'
8+
import { getFileList, parseUpdateInfo } from 'electron-updater/out/providers/Provider.js'
9+
10+
export interface VerifyUpdateManifestOptions {
11+
readonly artifactsDir: string
12+
readonly expectedVersion: string
13+
readonly platform: 'mac' | 'win'
14+
}
15+
16+
function assertSafeFilename(filename: string): void {
17+
if (
18+
basename(filename) !== filename
19+
|| !/^[A-Za-z0-9][A-Za-z0-9._+()-]*$/u.test(filename)
20+
) throw new Error(`Update manifest contains an unsafe artifact URL: ${filename}`)
21+
}
22+
23+
async function sha512(path: string): Promise<string> {
24+
const hash = createHash('sha512')
25+
for await (const chunk of createReadStream(path)) hash.update(chunk)
26+
return hash.digest('base64')
27+
}
28+
29+
/** Validate version, file references, sizes, checksums, aliases, and release date. */
30+
export async function verifyUpdateManifest(options: VerifyUpdateManifestOptions): Promise<void> {
31+
const manifestName = options.platform === 'mac' ? 'latest-mac.yml' : 'latest.yml'
32+
const manifestPath = join(options.artifactsDir, manifestName)
33+
const raw = await readFile(manifestPath, 'utf8')
34+
const info = parseUpdateInfo(raw, manifestName, pathToFileURL(manifestPath))
35+
if (info.version !== options.expectedVersion) {
36+
throw new Error(`${manifestName} version ${info.version} does not match ${options.expectedVersion}`)
37+
}
38+
39+
const releaseDate = info.releaseDate
40+
if (
41+
typeof releaseDate !== 'string'
42+
|| Number.isNaN(Date.parse(releaseDate))
43+
|| new Date(releaseDate).toISOString() !== releaseDate
44+
) throw new Error(`${manifestName} has an invalid releaseDate`)
45+
46+
const files = getFileList(info)
47+
const seen = new Set<string>()
48+
for (const file of files) {
49+
assertSafeFilename(file.url)
50+
if (seen.has(file.url)) throw new Error(`${manifestName} contains a duplicate artifact URL: ${file.url}`)
51+
seen.add(file.url)
52+
if (!Number.isSafeInteger(file.size) || (file.size ?? 0) <= 0) {
53+
throw new Error(`${manifestName} has an invalid size for ${file.url}`)
54+
}
55+
if (typeof file.sha512 !== 'string' || Buffer.from(file.sha512, 'base64').byteLength !== 64) {
56+
throw new Error(`${manifestName} has an invalid SHA-512 for ${file.url}`)
57+
}
58+
59+
const artifactPath = join(options.artifactsDir, file.url)
60+
const artifact = await lstat(artifactPath)
61+
if (!artifact.isFile()) throw new Error(`Update artifact is not a regular file: ${file.url}`)
62+
if (artifact.size !== file.size) throw new Error(`${file.url} size does not match ${manifestName}`)
63+
if (await sha512(artifactPath) !== file.sha512) {
64+
throw new Error(`${file.url} SHA-512 does not match ${manifestName}`)
65+
}
66+
}
67+
68+
const required = options.platform === 'mac'
69+
? [['-mac.zip', 'macOS ZIP'], ['.dmg', 'macOS DMG']] as const
70+
: [['-Setup.exe', 'Windows installer']] as const
71+
for (const [suffix, label] of required) {
72+
if (![...seen].some(filename => filename.endsWith(suffix))) {
73+
throw new Error(`${manifestName} does not reference the required ${label}`)
74+
}
75+
}
76+
77+
const legacy = info as typeof info & { readonly path?: unknown; readonly sha512?: unknown }
78+
if (typeof legacy.path !== 'string' || !seen.has(legacy.path)) {
79+
throw new Error(`${manifestName} top-level path does not match a file entry`)
80+
}
81+
const pathEntry = files.find(file => file.url === legacy.path)!
82+
if (legacy.sha512 !== pathEntry.sha512) {
83+
throw new Error(`${manifestName} top-level SHA-512 does not match ${legacy.path}`)
84+
}
85+
const requiredAliasSuffix = options.platform === 'mac' ? '-mac.zip' : '-Setup.exe'
86+
if (!legacy.path.endsWith(requiredAliasSuffix)) {
87+
throw new Error(`${manifestName} top-level path must reference ${requiredAliasSuffix}`)
88+
}
89+
}
90+
91+
async function main(): Promise<void> {
92+
const [platform, artifactsDir, expectedVersion] = process.argv.slice(2)
93+
if ((platform !== 'mac' && platform !== 'win') || artifactsDir === undefined || expectedVersion === undefined) {
94+
throw new Error('Usage: verify-update-manifest.ts <mac|win> <artifacts-directory> <version>')
95+
}
96+
await verifyUpdateManifest({ artifactsDir: resolve(artifactsDir), expectedVersion, platform })
97+
console.log(`${platform} update manifest verified for ${expectedVersion}`)
98+
}
99+
100+
const invokedPath = process.argv[1]
101+
if (invokedPath !== undefined && resolve(invokedPath) === fileURLToPath(import.meta.url)) {
102+
void main().catch((error: unknown) => {
103+
console.error(error instanceof Error ? error.message : String(error))
104+
process.exitCode = 1
105+
})
106+
}
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
/** Verify Windows release signatures with electron-updater's production verifier. */
2+
3+
import { readFileSync } from 'node:fs'
4+
import { dirname, join, resolve } from 'node:path'
5+
import { fileURLToPath, pathToFileURL } from 'node:url'
6+
import { verifySignature } from 'electron-updater/out/windowsExecutableCodeSignatureVerifier.js'
7+
import { parseUpdateInfo } from 'electron-updater/out/providers/Provider.js'
8+
import { verifyWindowsInstaller } from './verify-win-installer'
9+
10+
export type WindowsSignatureVerifier = (
11+
publisherNames: string[],
12+
path: string,
13+
) => Promise<string | null>
14+
15+
/** Verify the installer and packaged application against the updater publisher. */
16+
export async function verifyWindowsSignatures(
17+
desktopRoot: string,
18+
publisherName: string,
19+
verifier: WindowsSignatureVerifier = (names, path) => verifySignature(names, path, console),
20+
): Promise<void> {
21+
const publisher = publisherName.trim()
22+
if (publisher === '') throw new Error('Windows signing publisher is empty')
23+
verifyWindowsInstaller(desktopRoot)
24+
const { version } = JSON.parse(readFileSync(join(desktopRoot, 'package.json'), 'utf8')) as { version: string }
25+
const appUpdatePath = join(desktopRoot, 'dist', 'win-unpacked', 'resources', 'app-update.yml')
26+
const appUpdate = parseUpdateInfo(
27+
readFileSync(appUpdatePath, 'utf8'),
28+
'app-update.yml',
29+
pathToFileURL(appUpdatePath),
30+
) as unknown as { readonly publisherName?: string | readonly string[] }
31+
const configuredPublishers = typeof appUpdate.publisherName === 'string'
32+
? [appUpdate.publisherName]
33+
: appUpdate.publisherName
34+
const normalizedPublishers = configuredPublishers?.map((value) => value.trim())
35+
if (normalizedPublishers === undefined || normalizedPublishers.length !== 1) {
36+
throw new Error('Packaged updater configuration does not exactly match the expected Windows publisher')
37+
}
38+
if (normalizedPublishers[0] !== publisher) {
39+
throw new Error('Packaged updater configuration does not contain the expected Windows publisher')
40+
}
41+
const paths = [
42+
join(desktopRoot, 'dist', `Pythinker-${version}-x64-Setup.exe`),
43+
join(desktopRoot, 'dist', 'win-unpacked', 'Pythinker.exe'),
44+
]
45+
for (const path of paths) {
46+
const error = await verifier([publisher], path)
47+
if (error !== null) throw new Error(`Windows signature verification failed for ${path}: ${error}`)
48+
}
49+
}
50+
51+
async function main(): Promise<void> {
52+
const desktopRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..')
53+
const publisher = process.env['AZURE_SIGNING_PUBLISHER_NAME']
54+
?? process.env['WINDOWS_SIGNING_PUBLISHER_NAME']
55+
if (publisher === undefined) throw new Error('Windows signing publisher is not configured')
56+
await verifyWindowsSignatures(desktopRoot, publisher)
57+
console.log(`Windows release signatures verified for ${publisher}`)
58+
}
59+
60+
const invokedPath = process.argv[1]
61+
if (invokedPath !== undefined && resolve(invokedPath) === fileURLToPath(import.meta.url)) {
62+
void main().catch((error: unknown) => {
63+
console.error(error instanceof Error ? error.message : String(error))
64+
process.exitCode = 1
65+
})
66+
}

apps/desktop/src/updater.ts

Lines changed: 23 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,7 @@ let getWindow: (() => BrowserWindow | undefined) | undefined
129129
let initialCheckTimer: ReturnType<typeof setTimeout> | undefined
130130
let checkInterval: ReturnType<typeof setInterval> | undefined
131131
let checkPromise: Promise<UpdateState> | undefined
132+
let installRequestedVersion: string | undefined
132133
let listenersWired = false
133134
let initialized = false
134135
let updateTelemetryTrack: UpdateTelemetryTrack = () => {}
@@ -194,8 +195,8 @@ function updateState(next: Partial<UpdateState>): void {
194195
}
195196

196197
function persistSettings(next: UpdateSettings): void {
198+
writeUpdateSettings(app.getPath('userData'), next)
197199
settings = next
198-
writeUpdateSettings(app.getPath('userData'), settings)
199200
updateState({})
200201
}
201202

@@ -240,7 +241,7 @@ function disableUpdates(): UpdateState {
240241
function scheduleChecks(): void {
241242
if (checkInterval !== undefined) return
242243
checkInterval = setInterval(() => {
243-
if (settings.autoUpdate) void runCheck()
244+
if (settings.autoUpdate) void checkForUpdatesNow()
244245
}, CHECK_INTERVAL_MS)
245246
}
246247

@@ -352,6 +353,7 @@ export function initUpdater(
352353
skippedVersion: settings.skippedVersion,
353354
completedVersion: settings.completedVersion,
354355
}
356+
installRequestedVersion = undefined
355357
updateState({})
356358
clearTimers()
357359

@@ -371,7 +373,7 @@ export function initUpdater(
371373
app.once('will-quit', clearTimers)
372374

373375
if (settings.autoUpdate) {
374-
initialCheckTimer = setTimeout(() => { void runCheck() }, INITIAL_CHECK_DELAY_MS)
376+
initialCheckTimer = setTimeout(() => { void checkForUpdatesNow() }, INITIAL_CHECK_DELAY_MS)
375377
scheduleChecks()
376378
}
377379
}
@@ -407,6 +409,7 @@ export async function checkForUpdatesNow(): Promise<UpdateState> {
407409
return state
408410
}
409411
if (!hasUpdateConfig()) return disableUpdates()
412+
if (state.status === 'downloading' || state.status === 'downloaded') return state
410413

411414
try {
412415
configureExplicitConsent()
@@ -441,13 +444,15 @@ export function undoSkippedUpdate(): UpdateState {
441444
}
442445

443446
export function startUpdateDownload(): UpdateState {
444-
if (!app.isPackaged || !hasUpdateConfig() || state.status !== 'available') return state
447+
const canDownload = state.status === 'available'
448+
|| (state.status === 'error' && state.availableVersion !== undefined)
449+
if (!app.isPackaged || !hasUpdateConfig() || !canDownload) return state
445450
try {
446451
configureExplicitConsent()
447452
updateState({
448453
status: 'downloading',
449-
percent: 0,
450-
transferred: 0,
454+
percent: undefined,
455+
transferred: undefined,
451456
total: undefined,
452457
bytesPerSecond: undefined,
453458
message: undefined,
@@ -464,16 +469,21 @@ export function installDownloadedUpdateNow(): UpdateState {
464469
if (!app.isPackaged || !hasUpdateConfig() || state.status !== 'downloaded' || version === undefined) {
465470
return state
466471
}
467-
persistSettings({ ...settings, pendingInstallVersion: version })
468-
try {
469-
updateTelemetryTrack('desktop_update_install', { version })
470-
} catch {
471-
// Telemetry must never delay an explicit installation.
472-
}
472+
if (installRequestedVersion === version) return state
473+
installRequestedVersion = version
473474
try {
475+
persistSettings({ ...settings, pendingInstallVersion: version })
476+
try {
477+
updateTelemetryTrack('desktop_update_install', { version })
478+
} catch {
479+
// Telemetry must never delay an explicit installation.
480+
}
474481
autoUpdater.quitAndInstall()
475482
} catch (error) {
476-
persistSettings({ ...settings, pendingInstallVersion: undefined })
483+
installRequestedVersion = undefined
484+
if (settings.pendingInstallVersion === version) {
485+
persistSettings({ ...settings, pendingInstallVersion: undefined })
486+
}
477487
stateError(error)
478488
throw error
479489
}

apps/desktop/src/window-lifecycle.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,15 +75,24 @@ export function createDesktopLifecycle(options: DesktopLifecycleOptions): Deskto
7575
const prepareQuit = (): Promise<void> => {
7676
if (pendingPreparation !== undefined) return pendingPreparation
7777
quitting = true
78-
pendingPreparation = options.disposeHost().catch((error: unknown) => {
78+
let disposal: Promise<void>
79+
try {
80+
disposal = options.disposeHost()
81+
} catch (error) {
82+
disposal = Promise.reject(error)
83+
}
84+
pendingPreparation = disposal.catch((error: unknown) => {
7985
options.reportError?.(error)
86+
quitting = false
87+
pendingPreparation = undefined
88+
throw error
8089
})
8190
return pendingPreparation
8291
}
8392

8493
const requestQuit = (): Promise<void> => {
8594
if (pendingQuit !== undefined) return pendingQuit
86-
pendingQuit = prepareQuit().then(options.quit)
95+
pendingQuit = prepareQuit().catch(() => undefined).then(options.quit)
8796
return pendingQuit
8897
}
8998

0 commit comments

Comments
 (0)