@bun-native/protocol 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Bunly contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,5 @@
1
- # Temporary Holding Version
1
+ # @bun-native/protocol
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The private native protocol shared by Bun Native packages and hosts.
4
+
5
+ Part of [Bun Native](https://github.com/0xPlayerOne/bun-native), an experimental developer preview. Version 0.33.1 was built from [c56fdd675365](https://github.com/0xPlayerOne/bun-native/tree/c56fdd6753653a10144a4167c1d3dfb3c38a0647) and published from GitHub Actions with npm provenance. Use the Bun revision pinned in that commit's toolchain.json. Start with the [getting-started guide](https://github.com/0xPlayerOne/bun-native/blob/c56fdd6753653a10144a4167c1d3dfb3c38a0647/docs/getting-started.md); `bun-native host install` fetches the matching release host. The license notice is in LICENSE.
@@ -0,0 +1,75 @@
1
+ {
2
+ "schemaVersion": 1,
3
+ "commit": "c56fdd6753653a10144a4167c1d3dfb3c38a0647",
4
+ "tree": "c6e81b3f9d041e11dd0413b5fdd7a50910383854",
5
+ "files": {
6
+ "package.json": {
7
+ "bytes": 2115,
8
+ "sha256": "caf866d56f3cd7530d5735ecbf62512e9e22526bc44c75a4b48a18c26352c0b8"
9
+ },
10
+ "packages/protocol/package.json": {
11
+ "bytes": 129,
12
+ "sha256": "668f52c10a6db801c2d0c68c59998c489b50c0bb79cb7f60298dfbb83923b0ba"
13
+ },
14
+ "packages/protocol/src/app-menu.ts": {
15
+ "bytes": 6687,
16
+ "sha256": "a0346cff0bfe26a1b2d1eaf1470521d5602d59dd54f831b3353be56499a5efc3"
17
+ },
18
+ "packages/protocol/src/dialogs.ts": {
19
+ "bytes": 5261,
20
+ "sha256": "c26aab88e0e6ca9838b8c7dd471165e03f967694576a257dec88cff8343e4175"
21
+ },
22
+ "packages/protocol/src/drops.ts": {
23
+ "bytes": 2029,
24
+ "sha256": "03894f9b2b40f7645e74824de630bebc4d7d7145eafade79739248126826a42b"
25
+ },
26
+ "packages/protocol/src/elements.ts": {
27
+ "bytes": 17017,
28
+ "sha256": "b831c4906806cd8b46aab29983481c85617b84b4bcc6cde515c3c2d8f5f4dfb1"
29
+ },
30
+ "packages/protocol/src/files.ts": {
31
+ "bytes": 10818,
32
+ "sha256": "4fda4c2ebcc489f330b52ff042fe3b005b7b09718799f4b684d06c7728b86730"
33
+ },
34
+ "packages/protocol/src/framing.ts": {
35
+ "bytes": 4321,
36
+ "sha256": "63148dfde50d4e98dafd8e1923db43d290565453a28113cf356ed3e33c4a5d70"
37
+ },
38
+ "packages/protocol/src/index.ts": {
39
+ "bytes": 2940,
40
+ "sha256": "3aa7a5bb90bbcb4c58fa999a6179fcca860b53913984efb857505d90438e79ae"
41
+ },
42
+ "packages/protocol/src/message-dialogs.ts": {
43
+ "bytes": 4229,
44
+ "sha256": "3fe42b3c42ff255761a867c87d0ea69f0404ee9dd64ba57cda9f9573588db210"
45
+ },
46
+ "packages/protocol/src/messages.ts": {
47
+ "bytes": 48950,
48
+ "sha256": "17d92d1b5f08fb04f1dc9e6269291ffc7ff39f3c6ace3c9a6b7b216c6a63c2d3"
49
+ },
50
+ "packages/protocol/src/processes.ts": {
51
+ "bytes": 10019,
52
+ "sha256": "b64ee81b209a4e6ccf0965409c355a1bc3bfa8d813668aec385b50050be4f2b6"
53
+ },
54
+ "packages/protocol/src/services.ts": {
55
+ "bytes": 7933,
56
+ "sha256": "4906b13929e96382fe55c8f94316e5b6629c74a6482ce25c55c621bc449a45e9"
57
+ },
58
+ "packages/protocol/src/values.ts": {
59
+ "bytes": 2365,
60
+ "sha256": "cca3d96f72dde820f154b6e492ea1a94ee33d8af8da44aca6fd1039912de63c1"
61
+ },
62
+ "LICENSE": {
63
+ "bytes": 1075,
64
+ "sha256": "86673fb485f12fa9aaa3941dfccc9f69a7f8322f8141d0d5c73db4323de10ac5"
65
+ }
66
+ },
67
+ "toolchain": {
68
+ "bytes": 3234,
69
+ "sha256": "9f4d9362cb4cc38c0f4e705295789a0cbfec46ffdd007e0541b9441a7c3003a7"
70
+ },
71
+ "dependencyLock": {
72
+ "bytes": 28357,
73
+ "sha256": "65e3dbd01be4a1889367690faf1a9c3f08fb31d22dbd273d9bb9f39dd6baa6fb"
74
+ }
75
+ }
package/package.json CHANGED
@@ -1,6 +1,28 @@
1
1
  {
2
2
  "name": "@bun-native/protocol",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.33.1",
4
+ "type": "module",
5
+ "license": "MIT",
6
+ "files": [
7
+ "src",
8
+ "LICENSE",
9
+ "README.md",
10
+ "bun-native-source.json"
11
+ ],
12
+ "engines": {
13
+ "bun": "1.4.4-canary.1"
14
+ },
15
+ "exports": "./src/index.ts",
16
+ "description": "The private native protocol shared by Bun Native packages and hosts.",
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/0xPlayerOne/bun-native.git",
20
+ "directory": "packages/protocol"
21
+ },
22
+ "homepage": "https://github.com/0xPlayerOne/bun-native#readme",
23
+ "bugs": "https://github.com/0xPlayerOne/bun-native/issues",
24
+ "publishConfig": {
25
+ "access": "public",
26
+ "provenance": true
27
+ }
28
+ }
@@ -0,0 +1,217 @@
1
+ import { ProtocolError } from './framing.ts'
2
+ import { fields, keys, object, string } from './values.ts'
3
+
4
+ /** Top-level menus in one menu bar. */
5
+ const MAX_APP_MENUS = 16
6
+ /** Items, separators and submenus in one menu bar, at every depth. */
7
+ export const MAX_APP_MENU_ENTRIES = 128
8
+ /** A top-level menu is depth 1; submenus nest at most two levels below it. */
9
+ export const MAX_APP_MENU_DEPTH = 3
10
+ const MAX_APP_MENU_LABEL_BYTES = 64
11
+
12
+ const APP_MENU_NAMED_KEYS = Object.freeze([
13
+ 'enter',
14
+ 'escape',
15
+ 'tab',
16
+ 'space',
17
+ 'backspace',
18
+ 'delete',
19
+ 'up',
20
+ 'down',
21
+ 'left',
22
+ 'right',
23
+ 'home',
24
+ 'end',
25
+ 'pageup',
26
+ 'pagedown',
27
+ ] as const)
28
+ const APP_MENU_PUNCTUATION_KEYS = Object.freeze([
29
+ ',',
30
+ '.',
31
+ '/',
32
+ ';',
33
+ "'",
34
+ '[',
35
+ ']',
36
+ '-',
37
+ '=',
38
+ '`',
39
+ '\\',
40
+ ] as const)
41
+
42
+ /** The closed key set of a menu shortcut. */
43
+ export const APP_MENU_KEYS = Object.freeze([
44
+ ...'abcdefghijklmnopqrstuvwxyz0123456789',
45
+ ...Array.from({ length: 12 }, (_, index) => `f${index + 1}`),
46
+ ...APP_MENU_PUNCTUATION_KEYS,
47
+ ...APP_MENU_NAMED_KEYS,
48
+ ]) as readonly string[]
49
+
50
+ export interface MenuShortcutModifiers {
51
+ control: boolean
52
+ alt: boolean
53
+ shift: boolean
54
+ /** Command on macOS. */
55
+ platform: boolean
56
+ }
57
+
58
+ export interface MenuShortcut {
59
+ key: string
60
+ modifiers: MenuShortcutModifiers
61
+ }
62
+
63
+ export type AppMenuEntry =
64
+ | {
65
+ kind: 'item'
66
+ id: string
67
+ label: string
68
+ shortcut?: MenuShortcut
69
+ enabled: boolean
70
+ checked: boolean
71
+ }
72
+ | { kind: 'separator' }
73
+ | { kind: 'submenu'; label: string; enabled: boolean; items: AppMenuEntry[] }
74
+
75
+ export interface AppMenu {
76
+ title: string
77
+ items: AppMenuEntry[]
78
+ }
79
+
80
+ const ITEM_ID = /^[A-Za-z0-9._:-]{1,64}$/
81
+
82
+ /** `[A-Za-z0-9._:-]`, 1-64 bytes: an application identifier, never a native name. */
83
+ export function parseAppMenuItemId(value: unknown): string {
84
+ if (typeof value !== 'string' || !ITEM_ID.test(value))
85
+ throw new ProtocolError(
86
+ "Menu item id must be 1-64 bytes of letters, digits, '.', '_', ':' or '-'"
87
+ )
88
+ return value
89
+ }
90
+
91
+ function label(value: unknown, field: string): string {
92
+ const text = string(value, field, MAX_APP_MENU_LABEL_BYTES)
93
+ if (!text || /\p{Cc}/u.test(text))
94
+ throw new ProtocolError(field + ' must be nonempty and contain no control characters')
95
+ return text
96
+ }
97
+
98
+ function flag(data: Record<string, unknown>, key: string): boolean {
99
+ const value = data[key]
100
+ if (typeof value !== 'boolean') throw new ProtocolError('Menu ' + key + ' must be a boolean')
101
+ return value
102
+ }
103
+
104
+ const isFunctionKey = (key: string) => /^f([1-9]|1[0-2])$/.test(key)
105
+
106
+ const MENU_SHORTCUT_KEYS = fields('key', 'modifiers')
107
+ const MENU_MODIFIER_KEYS = fields('control', 'alt', 'shift', 'platform')
108
+
109
+ function parseMenuShortcut(value: unknown): MenuShortcut {
110
+ const data = object(value)
111
+ keys(data, MENU_SHORTCUT_KEYS)
112
+ const key = data['key']
113
+ if (typeof key !== 'string' || !APP_MENU_KEYS.includes(key))
114
+ throw new ProtocolError('Menu shortcut key is not in the closed key set')
115
+ const raw = object(data['modifiers'])
116
+ keys(raw, MENU_MODIFIER_KEYS)
117
+ const modifiers = {
118
+ control: flag(raw, 'control'),
119
+ alt: flag(raw, 'alt'),
120
+ shift: flag(raw, 'shift'),
121
+ platform: flag(raw, 'platform'),
122
+ }
123
+ if (!isFunctionKey(key) && !modifiers.control && !modifiers.alt && !modifiers.platform)
124
+ throw new ProtocolError('Menu shortcut requires control, alt or platform unless it is f1-f12')
125
+ return { key, modifiers }
126
+ }
127
+
128
+ const shortcutKey = ({ key, modifiers: m }: MenuShortcut) =>
129
+ `${+m.control}${+m.alt}${+m.shift}${+m.platform}${key}`
130
+
131
+ interface Census {
132
+ entries: number
133
+ ids: Set<string>
134
+ shortcuts: Set<string>
135
+ }
136
+
137
+ const ENTRIES_ITEM_KEYS = fields('kind', 'id', 'label', 'shortcut', 'enabled', 'checked')
138
+ const ENTRIES_SEPARATOR_KEYS = fields('kind')
139
+ const ENTRIES_SUBMENU_KEYS = fields('kind', 'label', 'enabled', 'items')
140
+
141
+ function entries(value: unknown, depth: number, census: Census): AppMenuEntry[] {
142
+ if (!Array.isArray(value) || !value.length)
143
+ throw new ProtocolError('A menu or submenu needs at least one entry')
144
+ return value.map((raw): AppMenuEntry => {
145
+ if (++census.entries > MAX_APP_MENU_ENTRIES)
146
+ throw new ProtocolError('A menu bar holds at most 128 entries')
147
+ const data = object(raw)
148
+ switch (data['kind']) {
149
+ case 'item': {
150
+ keys(data, ENTRIES_ITEM_KEYS)
151
+ const id = parseAppMenuItemId(data['id'])
152
+ if (census.ids.has(id))
153
+ throw new ProtocolError('Menu item ids must be unique in a menu bar')
154
+ census.ids.add(id)
155
+ const entry: AppMenuEntry = {
156
+ kind: 'item',
157
+ id,
158
+ label: label(data['label'], 'menu label'),
159
+ enabled: flag(data, 'enabled'),
160
+ checked: flag(data, 'checked'),
161
+ }
162
+ if ('shortcut' in data) {
163
+ const shortcut = parseMenuShortcut(data['shortcut'])
164
+ const identity = shortcutKey(shortcut)
165
+ if (census.shortcuts.has(identity))
166
+ throw new ProtocolError('Menu shortcuts must be unique in a menu bar')
167
+ census.shortcuts.add(identity)
168
+ entry.shortcut = shortcut
169
+ }
170
+ return entry
171
+ }
172
+ case 'separator':
173
+ keys(data, ENTRIES_SEPARATOR_KEYS)
174
+ return { kind: 'separator' }
175
+ case 'submenu': {
176
+ keys(data, ENTRIES_SUBMENU_KEYS)
177
+ const text = label(data['label'], 'menu label')
178
+ const enabled = flag(data, 'enabled')
179
+ if (depth >= MAX_APP_MENU_DEPTH)
180
+ throw new ProtocolError('Submenus nest at most two levels below a top-level menu')
181
+ return {
182
+ kind: 'submenu',
183
+ label: text,
184
+ enabled,
185
+ items: entries(data['items'], depth + 1, census),
186
+ }
187
+ }
188
+ default:
189
+ throw new ProtocolError('Unknown menu entry kind')
190
+ }
191
+ })
192
+ }
193
+
194
+ /**
195
+ * A context menu: the entries of one menu-bar menu, with the same bounds,
196
+ * unique ids and shortcuts, and submenus nested at most two levels deep.
197
+ */
198
+ export function parseContextMenuItems(value: unknown): AppMenuEntry[] {
199
+ return entries(value, 1, { entries: 0, ids: new Set(), shortcuts: new Set() })
200
+ }
201
+
202
+ const APP_MENUS_KEYS = fields('title', 'items')
203
+
204
+ /** An empty bar is valid: it removes the application's menus. */
205
+ export function parseAppMenus(value: unknown): AppMenu[] {
206
+ if (!Array.isArray(value) || value.length > MAX_APP_MENUS)
207
+ throw new ProtocolError('A menu bar holds at most 16 menus')
208
+ const census: Census = { entries: 0, ids: new Set(), shortcuts: new Set() }
209
+ return value.map((raw) => {
210
+ const data = object(raw)
211
+ keys(data, APP_MENUS_KEYS)
212
+ return {
213
+ title: label(data['title'], 'menu title'),
214
+ items: entries(data['items'], 1, census),
215
+ }
216
+ })
217
+ }
package/src/dialogs.ts ADDED
@@ -0,0 +1,146 @@
1
+ import { ProtocolError } from './framing.ts'
2
+ import { fields, keys, object, string } from './values.ts'
3
+
4
+ /** Live file dialogs per session. Each window owns at most one at a time. */
5
+ export const MAX_FILE_DIALOGS = 16
6
+ export const MAX_DIALOG_TITLE_BYTES = 256
7
+ export const MAX_DIALOG_LABEL_BYTES = 64
8
+ export const MAX_DIALOG_PATH_BYTES = 4_096
9
+ export const MAX_DIALOG_NAME_BYTES = 255
10
+ export const MAX_DIALOG_FILTERS = 16
11
+ export const MAX_DIALOG_FILTER_PATTERNS = 16
12
+ export const MAX_DIALOG_FILTER_NAME_BYTES = 128
13
+ export const MAX_DIALOG_PATTERN_BYTES = 64
14
+ /** Selected paths in one result. The whole result must also fit one frame. */
15
+ export const MAX_DIALOG_PATHS = 256
16
+
17
+ export const FILE_DIALOG_MODES = Object.freeze([
18
+ 'open_file',
19
+ 'open_files',
20
+ 'open_directory',
21
+ 'save_file',
22
+ ] as const)
23
+ export type FileDialogMode = (typeof FILE_DIALOG_MODES)[number]
24
+
25
+ export interface FileFilter {
26
+ name: string
27
+ patterns: readonly string[]
28
+ }
29
+
30
+ export interface FileDialogOptions {
31
+ mode: FileDialogMode
32
+ title: string
33
+ accept_label?: string
34
+ folder?: string
35
+ name?: string
36
+ filters?: readonly FileFilter[]
37
+ }
38
+
39
+ /** Paths are data: a selection carries no filesystem authority. */
40
+ export type FileDialogResult =
41
+ | { kind: 'selected'; paths: readonly string[] }
42
+ | { kind: 'cancelled' }
43
+ | { kind: 'failed'; code: 'native_failure' | 'payload_too_large' }
44
+
45
+ function line(value: unknown, field: string, limit: number): string {
46
+ const text = string(value, field, limit)
47
+ if (!text || /\p{Cc}/u.test(text))
48
+ throw new ProtocolError(field + ' must be nonempty and contain no control characters')
49
+ return text
50
+ }
51
+
52
+ /** A nonempty absolute POSIX path without NUL, at most 4096 UTF-8 bytes. */
53
+ function parseDialogPath(value: unknown, field = 'dialog path'): string {
54
+ const path = string(value, field, MAX_DIALOG_PATH_BYTES)
55
+ if (!path.startsWith('/')) throw new ProtocolError(field + ' must be absolute')
56
+ return path
57
+ }
58
+
59
+ const FILTER_KEYS = fields('name', 'patterns')
60
+
61
+ function filter(value: unknown): FileFilter {
62
+ const data = object(value)
63
+ keys(data, FILTER_KEYS)
64
+ const patterns = data['patterns']
65
+ if (
66
+ !Array.isArray(patterns) ||
67
+ patterns.length < 1 ||
68
+ patterns.length > MAX_DIALOG_FILTER_PATTERNS
69
+ )
70
+ throw new ProtocolError('A file filter needs between 1 and 16 patterns')
71
+ return {
72
+ name: line(data['name'], 'file filter name', MAX_DIALOG_FILTER_NAME_BYTES),
73
+ patterns: patterns.map((pattern) =>
74
+ line(pattern, 'file filter pattern', MAX_DIALOG_PATTERN_BYTES)
75
+ ),
76
+ }
77
+ }
78
+
79
+ const FILE_DIALOG_OPTIONS_KEYS = fields(
80
+ 'mode',
81
+ 'title',
82
+ 'accept_label',
83
+ 'folder',
84
+ 'name',
85
+ 'filters'
86
+ )
87
+
88
+ export function parseFileDialogOptions(value: unknown): FileDialogOptions {
89
+ const data = object(value)
90
+ keys(data, FILE_DIALOG_OPTIONS_KEYS)
91
+ const mode = FILE_DIALOG_MODES.find((candidate) => candidate === data['mode'])
92
+ if (!mode) throw new ProtocolError('Unknown file dialog mode')
93
+ const options: FileDialogOptions = {
94
+ mode,
95
+ title: line(data['title'], 'dialog title', MAX_DIALOG_TITLE_BYTES),
96
+ }
97
+ if ('accept_label' in data)
98
+ options.accept_label = line(data['accept_label'], 'dialog accept label', MAX_DIALOG_LABEL_BYTES)
99
+ if ('folder' in data) options.folder = parseDialogPath(data['folder'], 'dialog folder')
100
+ if ('name' in data) {
101
+ if (mode !== 'save_file') throw new ProtocolError('Only a save dialog accepts a suggested name')
102
+ const name = line(data['name'], 'suggested name', MAX_DIALOG_NAME_BYTES)
103
+ if (name.includes('/') || name === '.' || name === '..')
104
+ throw new ProtocolError('Suggested name must be one path component')
105
+ options.name = name
106
+ }
107
+ if ('filters' in data) {
108
+ const filters = data['filters']
109
+ if (!Array.isArray(filters) || filters.length > MAX_DIALOG_FILTERS)
110
+ throw new ProtocolError('A file dialog accepts at most 16 filters')
111
+ if (filters.length && mode === 'open_directory')
112
+ throw new ProtocolError('A folder dialog does not accept file filters')
113
+ if (filters.length) options.filters = filters.map(filter)
114
+ }
115
+ return options
116
+ }
117
+
118
+ const FILE_DIALOG_RESULT_SELECTED_KEYS = fields('kind', 'paths')
119
+ const FILE_DIALOG_RESULT_CANCELLED_KEYS = fields('kind')
120
+ const FILE_DIALOG_RESULT_FAILED_KEYS = fields('kind', 'code')
121
+
122
+ export function parseFileDialogResult(value: unknown): FileDialogResult {
123
+ const data = object(value)
124
+ switch (data['kind']) {
125
+ case 'selected': {
126
+ keys(data, FILE_DIALOG_RESULT_SELECTED_KEYS)
127
+ const paths = data['paths']
128
+ if (!Array.isArray(paths) || paths.length < 1 || paths.length > MAX_DIALOG_PATHS)
129
+ throw new ProtocolError('A dialog selection holds between 1 and 256 paths')
130
+ return { kind: 'selected', paths: paths.map((path) => parseDialogPath(path)) }
131
+ }
132
+ case 'cancelled':
133
+ keys(data, FILE_DIALOG_RESULT_CANCELLED_KEYS)
134
+ return { kind: 'cancelled' }
135
+ case 'failed': {
136
+ keys(data, FILE_DIALOG_RESULT_FAILED_KEYS)
137
+ const code = (['native_failure', 'payload_too_large'] as const).find(
138
+ (candidate) => candidate === data['code']
139
+ )
140
+ if (!code) throw new ProtocolError('Unknown file dialog failure')
141
+ return { kind: 'failed', code }
142
+ }
143
+ default:
144
+ throw new ProtocolError('Unknown file dialog result')
145
+ }
146
+ }
package/src/drops.ts ADDED
@@ -0,0 +1,49 @@
1
+ import { ProtocolError } from './framing.ts'
2
+ import { fields, integer, keys, object, string } from './values.ts'
3
+
4
+ /** Live drop targets across a session. */
5
+ export const MAX_DROP_TARGETS = 32
6
+ /** Items one drop may deliver. A larger drop is rejected, never truncated. */
7
+ export const MAX_DROP_ITEMS = 64
8
+ /** UTF-8 bytes of one untrusted display name. */
9
+ export const MAX_DROP_NAME_BYTES = 255
10
+
11
+ export const DRAG_STATES = Object.freeze(['entered', 'left'] as const)
12
+ export type DragState = (typeof DRAG_STATES)[number]
13
+ export const DROP_REJECTIONS = Object.freeze(['empty', 'limit_exceeded'] as const)
14
+ export type DropRejection = (typeof DROP_REJECTIONS)[number]
15
+
16
+ /** Untrusted display metadata for one dropped item. It is not a path. */
17
+ export interface DroppedItem {
18
+ name: string
19
+ }
20
+
21
+ function choice<T extends string>(value: unknown, values: readonly T[], field: string): T {
22
+ const found = values.find((candidate) => candidate === value)
23
+ if (!found) throw new ProtocolError(field + ' must be one of ' + values.join(', '))
24
+ return found
25
+ }
26
+
27
+ export const parseDragState = (value: unknown): DragState =>
28
+ choice(value, DRAG_STATES, 'Drag state')
29
+ export const parseDropRejection = (value: unknown): DropRejection =>
30
+ choice(value, DROP_REJECTIONS, 'Drop rejection')
31
+
32
+ export function parseDropItemIndex(value: unknown): number {
33
+ return integer(value, 'Dropped item index', 0, MAX_DROP_ITEMS - 1)
34
+ }
35
+
36
+ const DROPPED_ITEMS_KEYS = fields('name')
37
+
38
+ export function parseDroppedItems(value: unknown): DroppedItem[] {
39
+ if (!Array.isArray(value) || value.length < 1 || value.length > MAX_DROP_ITEMS)
40
+ throw new ProtocolError('A drop must deliver between 1 and ' + MAX_DROP_ITEMS + ' items')
41
+ return value.map((entry) => {
42
+ const data = object(entry)
43
+ keys(data, DROPPED_ITEMS_KEYS)
44
+ const name = string(data['name'], 'Dropped item name', MAX_DROP_NAME_BYTES)
45
+ if (!name || /\p{Cc}/u.test(name))
46
+ throw new ProtocolError('Dropped item name must be nonempty without control characters')
47
+ return { name }
48
+ })
49
+ }