diff --git a/.changeset/desktop-server-conflict.md b/.changeset/desktop-server-conflict.md new file mode 100644 index 00000000..5ec20f32 --- /dev/null +++ b/.changeset/desktop-server-conflict.md @@ -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. diff --git a/.changeset/mac-dmg-signing.md b/.changeset/mac-dmg-signing.md new file mode 100644 index 00000000..f02b09f3 --- /dev/null +++ b/.changeset/mac-dmg-signing.md @@ -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. diff --git a/apps/desktop/package.json b/apps/desktop/package.json index ef410697..714abf02 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -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": [ diff --git a/apps/desktop/scripts/finalize-mac-artifacts.ts b/apps/desktop/scripts/finalize-mac-artifacts.ts new file mode 100644 index 00000000..7e0dbe3a --- /dev/null +++ b/apps/desktop/scripts/finalize-mac-artifacts.ts @@ -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) +} diff --git a/apps/desktop/scripts/release-mac.ts b/apps/desktop/scripts/release-mac.ts index 2a844b22..33e1bab8 100644 --- a/apps/desktop/scripts/release-mac.ts +++ b/apps/desktop/scripts/release-mac.ts @@ -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 = [ @@ -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] diff --git a/apps/desktop/scripts/release-preflight.ts b/apps/desktop/scripts/release-preflight.ts index a6504873..2ce09e1f 100644 --- a/apps/desktop/scripts/release-preflight.ts +++ b/apps/desktop/scripts/release-preflight.ts @@ -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'], diff --git a/apps/desktop/src/host-supervisor.ts b/apps/desktop/src/host-supervisor.ts index 2d40ee7c..26987d0c 100644 --- a/apps/desktop/src/host-supervisor.ts +++ b/apps/desktop/src/host-supervisor.ts @@ -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 + * `/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 { /** diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index ae6ee0ed..e5d8ccd1 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -27,6 +27,7 @@ import { import { createHostSupervisor, isPortInUseError, + parseRunningServerConflict, resolveDesktopPort, spawnPythinkerServer, type HostSupervisor, @@ -345,6 +346,22 @@ async function boot(): Promise { } 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({ diff --git a/apps/desktop/tests/finalize-mac-artifacts.spec.ts b/apps/desktop/tests/finalize-mac-artifacts.spec.ts new file mode 100644 index 00000000..8d6194c2 --- /dev/null +++ b/apps/desktop/tests/finalize-mac-artifacts.spec.ts @@ -0,0 +1,160 @@ +import { createHash } from 'node:crypto' +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + buildNotarytoolArguments, + finalizeMacArtifacts, + rewriteLatestMacYaml, +} from '../scripts/finalize-mac-artifacts' + +const directories: string[] = [] + +interface NotarytoolCase { + readonly args: readonly string[] + readonly env: Readonly +} + +const notarytoolCases: readonly NotarytoolCase[] = [ + { + args: ['--key', '/private/AuthKey.p8', '--key-id', 'KEY123', '--issuer', 'issuer-id'], + env: { + APPLE_API_KEY: '/private/AuthKey.p8', + APPLE_API_KEY_ID: 'KEY123', + APPLE_API_ISSUER: 'issuer-id', + }, + }, + { + args: [ + '--apple-id', 'developer@example.test', + '--password', 'app-password', + '--team-id', 'TEAM123456', + ], + env: { + APPLE_ID: 'developer@example.test', + APPLE_APP_SPECIFIC_PASSWORD: 'app-password', + APPLE_TEAM_ID: 'TEAM123456', + }, + }, + { + args: [ + '--keychain-profile', 'pythinker-notary', + '--keychain', '/private/login.keychain-db', + ], + env: { + APPLE_KEYCHAIN: '/private/login.keychain-db', + APPLE_KEYCHAIN_PROFILE: 'pythinker-notary', + }, + }, +] + +afterEach(() => { + for (const directory of directories.splice(0)) rmSync(directory, { force: true, recursive: true }) +}) + +describe('macOS artifact finalization', () => { + it.each(notarytoolCases)('builds notarytool arguments for $args', (testCase: NotarytoolCase) => { + expect(buildNotarytoolArguments(testCase.env)).toEqual(testCase.args) + }) + + it('preserves the partial Apple ID credential failure with an API key trio', () => { + expect(() => buildNotarytoolArguments({ + APPLE_API_KEY: '/private/AuthKey.p8', + APPLE_API_KEY_ID: 'KEY123', + APPLE_API_ISSUER: 'issuer-id', + APPLE_TEAM_ID: 'TEAM123456', + })).toThrow('Incomplete macOS notarization credentials: missing APPLE_ID, APPLE_APP_SPECIFIC_PASSWORD') + }) + + it('updates both checksums and the file size without changing other metadata', () => { + const input = `version: 0.1.3 +files: + - url: Pythinker-0.1.3-arm64.dmg + sha512: old-files-checksum + size: 166113403 +path: Pythinker-0.1.3-arm64.dmg +sha512: old-top-level-checksum +releaseDate: '2026-08-17T19:11:07.700Z' +` + const output = rewriteLatestMacYaml( + input, + 'Pythinker-0.1.3-arm64.dmg', + 'new-base64-checksum', + 166125225, + ) + + expect(output).toBe(`version: 0.1.3 +files: + - url: Pythinker-0.1.3-arm64.dmg + sha512: new-base64-checksum + size: 166125225 +path: Pythinker-0.1.3-arm64.dmg +sha512: new-base64-checksum +releaseDate: '2026-08-17T19:11:07.700Z' +`) + }) + + it('uses the injected runner, repairs metadata, and removes the stale blockmap', () => { + const distDir = mkdtempSync(join(tmpdir(), 'pythinker-mac-artifacts-')) + directories.push(distDir) + const filename = 'Pythinker-0.1.3-arm64.dmg' + const dmg = Buffer.from('stapled dmg fixture') + writeFileSync(join(distDir, filename), dmg) + writeFileSync(join(distDir, `${filename}.blockmap`), 'stale') + writeFileSync(join(distDir, 'latest-mac.yml'), `files: + - url: ${filename} + sha512: old + size: 1 +path: ${filename} +sha512: old +`) + const runCommand = vi.fn(() => ({ + status: 0, + stderr: '', + stdout: '{"status":"Accepted"}', + })) + + finalizeMacArtifacts({ + distDir, + env: { APPLE_KEYCHAIN_PROFILE: 'pythinker-notary' }, + log: () => {}, + runCommand, + }) + + const checksum = createHash('sha512').update(dmg).digest('base64') + expect(readFileSync(join(distDir, 'latest-mac.yml'), 'utf8')).toContain(`sha512: ${checksum}`) + expect(() => readFileSync(join(distDir, `${filename}.blockmap`))).toThrow('ENOENT') + expect(runCommand).toHaveBeenCalledTimes(2) + expect(runCommand).toHaveBeenNthCalledWith(1, 'xcrun', expect.arrayContaining([ + 'notarytool', 'submit', join(distDir, filename), '--wait', + ])) + expect(runCommand).toHaveBeenNthCalledWith(2, 'xcrun', ['stapler', 'staple', join(distDir, filename)]) + }) + + it('prints and rejects a non-accepted notarytool result before stapling', () => { + const distDir = mkdtempSync(join(tmpdir(), 'pythinker-mac-artifacts-')) + directories.push(distDir) + const filename = 'Pythinker-0.1.3-arm64.dmg' + writeFileSync(join(distDir, filename), 'signed dmg fixture') + writeFileSync(join(distDir, 'latest-mac.yml'), `files: + - url: ${filename} + sha512: old + size: 1 +`) + const output = '{"status":"Invalid","message":"The signature is invalid"}' + const runCommand = vi.fn(() => ({ status: 0, stderr: '', stdout: output })) + const log = vi.fn() + + expect(() => { + finalizeMacArtifacts({ + distDir, + env: { APPLE_KEYCHAIN_PROFILE: 'pythinker-notary' }, + log: (message) => { log(message) }, + runCommand, + }) + }).toThrow('status: Invalid') + expect(log).toHaveBeenCalledWith(output) + expect(runCommand).toHaveBeenCalledTimes(1) + }) +}) diff --git a/apps/desktop/tests/host-supervisor.spec.ts b/apps/desktop/tests/host-supervisor.spec.ts index ff08b9c0..a9678d3d 100644 --- a/apps/desktop/tests/host-supervisor.spec.ts +++ b/apps/desktop/tests/host-supervisor.spec.ts @@ -139,6 +139,28 @@ describe('desktop Host port', () => { )).toBe(true) expect(hostSupervisor.isPortInUseError('desktop Host exited before readiness (code 1, signal null)')).toBe(false) }) + + it('detects output that reports a single-instance lock conflict', () => { + // Verbatim failure observed when a CLI server held the global lock. + const observed = [ + 'desktop Host exited before readiness (code 1, signal null)', + 'Host output:', + 'server already running (pid=78405, port=58700, started=2026-08-17T18:06:12.341Z)', + ].join('\n') + + expect(hostSupervisor.parseRunningServerConflict(observed)).toEqual({ + pid: 78_405, + port: 58_700, + startedAt: '2026-08-17T18:06:12.341Z', + }) + }) + + it('ignores failures that are not a lock conflict', () => { + expect(hostSupervisor.parseRunningServerConflict( + 'listen EADDRINUSE: address already in use 127.0.0.1:24827', + )).toBeUndefined() + expect(hostSupervisor.parseRunningServerConflict('server already running')).toBeUndefined() + }) }) describe('desktop Host supervisor', () => { diff --git a/apps/desktop/tests/packaging-config.spec.ts b/apps/desktop/tests/packaging-config.spec.ts index 91b90649..be8a9841 100644 --- a/apps/desktop/tests/packaging-config.spec.ts +++ b/apps/desktop/tests/packaging-config.spec.ts @@ -8,6 +8,21 @@ interface DesktopPackage { readonly build: { readonly afterPack: string readonly appId: string + readonly dmg: { + readonly contents: readonly { + readonly path?: string + readonly type: string + readonly x: number + readonly y: number + }[] + readonly iconSize: number + readonly sign: boolean + readonly title: string + readonly window: { + readonly height: number + readonly width: number + } + } readonly electronDist?: string readonly extraResources: readonly { readonly from: string @@ -57,6 +72,7 @@ const desktopPackage = JSON.parse( const rootPackage = JSON.parse( readFileSync(resolve(repositoryRoot, 'package.json'), 'utf8'), ) as RootPackage +const releaseMacSource = readFileSync(resolve(desktopRoot, 'scripts/release-mac.ts'), 'utf8') describe('desktop packaging configuration', () => { it('packages the application with expected metadata', () => { @@ -108,6 +124,20 @@ describe('desktop packaging configuration', () => { expect(command).toBe('node --import tsx scripts/release-mac.ts') expect(desktopPackage.build.mac.hardenedRuntime).toBe(true) expect(desktopPackage.build.mac.notarize).toBe(true) + expect(desktopPackage.build.dmg.sign).toBe(true) + expect(releaseMacSource).toContain("'--mac', 'dmg'") + }) + + it('lays out the macOS DMG installer window', () => { + expect(desktopPackage.build.dmg).toMatchObject({ + iconSize: 128, + title: 'Pythinker ${version}', + window: { height: 400, width: 660 }, + }) + expect(desktopPackage.build.dmg.contents).toEqual([ + { x: 180, y: 200, type: 'file' }, + { x: 480, y: 200, type: 'link', path: '/Applications' }, + ]) }) it('configures the Windows x64 NSIS installer', () => { diff --git a/apps/pythinker-code/src/cli/sub/server/run.ts b/apps/pythinker-code/src/cli/sub/server/run.ts index 15913486..1509f37d 100644 --- a/apps/pythinker-code/src/cli/sub/server/run.ts +++ b/apps/pythinker-code/src/cli/sub/server/run.ts @@ -138,7 +138,10 @@ export function buildRunCommand(cmd: Command, options: { defaultOpen: boolean }) `${error instanceof Error ? error.message : String(error)}\n`, ); } finally { - process.exit(1); + // Errors that declare an exit code mean it: ServerLockedError uses 2 so + // callers can tell a single-instance conflict from a generic failure. + const declared = (error as { readonly exitCode?: unknown }).exitCode; + process.exit(typeof declared === 'number' ? declared : 1); } } });