返回源码地图

apps/desktop/src/host-process.ts

main snapshot · da00f7f5358f · 正文引用章节 15 / 15;完整原文可核对,不声称全文件人工逐行审计

完整原文供逐行核对;页面收录不代表每行都经过人工语义审核。MIT 许可见 许可证。

1/** Electron Node-mode child lifecycle for the shared Web application. */
2
3import { spawn, type ChildProcess } from 'node:child_process'
4import { join } from 'node:path'
5import type { PlatformSession } from '@deepseek-ai/dsh-deepseek-account'
6import { desktopNodeEnvironment } from './node-environment.ts'
7
8interface ReadyEvent {
9 readonly type: 'ready'
10 readonly url: string
11 readonly injections?: readonly unknown[] | undefined
12}
13
14interface FatalEvent {
15 readonly type: 'fatal'
16 readonly message: string
17 /** The Host's complete inspected error: stack, enumerable properties, cause chain. */
18 readonly diagnostic?: string
19}
20
21interface PlatformSessionEvent {
22 readonly type: 'platform-session'
23 readonly session: PlatformSession | null
24}
25
26type DesktopHostEvent = ReadyEvent | FatalEvent | PlatformSessionEvent | { readonly type: 'shutdown-complete' } | {
27 readonly type: 'update-tasks'
28 readonly requestId: number
29 readonly active: boolean
30 readonly error?: string
31} | {
32 readonly type: 'quit-inspection'
33 readonly requestId: number
34 readonly activeTasks: boolean
35 readonly scheduledTasks: boolean
36 readonly error?: string
37}
38
39/** Correlated answer to one shell control request. */
40type DesktopHostControlResponse = Extract<DesktopHostEvent, { readonly requestId: number }>
41
42/** What quitting now would affect, as reported by the Host. */
43export interface DesktopQuitInspection {
44 readonly activeTasks: boolean
45 readonly scheduledTasks: boolean
46}
47
48/** Quit inspection deadline; a slower Host counts as unknown work and the shell asks before quitting. */
49export const QUIT_INSPECTION_DEADLINE_MS = 2_000
50
51const MAX_HOST_DIAGNOSTIC_CHARS = 64 * 1024
52
53function isDesktopHostEvent(message: unknown): message is DesktopHostEvent {
54 if (typeof message !== 'object' || message === null || !('type' in message)) return false
55 const candidate = message as Record<string, unknown>
56 switch (candidate.type) {
57 case 'shutdown-complete':
58 return true
59 case 'ready':
60 return typeof candidate.url === 'string'
61 case 'platform-session': {
62 const session = candidate.session
63 if (session === null) return true
64 if (typeof session !== 'object' || !('origin' in session) || !('token' in session)
65 || typeof session.origin !== 'string' || typeof session.token !== 'string' || session.token.length === 0) return false
66 if (!('userId' in session) || (session.userId !== null
67 && (typeof session.userId !== 'string' || session.userId.length === 0))) return false
68 if ('embeddedPageDist' in session && typeof session.embeddedPageDist !== 'string') return false
69 if ('requestHeaders' in session && (typeof session.requestHeaders !== 'object' || session.requestHeaders === null
70 || 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 false
74 try {
75 const url = new URL(session.origin)
76 return url.origin === session.origin && !url.username && !url.password
77 && (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 false
90 }
91}
92
93async function exitsWithin(exit: Promise<void>, milliseconds: number): Promise<boolean> {
94 let timer: ReturnType<typeof setTimeout> | undefined
95 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}
105
106/** Browser authentication URL reported by the running Web application. */
107export interface DesktopHostReady {
108 readonly url: string
109 readonly injections?: readonly unknown[] | undefined
110}
111
112/** The child has exited, but task teardown did not finish successfully. */
113export class DesktopHostUncleanExitError extends Error {}
114
115/**
116 * A Host failure reported over IPC before the process exited. `message` is what
117 * the Host chose to show; `diagnostic` is its complete inspected error, kept
118 * separately so a crash report can print it verbatim instead of a string escaped
119 * inside another error's properties.
120 */
121export class DesktopHostFatalError extends Error {
122 readonly #diagnostic: string | undefined
123
124 /**
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 = diagnostic
131 }
132
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}
136
137/** One Web backend running under the Electron executable in Node mode. */
138export class DesktopHostProcess {
139 private child: ChildProcess | undefined
140 private readyResolve!: (ready: DesktopHostReady) => void
141 private readyReject!: (error: Error) => void
142 private readonly readyPromise = new Promise<DesktopHostReady>((resolve, reject) => {
143 this.readyResolve = resolve
144 this.readyReject = reject
145 })
146 private exitPromise: Promise<void> | undefined
147 private stderr = ''
148 private failureReported = false
149 private stopping = false
150 private shutdownCompleted = false
151 private nextControlId = 1
152 private readonly controlRequests = new Map<number, {
153 resolve: (response: DesktopHostControlResponse) => void
154 reject: (error: Error) => void
155 }>()
156
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 sibling
165 * `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 },
178
179 private readonly onPlatformSession?: (session: PlatformSession | null) => void,
180 ) {}
181
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.readyPromise
188 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 = child
203 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 return
211 }
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 = true
216 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.readyPromise
235 }
236
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.active
247 }
248
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 misses
252 * {@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 }
259
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.child
265 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> | undefined
270 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 }
281
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.child
290 if (child === undefined) return
291 this.stopping = true
292 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 = undefined
304 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 }
309
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 = true
317 try { this.onFailure?.(error) } catch (listenerError) {
318 console.error('desktop host failure listener failed', listenerError)
319 }
320 }
321 }
322}