@bun-native/protocol 0.0.0-stage → 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/src/files.ts ADDED
@@ -0,0 +1,281 @@
1
+ import { ProtocolError } from './framing.ts'
2
+ import { integer, keys, object, string } from './values.ts'
3
+
4
+ export const MAX_FILESYSTEM_ROOTS = 8
5
+ export const MAX_FILE_GRANTS = 32
6
+ const MAX_FILE_PATH_BYTES = 1_024
7
+ const MAX_FILE_PATH_COMPONENTS = 32
8
+ const MAX_FILE_NAME_BYTES = 255
9
+ const MAX_ROOT_PATH_BYTES = 4_096
10
+ export const MAX_FILE_CHUNK_BYTES = 16_384
11
+ export const MAX_DIRECTORY_ENTRIES = 64
12
+ const MAX_FILE_CHUNK_BASE64 = Math.ceil(MAX_FILE_CHUNK_BYTES / 3) * 4
13
+ const FILESYSTEM_ROOT_ARGUMENT = '--bun-native-filesystem-root='
14
+ const encoder = new TextEncoder()
15
+
16
+ export const FILE_ACCESS = Object.freeze(['read', 'read_write'] as const)
17
+ export type FileAccess = (typeof FILE_ACCESS)[number]
18
+
19
+ export interface FilesystemRoot {
20
+ id: string
21
+ path: string
22
+ access: FileAccess
23
+ }
24
+
25
+ export type WriteMode = 'create' | 'truncate' | 'append'
26
+ export type FileKind = 'file' | 'directory' | 'symlink' | 'other'
27
+
28
+ export type FileOperation =
29
+ | { kind: 'stat' }
30
+ | { kind: 'list'; after?: string }
31
+ | { kind: 'read'; offset: number; length: number }
32
+ | { kind: 'write'; mode: WriteMode; data: string }
33
+ | { kind: 'create_directory' }
34
+ | { kind: 'remove' }
35
+
36
+ export interface DirectoryEntry {
37
+ name: string
38
+ kind: FileKind
39
+ }
40
+
41
+ export type FileResult =
42
+ | { kind: 'stat'; file_kind: FileKind; size: number; modified_ms: number }
43
+ | { kind: 'list'; entries: DirectoryEntry[]; complete: boolean; omitted: number }
44
+ | { kind: 'read'; data: string; end_of_file: boolean }
45
+ | { kind: 'written'; bytes: number }
46
+ | { kind: 'done' }
47
+
48
+ export function parseRootId(value: unknown): string {
49
+ if (typeof value !== 'string' || !/^[a-z][a-z0-9_-]{0,31}$/.test(value))
50
+ throw new ProtocolError(
51
+ 'Filesystem root ID must be 1-32 lowercase ASCII letters, digits, "-" or "_", starting with a letter'
52
+ )
53
+ return value
54
+ }
55
+
56
+ const normalized = (names: string[]) =>
57
+ names.every((name) => name !== '' && name !== '.' && name !== '..')
58
+
59
+ /** `/` or `/`-joined names. */
60
+ function isPosixRootPath(path: string): boolean {
61
+ return path === '/' || (path.startsWith('/') && normalized(path.slice(1).split('/')))
62
+ }
63
+
64
+ /** `X:\` alone or followed by `\`-joined names without `/` or `:` (no UNC or `\\?\`). */
65
+ function isWindowsRootPath(path: string): boolean {
66
+ if (!/^[A-Za-z]:\\/.test(path)) return false
67
+ const rest = path.slice(3)
68
+ return rest === '' || (!/[/:]/.test(rest) && normalized(rest.split('\\')))
69
+ }
70
+
71
+ /**
72
+ * An absolute normalized POSIX path or Windows drive path. The native host
73
+ * accepts only its own platform's form.
74
+ */
75
+ export function parseRootPath(value: unknown): string {
76
+ const path = string(value, 'filesystem root path', MAX_ROOT_PATH_BYTES)
77
+ if (/\p{Cc}/u.test(path) || !(isPosixRootPath(path) || isWindowsRootPath(path)))
78
+ throw new ProtocolError(
79
+ 'Filesystem root path must be absolute and normalized, without control characters'
80
+ )
81
+ return path
82
+ }
83
+
84
+ export function parseFileAccess(value: unknown): FileAccess {
85
+ if (!FILE_ACCESS.includes(value as FileAccess))
86
+ throw new ProtocolError('File access must be "read" or "read_write"')
87
+ return value as FileAccess
88
+ }
89
+
90
+ function root(value: unknown): FilesystemRoot {
91
+ const data = object(value)
92
+ keys(data, ['id', 'path', 'access'])
93
+ return {
94
+ id: parseRootId(data['id']),
95
+ path: parseRootPath(data['path']),
96
+ access: parseFileAccess(data['access']),
97
+ }
98
+ }
99
+
100
+ export function parseFilesystemRoots(value: unknown): FilesystemRoot[] {
101
+ if (!Array.isArray(value) || value.length > MAX_FILESYSTEM_ROOTS)
102
+ throw new ProtocolError('At most ' + MAX_FILESYSTEM_ROOTS + ' filesystem roots may be granted')
103
+ const roots = value.map(root)
104
+ if (new Set(roots.map((item) => item.id)).size !== roots.length)
105
+ throw new ProtocolError('Filesystem root IDs must be unique')
106
+ return roots
107
+ }
108
+
109
+ /** The reserved native launch argument for one validated root. */
110
+ export function filesystemRootArgument(value: FilesystemRoot): string {
111
+ const item = root(value)
112
+ return FILESYSTEM_ROOT_ARGUMENT + item.access + ':' + item.id + ':' + item.path
113
+ }
114
+
115
+ /** Recover the launch roots from native arguments, in order. */
116
+ export function filesystemRootsFromArguments(argv: readonly string[]): FilesystemRoot[] {
117
+ return parseFilesystemRoots(
118
+ argv
119
+ .filter((argument) => argument.startsWith(FILESYSTEM_ROOT_ARGUMENT))
120
+ .map((argument) => {
121
+ const [access, id, ...path] = argument.slice(FILESYSTEM_ROOT_ARGUMENT.length).split(':')
122
+ return { access, id, path: path.join(':') }
123
+ })
124
+ )
125
+ }
126
+
127
+ function parseFileName(value: unknown): string {
128
+ const name = string(value, 'file name', MAX_FILE_NAME_BYTES)
129
+ if (!name || name === '.' || name === '..' || name.includes('/') || /\p{Cc}/u.test(name))
130
+ throw new ProtocolError(
131
+ 'File name must be 1-255 bytes, not "." or "..", without "/" or control characters'
132
+ )
133
+ return name
134
+ }
135
+
136
+ /** Empty (the grant root) or relative names joined by `/`. */
137
+ export function parseFilePath(value: unknown): string {
138
+ const path = string(value, 'file path', MAX_FILE_PATH_BYTES)
139
+ if (!path) return path
140
+ const names = path.split('/')
141
+ if (names.length > MAX_FILE_PATH_COMPONENTS)
142
+ throw new ProtocolError('File path exceeds ' + MAX_FILE_PATH_COMPONENTS + ' components')
143
+ for (const name of names) parseFileName(name)
144
+ return path
145
+ }
146
+
147
+ export function parseChunk(value: unknown): string {
148
+ if (
149
+ typeof value !== 'string' ||
150
+ value.length > MAX_FILE_CHUNK_BASE64 ||
151
+ !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value) ||
152
+ (value.length / 4) * 3 - (value.match(/=*$/)?.[0].length ?? 0) > MAX_FILE_CHUNK_BYTES
153
+ )
154
+ throw new ProtocolError('File data must be padded standard base64 of at most 16384 bytes')
155
+ return value
156
+ }
157
+
158
+ const WRITE_MODES: readonly WriteMode[] = ['create', 'truncate', 'append']
159
+ const FILE_KINDS: readonly FileKind[] = ['file', 'directory', 'symlink', 'other']
160
+
161
+ export function parseFileOperation(value: unknown, path: string): FileOperation {
162
+ const data = object(value)
163
+ const writes = ['write', 'create_directory', 'remove'].includes(data['kind'] as string)
164
+ if (writes && !path)
165
+ throw new ProtocolError('The grant root itself cannot be written, created or removed')
166
+ switch (data['kind']) {
167
+ case 'stat':
168
+ case 'create_directory':
169
+ case 'remove':
170
+ keys(data, ['kind'])
171
+ return { kind: data['kind'] }
172
+ case 'list':
173
+ keys(data, ['kind', 'after'])
174
+ return 'after' in data
175
+ ? { kind: 'list', after: parseFileName(data['after']) }
176
+ : { kind: 'list' }
177
+ case 'read':
178
+ keys(data, ['kind', 'offset', 'length'])
179
+ return {
180
+ kind: 'read',
181
+ offset: integer(
182
+ data['offset'],
183
+ 'file read offset',
184
+ 0,
185
+ Number.MAX_SAFE_INTEGER - MAX_FILE_CHUNK_BYTES
186
+ ),
187
+ length: integer(data['length'], 'file read length', 1, MAX_FILE_CHUNK_BYTES),
188
+ }
189
+ case 'write': {
190
+ keys(data, ['kind', 'mode', 'data'])
191
+ const mode = WRITE_MODES.find((item) => item === data['mode'])
192
+ if (!mode) throw new ProtocolError('Unknown file write mode')
193
+ return { kind: 'write', mode, data: parseChunk(data['data']) }
194
+ }
195
+ default:
196
+ throw new ProtocolError('Unknown file operation')
197
+ }
198
+ }
199
+
200
+ function fileKind(value: unknown): FileKind {
201
+ const kind = FILE_KINDS.find((item) => item === value)
202
+ if (!kind) throw new ProtocolError('Unknown file kind')
203
+ return kind
204
+ }
205
+
206
+ function compareBytes(left: string, right: string): number {
207
+ const a = encoder.encode(left)
208
+ const b = encoder.encode(right)
209
+ for (let index = 0; index < Math.min(a.length, b.length); index++)
210
+ if (a[index] !== b[index]) return a[index]! - b[index]!
211
+ return a.length - b.length
212
+ }
213
+
214
+ export function parseFileResult(value: unknown): FileResult {
215
+ const data = object(value)
216
+ switch (data['kind']) {
217
+ case 'stat':
218
+ keys(data, ['kind', 'file_kind', 'size', 'modified_ms'])
219
+ return {
220
+ kind: 'stat',
221
+ file_kind: fileKind(data['file_kind']),
222
+ size: integer(data['size'], 'file size'),
223
+ modified_ms: integer(
224
+ data['modified_ms'],
225
+ 'file modification time',
226
+ -Number.MAX_SAFE_INTEGER
227
+ ),
228
+ }
229
+ case 'list': {
230
+ keys(data, ['kind', 'entries', 'complete', 'omitted'])
231
+ const entries = data['entries']
232
+ if (!Array.isArray(entries) || entries.length > MAX_DIRECTORY_ENTRIES)
233
+ throw new ProtocolError('Directory page exceeds ' + MAX_DIRECTORY_ENTRIES + ' entries')
234
+ const parsed = entries.map((entry) => {
235
+ const item = object(entry)
236
+ keys(item, ['name', 'kind'])
237
+ return { name: parseFileName(item['name']), kind: fileKind(item['kind']) }
238
+ })
239
+ for (let index = 1; index < parsed.length; index++)
240
+ if (compareBytes(parsed[index - 1]!.name, parsed[index]!.name) >= 0)
241
+ throw new ProtocolError('Directory entries must be unique and sorted by name')
242
+ if (typeof data['complete'] !== 'boolean')
243
+ throw new ProtocolError('Directory completeness must be a boolean')
244
+ return {
245
+ kind: 'list',
246
+ entries: parsed,
247
+ complete: data['complete'],
248
+ omitted: integer(data['omitted'], 'omitted directory entries'),
249
+ }
250
+ }
251
+ case 'read':
252
+ keys(data, ['kind', 'data', 'end_of_file'])
253
+ if (typeof data['end_of_file'] !== 'boolean')
254
+ throw new ProtocolError('File end-of-file flag must be a boolean')
255
+ return { kind: 'read', data: parseChunk(data['data']), end_of_file: data['end_of_file'] }
256
+ case 'written':
257
+ keys(data, ['kind', 'bytes'])
258
+ return {
259
+ kind: 'written',
260
+ bytes: integer(data['bytes'], 'written bytes', 0, MAX_FILE_CHUNK_BYTES),
261
+ }
262
+ case 'done':
263
+ keys(data, ['kind'])
264
+ return { kind: 'done' }
265
+ default:
266
+ throw new ProtocolError('Unknown file result')
267
+ }
268
+ }
269
+
270
+ /** The result shape each operation produces. */
271
+ export function fileResultAnswers(operation: FileOperation, result: FileResult): boolean {
272
+ const expected = {
273
+ stat: 'stat',
274
+ list: 'list',
275
+ read: 'read',
276
+ write: 'written',
277
+ create_directory: 'done',
278
+ remove: 'done',
279
+ }[operation.kind]
280
+ return result.kind === expected
281
+ }
package/src/framing.ts ADDED
@@ -0,0 +1,85 @@
1
+ /** Maximum UTF-8 JSON payload bytes, excluding the newline delimiter. */
2
+ export const MAX_FRAME_BYTES = 65_536
3
+
4
+ export class ProtocolError extends Error {
5
+ override readonly name = 'ProtocolError'
6
+ }
7
+
8
+ const encoder = new TextEncoder()
9
+
10
+ /** Encodes one JSON value. This does not validate a command or event schema. */
11
+ export function encodeFrame(value: unknown): Uint8Array {
12
+ let json: string | undefined
13
+ try {
14
+ json = JSON.stringify(value)
15
+ } catch (error) {
16
+ throw new ProtocolError('Frame is not a JSON value', { cause: error })
17
+ }
18
+ if (json === undefined) throw new ProtocolError('Frame is not a JSON value')
19
+ // Each UTF-16 code unit encodes to at least one UTF-8 byte.
20
+ if (json.length > MAX_FRAME_BYTES) throw new ProtocolError('Frame exceeds byte limit')
21
+ const encoded = encoder.encode(`${json}\n`)
22
+ if (encoded.byteLength - 1 > MAX_FRAME_BYTES) throw new ProtocolError('Frame exceeds byte limit')
23
+ return encoded
24
+ }
25
+
26
+ /**
27
+ * Incremental, bounded JSON-lines decoder for a native process pipe.
28
+ * A protocol error poisons the decoder; the caller must terminate that session.
29
+ * Successful finish closes the decoder and is idempotent.
30
+ */
31
+ export class FrameDecoder {
32
+ private readonly buffer = new Uint8Array(MAX_FRAME_BYTES)
33
+ // Preserve a leading BOM so JSON.parse rejects it, matching Rust serde_json.
34
+ private readonly utf8 = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true })
35
+ private length = 0
36
+ private state: 'open' | 'finished' | 'failed' = 'open'
37
+
38
+ push(chunk: Uint8Array): unknown[] {
39
+ if (this.state !== 'open')
40
+ throw new ProtocolError(
41
+ this.state === 'failed'
42
+ ? 'Decoder is closed after a protocol error'
43
+ : 'Decoder is closed after end of stream'
44
+ )
45
+ const frames: unknown[] = []
46
+ let start = 0
47
+ try {
48
+ while (start < chunk.length) {
49
+ const newline = chunk.indexOf(10, start)
50
+ const end = newline === -1 ? chunk.length : newline
51
+ const size = end - start
52
+ if (this.length + size > MAX_FRAME_BYTES)
53
+ throw new ProtocolError('Frame exceeds byte limit')
54
+ if (newline !== -1 && this.length === 0) {
55
+ // A frame wholly inside this chunk decodes in place, without a copy.
56
+ if (size === 0) throw new ProtocolError('Empty frame')
57
+ frames.push(JSON.parse(this.utf8.decode(chunk.subarray(start, end))) as unknown)
58
+ start = newline + 1
59
+ continue
60
+ }
61
+ this.buffer.set(chunk.subarray(start, end), this.length)
62
+ this.length += size
63
+ if (newline === -1) break
64
+ const json = this.utf8.decode(this.buffer.subarray(0, this.length))
65
+ frames.push(JSON.parse(json) as unknown)
66
+ this.length = 0
67
+ start = newline + 1
68
+ }
69
+ } catch (error) {
70
+ this.state = 'failed'
71
+ if (error instanceof ProtocolError) throw error
72
+ throw new ProtocolError('Invalid UTF-8 or JSON in native frame', { cause: error })
73
+ }
74
+ return frames
75
+ }
76
+
77
+ finish(): void {
78
+ if (this.state === 'failed') throw new ProtocolError('Decoder is closed after a protocol error')
79
+ if (this.length !== 0) {
80
+ this.state = 'failed'
81
+ throw new ProtocolError('Native stream ended inside a frame')
82
+ }
83
+ this.state = 'finished'
84
+ }
85
+ }
package/src/index.ts ADDED
@@ -0,0 +1,150 @@
1
+ export { encodeFrame, FrameDecoder, MAX_FRAME_BYTES, ProtocolError } from './framing.ts'
2
+ export {
3
+ DECORATION_FIELDS,
4
+ MAX_ACCESSIBLE_SET_SIZE,
5
+ MAX_OVERLAY_DEPTH,
6
+ parseElement,
7
+ SPLIT_DIVIDER,
8
+ type Element,
9
+ type OverlayElement,
10
+ type AccessibleLabel,
11
+ type BoxAccessibility,
12
+ type OverlayKeyboard,
13
+ type SplitStyle,
14
+ type Style,
15
+ } from './elements.ts'
16
+ export { MAX_TEXT_BYTES } from './values.ts'
17
+ export {
18
+ FILE_ACCESS,
19
+ fileResultAnswers,
20
+ filesystemRootArgument,
21
+ filesystemRootsFromArguments,
22
+ MAX_DIRECTORY_ENTRIES,
23
+ MAX_FILE_CHUNK_BYTES,
24
+ MAX_FILE_GRANTS,
25
+ MAX_FILESYSTEM_ROOTS,
26
+ parseFilePath,
27
+ parseFilesystemRoots,
28
+ parseRootId,
29
+ type DirectoryEntry,
30
+ type FileAccess,
31
+ type FileKind,
32
+ type FileOperation,
33
+ type FileResult,
34
+ type FilesystemRoot,
35
+ type WriteMode,
36
+ } from './files.ts'
37
+ export {
38
+ MAX_PROCESS_ARGS,
39
+ MAX_PROCESS_ENV,
40
+ MAX_PROCESS_EXECUTABLES,
41
+ MAX_PROCESS_OUTPUT_CREDIT,
42
+ MAX_PROCESSES,
43
+ parseProcessExecutables,
44
+ parseProcessLaunch,
45
+ parseProcessLimits,
46
+ parseProcessLimitsArgument,
47
+ PROCESS_SIGNALS,
48
+ processArguments,
49
+ processLimitsArgument,
50
+ processesFromArguments,
51
+ type ProcessExecutable,
52
+ type ProcessLimits,
53
+ type ProcessExit,
54
+ type ProcessLaunch,
55
+ type ProcessSignal,
56
+ type ProcessStream,
57
+ type TerminalSize,
58
+ } from './processes.ts'
59
+ export {
60
+ APP_MENU_KEYS,
61
+ APP_MENU_NAMED_KEYS,
62
+ APP_MENU_PUNCTUATION_KEYS,
63
+ MAX_APP_MENU_DEPTH,
64
+ MAX_APP_MENU_ENTRIES,
65
+ MAX_APP_MENU_ITEM_ID_BYTES,
66
+ MAX_APP_MENU_LABEL_BYTES,
67
+ MAX_APP_MENUS,
68
+ parseAppMenus,
69
+ parseContextMenuItems,
70
+ type AppMenu,
71
+ type AppMenuEntry,
72
+ type MenuShortcut,
73
+ type MenuShortcutModifiers,
74
+ } from './app-menu.ts'
75
+ export {
76
+ FILE_DIALOG_MODES,
77
+ MAX_DIALOG_FILTER_NAME_BYTES,
78
+ MAX_DIALOG_FILTER_PATTERNS,
79
+ MAX_DIALOG_FILTERS,
80
+ MAX_DIALOG_LABEL_BYTES,
81
+ MAX_DIALOG_NAME_BYTES,
82
+ MAX_DIALOG_PATH_BYTES,
83
+ MAX_DIALOG_PATHS,
84
+ MAX_DIALOG_PATTERN_BYTES,
85
+ MAX_DIALOG_TITLE_BYTES,
86
+ MAX_FILE_DIALOGS,
87
+ parseFileDialogOptions,
88
+ type FileDialogMode,
89
+ type FileDialogOptions,
90
+ type FileDialogResult,
91
+ type FileFilter,
92
+ } from './dialogs.ts'
93
+ export {
94
+ MAX_MESSAGE_DIALOG_BUTTONS,
95
+ MAX_MESSAGE_DIALOG_DETAIL_BYTES,
96
+ MAX_MESSAGE_DIALOG_ID_BYTES,
97
+ MAX_MESSAGE_DIALOG_LABEL_BYTES,
98
+ MAX_MESSAGE_DIALOG_MESSAGE_BYTES,
99
+ MAX_MESSAGE_DIALOG_TITLE_BYTES,
100
+ MAX_MESSAGE_DIALOGS,
101
+ parseMessageDialogOptions,
102
+ type MessageDialogButton,
103
+ type MessageDialogOptions,
104
+ type MessageDialogResult,
105
+ } from './message-dialogs.ts'
106
+ export {
107
+ DRAG_STATES,
108
+ DROP_REJECTIONS,
109
+ MAX_DROP_ITEMS,
110
+ MAX_DROP_NAME_BYTES,
111
+ MAX_DROP_TARGETS,
112
+ type DragState,
113
+ type DroppedItem,
114
+ type DropRejection,
115
+ } from './drops.ts'
116
+ export {
117
+ GLOBAL_SHORTCUT_KEYS,
118
+ MAX_CLIPBOARD_BYTES,
119
+ MAX_GLOBAL_SHORTCUTS,
120
+ MAX_NOTIFICATION_BODY_BYTES,
121
+ MAX_NOTIFICATION_TITLE_BYTES,
122
+ MAX_NOTIFICATIONS,
123
+ NOTIFICATION_DISMISSALS,
124
+ type ClipboardText,
125
+ type DisplaySnapshot,
126
+ type GlobalShortcutKey,
127
+ type NotificationDismissal,
128
+ type ServicePolicy,
129
+ type ShortcutModifiers,
130
+ } from './services.ts'
131
+ export {
132
+ DEFAULT_LIMITS,
133
+ ERROR_CODES,
134
+ MAX_OPERATIONS,
135
+ MAX_SCROLL_OFFSET,
136
+ parseCommand,
137
+ parseEvent,
138
+ PROTOCOL_VERSION,
139
+ REQUIRED_CAPABILITIES,
140
+ type Command,
141
+ type NativeEvent,
142
+ type Operation,
143
+ type Limits,
144
+ type ErrorCode,
145
+ type Capability,
146
+ type KeyModifiers,
147
+ type PointerButton,
148
+ type DismissReason,
149
+ type ResizeSource,
150
+ } from './messages.ts'
@@ -0,0 +1,125 @@
1
+ import { ProtocolError } from './framing.ts'
2
+ import { keys, object, string } from './values.ts'
3
+
4
+ /** Live OS message dialogs per session. Each window owns at most one at a time. */
5
+ export const MAX_MESSAGE_DIALOGS = 16
6
+ export const MAX_MESSAGE_DIALOG_BUTTONS = 4
7
+ export const MAX_MESSAGE_DIALOG_TITLE_BYTES = 256
8
+ export const MAX_MESSAGE_DIALOG_MESSAGE_BYTES = 2_048
9
+ export const MAX_MESSAGE_DIALOG_DETAIL_BYTES = 4_096
10
+ export const MAX_MESSAGE_DIALOG_LABEL_BYTES = 64
11
+ export const MAX_MESSAGE_DIALOG_ID_BYTES = 64
12
+
13
+ export interface MessageDialogButton {
14
+ id: string
15
+ label: string
16
+ }
17
+
18
+ export interface MessageDialogOptions {
19
+ title: string
20
+ message: string
21
+ detail?: string
22
+ buttons: readonly MessageDialogButton[]
23
+ default_button: string
24
+ cancel_button?: string
25
+ dismissible?: boolean
26
+ }
27
+
28
+ export type MessageDialogResult =
29
+ | { kind: 'button'; id: string }
30
+ | { kind: 'dismissed' }
31
+ | { kind: 'failed' }
32
+
33
+ /** Nonblank text; a block may contain line feeds, a line may not. */
34
+ function text(value: unknown, field: string, limit: number, block: boolean): string {
35
+ const result = string(value, field, limit)
36
+ if (!result.trim()) throw new ProtocolError(field + ' must be nonblank')
37
+ if ((block ? /[^\P{Cc}\n]/u : /\p{Cc}/u).test(result))
38
+ throw new ProtocolError(field + ' contains a control character')
39
+ return result
40
+ }
41
+
42
+ function button(value: unknown): MessageDialogButton {
43
+ const data = object(value)
44
+ keys(data, ['id', 'label'])
45
+ return {
46
+ id: text(data['id'], 'message dialog button id', MAX_MESSAGE_DIALOG_ID_BYTES, false),
47
+ label: text(
48
+ data['label'],
49
+ 'message dialog button label',
50
+ MAX_MESSAGE_DIALOG_LABEL_BYTES,
51
+ false
52
+ ),
53
+ }
54
+ }
55
+
56
+ export function parseMessageDialogOptions(value: unknown): MessageDialogOptions {
57
+ const data = object(value)
58
+ keys(data, [
59
+ 'title',
60
+ 'message',
61
+ 'detail',
62
+ 'buttons',
63
+ 'default_button',
64
+ 'cancel_button',
65
+ 'dismissible',
66
+ ])
67
+ const list = data['buttons']
68
+ if (!Array.isArray(list) || list.length < 1 || list.length > MAX_MESSAGE_DIALOG_BUTTONS)
69
+ throw new ProtocolError('A message dialog needs between 1 and 4 buttons')
70
+ const buttons = list.map(button)
71
+ const ids = new Set(buttons.map((entry) => entry.id))
72
+ if (ids.size !== buttons.length)
73
+ throw new ProtocolError('Message dialog button ids must be unique')
74
+ const reference = (field: string) => {
75
+ const name = string(data[field], 'message dialog ' + field, MAX_MESSAGE_DIALOG_ID_BYTES)
76
+ if (!ids.has(name))
77
+ throw new ProtocolError('Message dialog ' + field + ' must name one of its buttons')
78
+ return name
79
+ }
80
+ const options: MessageDialogOptions = {
81
+ title: text(data['title'], 'message dialog title', MAX_MESSAGE_DIALOG_TITLE_BYTES, false),
82
+ message: text(
83
+ data['message'],
84
+ 'message dialog message',
85
+ MAX_MESSAGE_DIALOG_MESSAGE_BYTES,
86
+ true
87
+ ),
88
+ buttons,
89
+ default_button: reference('default_button'),
90
+ }
91
+ if ('detail' in data)
92
+ options.detail = text(
93
+ data['detail'],
94
+ 'message dialog detail',
95
+ MAX_MESSAGE_DIALOG_DETAIL_BYTES,
96
+ true
97
+ )
98
+ if ('cancel_button' in data) options.cancel_button = reference('cancel_button')
99
+ if ('dismissible' in data) {
100
+ if (typeof data['dismissible'] !== 'boolean')
101
+ throw new ProtocolError('Message dialog dismissible must be a boolean')
102
+ if (data['dismissible']) options.dismissible = true
103
+ }
104
+ return options
105
+ }
106
+
107
+ export function parseMessageDialogResult(value: unknown): MessageDialogResult {
108
+ const data = object(value)
109
+ switch (data['kind']) {
110
+ case 'button':
111
+ keys(data, ['kind', 'id'])
112
+ return {
113
+ kind: 'button',
114
+ id: text(data['id'], 'message dialog result id', MAX_MESSAGE_DIALOG_ID_BYTES, false),
115
+ }
116
+ case 'dismissed':
117
+ keys(data, ['kind'])
118
+ return { kind: 'dismissed' }
119
+ case 'failed':
120
+ keys(data, ['kind'])
121
+ return { kind: 'failed' }
122
+ default:
123
+ throw new ProtocolError('Unknown message dialog result')
124
+ }
125
+ }