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-update-consent.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@pymodel/pythinker-code": patch
---

Add explicit download and restart controls with live progress for desktop updates.
231 changes: 155 additions & 76 deletions .github/workflows/desktop-release.yml

Large diffs are not rendered by default.

8 changes: 5 additions & 3 deletions apps/desktop/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,17 +66,19 @@ rmdir "$MOUNT_POINT"

### Windows

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`.
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.

#### Azure Artifact Signing

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.
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.

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.

## Known limitations

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.

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

## Model Experience

Expand Down
4 changes: 3 additions & 1 deletion apps/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,11 @@
"devDependencies": {
"@pymodel/pythinker-telemetry": "workspace:*",
"@types/node": "^26.1.2",
"@types/semver": "^7.7.0",
"electron": "43.4.0",
"electron-builder": "26.15.3",
"electron-updater": "^6.8.9",
"electron-updater": "6.8.9",
"semver": "^7.7.4",
"tsdown": "0.22.3",
"typescript": "6.0.3",
"vitest": "4.1.9"
Expand Down
10 changes: 10 additions & 0 deletions apps/desktop/scripts/assert-windows-release-signing.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
/** Fail a tagged desktop release unless Windows signing is complete. */

import { requireWindowsReleaseSigning } from './package-win'

try {
console.log(`Windows release signing is configured for ${requireWindowsReleaseSigning(process.env)}`)
} catch (error) {
console.error(error instanceof Error ? error.message : String(error))
process.exit(1)
}
37 changes: 35 additions & 2 deletions apps/desktop/scripts/package-win.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,16 +46,49 @@ export function windowsSigningArgs(env: NodeJS.ProcessEnv): readonly string[] {
}
missing.sort()

if (missing.length === values.length) return []
if (missing.length > 0) {
const certificateValues: readonly (readonly [string, string | undefined])[] = [
['WIN_CSC_LINK', trimmedValue(env['WIN_CSC_LINK'])],
['WIN_CSC_KEY_PASSWORD', trimmedValue(env['WIN_CSC_KEY_PASSWORD'])],
['WINDOWS_SIGNING_PUBLISHER_NAME', trimmedValue(env['WINDOWS_SIGNING_PUBLISHER_NAME'])],
]
const missingCertificateValues = certificateValues
.filter(([, value]) => value === undefined)
.map(([name]) => name)
const hasCertificateValue = missingCertificateValues.length < certificateValues.length
const hasAzureValue = missing.length < values.length

if (hasCertificateValue && missingCertificateValues.length > 0) {
throw new Error(
`Windows certificate signing is partially configured; missing: ${missingCertificateValues.join(', ')}. Set all three signing variables or none.`,
)
}
if (hasAzureValue && hasCertificateValue) {
throw new Error('Choose one Windows signing method; Azure and certificate signing are both configured.')
}

if (!hasAzureValue && !hasCertificateValue) return []
if (hasAzureValue && missing.length > 0) {
throw new Error(
`Windows signing is partially configured; missing: ${missing.join(', ')}. Set all seven signing variables or none.`,
)
}

const publisherName = hasAzureValue
? trimmedValue(env['AZURE_SIGNING_PUBLISHER_NAME'])!
: trimmedValue(env['WINDOWS_SIGNING_PUBLISHER_NAME'])!
if (!hasAzureValue) args.length = 0
args.push('--config.win.publisherName', publisherName)
return args
}

/** Require one complete signing method for a tagged Windows release. */
export function requireWindowsReleaseSigning(env: NodeJS.ProcessEnv): string {
const args = windowsSigningArgs(env)
if (args.length === 0) throw new Error('Windows release signing is not configured')
return trimmedValue(env['AZURE_SIGNING_PUBLISHER_NAME'])
?? trimmedValue(env['WINDOWS_SIGNING_PUBLISHER_NAME'])!
}

/** Return the package-manager invocation for a Windows installer build. */
export function windowsPackageInvocation(platform: string, env: NodeJS.ProcessEnv, publish: string): {
readonly command: string
Expand Down
106 changes: 106 additions & 0 deletions apps/desktop/scripts/verify-update-manifest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
/** Verify every file referenced by an electron-updater release manifest. */

import { createHash } from 'node:crypto'
import { createReadStream } from 'node:fs'
import { lstat, readFile } from 'node:fs/promises'
import { basename, join, resolve } from 'node:path'
import { pathToFileURL, fileURLToPath } from 'node:url'
import { getFileList, parseUpdateInfo } from 'electron-updater/out/providers/Provider.js'

export interface VerifyUpdateManifestOptions {
readonly artifactsDir: string
readonly expectedVersion: string
readonly platform: 'mac' | 'win'
}

function assertSafeFilename(filename: string): void {
if (
basename(filename) !== filename
|| !/^[A-Za-z0-9][A-Za-z0-9._+()-]*$/u.test(filename)
) throw new Error(`Update manifest contains an unsafe artifact URL: ${filename}`)
}

async function sha512(path: string): Promise<string> {
const hash = createHash('sha512')
for await (const chunk of createReadStream(path)) hash.update(chunk)
return hash.digest('base64')
}

/** Validate version, file references, sizes, checksums, aliases, and release date. */
export async function verifyUpdateManifest(options: VerifyUpdateManifestOptions): Promise<void> {
const manifestName = options.platform === 'mac' ? 'latest-mac.yml' : 'latest.yml'
const manifestPath = join(options.artifactsDir, manifestName)
const raw = await readFile(manifestPath, 'utf8')
const info = parseUpdateInfo(raw, manifestName, pathToFileURL(manifestPath))
if (info.version !== options.expectedVersion) {
throw new Error(`${manifestName} version ${info.version} does not match ${options.expectedVersion}`)
}

const releaseDate = info.releaseDate
if (
typeof releaseDate !== 'string'
|| Number.isNaN(Date.parse(releaseDate))
|| new Date(releaseDate).toISOString() !== releaseDate
) throw new Error(`${manifestName} has an invalid releaseDate`)

const files = getFileList(info)
const seen = new Set<string>()
for (const file of files) {
assertSafeFilename(file.url)
if (seen.has(file.url)) throw new Error(`${manifestName} contains a duplicate artifact URL: ${file.url}`)
seen.add(file.url)
if (!Number.isSafeInteger(file.size) || (file.size ?? 0) <= 0) {
throw new Error(`${manifestName} has an invalid size for ${file.url}`)
}
if (typeof file.sha512 !== 'string' || Buffer.from(file.sha512, 'base64').byteLength !== 64) {
throw new Error(`${manifestName} has an invalid SHA-512 for ${file.url}`)
}

const artifactPath = join(options.artifactsDir, file.url)
const artifact = await lstat(artifactPath)
if (!artifact.isFile()) throw new Error(`Update artifact is not a regular file: ${file.url}`)
if (artifact.size !== file.size) throw new Error(`${file.url} size does not match ${manifestName}`)
if (await sha512(artifactPath) !== file.sha512) {
throw new Error(`${file.url} SHA-512 does not match ${manifestName}`)
}
}

const required = options.platform === 'mac'
? [['-mac.zip', 'macOS ZIP'], ['.dmg', 'macOS DMG']] as const
: [['-Setup.exe', 'Windows installer']] as const
for (const [suffix, label] of required) {
if (![...seen].some(filename => filename.endsWith(suffix))) {
throw new Error(`${manifestName} does not reference the required ${label}`)
}
}

const legacy = info as typeof info & { readonly path?: unknown; readonly sha512?: unknown }
if (typeof legacy.path !== 'string' || !seen.has(legacy.path)) {
throw new Error(`${manifestName} top-level path does not match a file entry`)
}
const pathEntry = files.find(file => file.url === legacy.path)!
if (legacy.sha512 !== pathEntry.sha512) {
throw new Error(`${manifestName} top-level SHA-512 does not match ${legacy.path}`)
}
const requiredAliasSuffix = options.platform === 'mac' ? '-mac.zip' : '-Setup.exe'
if (!legacy.path.endsWith(requiredAliasSuffix)) {
throw new Error(`${manifestName} top-level path must reference ${requiredAliasSuffix}`)
}
}

async function main(): Promise<void> {
const [platform, artifactsDir, expectedVersion] = process.argv.slice(2)
if ((platform !== 'mac' && platform !== 'win') || artifactsDir === undefined || expectedVersion === undefined) {
throw new Error('Usage: verify-update-manifest.ts <mac|win> <artifacts-directory> <version>')
}
await verifyUpdateManifest({ artifactsDir: resolve(artifactsDir), expectedVersion, platform })
console.log(`${platform} update manifest verified for ${expectedVersion}`)
}

const invokedPath = process.argv[1]
if (invokedPath !== undefined && resolve(invokedPath) === fileURLToPath(import.meta.url)) {
void main().catch((error: unknown) => {
console.error(error instanceof Error ? error.message : String(error))
process.exitCode = 1
})
}
66 changes: 66 additions & 0 deletions apps/desktop/scripts/verify-windows-signatures.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
/** Verify Windows release signatures with electron-updater's production verifier. */

import { readFileSync } from 'node:fs'
import { dirname, join, resolve } from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
import { verifySignature } from 'electron-updater/out/windowsExecutableCodeSignatureVerifier.js'
import { parseUpdateInfo } from 'electron-updater/out/providers/Provider.js'
import { verifyWindowsInstaller } from './verify-win-installer'

export type WindowsSignatureVerifier = (
publisherNames: string[],
path: string,
) => Promise<string | null>

/** Verify the installer and packaged application against the updater publisher. */
export async function verifyWindowsSignatures(
desktopRoot: string,
publisherName: string,
verifier: WindowsSignatureVerifier = (names, path) => verifySignature(names, path, console),
): Promise<void> {
const publisher = publisherName.trim()
if (publisher === '') throw new Error('Windows signing publisher is empty')
verifyWindowsInstaller(desktopRoot)
const { version } = JSON.parse(readFileSync(join(desktopRoot, 'package.json'), 'utf8')) as { version: string }
const appUpdatePath = join(desktopRoot, 'dist', 'win-unpacked', 'resources', 'app-update.yml')
const appUpdate = parseUpdateInfo(
readFileSync(appUpdatePath, 'utf8'),
'app-update.yml',
pathToFileURL(appUpdatePath),
) as unknown as { readonly publisherName?: string | readonly string[] }
const configuredPublishers = typeof appUpdate.publisherName === 'string'
? [appUpdate.publisherName]
: appUpdate.publisherName
const normalizedPublishers = configuredPublishers?.map((value) => value.trim())
if (normalizedPublishers === undefined || normalizedPublishers.length !== 1) {
throw new Error('Packaged updater configuration does not exactly match the expected Windows publisher')
}
if (normalizedPublishers[0] !== publisher) {
throw new Error('Packaged updater configuration does not contain the expected Windows publisher')
}
const paths = [
join(desktopRoot, 'dist', `Pythinker-${version}-x64-Setup.exe`),
join(desktopRoot, 'dist', 'win-unpacked', 'Pythinker.exe'),
]
for (const path of paths) {
const error = await verifier([publisher], path)
if (error !== null) throw new Error(`Windows signature verification failed for ${path}: ${error}`)
}
}

async function main(): Promise<void> {
const desktopRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const publisher = process.env['AZURE_SIGNING_PUBLISHER_NAME']
?? process.env['WINDOWS_SIGNING_PUBLISHER_NAME']
if (publisher === undefined) throw new Error('Windows signing publisher is not configured')
await verifyWindowsSignatures(desktopRoot, publisher)
console.log(`Windows release signatures verified for ${publisher}`)
}

const invokedPath = process.argv[1]
if (invokedPath !== undefined && resolve(invokedPath) === fileURLToPath(import.meta.url)) {
void main().catch((error: unknown) => {
console.error(error instanceof Error ? error.message : String(error))
process.exitCode = 1
})
}
67 changes: 50 additions & 17 deletions apps/desktop/src/host-supervisor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type { Readable } from 'node:stream'
const READINESS_PREFIX = 'Pythinker server: '
const DEFAULT_READINESS_TIMEOUT_MS = 90_000
const DEFAULT_SHUTDOWN_TIMEOUT_MS = 5_000
const HOST_SHUTDOWN_REQUEST_TIMEOUT_MS = 1_000
const TASKKILL_TIMEOUT_MS = 5_000
const MAX_STARTUP_OUTPUT_CHARS = 32_768

Expand Down Expand Up @@ -165,6 +166,8 @@ export interface HostSupervisorOptions {
readonly readinessTimeoutMs?: number
/** Grace after SIGTERM before SIGKILL. */
readonly shutdownTimeoutMs?: number
/** Ask a ready Host to drain and stop before process-signal fallback. */
readonly requestShutdown?: (ready: HostReady) => Promise<void>
/** Receives bounded Host output for desktop diagnostics. */
readonly log?: (line: string) => void
/** Called when a ready Host exits outside an application-owned shutdown. */
Expand Down Expand Up @@ -195,6 +198,38 @@ function deferred<T>(): Deferred<T> {
return { promise, resolve, reject }
}

async function waitForExit(exited: Promise<void>, timeoutMs: number): Promise<boolean> {
let timer: ReturnType<typeof setTimeout> | undefined
const outcome = await Promise.race([
exited.then(() => true),
new Promise<false>((resolve) => {
timer = setTimeout(() => { resolve(false) }, timeoutMs)
}),
])
if (timer !== undefined) clearTimeout(timer)
return outcome
}

/** Request the authenticated graceful shutdown route of a validated loopback Host. */
export async function requestHostShutdown(ready: HostReady): Promise<void> {
const origin = new URL(ready.origin)
if (origin.protocol !== 'http:' || (origin.hostname !== '127.0.0.1' && origin.hostname !== 'localhost')) {
throw new Error('desktop Host shutdown requires a loopback HTTP origin')
}
const controller = new AbortController()
const timer = setTimeout(() => { controller.abort() }, HOST_SHUTDOWN_REQUEST_TIMEOUT_MS)
try {
const response = await fetch(new URL('/api/v1/shutdown', origin), {
method: 'POST',
headers: ready.token === undefined ? undefined : { Authorization: `Bearer ${ready.token}` },
signal: controller.signal,
})
if (!response.ok) throw new Error(`desktop Host shutdown returned HTTP ${String(response.status)}`)
} finally {
clearTimeout(timer)
}
}

/**
* Create a single-owner Host supervisor.
* @param options - Child-process operations and bounded lifecycle timings.
Expand All @@ -208,6 +243,7 @@ export function createHostSupervisor(options: HostSupervisorOptions): HostSuperv
let shutdownPromise: Promise<void> | undefined
let exited: Promise<void> | undefined
let exitResult: Deferred<void> | undefined
let readyInfo: HostReady | undefined
let ready = false
let shuttingDown = false
let output = ''
Expand Down Expand Up @@ -255,6 +291,7 @@ export function createHostSupervisor(options: HostSupervisorOptions): HostSuperv
if (url === undefined || settled) return
settled = true
ready = true
readyInfo = url
cleanupStartup()
resolve(url)
} catch (error) {
Expand Down Expand Up @@ -290,22 +327,19 @@ export function createHostSupervisor(options: HostSupervisorOptions): HostSuperv
const spawned = child
if (spawned === undefined) return
shuttingDown = true
spawned.kill('SIGTERM')
const closed = exited ?? Promise.resolve()
let timer: ReturnType<typeof setTimeout> | undefined
const outcome = await Promise.race([
closed.then(() => 'closed' as const),
new Promise<'timeout'>((resolve) => {
timer = setTimeout(() => {
resolve('timeout')
}, shutdownTimeoutMs)
}),
])
if (timer !== undefined) clearTimeout(timer)
if (outcome === 'timeout') {
spawned.kill('SIGKILL')
await closed
if (readyInfo !== undefined && options.requestShutdown !== undefined) {
try {
await options.requestShutdown(readyInfo)
if (await waitForExit(closed, shutdownTimeoutMs)) return
} catch {
// Signal fallback handles unavailable or rejected shutdown requests.
}
}
spawned.kill('SIGTERM')
if (await waitForExit(closed, shutdownTimeoutMs)) return
spawned.kill('SIGKILL')
await closed
})()
return shutdownPromise
}
Expand Down Expand Up @@ -399,9 +433,8 @@ export function spawnPythinkerServer(options: SpawnPythinkerServerOptions): Host
* Because this call is synchronous, keep it bounded so a stalled taskkill falls
* back to a single-process kill instead of blocking the Electron main loop indefinitely.
*
* ponytail: /F makes every Windows stop a forced stop — Node cannot deliver a
* graceful SIGTERM to a Windows child at all. Add a stdin or IPC shutdown
* channel to the Host if graceful Windows teardown is ever needed.
* The supervisor first uses the authenticated HTTP shutdown route. `/F` is
* only the bounded fallback when the Host does not drain and exit.
*/
function killProcessTree(child: ChildProcessByStdio<null, Readable, Readable>, signal: 'SIGTERM' | 'SIGKILL'): void {
if (process.platform !== 'win32' || child.pid === undefined) {
Expand Down
Loading
Loading