From b544e55f2240aa1d0cc7a3beb3b1b53d1f0800e1 Mon Sep 17 00:00:00 2001 From: elkaix Date: Mon, 17 Aug 2026 15:24:53 -0400 Subject: [PATCH 1/3] fix(desktop): sign, notarize and staple the macOS DMG The DMG container was left unsigned and unnotarized: only mac.notarize was set, which covers the .app, while dmg.sign kept its default of false. A downloaded DMG was rejected by Gatekeeper with 'no usable signature'. Signing the DMG by hand afterwards fixed Gatekeeper but broke auto-update, because notarizing and stapling mutate the DMG after electron-builder has already written its sha512 and size into latest-mac.yml. electron-updater verifies downloads against that checksum. Enable dmg.sign so signing happens while the DMG is built, then notarize, staple and rewrite latest-mac.yml from the finished artifact in a step that runs after electron-builder returns. Delete the stale blockmap so a differential update falls back to a full download instead of corrupting. Also give the install window a deliberate layout instead of the stock electron-builder appearance. --- apps/desktop/package.json | 22 ++ .../desktop/scripts/finalize-mac-artifacts.ts | 193 ++++++++++++++++++ apps/desktop/scripts/release-mac.ts | 5 + apps/desktop/scripts/release-preflight.ts | 2 +- .../tests/finalize-mac-artifacts.spec.ts | 160 +++++++++++++++ apps/desktop/tests/packaging-config.spec.ts | 30 +++ 6 files changed, 411 insertions(+), 1 deletion(-) create mode 100644 apps/desktop/scripts/finalize-mac-artifacts.ts create mode 100644 apps/desktop/tests/finalize-mac-artifacts.spec.ts 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/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/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', () => { From 2771ff32f615b418e70e94ad4c01c46f88721324 Mon Sep 17 00:00:00 2001 From: elkaix Date: Mon, 17 Aug 2026 15:34:09 -0400 Subject: [PATCH 2/3] fix(desktop): explain why a second server cannot start The server lock at /server/lock is global rather than per-port, so a CLI server on any port stops the desktop Host from starting. The Host exited non-zero and the desktop reported only 'Host exited before readiness (code 1, signal null)', which names neither the conflicting process nor a way out. Parse the Host's canonical conflict line and show a Retry/Quit dialog naming the process, port and start time. Do not stop the other server: it may be serving live sessions, and the choice belongs to the user. Keep the single-instance rule itself. Two servers sharing one Pythinker home would race on session state.json writes, truncate each other's wire.jsonl rewrites, and both claim the same next sequence in the shared event journal, so refusing to start is correct. Also honor an error's declared exit code in 'server run': ServerLockedError uses 2 to mark a lock conflict, which the catch-all flattened to 1. --- apps/desktop/src/host-supervisor.ts | 24 +++++++++++++++++++ apps/desktop/src/main.ts | 17 +++++++++++++ apps/desktop/tests/host-supervisor.spec.ts | 22 +++++++++++++++++ apps/pythinker-code/src/cli/sub/server/run.ts | 5 +++- 4 files changed, 67 insertions(+), 1 deletion(-) 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/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/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); } } }); From 45b1c62be7159dfc6eeb19ff7fdb3b7228e4b3b2 Mon Sep 17 00:00:00 2001 From: elkaix Date: Mon, 17 Aug 2026 17:13:57 -0400 Subject: [PATCH 3/3] chore: add changesets for the macOS signing and server conflict fixes --- .changeset/desktop-server-conflict.md | 5 +++++ .changeset/mac-dmg-signing.md | 5 +++++ 2 files changed, 10 insertions(+) create mode 100644 .changeset/desktop-server-conflict.md create mode 100644 .changeset/mac-dmg-signing.md 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.