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
26 changes: 24 additions & 2 deletions apps/desktop/src/host-supervisor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,26 @@ const DEFAULT_SHUTDOWN_TIMEOUT_MS = 5_000
const TASKKILL_TIMEOUT_MS = 5_000
const MAX_STARTUP_OUTPUT_CHARS = 32_768

export const DESKTOP_PACKAGED_PORT = 24_827
export const DESKTOP_DEV_PORT = 24_828

/** Resolve the fixed Host port, with an optional validated environment override. */
export function resolveDesktopPort(env: NodeJS.ProcessEnv, isPackaged: boolean): number {
const value = env['PYTHINKER_DESKTOP_PORT']
if (value === undefined) return isPackaged ? DESKTOP_PACKAGED_PORT : DESKTOP_DEV_PORT

const port = Number(value)
if (!/^\d+$/u.test(value) || !Number.isInteger(port) || port < 1 || port > 65_535) {
throw new Error(`PYTHINKER_DESKTOP_PORT must be an integer from 1 to 65535; received ${JSON.stringify(value)}`)
}
return port
}

/** Return whether Host output reports that its fixed port is already occupied. */
export function isPortInUseError(message: string): boolean {
return /EADDRINUSE|address already in use/iu.test(message)
}

/** Incremental parser for the Web Host's canonical readiness line. */
export interface ReadinessParser {
/**
Expand Down Expand Up @@ -257,6 +277,8 @@ export interface SpawnPythinkerServerOptions {
readonly cwd: string
/** Frozen environment for the Host process. */
readonly env: NodeJS.ProcessEnv
/** Fixed loopback port for the Host server. */
readonly port: number
/** Run the Electron executable as its bundled Node runtime. */
readonly electronRunAsNode?: boolean
}
Expand All @@ -272,7 +294,7 @@ function streamAdapter(stream: NodeJS.ReadableStream): HostChild['stdout'] {
}

