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

Say why the desktop app cannot start when another Pythinker server is already running. It now names the process, port and start time and offers Retry or Quit, in place of an exit code that explained nothing. Stopping the other server stays the user's choice.
5 changes: 5 additions & 0 deletions .changeset/mac-dmg-signing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@pymodel/pythinker-code': patch
---

Sign, notarize and staple the macOS disk image, so a downloaded desktop build no longer opens with a Gatekeeper warning, and keep the update metadata in step with the finished file. The install window also gets a deliberate icon layout in place of the stock one.
22 changes: 22 additions & 0 deletions apps/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,28 @@
"dir"
]
},
"dmg": {
"sign": true,
"title": "Pythinker ${version}",
"window": {
"width": 660,
"height": 400
},
"iconSize": 128,
"contents": [
{
"x": 180,
"y": 200,
"type": "file"
},
{
"x": 480,
"y": 200,
"type": "link",
"path": "/Applications"
}
]
},
"win": {
"icon": "build/icon.png",
"target": [
Expand Down
193 changes: 193 additions & 0 deletions apps/desktop/scripts/finalize-mac-artifacts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
/** Notarize and staple built DMGs, then repair their update metadata. */

import { spawnSync } from 'node:child_process'
import { createHash } from 'node:crypto'
import {
existsSync,
readFileSync,
readdirSync,
statSync,
unlinkSync,
writeFileSync,
} from 'node:fs'
import { basename, join } from 'node:path'
import { resolveNotarizationCredentials } from './release-preflight'

export interface CommandResult {
readonly status: number | null
readonly stderr: string
readonly stdout: string
}

export type CommandRunner = (command: string, args: readonly string[]) => CommandResult

export interface FinalizeMacArtifactsOptions {
readonly distDir: string
readonly env: NodeJS.ProcessEnv
readonly log?: (message: string) => void
readonly runCommand?: CommandRunner
}

function requiredValue(env: NodeJS.ProcessEnv, name: string): string {
return env[name]!.trim()
}

/** Build the notarytool credential arguments selected by the release preflight. */
export function buildNotarytoolArguments(env: NodeJS.ProcessEnv): readonly string[] {
switch (resolveNotarizationCredentials(env)) {
case 'api-key':
return [
'--key', requiredValue(env, 'APPLE_API_KEY'),
'--key-id', requiredValue(env, 'APPLE_API_KEY_ID'),
'--issuer', requiredValue(env, 'APPLE_API_ISSUER'),
]
case 'apple-id':
return [
'--apple-id', requiredValue(env, 'APPLE_ID'),
'--password', requiredValue(env, 'APPLE_APP_SPECIFIC_PASSWORD'),
'--team-id', requiredValue(env, 'APPLE_TEAM_ID'),
]
case 'keychain-profile': {
const args = ['--keychain-profile', requiredValue(env, 'APPLE_KEYCHAIN_PROFILE')]
const keychain = env['APPLE_KEYCHAIN']?.trim()
if (keychain !== undefined && keychain !== '') args.push('--keychain', keychain)
return args
}
}
}

function yamlScalar(value: string): string {
const trimmed = value.trim()
if (
(trimmed.startsWith("'") && trimmed.endsWith("'"))
|| (trimmed.startsWith('"') && trimmed.endsWith('"'))
) return trimmed.slice(1, -1)
return trimmed
}

/** Update all checksum and size fields associated with one DMG. */
export function rewriteLatestMacYaml(
yaml: string,
filename: string,
sha512: string,
size: number,
): string {
const lines = yaml.split('\n')
let fileEntryIndent: number | undefined
let topLevelPathMatches = false
let checksumUpdates = 0
let sizeUpdates = 0

for (let index = 0; index < lines.length; index += 1) {
const line = lines[index]!
const indentation = line.search(/\S|$/)
const url = line.match(/^(\s*)-\s+url:\s*(.+?)\s*$/)
if (url !== null) {
fileEntryIndent = yamlScalar(url[2]!) === filename ? url[1]!.length : undefined
continue
}

if (fileEntryIndent !== undefined) {
if (line.trim() !== '' && indentation <= fileEntryIndent) {
fileEntryIndent = undefined
} else {
const checksum = line.match(/^(\s*)sha512:\s*.*$/)
if (checksum !== null) {
lines[index] = `${checksum[1]}sha512: ${sha512}`
checksumUpdates += 1
continue
}
const artifactSize = line.match(/^(\s*)size:\s*.*$/)
if (artifactSize !== null) {
lines[index] = `${artifactSize[1]}size: ${String(size)}`
sizeUpdates += 1
continue
}
}
}

if (topLevelPathMatches) {
if (line.startsWith('sha512:')) {
lines[index] = `sha512: ${sha512}`
checksumUpdates += 1
continue
}
if (line.startsWith('size:')) {
lines[index] = `size: ${String(size)}`
sizeUpdates += 1
continue
}
if (line.trim() !== '' && indentation === 0) topLevelPathMatches = false
}

const path = line.match(/^path:\s*(.+?)\s*$/)
if (path !== null) topLevelPathMatches = yamlScalar(path[1]!) === filename
}

if (checksumUpdates === 0 || sizeUpdates === 0) {
throw new Error(`latest-mac.yml does not contain complete metadata for ${filename}`)
}
return lines.join('\n')
}

function defaultCommandRunner(command: string, args: readonly string[]): CommandResult {
const result = spawnSync(command, args, { encoding: 'utf8' })
if (result.error !== undefined) throw result.error
return { status: result.status, stderr: result.stderr, stdout: result.stdout }
}

/** Finalize every DMG in the supplied desktop distribution directory. */
export function finalizeMacArtifacts(options: FinalizeMacArtifactsOptions): void {
const runCommand = options.runCommand ?? defaultCommandRunner
const log = options.log ?? console.log
const dmgs = readdirSync(options.distDir, { withFileTypes: true })
.filter(entry => entry.isFile() && entry.name.endsWith('.dmg'))
.map(entry => entry.name)
.sort()
if (dmgs.length === 0) throw new Error(`No DMG artifacts found in ${options.distDir}`)

const metadataPath = join(options.distDir, 'latest-mac.yml')
let metadata = readFileSync(metadataPath, 'utf8')
const credentialArgs = buildNotarytoolArguments(options.env)

for (const filename of dmgs) {
const dmgPath = join(options.distDir, filename)
const notarization = runCommand('xcrun', [
'notarytool', 'submit', dmgPath, '--wait', '--output-format', 'json', ...credentialArgs,
])
const notaryOutput = [notarization.stdout.trim(), notarization.stderr.trim()].filter(Boolean).join('\n')
if (notaryOutput !== '') log(notaryOutput)
if (notarization.status !== 0) {
throw new Error(`notarytool failed for ${filename} with status ${String(notarization.status)}:\n${notaryOutput}`)
}

let status: unknown
try {
status = (JSON.parse(notarization.stdout) as { readonly status?: unknown }).status
} catch {
throw new Error(`notarytool returned invalid JSON for ${filename}:\n${notaryOutput}`)
}
if (status !== 'Accepted') {
throw new Error(`notarytool did not accept ${filename} (status: ${String(status)}):\n${notaryOutput}`)
}

const stapling = runCommand('xcrun', ['stapler', 'staple', dmgPath])
const staplerOutput = [stapling.stdout.trim(), stapling.stderr.trim()].filter(Boolean).join('\n')
if (staplerOutput !== '') log(staplerOutput)
if (stapling.status !== 0) {
throw new Error(`stapler failed for ${filename} with status ${String(stapling.status)}:\n${staplerOutput}`)
}

const size = statSync(dmgPath).size
const sha512 = createHash('sha512').update(readFileSync(dmgPath)).digest('base64')
metadata = rewriteLatestMacYaml(metadata, filename, sha512, size)

const blockmapPath = `${dmgPath}.blockmap`
if (existsSync(blockmapPath)) {
unlinkSync(blockmapPath)
log(`Removed stale ${basename(blockmapPath)} because stapling changed the DMG; electron-updater will use a full download.`)
}
}

writeFileSync(metadataPath, metadata)
}
5 changes: 5 additions & 0 deletions apps/desktop/scripts/release-mac.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { spawnSync } from 'node:child_process'
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { finalizeMacArtifacts } from './finalize-mac-artifacts'
import { adaptMacReleaseEnvironment, assertMacReleaseReady } from './release-preflight'

const RELEASE_VARIABLES = [
Expand Down Expand Up @@ -50,6 +51,10 @@ export function releaseMac(): void {
'exec', 'electron-builder', '--mac', 'dmg',
'--config.forceCodeSigning=true', '--config.mac.notarize=true',
], desktopRoot, releaseEnvironment)
finalizeMacArtifacts({
distDir: resolve(desktopRoot, 'dist'),
env: releaseEnvironment,
})
}

const invokedPath = process.argv[1]
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/scripts/release-preflight.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ function resolveCredentialGroup(
return source
}

function resolveNotarizationCredentials(env: NodeJS.ProcessEnv): NotarizationCredentialSource {
export function resolveNotarizationCredentials(env: NodeJS.ProcessEnv): NotarizationCredentialSource {
const appleId = resolveCredentialGroup(
env,
['APPLE_ID', 'APPLE_APP_SPECIFIC_PASSWORD', 'APPLE_TEAM_ID'],
Expand Down
24 changes: 24 additions & 0 deletions apps/desktop/src/host-supervisor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,30 @@ export function isPortInUseError(message: string): boolean {
return /EADDRINUSE|address already in use/iu.test(message)
}

/** The live server described by the Host's single-instance conflict line. */
export interface RunningServerConflict {
readonly pid: number
readonly port: number
readonly startedAt: string
}

/**
* Recognize the Host's single-instance lock conflict.
*
* The server refuses to start while another one holds the lock at
* `<PYTHINKER_CODE_HOME>/server/lock`, and that lock is global rather than
* per-port, so a CLI server on any port blocks the desktop Host. Without this
* the conflict surfaces only as a generic non-zero exit, which tells the user
* nothing about which process to stop.
* @param message - Host output, including the diagnostic appended on failure.
* @returns The conflicting server's details, or undefined for other failures.
*/
export function parseRunningServerConflict(message: string): RunningServerConflict | undefined {
const match = /server already running \(pid=(\d+), port=(\d+), started=([^)]*)\)/u.exec(message)
if (match === null) return undefined
return { pid: Number(match[1]), port: Number(match[2]), startedAt: match[3]! }
}

/** Incremental parser for the Web Host's canonical readiness line. */
export interface ReadinessParser {
/**
Expand Down
17 changes: 17 additions & 0 deletions apps/desktop/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
import {
createHostSupervisor,
isPortInUseError,
parseRunningServerConflict,
resolveDesktopPort,
spawnPythinkerServer,
type HostSupervisor,
Expand Down Expand Up @@ -345,6 +346,22 @@ async function boot(): Promise<void> {
} catch (error) {
track('desktop_server_failed')
const message = error instanceof Error ? error.message : String(error)

const conflict = parseRunningServerConflict(message)
if (conflict !== undefined) {
const conflicted = await dialog.showMessageBox({
type: 'error',
buttons: ['Retry', 'Quit'],
defaultId: 0,
cancelId: 1,
title: `${APP_NAME} cannot start its server`,
message: `Another Pythinker server is already running (process ${String(conflict.pid)} on port ${String(conflict.port)}, started ${conflict.startedAt}). Only one server can run at a time, because they would share the same session files. Stop that server, then retry.`,
})
if (conflicted.response === 0) continue
await requestAppQuit()
return
}

if (!isPortInUseError(message)) throw error

const result = await dialog.showMessageBox({
Expand Down
Loading
Loading