@bun-native/runtime 0.0.0-stage → 0.33.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts ADDED
@@ -0,0 +1,1060 @@
1
+ import {
2
+ filesystemRootArgument,
3
+ fileResultAnswers,
4
+ parseFilesystemRoots,
5
+ parseProcessExecutables,
6
+ processArguments,
7
+ encodeFrame,
8
+ FrameDecoder,
9
+ MAX_EVENT_FRAME_BYTES,
10
+ parseCommand,
11
+ parseEvent,
12
+ PROTOCOL_VERSION,
13
+ REQUIRED_CAPABILITIES,
14
+ ProtocolError,
15
+ type Command,
16
+ type NativeEvent,
17
+ type ErrorCode,
18
+ type Limits,
19
+ type ServicePolicy,
20
+ type Capability,
21
+ type FileAccess,
22
+ type HostEvent,
23
+ } from '@bun-native/protocol'
24
+ import { isAbsolute } from 'node:path'
25
+ import type { ReadableStreamDefaultReader } from 'node:stream/web'
26
+ import { exitDiagnostic, NativeHostExitError } from './exit-diagnostic.ts'
27
+ import { StartupTrace } from './startup-trace.ts'
28
+
29
+ export {
30
+ MAX_DIAGNOSTIC_TAIL,
31
+ NativeHostExitError,
32
+ type NativeHostExitDiagnostic,
33
+ type NativeHostExitReason,
34
+ } from './exit-diagnostic.ts'
35
+ export { installedRelease, reportHealthy, type InstalledRelease } from './installation.ts'
36
+ export type { ApplicationEvent, HostEvent, WindowEvent } from '@bun-native/protocol'
37
+
38
+ type WithoutId<T> = T extends { id: number } ? Omit<T, 'id'> : never
39
+ export type WindowCommand = WithoutId<
40
+ Extract<
41
+ Command,
42
+ {
43
+ type:
44
+ | 'open'
45
+ | 'batch'
46
+ | 'close'
47
+ | 'display_snapshot'
48
+ | 'clipboard_read'
49
+ | 'clipboard_write'
50
+ | 'register_global_shortcut'
51
+ | 'unregister_global_shortcut'
52
+ | 'post_notification'
53
+ | 'withdraw_notification'
54
+ | 'open_file_grant'
55
+ | 'close_file_grant'
56
+ | 'file_request'
57
+ | 'spawn_process'
58
+ | 'write_process'
59
+ | 'close_process_input'
60
+ | 'resize_process'
61
+ | 'signal_process'
62
+ | 'acknowledge_process_output'
63
+ | 'release_process'
64
+ | 'show_file_dialog'
65
+ | 'close_file_dialog'
66
+ | 'show_message_dialog'
67
+ | 'close_message_dialog'
68
+ | 'show_context_menu'
69
+ | 'close_context_menu'
70
+ | 'register_drop_target'
71
+ | 'unregister_drop_target'
72
+ | 'resolve_dropped_item'
73
+ | 'set_app_menu'
74
+ }
75
+ >
76
+ >
77
+ export type HostResponse = Extract<
78
+ NativeEvent,
79
+ {
80
+ type:
81
+ | 'ack'
82
+ | 'opened'
83
+ | 'applied'
84
+ | 'display_snapshot'
85
+ | 'clipboard_text'
86
+ | 'clipboard_written'
87
+ | 'global_shortcut_registered'
88
+ | 'global_shortcut_unregistered'
89
+ | 'notification_posted'
90
+ | 'notification_withdrawn'
91
+ | 'file_grant_opened'
92
+ | 'file_grant_closed'
93
+ | 'file_result'
94
+ | 'process_spawned'
95
+ | 'process_updated'
96
+ | 'process_released'
97
+ | 'file_dialog_opened'
98
+ | 'file_dialog_closed'
99
+ | 'message_dialog_opened'
100
+ | 'message_dialog_closed'
101
+ | 'context_menu_opened'
102
+ | 'context_menu_closed'
103
+ | 'drop_target_registered'
104
+ | 'drop_target_unregistered'
105
+ | 'dropped_item_resolved'
106
+ | 'app_menu_set'
107
+ }
108
+ >
109
+
110
+ export interface ProcessLimitPermissions {
111
+ /** CPU seconds before the child is stopped (SIGXCPU on POSIX). */
112
+ readonly cpuSeconds?: number
113
+ /** Address space (Linux) or committed memory (Windows) in bytes. */
114
+ readonly memoryBytes?: number
115
+ /** Open descriptors (POSIX only). */
116
+ readonly openFiles?: number
117
+ }
118
+
119
+ export interface ServicePermissions {
120
+ /** Default deny. Fixed for the native process lifetime; no wire request can grant it. */
121
+ readonly windowDisplay?: 'allow' | 'deny'
122
+ readonly clipboardRead?: 'allow' | 'deny'
123
+ readonly clipboardWrite?: 'allow' | 'deny'
124
+ /** OS-wide key grabs that deliver while another application is focused. */
125
+ readonly globalShortcuts?: 'allow' | 'deny'
126
+ /** Desktop notifications posted by an owned window. */
127
+ readonly notifications?: 'allow' | 'deny'
128
+ /** Owned drop targets that receive external file drops as untrusted names. */
129
+ readonly dragDrop?: 'allow' | 'deny'
130
+ /**
131
+ * Directories an owned window may open as scoped file grants. Each `path`
132
+ * is absolute and normalized; `access` caps every grant on that root.
133
+ */
134
+ readonly filesystemRoots?: readonly Readonly<{ id: string; path: string; access: FileAccess }>[]
135
+ /**
136
+ * Executables an owned window may start as child processes, by ID. Each
137
+ * `executable` is an absolute normalized path; nothing else can be started.
138
+ * Optional `limits` apply to every child started from that entry; a
139
+ * platform that cannot enforce one refuses the spawn with
140
+ * `service_unavailable` (macOS has no memory limit, Windows no open-files
141
+ * limit).
142
+ */
143
+ readonly processes?: readonly Readonly<{
144
+ id: string
145
+ executable: string
146
+ limits?: Readonly<ProcessLimitPermissions>
147
+ }>[]
148
+ /** OS file open/save/folder dialogs owned by a window. Paths are data, never grants. */
149
+ readonly fileDialogs?: 'allow' | 'deny'
150
+ /** The application menu bar (macOS). Selections are reported as item IDs. */
151
+ readonly appMenu?: 'allow' | 'deny'
152
+ /** OS message dialogs (Windows task dialogs, macOS alert sheets) instead of the in-window overlay. */
153
+ readonly taskDialogs?: 'allow' | 'deny'
154
+ }
155
+
156
+ export class NativeCommandError extends Error {
157
+ override readonly name = 'NativeCommandError'
158
+ constructor(
159
+ readonly code: ErrorCode,
160
+ readonly requestId: number,
161
+ message: string
162
+ ) {
163
+ super('Native ' + code + ': ' + message)
164
+ }
165
+ }
166
+
167
+ export interface HostOptions {
168
+ /** Explicit prebuilt executable; starting an app never invokes Cargo. */
169
+ executable: string
170
+ /** Optional native process arguments, passed without a shell. */
171
+ args?: readonly string[]
172
+ requestTimeoutMs?: number
173
+ permissions?: ServicePermissions
174
+ }
175
+
176
+ function launchPolicy(value: unknown = {}): Readonly<ServicePolicy> {
177
+ if (!value || typeof value !== 'object' || Array.isArray(value))
178
+ throw new TypeError('Native permissions must be an object')
179
+ if (
180
+ Object.keys(value).some(
181
+ (key) =>
182
+ ![
183
+ 'windowDisplay',
184
+ 'clipboardRead',
185
+ 'clipboardWrite',
186
+ 'globalShortcuts',
187
+ 'notifications',
188
+ 'dragDrop',
189
+ 'filesystemRoots',
190
+ 'processes',
191
+ 'fileDialogs',
192
+ 'appMenu',
193
+ 'taskDialogs',
194
+ ].includes(key)
195
+ )
196
+ )
197
+ throw new TypeError('Unknown native service permission')
198
+ const grant = (key: keyof ServicePermissions) => {
199
+ const permission = Object.hasOwn(value, key) ? (value as Record<string, unknown>)[key] : 'deny'
200
+ if (permission !== 'allow' && permission !== 'deny')
201
+ throw new TypeError(key + ' permission must be allow or deny')
202
+ return permission === 'allow'
203
+ }
204
+ return Object.freeze({
205
+ window_display: grant('windowDisplay'),
206
+ clipboard_read: grant('clipboardRead'),
207
+ clipboard_write: grant('clipboardWrite'),
208
+ global_shortcuts: grant('globalShortcuts'),
209
+ notifications: grant('notifications'),
210
+ drag_drop: grant('dragDrop'),
211
+ filesystem_roots: filesystemRoots(value),
212
+ processes: processes(value),
213
+ file_dialogs: grant('fileDialogs'),
214
+ app_menu: grant('appMenu'),
215
+ task_dialogs: grant('taskDialogs'),
216
+ })
217
+ }
218
+
219
+ function processes(value: object): ServicePolicy['processes'] {
220
+ const entries = Object.hasOwn(value, 'processes')
221
+ ? (value as Record<string, unknown>)['processes']
222
+ : []
223
+ try {
224
+ return Object.freeze(
225
+ parseProcessExecutables(wireProcesses(entries)).map((entry) =>
226
+ Object.freeze(entry.limits ? { ...entry, limits: Object.freeze(entry.limits) } : entry)
227
+ )
228
+ ) as ServicePolicy['processes']
229
+ } catch (error) {
230
+ throw new TypeError(
231
+ 'processes must list at most 8 unique { id, executable, limits? } entries: ' +
232
+ (error as Error).message
233
+ )
234
+ }
235
+ }
236
+
237
+ const LIMIT_WIRE_NAMES = {
238
+ cpuSeconds: 'cpu_seconds',
239
+ memoryBytes: 'memory_bytes',
240
+ openFiles: 'open_files',
241
+ } as const
242
+
243
+ /** Public camelCase limits become the wire's snake_case; anything else is left for the parser to reject. */
244
+ function wireProcesses(entries: unknown): unknown {
245
+ if (!Array.isArray(entries)) return entries
246
+ return entries.map((entry: unknown) => {
247
+ if (!entry || typeof entry !== 'object' || !Object.hasOwn(entry, 'limits')) return entry
248
+ const { limits, ...rest } = entry as { limits: unknown }
249
+ if (!limits || typeof limits !== 'object' || Array.isArray(limits)) return { ...rest, limits }
250
+ return {
251
+ ...rest,
252
+ limits: Object.fromEntries(
253
+ Object.entries(limits).map(([name, value]) => [
254
+ Object.hasOwn(LIMIT_WIRE_NAMES, name)
255
+ ? LIMIT_WIRE_NAMES[name as keyof typeof LIMIT_WIRE_NAMES]
256
+ : name,
257
+ value,
258
+ ])
259
+ ),
260
+ }
261
+ })
262
+ }
263
+
264
+ function filesystemRoots(value: object): ServicePolicy['filesystem_roots'] {
265
+ const roots = Object.hasOwn(value, 'filesystemRoots')
266
+ ? (value as Record<string, unknown>)['filesystemRoots']
267
+ : []
268
+ try {
269
+ return Object.freeze(
270
+ parseFilesystemRoots(roots).map((root) => Object.freeze(root))
271
+ ) as ServicePolicy['filesystem_roots']
272
+ } catch (error) {
273
+ throw new TypeError(
274
+ 'filesystemRoots must list at most 8 unique { id, path, access } roots: ' +
275
+ (error as Error).message
276
+ )
277
+ }
278
+ }
279
+
280
+ function samePolicy(left: Readonly<ServicePolicy>, right: Readonly<ServicePolicy>): boolean {
281
+ return (
282
+ left.window_display === right.window_display &&
283
+ left.clipboard_read === right.clipboard_read &&
284
+ left.clipboard_write === right.clipboard_write &&
285
+ left.global_shortcuts === right.global_shortcuts &&
286
+ left.notifications === right.notifications &&
287
+ (left.file_dialogs ?? false) === (right.file_dialogs ?? false) &&
288
+ (left.app_menu ?? false) === (right.app_menu ?? false) &&
289
+ (left.task_dialogs ?? false) === (right.task_dialogs ?? false) &&
290
+ left.drag_drop === right.drag_drop &&
291
+ left.filesystem_roots.length === right.filesystem_roots.length &&
292
+ left.filesystem_roots.every(
293
+ (root, index) =>
294
+ root.id === right.filesystem_roots[index]!.id &&
295
+ root.path === right.filesystem_roots[index]!.path &&
296
+ root.access === right.filesystem_roots[index]!.access
297
+ ) &&
298
+ left.processes.length === right.processes.length &&
299
+ left.processes.every(
300
+ (entry, index) =>
301
+ entry.id === right.processes[index]!.id &&
302
+ entry.executable === right.processes[index]!.executable &&
303
+ entry.limits?.cpu_seconds === right.processes[index]!.limits?.cpu_seconds &&
304
+ entry.limits?.memory_bytes === right.processes[index]!.limits?.memory_bytes &&
305
+ entry.limits?.open_files === right.processes[index]!.limits?.open_files
306
+ )
307
+ )
308
+ }
309
+
310
+ type Pending = {
311
+ command: Command
312
+ resolve: (response: HostResponse) => void
313
+ reject: (error: Error) => void
314
+ timer: ReturnType<typeof setTimeout>
315
+ }
316
+
317
+ /** Diagnostic only: when the serialized stdin write and flush of one request settled. */
318
+ type RequestWrite = { readonly queuedAt: number; writtenAt?: number; flushedAt?: number }
319
+
320
+ function describeWrite(write: RequestWrite): string {
321
+ const at = (time: number) => '+' + Math.round(time - write.queuedAt) + 'ms'
322
+ const state =
323
+ write.flushedAt !== undefined ? 'flushed' : write.writtenAt !== undefined ? 'written' : 'queued'
324
+ return (
325
+ `write ${state}: queued +0ms` +
326
+ (write.writtenAt === undefined ? '' : ', written ' + at(write.writtenAt)) +
327
+ (write.flushedAt === undefined ? '' : ', flushed ' + at(write.flushedAt))
328
+ )
329
+ }
330
+
331
+ type WaitingReader = {
332
+ resolve: (value: IteratorResult<HostEvent>) => void
333
+ reject: (error: Error) => void
334
+ }
335
+
336
+ const MAX_PENDING_COMMANDS = 128
337
+ const MAX_QUEUED_EVENTS = 128
338
+
339
+ /**
340
+ * Owns one native process and its bounded, versioned channel. Private transport;
341
+ * framework-specific reconciliation belongs above this shared connection.
342
+ */
343
+ export class NativeHost implements AsyncIterable<HostEvent> {
344
+ readonly exited: Promise<number>
345
+ private readonly process: Bun.Subprocess<'pipe', 'pipe', 'pipe'>
346
+ private readonly stdout: ReadableStreamDefaultReader<Uint8Array>
347
+ private readonly stderr: ReadableStreamDefaultReader<Uint8Array>
348
+ private readonly timeout: number
349
+ private readonly pending = new Map<number, Pending>()
350
+ private readonly events: HostEvent[] = []
351
+ private readonly finished: Promise<void>
352
+ private waiting: WaitingReader | undefined
353
+ /** Queued events the consumer has taken; progress while reading is paused. */
354
+ private taken = 0
355
+ private failure: Error | undefined
356
+ private phase: 'starting' | 'running' | 'stopping' | 'closed' = 'starting'
357
+ private ready = false
358
+ private negotiatedLimits: Readonly<Limits> | undefined
359
+ private negotiatedServicePolicy: Readonly<ServicePolicy> | undefined
360
+ private negotiatedCapabilities: readonly Capability[] | undefined
361
+ private sequence = 0
362
+ private nextId = 1
363
+ private writeTail: Promise<void> = Promise.resolve()
364
+ private stderrTail = ''
365
+ private stderrClipped = false
366
+ private stderrPanic: string | null = null
367
+ private iteratorTaken = false
368
+ private readonly startupTrace = StartupTrace.fromEnvironment()
369
+
370
+ private constructor(
371
+ options: HostOptions,
372
+ private readonly requestedPolicy: Readonly<ServicePolicy>
373
+ ) {
374
+ this.timeout = options.requestTimeoutMs ?? 5_000
375
+ this.process = Bun.spawn({
376
+ cmd: [
377
+ options.executable,
378
+ ...(options.args ?? []),
379
+ ...(requestedPolicy.window_display ? ['--bun-native-allow-window-display'] : []),
380
+ ...(requestedPolicy.clipboard_read ? ['--bun-native-allow-clipboard-read'] : []),
381
+ ...(requestedPolicy.clipboard_write ? ['--bun-native-allow-clipboard-write'] : []),
382
+ ...(requestedPolicy.global_shortcuts ? ['--bun-native-allow-global-shortcuts'] : []),
383
+ ...(requestedPolicy.notifications ? ['--bun-native-allow-notifications'] : []),
384
+ ...(requestedPolicy.drag_drop ? ['--bun-native-allow-drag-drop'] : []),
385
+ ...requestedPolicy.filesystem_roots.map(filesystemRootArgument),
386
+ ...requestedPolicy.processes.flatMap(processArguments),
387
+ ...(requestedPolicy.file_dialogs ? ['--bun-native-allow-file-dialogs'] : []),
388
+ ...(requestedPolicy.app_menu ? ['--bun-native-allow-app-menu'] : []),
389
+ ...(requestedPolicy.task_dialogs ? ['--bun-native-allow-task-dialogs'] : []),
390
+ ],
391
+ stdin: 'pipe',
392
+ stdout: 'pipe',
393
+ stderr: 'pipe',
394
+ // A terminal's Ctrl-C signals its whole foreground process group. The
395
+ // application owns the host's lifetime and ends it through `shutdown`
396
+ // (or closed stdin), so the host runs in its own session on POSIX.
397
+ detached: process.platform !== 'win32',
398
+ })
399
+ this.stdout = this.process.stdout.getReader()
400
+ this.stderr = this.process.stderr.getReader()
401
+ this.exited = this.process.exited
402
+ this.finished = this.watch()
403
+ }
404
+
405
+ static async launch(options: HostOptions): Promise<NativeHost> {
406
+ if (!isAbsolute(options.executable)) throw new Error('Native host path must be absolute')
407
+ const timeout = options.requestTimeoutMs ?? 5_000
408
+ if (!Number.isSafeInteger(timeout) || timeout < 1 || timeout > 60_000)
409
+ throw new Error('requestTimeoutMs must be an integer between 1 and 60000')
410
+ const policy = launchPolicy(options.permissions)
411
+ if (
412
+ options.args?.some(
413
+ (argument) => typeof argument !== 'string' || argument.startsWith('--bun-native-')
414
+ )
415
+ )
416
+ throw new TypeError(
417
+ 'Native service arguments are reserved for the explicit permissions option'
418
+ )
419
+ const host = new NativeHost(options, policy)
420
+ try {
421
+ await host.request({ type: 'hello', protocol: PROTOCOL_VERSION })
422
+ if (!host.ready) throw new ProtocolError('Host acknowledged hello before protocol readiness')
423
+ host.phase = 'running'
424
+ return host
425
+ } catch (error) {
426
+ host.fail(error)
427
+ await host.finished
428
+ throw host.failure
429
+ }
430
+ }
431
+
432
+ /** Whether the transport is still open (false once it failed or shut down). */
433
+ get running(): boolean {
434
+ return this.phase === 'running'
435
+ }
436
+
437
+ get limits(): Readonly<Limits> {
438
+ if (!this.negotiatedLimits) throw new ProtocolError('Native limits are not negotiated')
439
+ return this.negotiatedLimits
440
+ }
441
+
442
+ get servicePolicy(): Readonly<ServicePolicy> {
443
+ if (!this.negotiatedServicePolicy)
444
+ throw new ProtocolError('Native service policy is not negotiated')
445
+ return this.negotiatedServicePolicy
446
+ }
447
+
448
+ get capabilities(): readonly Capability[] {
449
+ if (!this.negotiatedCapabilities)
450
+ throw new ProtocolError('Native capabilities are not negotiated')
451
+ return this.negotiatedCapabilities
452
+ }
453
+
454
+ /** Resolves after the native UI thread acknowledges the operation. */
455
+ send(
456
+ command: Extract<WindowCommand, { type: 'open' }>
457
+ ): Promise<Extract<HostResponse, { type: 'opened' }>>
458
+ send(
459
+ command: Extract<WindowCommand, { type: 'batch' }>
460
+ ): Promise<Extract<HostResponse, { type: 'applied' }>>
461
+ send(
462
+ command: Extract<WindowCommand, { type: 'close' }>
463
+ ): Promise<Extract<HostResponse, { type: 'ack' }>>
464
+ send(
465
+ command: Extract<WindowCommand, { type: 'display_snapshot' }>
466
+ ): Promise<Extract<HostResponse, { type: 'display_snapshot' }>>
467
+ send(
468
+ command: Extract<WindowCommand, { type: 'clipboard_read' }>
469
+ ): Promise<Extract<HostResponse, { type: 'clipboard_text' }>>
470
+ send(
471
+ command: Extract<WindowCommand, { type: 'clipboard_write' }>
472
+ ): Promise<Extract<HostResponse, { type: 'clipboard_written' }>>
473
+ send(
474
+ command: Extract<WindowCommand, { type: 'register_global_shortcut' }>
475
+ ): Promise<Extract<HostResponse, { type: 'global_shortcut_registered' }>>
476
+ send(
477
+ command: Extract<WindowCommand, { type: 'unregister_global_shortcut' }>
478
+ ): Promise<Extract<HostResponse, { type: 'global_shortcut_unregistered' }>>
479
+ send(
480
+ command: Extract<WindowCommand, { type: 'post_notification' }>
481
+ ): Promise<Extract<HostResponse, { type: 'notification_posted' }>>
482
+ send(
483
+ command: Extract<WindowCommand, { type: 'withdraw_notification' }>
484
+ ): Promise<Extract<HostResponse, { type: 'notification_withdrawn' }>>
485
+ send(
486
+ command: Extract<WindowCommand, { type: 'open_file_grant' }>
487
+ ): Promise<Extract<HostResponse, { type: 'file_grant_opened' }>>
488
+ send(
489
+ command: Extract<WindowCommand, { type: 'close_file_grant' }>
490
+ ): Promise<Extract<HostResponse, { type: 'file_grant_closed' }>>
491
+ send(
492
+ command: Extract<WindowCommand, { type: 'file_request' }>
493
+ ): Promise<Extract<HostResponse, { type: 'file_result' }>>
494
+ send(
495
+ command: Extract<WindowCommand, { type: 'spawn_process' }>
496
+ ): Promise<Extract<HostResponse, { type: 'process_spawned' }>>
497
+ send(
498
+ command: Extract<WindowCommand, { type: 'release_process' }>
499
+ ): Promise<Extract<HostResponse, { type: 'process_released' }>>
500
+ send(
501
+ command: Extract<
502
+ WindowCommand,
503
+ {
504
+ type:
505
+ | 'write_process'
506
+ | 'close_process_input'
507
+ | 'resize_process'
508
+ | 'signal_process'
509
+ | 'acknowledge_process_output'
510
+ }
511
+ >
512
+ ): Promise<Extract<HostResponse, { type: 'process_updated' }>>
513
+ send(
514
+ command: Extract<WindowCommand, { type: 'show_file_dialog' }>
515
+ ): Promise<Extract<HostResponse, { type: 'file_dialog_opened' }>>
516
+ send(
517
+ command: Extract<WindowCommand, { type: 'close_file_dialog' }>
518
+ ): Promise<Extract<HostResponse, { type: 'file_dialog_closed' }>>
519
+ send(
520
+ command: Extract<WindowCommand, { type: 'show_message_dialog' }>
521
+ ): Promise<Extract<HostResponse, { type: 'message_dialog_opened' }>>
522
+ send(
523
+ command: Extract<WindowCommand, { type: 'close_message_dialog' }>
524
+ ): Promise<Extract<HostResponse, { type: 'message_dialog_closed' }>>
525
+ send(
526
+ command: Extract<WindowCommand, { type: 'show_context_menu' }>
527
+ ): Promise<Extract<HostResponse, { type: 'context_menu_opened' }>>
528
+ send(
529
+ command: Extract<WindowCommand, { type: 'close_context_menu' }>
530
+ ): Promise<Extract<HostResponse, { type: 'context_menu_closed' }>>
531
+ send(
532
+ command: Extract<WindowCommand, { type: 'register_drop_target' }>
533
+ ): Promise<Extract<HostResponse, { type: 'drop_target_registered' }>>
534
+ send(
535
+ command: Extract<WindowCommand, { type: 'unregister_drop_target' }>
536
+ ): Promise<Extract<HostResponse, { type: 'drop_target_unregistered' }>>
537
+ send(
538
+ command: Extract<WindowCommand, { type: 'resolve_dropped_item' }>
539
+ ): Promise<Extract<HostResponse, { type: 'dropped_item_resolved' }>>
540
+ send(
541
+ command: Extract<WindowCommand, { type: 'set_app_menu' }>
542
+ ): Promise<Extract<HostResponse, { type: 'app_menu_set' }>>
543
+ send(command: WindowCommand): Promise<HostResponse>
544
+ send(command: WindowCommand): Promise<HostResponse> {
545
+ if (this.phase !== 'running') return Promise.reject(this.closedError())
546
+ return this.request(command)
547
+ }
548
+
549
+ async shutdown(): Promise<void> {
550
+ if (this.phase === 'closed' || this.phase === 'stopping') {
551
+ await this.finished
552
+ if (this.failure) throw this.failure
553
+ return
554
+ }
555
+ this.phase = 'stopping'
556
+ try {
557
+ await this.request({ type: 'shutdown' })
558
+ this.process.stdin.end()
559
+ const deadline = setTimeout(
560
+ () => this.fail(new Error('Native host did not exit')),
561
+ this.timeout
562
+ )
563
+ try {
564
+ await this.finished
565
+ } finally {
566
+ clearTimeout(deadline)
567
+ }
568
+ if (this.failure) throw this.failure
569
+ } catch (error) {
570
+ this.fail(error)
571
+ await this.finished
572
+ throw this.failure
573
+ }
574
+ }
575
+
576
+ [Symbol.asyncIterator](): AsyncIterator<HostEvent> {
577
+ if (this.iteratorTaken) throw new Error('Native events have exactly one consumer')
578
+ this.iteratorTaken = true
579
+ return {
580
+ next: () => {
581
+ if (this.failure) return Promise.reject(this.failure)
582
+ const event = this.events.shift()
583
+ if (event) {
584
+ this.taken += 1
585
+ return Promise.resolve({ done: false, value: event })
586
+ }
587
+ if (this.phase === 'closed') return Promise.resolve({ done: true, value: undefined })
588
+ if (this.waiting)
589
+ return Promise.reject(new Error('Concurrent event reads are not supported'))
590
+ return new Promise<IteratorResult<HostEvent>>((resolve, reject) => {
591
+ this.waiting = { resolve, reject }
592
+ })
593
+ },
594
+ return: async () => {
595
+ await this.shutdown()
596
+ return { done: true, value: undefined }
597
+ },
598
+ }
599
+ }
600
+
601
+ private request(input: WithoutId<Command>): Promise<HostResponse> {
602
+ if (this.phase === 'closed') return Promise.reject(this.closedError())
603
+ if (this.pending.size >= MAX_PENDING_COMMANDS)
604
+ return Promise.reject(new Error('Native command limit exceeded'))
605
+ let command: Command
606
+ let frame: Uint8Array
607
+ try {
608
+ command = parseCommand({ ...input, id: this.nextId++ })
609
+ frame = encodeFrame(command)
610
+ if (this.negotiatedLimits) {
611
+ if (frame.byteLength - 1 > this.negotiatedLimits.max_frame_bytes)
612
+ throw new ProtocolError('Command exceeds the negotiated frame limit')
613
+ if (
614
+ command.type === 'batch' &&
615
+ command.operations.length > this.negotiatedLimits.max_operations
616
+ )
617
+ throw new ProtocolError('Command exceeds the negotiated batch limit')
618
+ if (
619
+ command.type === 'clipboard_write' &&
620
+ Buffer.byteLength(command.text, 'utf8') > this.negotiatedLimits.max_clipboard_bytes
621
+ )
622
+ throw new ProtocolError('Clipboard write exceeds the negotiated text limit')
623
+ const chunk = this.negotiatedLimits.max_file_chunk_bytes
624
+ if (
625
+ command.type === 'file_request' &&
626
+ ((command.operation.kind === 'read' && command.operation.length > chunk) ||
627
+ (command.operation.kind === 'write' &&
628
+ Buffer.from(command.operation.data, 'base64').byteLength > chunk))
629
+ )
630
+ throw new ProtocolError('File request exceeds the negotiated chunk limit')
631
+ }
632
+ } catch (error) {
633
+ return Promise.reject(error)
634
+ }
635
+ const write: RequestWrite = { queuedAt: performance.now() }
636
+ const result = new Promise<HostResponse>((resolve, reject) => {
637
+ const timer = setTimeout(
638
+ () =>
639
+ this.fail(
640
+ new Error(
641
+ `Native ${command.type} request ${command.id} timed out (${describeWrite(write)})`
642
+ )
643
+ ),
644
+ this.timeout
645
+ )
646
+ this.pending.set(command.id, {
647
+ command,
648
+ resolve,
649
+ reject,
650
+ timer,
651
+ })
652
+ })
653
+ this.startupTrace?.request('request_queued', command, this.process.pid)
654
+ // FileSink buffering is bounded by the pending-command cap and frame limit.
655
+ this.writeTail = this.writeTail
656
+ .then(async () => {
657
+ if (this.phase === 'closed') return
658
+ this.startupTrace?.request('stdin_write_enter', command, this.process.pid)
659
+ this.process.stdin.write(frame)
660
+ write.writtenAt = performance.now()
661
+ this.startupTrace?.request('stdin_write_returned', command, this.process.pid)
662
+ this.startupTrace?.request('stdin_flush_enter', command, this.process.pid)
663
+ await this.process.stdin.flush()
664
+ write.flushedAt = performance.now()
665
+ this.startupTrace?.request('stdin_flushed', command, this.process.pid)
666
+ })
667
+ .catch((error: unknown) => this.fail(error))
668
+ return result
669
+ }
670
+
671
+ private async readStdout(): Promise<void> {
672
+ const decoder = new FrameDecoder(MAX_EVENT_FRAME_BYTES)
673
+ try {
674
+ for (;;) {
675
+ const { done, value } = await this.stdout.read()
676
+ if (done) break
677
+ for (const raw of decoder.push(value)) this.receive(parseEvent(raw))
678
+ if (this.events.length >= MAX_QUEUED_EVENTS) await this.drainEvents()
679
+ }
680
+ decoder.finish()
681
+ if (
682
+ this.phase !== 'stopping' &&
683
+ this.phase !== 'closed' &&
684
+ !(await this.exitsWithinTimeout())
685
+ )
686
+ throw new ProtocolError('Native output closed before shutdown')
687
+ } finally {
688
+ this.stdout.releaseLock()
689
+ }
690
+ }
691
+
692
+ /**
693
+ * One pipe read can coalesce more frames than the cap while the consumer is
694
+ * keeping up. Over the cap, reading pauses for macrotask turns while the
695
+ * consumer takes events, and fails only a turn in which it took none, so
696
+ * queued events stay bounded by the cap plus one read.
697
+ */
698
+ private async drainEvents(): Promise<void> {
699
+ while (this.events.length >= MAX_QUEUED_EVENTS) {
700
+ const taken = this.taken
701
+ await new Promise<void>((resolve) => setImmediate(resolve))
702
+ if (this.phase === 'closed') return
703
+ if (this.taken === taken) throw new ProtocolError('Native event limit exceeded')
704
+ }
705
+ }
706
+
707
+ private async readStderr(): Promise<void> {
708
+ const decoder = new TextDecoder()
709
+ try {
710
+ for (;;) {
711
+ const { done, value } = await this.stderr.read()
712
+ if (done) break
713
+ this.appendStderr(decoder.decode(value, { stream: true }))
714
+ }
715
+ this.appendStderr(decoder.decode())
716
+ } finally {
717
+ this.stderr.releaseLock()
718
+ }
719
+ }
720
+
721
+ private appendStderr(text: string): void {
722
+ const tail = this.stderrTail + text
723
+ // Keep the first complete panic header and message line: a long backtrace
724
+ // can push them out of the bounded tail before the host exits.
725
+ if (this.stderrPanic === null) {
726
+ const panic = /(^|\n)([^\n]*panicked at [^\n]*\n[^\n]*)\n/.exec(tail)
727
+ if (panic && (panic[1] || !this.stderrClipped)) this.stderrPanic = panic[2]!
728
+ }
729
+ if (tail.length > 4_096) this.stderrClipped = true
730
+ this.stderrTail = tail.slice(-4_096)
731
+ }
732
+
733
+ /**
734
+ * Unexpected EOF usually precedes the exit status of a crashed host. Wait for
735
+ * that status, bounded, so the failure can carry its exit diagnostic.
736
+ */
737
+ private async exitsWithinTimeout(): Promise<boolean> {
738
+ let timer: ReturnType<typeof setTimeout> | undefined
739
+ try {
740
+ return await Promise.race([
741
+ this.exited.then(() => true),
742
+ new Promise<false>((resolve) => {
743
+ timer = setTimeout(() => resolve(false), this.timeout)
744
+ }),
745
+ ])
746
+ } finally {
747
+ clearTimeout(timer)
748
+ }
749
+ }
750
+
751
+ private exitError(status: number): NativeHostExitError {
752
+ const phase = this.phase === 'closed' ? 'running' : this.phase
753
+ return new NativeHostExitError(
754
+ exitDiagnostic({
755
+ exitCode: this.process.exitCode,
756
+ signal: this.process.signalCode === null ? null : String(this.process.signalCode),
757
+ phase,
758
+ pendingRequests: this.pending.size,
759
+ stderr: this.stderrTail,
760
+ stderrClipped: this.stderrClipped,
761
+ panicReport: this.stderrPanic,
762
+ environment: process.env,
763
+ }),
764
+ status
765
+ )
766
+ }
767
+
768
+ private cancelOutput(): void {
769
+ // The host can exit while another process still holds inherited pipe ends.
770
+ // Cancel our reads directly; killing the already-exited host cannot close them.
771
+ void this.stdout.cancel().catch(() => {})
772
+ void this.stderr.cancel().catch(() => {})
773
+ }
774
+
775
+ private async watch(): Promise<void> {
776
+ const streams = [this.readStdout(), this.readStderr()]
777
+ let drainDeadline: ReturnType<typeof setTimeout> | undefined
778
+ const exit = this.exited.then((code) => {
779
+ // Allow buffered acknowledgements/diagnostics to drain, but bound EOF wait.
780
+ drainDeadline = setTimeout(
781
+ () => this.fail(new Error('Native output remained open after host exit')),
782
+ this.timeout
783
+ )
784
+ return code
785
+ })
786
+ try {
787
+ const [code] = await Promise.all([exit, ...streams])
788
+ if (code !== 0 || this.phase !== 'stopping' || this.pending.size) throw this.exitError(code)
789
+ this.phase = 'closed'
790
+ this.waiting?.resolve({ done: true, value: undefined })
791
+ this.waiting = undefined
792
+ } catch (error) {
793
+ this.fail(error)
794
+ await this.exited
795
+ } finally {
796
+ clearTimeout(drainDeadline)
797
+ this.cancelOutput()
798
+ await Promise.allSettled(streams)
799
+ this.startupTrace?.finished(
800
+ this.process.exitCode,
801
+ this.stderrTail,
802
+ this.stderrClipped,
803
+ this.process.pid
804
+ )
805
+ }
806
+ }
807
+
808
+ private receive(event: NativeEvent): void {
809
+ if (this.phase === 'closed') return
810
+ this.startupTrace?.response(event, this.process.pid)
811
+ if (
812
+ event.type === 'clipboard_text' &&
813
+ event.result.kind === 'text' &&
814
+ this.negotiatedLimits &&
815
+ Buffer.byteLength(event.result.text, 'utf8') > this.negotiatedLimits.max_clipboard_bytes
816
+ )
817
+ throw new ProtocolError('Clipboard response exceeds the negotiated text limit')
818
+ if (
819
+ event.type === 'file_result' &&
820
+ event.result.kind === 'read' &&
821
+ this.negotiatedLimits &&
822
+ Buffer.from(event.result.data, 'base64').byteLength >
823
+ this.negotiatedLimits.max_file_chunk_bytes
824
+ )
825
+ throw new ProtocolError('File read exceeds the negotiated chunk limit')
826
+ if (event.type === 'ready') {
827
+ if (this.ready || this.phase !== 'starting') throw new ProtocolError('Unexpected ready event')
828
+ if (REQUIRED_CAPABILITIES.some((capability) => !event.capabilities.includes(capability)))
829
+ throw new ProtocolError(
830
+ 'Native host lacks required protocol capabilities: ' + REQUIRED_CAPABILITIES.join(', ')
831
+ )
832
+ this.negotiatedLimits = Object.freeze(event.limits)
833
+ if (!samePolicy(event.service_policy, this.requestedPolicy))
834
+ throw new ProtocolError(
835
+ 'Native service policy does not match the explicit launch permissions'
836
+ )
837
+ this.negotiatedServicePolicy = this.requestedPolicy
838
+ this.negotiatedCapabilities = Object.freeze(event.capabilities)
839
+ this.ready = true
840
+ return
841
+ }
842
+ if (
843
+ event.type === 'ack' ||
844
+ event.type === 'opened' ||
845
+ event.type === 'applied' ||
846
+ event.type === 'display_snapshot' ||
847
+ event.type === 'clipboard_text' ||
848
+ event.type === 'clipboard_written' ||
849
+ event.type === 'global_shortcut_registered' ||
850
+ event.type === 'global_shortcut_unregistered' ||
851
+ event.type === 'notification_posted' ||
852
+ event.type === 'notification_withdrawn' ||
853
+ event.type === 'file_grant_opened' ||
854
+ event.type === 'file_grant_closed' ||
855
+ event.type === 'file_result' ||
856
+ event.type === 'process_spawned' ||
857
+ event.type === 'process_updated' ||
858
+ event.type === 'process_released' ||
859
+ event.type === 'file_dialog_opened' ||
860
+ event.type === 'file_dialog_closed' ||
861
+ event.type === 'message_dialog_opened' ||
862
+ event.type === 'message_dialog_closed' ||
863
+ event.type === 'context_menu_opened' ||
864
+ event.type === 'context_menu_closed' ||
865
+ event.type === 'drop_target_registered' ||
866
+ event.type === 'drop_target_unregistered' ||
867
+ event.type === 'dropped_item_resolved' ||
868
+ event.type === 'app_menu_set' ||
869
+ event.type === 'error'
870
+ ) {
871
+ if (event.type === 'error' && event.id === undefined)
872
+ throw new ProtocolError(`Native ${event.code}: ${event.message}`)
873
+ const requestId = event.id
874
+ const request = requestId === undefined ? undefined : this.pending.get(requestId)
875
+ if (!request || requestId === undefined) throw new ProtocolError('Unknown native response id')
876
+ clearTimeout(request.timer)
877
+ this.pending.delete(requestId)
878
+ if (event.type === 'error')
879
+ request.reject(new NativeCommandError(event.code, requestId, event.message))
880
+ else if (!this.matchesResponse(request.command, event)) {
881
+ const error = new ProtocolError(
882
+ 'Native response does not match its request, owner or revision'
883
+ )
884
+ request.reject(error)
885
+ throw error
886
+ } else request.resolve(event)
887
+ return
888
+ }
889
+ if (!this.ready) throw new ProtocolError('Native input arrived before protocol readiness')
890
+ if (event.sequence !== this.sequence + 1)
891
+ throw new ProtocolError('Native event sequence is out of order')
892
+ this.sequence = event.sequence
893
+ if (this.waiting) {
894
+ this.waiting.resolve({ done: false, value: event })
895
+ this.waiting = undefined
896
+ } else {
897
+ this.events.push(event)
898
+ }
899
+ }
900
+
901
+ private matchesResponse(command: Command, response: HostResponse): boolean {
902
+ switch (command.type) {
903
+ case 'display_snapshot':
904
+ return response.type === 'display_snapshot' && response.window_id === command.window_id
905
+ case 'clipboard_read':
906
+ return response.type === 'clipboard_text' && response.window_id === command.window_id
907
+ case 'clipboard_write':
908
+ return response.type === 'clipboard_written' && response.window_id === command.window_id
909
+ case 'register_global_shortcut':
910
+ return (
911
+ response.type === 'global_shortcut_registered' &&
912
+ response.window_id === command.window_id &&
913
+ response.shortcut_id === command.shortcut_id
914
+ )
915
+ case 'unregister_global_shortcut':
916
+ return (
917
+ response.type === 'global_shortcut_unregistered' &&
918
+ response.window_id === command.window_id &&
919
+ response.shortcut_id === command.shortcut_id
920
+ )
921
+ case 'post_notification':
922
+ return (
923
+ response.type === 'notification_posted' &&
924
+ response.window_id === command.window_id &&
925
+ response.notification_id === command.notification_id
926
+ )
927
+ case 'withdraw_notification':
928
+ return (
929
+ response.type === 'notification_withdrawn' &&
930
+ response.window_id === command.window_id &&
931
+ response.notification_id === command.notification_id
932
+ )
933
+ case 'open_file_grant':
934
+ case 'close_file_grant':
935
+ return (
936
+ response.type ===
937
+ (command.type === 'open_file_grant' ? 'file_grant_opened' : 'file_grant_closed') &&
938
+ response.window_id === command.window_id &&
939
+ response.grant_id === command.grant_id
940
+ )
941
+ case 'file_request':
942
+ return (
943
+ response.type === 'file_result' &&
944
+ response.window_id === command.window_id &&
945
+ response.grant_id === command.grant_id &&
946
+ fileResultAnswers(command.operation, response.result)
947
+ )
948
+ case 'spawn_process':
949
+ case 'write_process':
950
+ case 'close_process_input':
951
+ case 'resize_process':
952
+ case 'signal_process':
953
+ case 'acknowledge_process_output':
954
+ case 'release_process':
955
+ return (
956
+ response.type ===
957
+ (command.type === 'spawn_process'
958
+ ? 'process_spawned'
959
+ : command.type === 'release_process'
960
+ ? 'process_released'
961
+ : 'process_updated') &&
962
+ response.window_id === command.window_id &&
963
+ response.process_id === command.process_id
964
+ )
965
+ case 'show_file_dialog':
966
+ return (
967
+ response.type === 'file_dialog_opened' &&
968
+ response.window_id === command.window_id &&
969
+ response.dialog_id === command.dialog_id
970
+ )
971
+ case 'close_file_dialog':
972
+ return (
973
+ response.type === 'file_dialog_closed' &&
974
+ response.window_id === command.window_id &&
975
+ response.dialog_id === command.dialog_id
976
+ )
977
+ case 'show_message_dialog':
978
+ return (
979
+ response.type === 'message_dialog_opened' &&
980
+ response.window_id === command.window_id &&
981
+ response.dialog_id === command.dialog_id
982
+ )
983
+ case 'close_message_dialog':
984
+ return (
985
+ response.type === 'message_dialog_closed' &&
986
+ response.window_id === command.window_id &&
987
+ response.dialog_id === command.dialog_id
988
+ )
989
+ case 'show_context_menu':
990
+ case 'close_context_menu':
991
+ return (
992
+ response.type ===
993
+ (command.type === 'show_context_menu'
994
+ ? 'context_menu_opened'
995
+ : 'context_menu_closed') &&
996
+ response.window_id === command.window_id &&
997
+ response.menu_id === command.menu_id
998
+ )
999
+ case 'register_drop_target':
1000
+ case 'unregister_drop_target':
1001
+ return (
1002
+ response.type ===
1003
+ (command.type === 'register_drop_target'
1004
+ ? 'drop_target_registered'
1005
+ : 'drop_target_unregistered') &&
1006
+ response.window_id === command.window_id &&
1007
+ response.target_id === command.target_id
1008
+ )
1009
+ case 'resolve_dropped_item':
1010
+ return (
1011
+ response.type === 'dropped_item_resolved' &&
1012
+ response.window_id === command.window_id &&
1013
+ response.target_id === command.target_id &&
1014
+ response.drop_id === command.drop_id &&
1015
+ response.item === command.item &&
1016
+ response.root === command.root
1017
+ )
1018
+ case 'set_app_menu':
1019
+ return response.type === 'app_menu_set' && response.menu_id === command.menu_id
1020
+ case 'open':
1021
+ return (
1022
+ response.type === 'opened' &&
1023
+ response.window_id === command.window_id &&
1024
+ response.root_id === command.root_id &&
1025
+ response.tree_revision === 0
1026
+ )
1027
+ case 'batch':
1028
+ return (
1029
+ response.type === 'applied' &&
1030
+ response.window_id === command.window_id &&
1031
+ response.tree_revision === command.base_revision + 1
1032
+ )
1033
+ case 'hello':
1034
+ case 'close':
1035
+ case 'shutdown':
1036
+ return response.type === 'ack'
1037
+ }
1038
+ }
1039
+
1040
+ private closedError(): Error {
1041
+ return this.failure ?? new Error('Native host is not running')
1042
+ }
1043
+
1044
+ private fail(cause: unknown): void {
1045
+ if (this.failure) return
1046
+ this.failure = cause instanceof Error ? cause : new Error(String(cause))
1047
+ this.startupTrace?.failure(this.failure, this.stderrTail, this.stderrClipped, this.process.pid)
1048
+ this.phase = 'closed'
1049
+ for (const request of this.pending.values()) {
1050
+ clearTimeout(request.timer)
1051
+ request.reject(this.failure)
1052
+ }
1053
+ this.pending.clear()
1054
+ this.events.length = 0
1055
+ this.waiting?.reject(this.failure)
1056
+ this.waiting = undefined
1057
+ this.cancelOutput()
1058
+ this.process.kill('SIGKILL')
1059
+ }
1060
+ }