/**
* Spawn the production Pythinker server on an OS-assigned loopback port.
* Spawn the production Pythinker server on a fixed loopback port.
* @param options - Node runtime, built CLI and process environment.
* @returns The child handle consumed by {@link createHostSupervisor}.
*/
Expand All @@ -286,7 +308,7 @@ export function spawnPythinkerServer(options: SpawnPythinkerServerOptions): Host
'run',
'--foreground',
'--port',
'0',
String(options.port),
'--log-level',
'error',
], {
Expand Down
64 changes: 44 additions & 20 deletions apps/desktop/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,13 @@ import {
setCrashPhase,
track,
} from '@pymodel/pythinker-telemetry'
import { createHostSupervisor, spawnPythinkerServer, type HostSupervisor } from './host-supervisor'
import {
createHostSupervisor,
isPortInUseError,
resolveDesktopPort,
spawnPythinkerServer,
type HostSupervisor,
} from './host-supervisor'
import { createSplashWindow } from './splash'
import {
checkForUpdatesNow,
Expand Down Expand Up @@ -303,6 +309,7 @@ async function boot(): Promise<void> {
if (bootQuitPromise !== undefined) return
initializeDesktopTelemetry()
const paths = hostPaths()
const port = resolveDesktopPort(process.env, app.isPackaged)
assertHostArtifacts(paths)
const splash = createSplashWindow(desktopResources('splash'))
const destroySplash = (): void => {
Expand All @@ -313,26 +320,43 @@ async function boot(): Promise<void> {
const trayFrames = loadTrayImages()
createTray(trayFrames)
stopTrayAnimation = startTrayAnimation(trayFrames)
host = createHostSupervisor({
spawnHost: () => spawnPythinkerServer({
...paths,
env: {
...process.env,
PYTHINKER_DESKTOP: '1',
for (;;) {
host = createHostSupervisor({
spawnHost: () => spawnPythinkerServer({
...paths,
env: {
...process.env,
PYTHINKER_DESKTOP: '1',
},
port,
}),
log: chunk => process.stderr.write(chunk),
onUnexpectedExit: ({ code, signal }) => {
console.error(`desktop Host exited unexpectedly (code ${String(code)}, signal ${String(signal)})`)
void requestAppQuit()
},
}),
log: chunk => process.stderr.write(chunk),
onUnexpectedExit: ({ code, signal }) => {
console.error(`desktop Host exited unexpectedly (code ${String(code)}, signal ${String(signal)})`)
void requestAppQuit()
},
})
try {
hostOrigin = await host.start()
track('desktop_server_ready')
} catch (error) {
track('desktop_server_failed')
throw error
})
try {
hostOrigin = await host.start()
track('desktop_server_ready')
break
} catch (error) {
track('desktop_server_failed')
const message = error instanceof Error ? error.message : String(error)
if (!isPortInUseError(message)) throw error

const result = await dialog.showMessageBox({
type: 'error',
buttons: ['Retry', 'Quit'],
defaultId: 0,
cancelId: 1,
title: `${APP_NAME} needs its fixed port`,
message: `${APP_NAME} cannot use port ${String(port)}. It needs this fixed port so settings persist between launches. Free the port, or set PYTHINKER_DESKTOP_PORT to an unused port.`,
})
if (result.response === 0) continue
await requestAppQuit()
return
}
}
stopTrayAnimation?.()
stopTrayAnimation = undefined
Expand Down
23 changes: 22 additions & 1 deletion apps/desktop/src/updater.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { readFileSync, writeFileSync } from 'node:fs'
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import { app, type BrowserWindow } from 'electron'
import electronUpdater from 'electron-updater'

const { autoUpdater } = electronUpdater
const UPDATE_SETTINGS_FILE = 'update-settings.json'
const UPDATES_UNAVAILABLE_MESSAGE = 'Updates are not available for this build'
const INITIAL_CHECK_DELAY_MS = 10_000
const CHECK_INTERVAL_MS = 4 * 60 * 60 * 1_000

Expand Down Expand Up @@ -117,6 +118,20 @@ function clearTimers(): void {
checkInterval = undefined
}

function hasUpdateConfig(): boolean {
return existsSync(join(process.resourcesPath, 'app-update.yml'))
}

function disableUpdates(): UpdateState {
updateState({
status: 'disabled',
message: UPDATES_UNAVAILABLE_MESSAGE,
version: undefined,
percent: undefined,
})
return state
}

function scheduleChecks(): void {
if (checkInterval !== undefined) return
checkInterval = setInterval(() => {
Expand Down Expand Up @@ -181,6 +196,10 @@ export function initUpdater(
clearTimers()

if (!app.isPackaged) return
if (!hasUpdateConfig()) {
disableUpdates()
return
}

try {
autoUpdater.autoDownload = settings.autoUpdate
Expand Down Expand Up @@ -232,6 +251,7 @@ export async function checkForUpdatesNow(): Promise<UpdateState> {
updateState({ status: 'disabled' })
return state
}
if (!hasUpdateConfig()) return disableUpdates()

try {
autoUpdater.autoDownload = true
Expand All @@ -251,6 +271,7 @@ export function quitAndInstallNow(): UpdateState {
updateState({ status: 'disabled' })
return state
}
if (!hasUpdateConfig()) return disableUpdates()

try {
updateTelemetryTrack('desktop_update_install')
Expand Down
35 changes: 34 additions & 1 deletion apps/desktop/tests/host-supervisor.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
createReadinessParser,
type HostChild,
} from '../src/host-supervisor'
import * as hostSupervisor from '../src/host-supervisor'

vi.mock('node:child_process', { spy: true })

Expand Down Expand Up @@ -112,6 +113,34 @@ describe('desktop Host readiness', () => {
})
})

describe('desktop Host port', () => {
it('uses fixed ports for packaged and development builds without an override', () => {
expect(hostSupervisor.DESKTOP_PACKAGED_PORT).toBe(24_827)
expect(hostSupervisor.DESKTOP_DEV_PORT).toBe(24_828)
expect(hostSupervisor.resolveDesktopPort({}, true)).toBe(24_827)
expect(hostSupervisor.resolveDesktopPort({}, false)).toBe(24_828)
})

it('uses a valid port override for packaged and development builds', () => {
const env = { PYTHINKER_DESKTOP_PORT: '45231' }

expect(hostSupervisor.resolveDesktopPort(env, true)).toBe(45_231)
expect(hostSupervisor.resolveDesktopPort(env, false)).toBe(45_231)
})

it.each(['not-a-port', '70000'])('rejects an invalid port override: %s', (value) => {
expect(() => hostSupervisor.resolveDesktopPort({ PYTHINKER_DESKTOP_PORT: value }, true))
.toThrow(new RegExp(`PYTHINKER_DESKTOP_PORT.*${value}`, 'u'))
})

it('detects output that reports a port collision', () => {
expect(hostSupervisor.isPortInUseError(
'listen EADDRINUSE: address already in use 127.0.0.1:24827',
)).toBe(true)
expect(hostSupervisor.isPortInUseError('desktop Host exited before readiness (code 1, signal null)')).toBe(false)
})
})

describe('desktop Host supervisor', () => {
it('starts one child for concurrent callers and returns its stdout readiness URL', async () => {
const child = new FakeHostChild()
Expand Down Expand Up @@ -296,6 +325,7 @@ describe('desktop Host process', () => {
cliEntry: '/Applications/Pythinker.app/Contents/Resources/host/node_modules/@pymodel/pythinker-code/dist/launcher.mjs',
cwd: '/Users/tester',
env: { PYTHINKER_DESKTOP: '1' },
port: 24_827,
electronRunAsNode: true,
})

Expand All @@ -307,7 +337,7 @@ describe('desktop Host process', () => {
'run',
'--foreground',
'--port',
'0',
'24827',
'--log-level',
'error',
],
Expand Down Expand Up @@ -341,6 +371,7 @@ describe('desktop Host process', () => {
cliEntry: '/tmp/launcher.mjs',
cwd: '/tmp',
env: {},
port: 24_827,
})
host.kill('SIGTERM')

Expand Down Expand Up @@ -379,6 +410,7 @@ describe('desktop Host process', () => {
cliEntry: '/tmp/launcher.mjs',
cwd: '/tmp',
env: {},
port: 24_827,
})
host.kill('SIGTERM')

Expand All @@ -404,6 +436,7 @@ describe('desktop Host process', () => {
cliEntry: '/tmp/launcher.mjs',
cwd: '/tmp',
env: {},
port: 24_827,
})
host.kill('SIGTERM')

Expand Down
46 changes: 44 additions & 2 deletions apps/desktop/tests/updater.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,27 +6,49 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
vi.mock('electron', () => ({
app: {
isPackaged: false,
getPath: () => '',
getPath: vi.fn(() => ''),
once: vi.fn(),
},
}))

vi.mock('electron-updater', () => ({
default: { autoUpdater: {} },
default: {
autoUpdater: {
on: vi.fn(),
checkForUpdates: vi.fn(),
quitAndInstall: vi.fn(),
},
},
}))

import { app } from 'electron'
import electronUpdater from 'electron-updater'
import {
getUpdateState,
initUpdater,
readUpdateSettings,
trackUpdateTransition,
writeUpdateSettings,
type UpdateState,
} from '../src/updater'

const temporaryDirectories: string[] = []
const resourcesPathDescriptor = Object.getOwnPropertyDescriptor(process, 'resourcesPath')
const { autoUpdater } = electronUpdater

afterEach(() => {
for (const directory of temporaryDirectories.splice(0)) {
rmSync(directory, { recursive: true, force: true })
}
Object.defineProperty(app, 'isPackaged', { configurable: true, value: false })
vi.mocked(app.getPath).mockReset()
vi.mocked(app.getPath).mockReturnValue('')
if (resourcesPathDescriptor === undefined) {
Reflect.deleteProperty(process, 'resourcesPath')
} else {
Object.defineProperty(process, 'resourcesPath', resourcesPathDescriptor)
}
vi.clearAllMocks()
})

function temporaryDirectory(): string {
Expand Down Expand Up @@ -76,3 +98,23 @@ describe('update telemetry transitions', () => {
])
})
})

describe('packaged builds without update metadata', () => {
it('disables updates without wiring updater events', () => {
const directory = temporaryDirectory()
writeUpdateSettings(directory, { autoUpdate: false })
vi.mocked(app.getPath).mockReturnValue(directory)
Object.defineProperty(app, 'isPackaged', { configurable: true, value: true })
Object.defineProperty(process, 'resourcesPath', { configurable: true, value: directory })

initUpdater(() => undefined)

expect(getUpdateState()).toMatchObject({
status: 'disabled',
message: 'Updates are not available for this build',
autoUpdate: false,
})
expect(autoUpdater.on).not.toHaveBeenCalled()
expect(app.once).not.toHaveBeenCalled()
})
})
Loading