1
/** Electron Node-mode child lifecycle for the shared Web application. */3
import { spawn, type ChildProcess } from 'node:child_process'4
import { join } from 'node:path'5
import type { PlatformSession } from '@deepseek-ai/dsh-deepseek-account'6
import { desktopNodeEnvironment } from './node-environment.ts'8
interface ReadyEvent {9
readonly type: 'ready'10
readonly url: string11
readonly injections?: readonly unknown[] | undefined12
}14
interface FatalEvent {15
readonly type: 'fatal'16
readonly message: string17
/** The Host's complete inspected error: stack, enumerable properties, cause chain. */18
readonly diagnostic?: string19
}21
interface PlatformSessionEvent {22
readonly type: 'platform-session'23
readonly session: PlatformSession | null24
}26
type DesktopHostEvent = ReadyEvent | FatalEvent | PlatformSessionEvent | { readonly type: 'shutdown-complete' } | {27
readonly type: 'update-tasks'28
readonly requestId: number29
readonly active: boolean30
readonly error?: string31
} | {32
readonly type: 'quit-inspection'33
readonly requestId: number34
readonly activeTasks: boolean35
readonly scheduledTasks: boolean36
readonly error?: string37
}39
/** Correlated answer to one shell control request. */40
type DesktopHostControlResponse = Extract<DesktopHostEvent, { readonly requestId: number }>42
/** What quitting now would affect, as reported by the Host. */43
export interface DesktopQuitInspection {44
readonly activeTasks: boolean45
readonly scheduledTasks: boolean46
}48
/** Quit inspection deadline; a slower Host counts as unknown work and the shell asks before quitting. */49
export const QUIT_INSPECTION_DEADLINE_MS = 2_00051
const MAX_HOST_DIAGNOSTIC_CHARS = 64 * 102453
function isDesktopHostEvent(message: unknown): message is DesktopHostEvent {54
if (typeof message !== 'object' || message === null || !('type' in message)) return false55
const candidate = message as Record<string, unknown>56
switch (candidate.type) {57
case 'shutdown-complete':58
return true59
case 'ready':60
return typeof candidate.url === 'string'61
case 'platform-session': {62
const session = candidate.session63
if (session === null) return true64
if (typeof session !== 'object' || !('origin' in session) || !('token' in session)65
|| typeof session.origin !== 'string' || typeof session.token !== 'string' || session.token.length === 0) return false66
if (!('userId' in session) || (session.userId !== null67
&& (typeof session.userId !== 'string' || session.userId.length === 0))) return false68
if ('embeddedPageDist' in session && typeof session.embeddedPageDist !== 'string') return false69
if ('requestHeaders' in session && (typeof session.requestHeaders !== 'object' || session.requestHeaders === null70
|| Array.isArray(session.requestHeaders)71
|| Object.entries(session.requestHeaders).some(([name, value]) => typeof value !== 'string'72
|| name !== name.toLowerCase() || /[\r\n]/.test(value)73
|| ['authorization', 'x-dsh-auth-token', 'host', 'content-length', 'transfer-encoding', 'connection', 'content-type'].includes(name)))) return false74
try {75
const url = new URL(session.origin)76
return url.origin === session.origin && !url.username && !url.password77
&& (url.protocol === 'https:' || (url.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname)))78
} catch { return false }79
}80
case 'fatal':81
return typeof candidate.message === 'string' && (candidate.diagnostic === undefined || typeof candidate.diagnostic === 'string')82
case 'update-tasks':83
return Number.isSafeInteger(candidate.requestId) && typeof candidate.active === 'boolean'84
&& (candidate.error === undefined || typeof candidate.error === 'string')85
case 'quit-inspection':86
return Number.isSafeInteger(candidate.requestId) && typeof candidate.activeTasks === 'boolean'87
&& typeof candidate.scheduledTasks === 'boolean' && (candidate.error === undefined || typeof candidate.error === 'string')88
default:89
return false90
}91
}93
async function exitsWithin(exit: Promise<void>, milliseconds: number): Promise<boolean> {94
let timer: ReturnType<typeof setTimeout> | undefined95
const timeout = new Promise<false>((resolve) => {96
timer = setTimeout(() => { resolve(false) }, milliseconds)97
timer.unref()98
})99
try {100
return await Promise.race([exit.then(() => true), timeout])101
} finally {102
if (timer !== undefined) clearTimeout(timer)103
}104
}106
/** Browser authentication URL reported by the running Web application. */107
export interface DesktopHostReady {108
readonly url: string109
readonly injections?: readonly unknown[] | undefined110
}112
/** The child has exited, but task teardown did not finish successfully. */113
export class DesktopHostUncleanExitError extends Error {}115
/**116
* A Host failure reported over IPC before the process exited. `message` is what117
* the Host chose to show; `diagnostic` is its complete inspected error, kept118
* separately so a crash report can print it verbatim instead of a string escaped119
* inside another error's properties.120
*/121
export class DesktopHostFatalError extends Error {122
readonly #diagnostic: string | undefined124
/**125
* @param message - The Host's failure message.126
* @param diagnostic - The Host's inspected error, when the Host supplied one.127
*/128
constructor(message: string, diagnostic: string | undefined) {129
super(message)130
this.#diagnostic = diagnostic131
}133
/** The Host's inspected error; a getter so `util.inspect` of this error does not repeat it as an escaped property. */134
get diagnostic(): string | undefined { return this.#diagnostic }135
}137
/** One Web backend running under the Electron executable in Node mode. */138
export class DesktopHostProcess {139
private child: ChildProcess | undefined140
private readyResolve!: (ready: DesktopHostReady) => void141
private readyReject!: (error: Error) => void142
private readonly readyPromise = new Promise<DesktopHostReady>((resolve, reject) => {143
this.readyResolve = resolve144
this.readyReject = reject145
})146
private exitPromise: Promise<void> | undefined147
private stderr = ''148
private failureReported = false149
private stopping = false150
private shutdownCompleted = false151
private nextControlId = 1152
private readonly controlRequests = new Map<number, {153
resolve: (response: DesktopHostControlResponse) => void154
reject: (error: Error) => void155
}>()157
/**158
* @param node - Absolute Electron executable in Node mode.159
* @param runtimeDir - Immutable packages carried by the current application.160
* @param projectDir - Desktop plugin profile and child working directory.161
* @param inspectPort - Optional loopback inspector port for workspace development.162
* @param environment - Environment inherited by the Host and its plugin subprocesses.163
* @param onFailure - Receives the first unexpected child failure, including after readiness.164
* @param primaryRuntime - Optional bundled dependency payload; when supplied, missing sibling165
* `office-skills` resources fail Host startup.166
* @param packageManager - Bundled pnpm entry and Node launcher directory, scoped to package operations.167
* @param onPlatformSession - Private credential updates for embedded Platform views.168
*/169
constructor(170
private readonly node: string,171
private readonly runtimeDir: string,172
private readonly projectDir: string,173
private readonly inspectPort?: number,174
private readonly environment: NodeJS.ProcessEnv = process.env,175
private readonly onFailure?: (error: Error) => void,176
private readonly primaryRuntime?: string,177
private readonly packageManager?: { readonly pnpm: string; readonly nodeBin: string },179
private readonly onPlatformSession?: (session: PlatformSession | null) => void,180
) {}182
/**183
* Start this child once and await its Web application URL.184
* @returns Ready facts supplied by the child after application startup.185
*/186
async start(): Promise<DesktopHostReady> {187
if (this.child !== undefined) return this.readyPromise188
const entry = join(this.runtimeDir, 'node_modules', '@deepseek-ai', 'dsh-desktop-host', 'lib', 'index.js')189
const child = spawn(this.node, [190
'--expose-internals',191
...(this.inspectPort === undefined ? [] : [`--inspect=127.0.0.1:${String(this.inspectPort)}`]),192
entry,193
this.runtimeDir,194
this.projectDir,195
this.primaryRuntime ?? join(this.runtimeDir, '..', 'runtime', 'primary-runtime'),196
...this.packageManager === undefined ? [] : [this.packageManager.pnpm, this.packageManager.nodeBin],197
], {198
cwd: this.projectDir,199
env: desktopNodeEnvironment(this.node, undefined, this.environment),200
stdio: ['ignore', 'pipe', 'pipe', 'ipc'],201
})202
this.child = child203
child.stderr?.setEncoding('utf8')204
child.stderr?.on('data', (chunk: string) => { this.stderr = (this.stderr + chunk).slice(-MAX_HOST_DIAGNOSTIC_CHARS) })205
child.stdout?.pipe(process.stdout)206
child.on('message', (message: unknown) => {207
if (!isDesktopHostEvent(message)) {208
this.fail(new Error('dsh desktop host sent an invalid IPC event'))209
child.kill('SIGTERM')210
return211
}212
if (message.type === 'ready') this.readyResolve({ url: message.url, injections: message.injections })213
else if (message.type === 'platform-session') this.onPlatformSession?.(message.session)214
else if (message.type === 'shutdown-complete') {215
if (this.stopping) this.shutdownCompleted = true216
else this.fail(new Error('dsh desktop host acknowledged an unrequested shutdown'))217
}218
else if (message.type === 'fatal') this.fail(new DesktopHostFatalError(message.message, message.diagnostic))219
else {220
const request = this.controlRequests.get(message.requestId)221
if (message.error === undefined) request?.resolve(message)222
else request?.reject(new Error(message.error))223
}224
})225
child.once('error', (error) => { this.fail(error) })226
this.exitPromise = new Promise<void>((resolve) => {227
child.once('close', (code) => {228
const suffix = this.stderr.trim() === '' ? '' : `: ${this.stderr.trim()}`229
if (code !== 0 && code !== null) this.fail(new Error(`dsh desktop host exited with ${String(code)}${suffix}`))230
else this.fail(new Error(`dsh desktop host stopped${suffix}`))231
resolve()232
})233
})234
return this.readyPromise235
}237
/**238
* Inspect active work or lock request admission for update handoff.239
* @param action - Read-only inspection, admission lock, or recovery unlock.240
* @returns Whether live tasks would be affected. Locking drains admitted API requests before inspecting tasks;241
* an unanswered drain fails at the control-request deadline without authorizing installation.242
*/243
async updateTasks(action: 'inspect' | 'lock' | 'unlock'): Promise<boolean> {244
const response = await this.control({ type: 'update-tasks', action }, 10_000, 'desktop update: task inspection timed out')245
if (response.type !== 'update-tasks') throw new Error('desktop update: Host answered with a different control response')246
return response.active247
}249
/**250
* Ask the Host what quitting now would interrupt.251
* @returns Active tasks and armed scheduled reminders; rejects when the Host is unavailable or misses252
* {@link QUIT_INSPECTION_DEADLINE_MS}, and the shell then asks before quitting.253
*/254
async inspectQuit(): Promise<DesktopQuitInspection> {255
const response = await this.control({ type: 'quit-inspection' }, QUIT_INSPECTION_DEADLINE_MS, 'desktop quit: inspection timed out')256
if (response.type !== 'quit-inspection') throw new Error('desktop quit: Host answered with a different control response')257
return { activeTasks: response.activeTasks, scheduledTasks: response.scheduledTasks }258
}260
private async control(261
request: { readonly type: 'update-tasks'; readonly action: 'inspect' | 'lock' | 'unlock' } | { readonly type: 'quit-inspection' },262
deadlineMs: number, deadlineMessage: string,263
): Promise<DesktopHostControlResponse> {264
const child = this.child265
if (child === undefined || !child.connected || this.failureReported || this.stopping) {266
throw new Error(`${request.type === 'update-tasks' ? 'desktop update' : 'desktop quit'}: Host is unavailable`)267
}268
const requestId = this.nextControlId++269
let timer: ReturnType<typeof setTimeout> | undefined270
try {271
return await new Promise<DesktopHostControlResponse>((resolve, reject) => {272
this.controlRequests.set(requestId, { resolve, reject })273
timer = setTimeout(() => { reject(new Error(deadlineMessage)) }, deadlineMs)274
child.send({ ...request, requestId }, (error) => { if (error !== null) reject(error) })275
})276
} finally {277
clearTimeout(timer)278
this.controlRequests.delete(requestId)279
}280
}282
/**283
* Request teardown and await child exit, escalating termination when needed.284
* @param requireGraceful - Reject update handoff after forced termination or unsuccessful child exit.285
* @returns Completion of owned process teardown. DesktopHostUncleanExitError confirms exit but refuses installation;286
* other failures do not confirm exit.287
*/288
async stop(requireGraceful = false): Promise<void> {289
const child = this.child290
if (child === undefined) return291
this.stopping = true292
this.onPlatformSession?.(null)293
if (child.connected) child.send({ type: 'shutdown' }, (error) => { if (error !== null) this.fail(error) })294
const exited = this.exitPromise ?? Promise.resolve()295
const graceful = await exitsWithin(exited, 10_000)296
if (!graceful) child.kill('SIGTERM')297
if (!await exitsWithin(exited, 5_000)) {298
child.kill('SIGKILL')299
if (!await exitsWithin(exited, 5_000)) {300
throw new Error('dsh desktop host did not exit after SIGKILL')301
}302
}303
this.child = undefined304
if (requireGraceful && (!graceful || child.exitCode !== 0 || !this.shutdownCompleted)) {305
// This diagnostic reaches expandable UI; arbitrary plugin stderr can contain credentials.306
throw new DesktopHostUncleanExitError(`desktop update: Host did not complete graceful task teardown (exit ${String(child.exitCode)}, signal ${String(child.signalCode)}, shutdown acknowledged ${String(this.shutdownCompleted)}, graceful deadline exceeded ${String(!graceful)})`)307
}308
}310
private fail(error: Error): void {311
this.onPlatformSession?.(null)312
this.readyReject(error)313
for (const request of this.controlRequests.values()) request.reject(error)314
this.controlRequests.clear()315
if (!this.failureReported && !this.stopping) {316
this.failureReported = true317
try { this.onFailure?.(error) } catch (listenerError) {318
console.error('desktop host failure listener failed', listenerError)319
}320
}321
}322
}