165 lines
6.4 KiB
TypeScript
165 lines
6.4 KiB
TypeScript
/** Electron Node-mode child lifecycle for the shared Web application. */
|
|
|
|
import { spawn, type ChildProcess } from 'node:child_process'
|
|
import { join } from 'node:path'
|
|
import { desktopNodeEnvironment } from './node-environment.ts'
|
|
|
|
interface ReadyEvent {
|
|
readonly type: 'ready'
|
|
readonly url: string
|
|
readonly injections?: readonly unknown[] | undefined
|
|
}
|
|
|
|
interface FatalEvent {
|
|
readonly type: 'fatal'
|
|
readonly message: string
|
|
}
|
|
|
|
type DesktopHostEvent = ReadyEvent | FatalEvent
|
|
|
|
const MAX_HOST_DIAGNOSTIC_CHARS = 64 * 1024
|
|
|
|
function isDesktopHostEvent(message: unknown): message is DesktopHostEvent {
|
|
if (typeof message !== 'object' || message === null || !('type' in message)) return false
|
|
const candidate = message as Record<string, unknown>
|
|
switch (candidate.type) {
|
|
case 'ready':
|
|
return typeof candidate.url === 'string'
|
|
case 'fatal':
|
|
return typeof candidate.message === 'string'
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
async function exitsWithin(exit: Promise<void>, milliseconds: number): Promise<boolean> {
|
|
let timer: ReturnType<typeof setTimeout> | undefined
|
|
const timeout = new Promise<false>((resolve) => {
|
|
timer = setTimeout(() => { resolve(false) }, milliseconds)
|
|
timer.unref()
|
|
})
|
|
try {
|
|
return await Promise.race([exit.then(() => true), timeout])
|
|
} finally {
|
|
if (timer !== undefined) clearTimeout(timer)
|
|
}
|
|
}
|
|
|
|
/** Browser authentication URL reported by the running Web application. */
|
|
export interface DesktopHostReady {
|
|
readonly url: string
|
|
readonly injections?: readonly unknown[] | undefined
|
|
}
|
|
|
|
/** One Web backend running under the Electron executable in Node mode. */
|
|
export class DesktopHostProcess {
|
|
private child: ChildProcess | undefined
|
|
private readyResolve!: (ready: DesktopHostReady) => void
|
|
private readyReject!: (error: Error) => void
|
|
private readonly readyPromise = new Promise<DesktopHostReady>((resolve, reject) => {
|
|
this.readyResolve = resolve
|
|
this.readyReject = reject
|
|
})
|
|
private exitPromise: Promise<void> | undefined
|
|
private stderr = ''
|
|
private failureReported = false
|
|
private stopping = false
|
|
|
|
/**
|
|
* @param node - Absolute Electron executable in Node mode.
|
|
* @param runtimeDir - Immutable packages carried by the current application.
|
|
* @param projectDir - Desktop plugin profile and child working directory.
|
|
* @param inspectPort - Optional loopback inspector port for workspace development.
|
|
* @param environment - Environment inherited by the Host and its plugin subprocesses.
|
|
* @param onFailure - Receives the first unexpected child failure, including after readiness.
|
|
* @param primaryRuntime - Optional bundled dependency payload; when supplied, missing sibling
|
|
* `office-skills` resources fail Host startup.
|
|
* @param packageManager - Bundled pnpm entry and Node launcher directory, scoped to package operations.
|
|
* @param profileResolution - Package resolution mode for the application-owned profile.
|
|
*/
|
|
constructor(
|
|
private readonly node: string,
|
|
private readonly runtimeDir: string,
|
|
private readonly projectDir: string,
|
|
private readonly inspectPort?: number,
|
|
private readonly environment: NodeJS.ProcessEnv = process.env,
|
|
private readonly onFailure?: (error: Error) => void,
|
|
private readonly primaryRuntime?: string,
|
|
private readonly profileResolution: 'link' | 'runtime' = 'link',
|
|
private readonly packageManager?: { readonly pnpm: string; readonly nodeBin: string },
|
|
) {}
|
|
|
|
/**
|
|
* Start this child once and await its Web application URL.
|
|
* @returns Ready facts supplied by the child after application startup.
|
|
*/
|
|
async start(): Promise<DesktopHostReady> {
|
|
if (this.child !== undefined) return this.readyPromise
|
|
const entry = join(this.runtimeDir, 'node_modules', '@deepseek-ai', 'dsh-desktop-host', 'lib', 'index.js')
|
|
const child = spawn(this.node, [
|
|
'--expose-internals',
|
|
...(this.inspectPort === undefined ? [] : [`--inspect=127.0.0.1:${String(this.inspectPort)}`]),
|
|
entry,
|
|
this.runtimeDir,
|
|
this.projectDir,
|
|
this.primaryRuntime ?? join(this.runtimeDir, '..', 'runtime', 'primary-runtime'),
|
|
this.profileResolution,
|
|
...this.packageManager === undefined ? [] : [this.packageManager.pnpm, this.packageManager.nodeBin],
|
|
], {
|
|
cwd: this.projectDir,
|
|
env: desktopNodeEnvironment(this.node, undefined, this.environment),
|
|
stdio: ['ignore', 'pipe', 'pipe', 'ipc'],
|
|
})
|
|
this.child = child
|
|
child.stderr?.setEncoding('utf8')
|
|
child.stderr?.on('data', (chunk: string) => { this.stderr = (this.stderr + chunk).slice(-MAX_HOST_DIAGNOSTIC_CHARS) })
|
|
child.stdout?.pipe(process.stdout)
|
|
child.on('message', (message: unknown) => {
|
|
if (!isDesktopHostEvent(message)) {
|
|
this.fail(new Error('dsh desktop host sent an invalid IPC event'))
|
|
child.kill('SIGTERM')
|
|
return
|
|
}
|
|
if (message.type === 'ready') this.readyResolve({ url: message.url, injections: message.injections })
|
|
else this.fail(new Error(message.message))
|
|
})
|
|
child.once('error', (error) => { this.fail(error) })
|
|
this.exitPromise = new Promise<void>((resolve) => {
|
|
child.once('close', (code) => {
|
|
const suffix = this.stderr.trim() === '' ? '' : `: ${this.stderr.trim()}`
|
|
if (code !== 0 && code !== null) this.fail(new Error(`dsh desktop host exited with ${String(code)}${suffix}`))
|
|
else this.fail(new Error(`dsh desktop host stopped${suffix}`))
|
|
resolve()
|
|
})
|
|
})
|
|
return this.readyPromise
|
|
}
|
|
|
|
/** Request graceful teardown and await exit, escalating termination when needed. */
|
|
async stop(): Promise<void> {
|
|
const child = this.child
|
|
if (child === undefined) return
|
|
this.stopping = true
|
|
if (child.connected) child.send({ type: 'shutdown' }, (error) => { if (error !== null) this.fail(error) })
|
|
const exited = this.exitPromise ?? Promise.resolve()
|
|
if (!await exitsWithin(exited, 10_000)) child.kill('SIGTERM')
|
|
if (!await exitsWithin(exited, 5_000)) {
|
|
child.kill('SIGKILL')
|
|
if (!await exitsWithin(exited, 5_000)) {
|
|
throw new Error('dsh desktop host did not exit after SIGKILL')
|
|
}
|
|
}
|
|
this.child = undefined
|
|
}
|
|
|
|
private fail(error: Error): void {
|
|
this.readyReject(error)
|
|
if (!this.failureReported && !this.stopping) {
|
|
this.failureReported = true
|
|
try { this.onFailure?.(error) } catch (listenerError) {
|
|
console.error('desktop host failure listener failed', listenerError)
|
|
}
|
|
}
|
|
}
|
|
}
|