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