@bun-native/protocol 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 +75 -0
- package/package.json +28 -1
- package/src/app-menu.ts +209 -0
- package/src/dialogs.ts +131 -0
- package/src/drops.ts +47 -0
- package/src/elements.ts +444 -0
- package/src/files.ts +281 -0
- package/src/framing.ts +85 -0
- package/src/index.ts +150 -0
- package/src/message-dialogs.ts +125 -0
- package/src/messages.ts +1388 -0
- package/src/processes.ts +252 -0
- package/src/services.ts +197 -0
- package/src/values.ts +55 -0
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
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
# @bun-native/protocol
|
|
2
|
+
|
|
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.0 was built from [fa8bef754122](https://github.com/0xPlayerOne/bun-native/tree/fa8bef754122621d4235eb06e30bb1085b4f7b1e) 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/fa8bef754122621d4235eb06e30bb1085b4f7b1e/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": "fa8bef754122621d4235eb06e30bb1085b4f7b1e",
|
|
4
|
+
"tree": "f805e2eb19a4e10fc29d1dc827cf5bc617b5773b",
|
|
5
|
+
"files": {
|
|
6
|
+
"package.json": {
|
|
7
|
+
"bytes": 2164,
|
|
8
|
+
"sha256": "db50b6f81f25c113a46f8c9b038bc548a92effaeb4ad9562e5e95044baa7b66f"
|
|
9
|
+
},
|
|
10
|
+
"packages/protocol/package.json": {
|
|
11
|
+
"bytes": 129,
|
|
12
|
+
"sha256": "668f52c10a6db801c2d0c68c59998c489b50c0bb79cb7f60298dfbb83923b0ba"
|
|
13
|
+
},
|
|
14
|
+
"packages/protocol/src/app-menu.ts": {
|
|
15
|
+
"bytes": 6435,
|
|
16
|
+
"sha256": "52ec565f7423671a13e3bc43d49129496ed9869f22b4fa1b7e3eba79191137b8"
|
|
17
|
+
},
|
|
18
|
+
"packages/protocol/src/dialogs.ts": {
|
|
19
|
+
"bytes": 4896,
|
|
20
|
+
"sha256": "c2ea74d7175c75710146c788b261e5566301da5952359b8e50bc69a323cdef00"
|
|
21
|
+
},
|
|
22
|
+
"packages/protocol/src/drops.ts": {
|
|
23
|
+
"bytes": 1968,
|
|
24
|
+
"sha256": "478b9952e406853b22e32e8b5c227fdc5936363665d984c628d18581de112191"
|
|
25
|
+
},
|
|
26
|
+
"packages/protocol/src/elements.ts": {
|
|
27
|
+
"bytes": 16161,
|
|
28
|
+
"sha256": "3529a4f7fefa9d56880a5f32be83b7daea9ad492784478ddc413d2deb85f5283"
|
|
29
|
+
},
|
|
30
|
+
"packages/protocol/src/files.ts": {
|
|
31
|
+
"bytes": 10163,
|
|
32
|
+
"sha256": "d4d890f22b6abdc713e544a4e3f7b0ed53df4a1d5f44044febea8d8cf9ca96a5"
|
|
33
|
+
},
|
|
34
|
+
"packages/protocol/src/framing.ts": {
|
|
35
|
+
"bytes": 3259,
|
|
36
|
+
"sha256": "47d2473785f245d8f85bd1447fba3f7ed47d5ddac170f590df5c83623064158a"
|
|
37
|
+
},
|
|
38
|
+
"packages/protocol/src/index.ts": {
|
|
39
|
+
"bytes": 3388,
|
|
40
|
+
"sha256": "7c7fd785b5037bc74ce0594f0ec5c49fca7ef9ae0b4dbab8fbf83d8427f6ead8"
|
|
41
|
+
},
|
|
42
|
+
"packages/protocol/src/message-dialogs.ts": {
|
|
43
|
+
"bytes": 3997,
|
|
44
|
+
"sha256": "551dc23a2fde7771c28e485212d670f37db199da6f2f006344b0dc6b9064ef2b"
|
|
45
|
+
},
|
|
46
|
+
"packages/protocol/src/messages.ts": {
|
|
47
|
+
"bytes": 43791,
|
|
48
|
+
"sha256": "cef29b0d2fa4b84d73df5c9b8abbf321dafc6a66d688ad00838f0c8aa50a1d03"
|
|
49
|
+
},
|
|
50
|
+
"packages/protocol/src/processes.ts": {
|
|
51
|
+
"bytes": 9709,
|
|
52
|
+
"sha256": "80f109623ec5f17bc21fb20c4e3093829124839c6c9e5b2457120bff0a45c778"
|
|
53
|
+
},
|
|
54
|
+
"packages/protocol/src/services.ts": {
|
|
55
|
+
"bytes": 7587,
|
|
56
|
+
"sha256": "eb128b1ef12d2650acc53b71c2c802f35d3fb983d1f584aa1d81251adfccd37b"
|
|
57
|
+
},
|
|
58
|
+
"packages/protocol/src/values.ts": {
|
|
59
|
+
"bytes": 2078,
|
|
60
|
+
"sha256": "75d8f7ee9772c1aa2929a9dc4c416f42569795db089d17321398c3cdf482731b"
|
|
61
|
+
},
|
|
62
|
+
"LICENSE": {
|
|
63
|
+
"bytes": 1075,
|
|
64
|
+
"sha256": "86673fb485f12fa9aaa3941dfccc9f69a7f8322f8141d0d5c73db4323de10ac5"
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
"toolchain": {
|
|
68
|
+
"bytes": 3012,
|
|
69
|
+
"sha256": "00c3f707aae8abe9ca75cd2e162f5e1c7c34362a03675da39afead60d856066a"
|
|
70
|
+
},
|
|
71
|
+
"dependencyLock": {
|
|
72
|
+
"bytes": 28357,
|
|
73
|
+
"sha256": "65e3dbd01be4a1889367690faf1a9c3f08fb31d22dbd273d9bb9f39dd6baa6fb"
|
|
74
|
+
}
|
|
75
|
+
}
|
package/package.json
CHANGED
|
@@ -1 +1,28 @@
|
|
|
1
|
-
{
|
|
1
|
+
{
|
|
2
|
+
"name": "@bun-native/protocol",
|
|
3
|
+
"version": "0.33.0",
|
|
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
|
+
}
|
package/src/app-menu.ts
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import { ProtocolError } from './framing.ts'
|
|
2
|
+
import { keys, object, string } from './values.ts'
|
|
3
|
+
|
|
4
|
+
/** Top-level menus in one menu bar. */
|
|
5
|
+
export 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
|
+
export const MAX_APP_MENU_LABEL_BYTES = 64
|
|
11
|
+
export const MAX_APP_MENU_ITEM_ID_BYTES = 64
|
|
12
|
+
|
|
13
|
+
export const APP_MENU_NAMED_KEYS = Object.freeze([
|
|
14
|
+
'enter',
|
|
15
|
+
'escape',
|
|
16
|
+
'tab',
|
|
17
|
+
'space',
|
|
18
|
+
'backspace',
|
|
19
|
+
'delete',
|
|
20
|
+
'up',
|
|
21
|
+
'down',
|
|
22
|
+
'left',
|
|
23
|
+
'right',
|
|
24
|
+
'home',
|
|
25
|
+
'end',
|
|
26
|
+
'pageup',
|
|
27
|
+
'pagedown',
|
|
28
|
+
] as const)
|
|
29
|
+
export const APP_MENU_PUNCTUATION_KEYS = Object.freeze([
|
|
30
|
+
',',
|
|
31
|
+
'.',
|
|
32
|
+
'/',
|
|
33
|
+
';',
|
|
34
|
+
"'",
|
|
35
|
+
'[',
|
|
36
|
+
']',
|
|
37
|
+
'-',
|
|
38
|
+
'=',
|
|
39
|
+
'`',
|
|
40
|
+
'\\',
|
|
41
|
+
] as const)
|
|
42
|
+
|
|
43
|
+
/** The closed key set of a menu shortcut. */
|
|
44
|
+
export const APP_MENU_KEYS = Object.freeze([
|
|
45
|
+
...'abcdefghijklmnopqrstuvwxyz0123456789',
|
|
46
|
+
...Array.from({ length: 12 }, (_, index) => `f${index + 1}`),
|
|
47
|
+
...APP_MENU_PUNCTUATION_KEYS,
|
|
48
|
+
...APP_MENU_NAMED_KEYS,
|
|
49
|
+
]) as readonly string[]
|
|
50
|
+
|
|
51
|
+
export interface MenuShortcutModifiers {
|
|
52
|
+
control: boolean
|
|
53
|
+
alt: boolean
|
|
54
|
+
shift: boolean
|
|
55
|
+
/** Command on macOS. */
|
|
56
|
+
platform: boolean
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface MenuShortcut {
|
|
60
|
+
key: string
|
|
61
|
+
modifiers: MenuShortcutModifiers
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export type AppMenuEntry =
|
|
65
|
+
| {
|
|
66
|
+
kind: 'item'
|
|
67
|
+
id: string
|
|
68
|
+
label: string
|
|
69
|
+
shortcut?: MenuShortcut
|
|
70
|
+
enabled: boolean
|
|
71
|
+
checked: boolean
|
|
72
|
+
}
|
|
73
|
+
| { kind: 'separator' }
|
|
74
|
+
| { kind: 'submenu'; label: string; enabled: boolean; items: AppMenuEntry[] }
|
|
75
|
+
|
|
76
|
+
export interface AppMenu {
|
|
77
|
+
title: string
|
|
78
|
+
items: AppMenuEntry[]
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const ITEM_ID = /^[A-Za-z0-9._:-]{1,64}$/
|
|
82
|
+
|
|
83
|
+
/** `[A-Za-z0-9._:-]`, 1-64 bytes: an application identifier, never a native name. */
|
|
84
|
+
export function parseAppMenuItemId(value: unknown): string {
|
|
85
|
+
if (typeof value !== 'string' || !ITEM_ID.test(value))
|
|
86
|
+
throw new ProtocolError(
|
|
87
|
+
"Menu item id must be 1-64 bytes of letters, digits, '.', '_', ':' or '-'"
|
|
88
|
+
)
|
|
89
|
+
return value
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function label(value: unknown, field: string): string {
|
|
93
|
+
const text = string(value, field, MAX_APP_MENU_LABEL_BYTES)
|
|
94
|
+
if (!text || /\p{Cc}/u.test(text))
|
|
95
|
+
throw new ProtocolError(field + ' must be nonempty and contain no control characters')
|
|
96
|
+
return text
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function flag(data: Record<string, unknown>, key: string): boolean {
|
|
100
|
+
const value = data[key]
|
|
101
|
+
if (typeof value !== 'boolean') throw new ProtocolError('Menu ' + key + ' must be a boolean')
|
|
102
|
+
return value
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const isFunctionKey = (key: string) => /^f([1-9]|1[0-2])$/.test(key)
|
|
106
|
+
|
|
107
|
+
function parseMenuShortcut(value: unknown): MenuShortcut {
|
|
108
|
+
const data = object(value)
|
|
109
|
+
keys(data, ['key', 'modifiers'])
|
|
110
|
+
const key = data['key']
|
|
111
|
+
if (typeof key !== 'string' || !APP_MENU_KEYS.includes(key))
|
|
112
|
+
throw new ProtocolError('Menu shortcut key is not in the closed key set')
|
|
113
|
+
const raw = object(data['modifiers'])
|
|
114
|
+
keys(raw, ['control', 'alt', 'shift', 'platform'])
|
|
115
|
+
const modifiers = {
|
|
116
|
+
control: flag(raw, 'control'),
|
|
117
|
+
alt: flag(raw, 'alt'),
|
|
118
|
+
shift: flag(raw, 'shift'),
|
|
119
|
+
platform: flag(raw, 'platform'),
|
|
120
|
+
}
|
|
121
|
+
if (!isFunctionKey(key) && !modifiers.control && !modifiers.alt && !modifiers.platform)
|
|
122
|
+
throw new ProtocolError('Menu shortcut requires control, alt or platform unless it is f1-f12')
|
|
123
|
+
return { key, modifiers }
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const shortcutKey = ({ key, modifiers: m }: MenuShortcut) =>
|
|
127
|
+
`${+m.control}${+m.alt}${+m.shift}${+m.platform}${key}`
|
|
128
|
+
|
|
129
|
+
interface Census {
|
|
130
|
+
entries: number
|
|
131
|
+
ids: Set<string>
|
|
132
|
+
shortcuts: Set<string>
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function entries(value: unknown, depth: number, census: Census): AppMenuEntry[] {
|
|
136
|
+
if (!Array.isArray(value) || !value.length)
|
|
137
|
+
throw new ProtocolError('A menu or submenu needs at least one entry')
|
|
138
|
+
return value.map((raw): AppMenuEntry => {
|
|
139
|
+
if (++census.entries > MAX_APP_MENU_ENTRIES)
|
|
140
|
+
throw new ProtocolError('A menu bar holds at most 128 entries')
|
|
141
|
+
const data = object(raw)
|
|
142
|
+
switch (data['kind']) {
|
|
143
|
+
case 'item': {
|
|
144
|
+
keys(data, ['kind', 'id', 'label', 'shortcut', 'enabled', 'checked'])
|
|
145
|
+
const id = parseAppMenuItemId(data['id'])
|
|
146
|
+
if (census.ids.has(id))
|
|
147
|
+
throw new ProtocolError('Menu item ids must be unique in a menu bar')
|
|
148
|
+
census.ids.add(id)
|
|
149
|
+
const entry: AppMenuEntry = {
|
|
150
|
+
kind: 'item',
|
|
151
|
+
id,
|
|
152
|
+
label: label(data['label'], 'menu label'),
|
|
153
|
+
enabled: flag(data, 'enabled'),
|
|
154
|
+
checked: flag(data, 'checked'),
|
|
155
|
+
}
|
|
156
|
+
if ('shortcut' in data) {
|
|
157
|
+
const shortcut = parseMenuShortcut(data['shortcut'])
|
|
158
|
+
const identity = shortcutKey(shortcut)
|
|
159
|
+
if (census.shortcuts.has(identity))
|
|
160
|
+
throw new ProtocolError('Menu shortcuts must be unique in a menu bar')
|
|
161
|
+
census.shortcuts.add(identity)
|
|
162
|
+
entry.shortcut = shortcut
|
|
163
|
+
}
|
|
164
|
+
return entry
|
|
165
|
+
}
|
|
166
|
+
case 'separator':
|
|
167
|
+
keys(data, ['kind'])
|
|
168
|
+
return { kind: 'separator' }
|
|
169
|
+
case 'submenu': {
|
|
170
|
+
keys(data, ['kind', 'label', 'enabled', 'items'])
|
|
171
|
+
const text = label(data['label'], 'menu label')
|
|
172
|
+
const enabled = flag(data, 'enabled')
|
|
173
|
+
if (depth >= MAX_APP_MENU_DEPTH)
|
|
174
|
+
throw new ProtocolError('Submenus nest at most two levels below a top-level menu')
|
|
175
|
+
return {
|
|
176
|
+
kind: 'submenu',
|
|
177
|
+
label: text,
|
|
178
|
+
enabled,
|
|
179
|
+
items: entries(data['items'], depth + 1, census),
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
default:
|
|
183
|
+
throw new ProtocolError('Unknown menu entry kind')
|
|
184
|
+
}
|
|
185
|
+
})
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* A context menu: the entries of one menu-bar menu, with the same bounds,
|
|
190
|
+
* unique ids and shortcuts, and submenus nested at most two levels deep.
|
|
191
|
+
*/
|
|
192
|
+
export function parseContextMenuItems(value: unknown): AppMenuEntry[] {
|
|
193
|
+
return entries(value, 1, { entries: 0, ids: new Set(), shortcuts: new Set() })
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** An empty bar is valid: it removes the application's menus. */
|
|
197
|
+
export function parseAppMenus(value: unknown): AppMenu[] {
|
|
198
|
+
if (!Array.isArray(value) || value.length > MAX_APP_MENUS)
|
|
199
|
+
throw new ProtocolError('A menu bar holds at most 16 menus')
|
|
200
|
+
const census: Census = { entries: 0, ids: new Set(), shortcuts: new Set() }
|
|
201
|
+
return value.map((raw) => {
|
|
202
|
+
const data = object(raw)
|
|
203
|
+
keys(data, ['title', 'items'])
|
|
204
|
+
return {
|
|
205
|
+
title: label(data['title'], 'menu title'),
|
|
206
|
+
items: entries(data['items'], 1, census),
|
|
207
|
+
}
|
|
208
|
+
})
|
|
209
|
+
}
|
package/src/dialogs.ts
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { ProtocolError } from './framing.ts'
|
|
2
|
+
import { 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
|
+
function filter(value: unknown): FileFilter {
|
|
60
|
+
const data = object(value)
|
|
61
|
+
keys(data, ['name', 'patterns'])
|
|
62
|
+
const patterns = data['patterns']
|
|
63
|
+
if (
|
|
64
|
+
!Array.isArray(patterns) ||
|
|
65
|
+
patterns.length < 1 ||
|
|
66
|
+
patterns.length > MAX_DIALOG_FILTER_PATTERNS
|
|
67
|
+
)
|
|
68
|
+
throw new ProtocolError('A file filter needs between 1 and 16 patterns')
|
|
69
|
+
return {
|
|
70
|
+
name: line(data['name'], 'file filter name', MAX_DIALOG_FILTER_NAME_BYTES),
|
|
71
|
+
patterns: patterns.map((pattern) =>
|
|
72
|
+
line(pattern, 'file filter pattern', MAX_DIALOG_PATTERN_BYTES)
|
|
73
|
+
),
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function parseFileDialogOptions(value: unknown): FileDialogOptions {
|
|
78
|
+
const data = object(value)
|
|
79
|
+
keys(data, ['mode', 'title', 'accept_label', 'folder', 'name', 'filters'])
|
|
80
|
+
const mode = FILE_DIALOG_MODES.find((candidate) => candidate === data['mode'])
|
|
81
|
+
if (!mode) throw new ProtocolError('Unknown file dialog mode')
|
|
82
|
+
const options: FileDialogOptions = {
|
|
83
|
+
mode,
|
|
84
|
+
title: line(data['title'], 'dialog title', MAX_DIALOG_TITLE_BYTES),
|
|
85
|
+
}
|
|
86
|
+
if ('accept_label' in data)
|
|
87
|
+
options.accept_label = line(data['accept_label'], 'dialog accept label', MAX_DIALOG_LABEL_BYTES)
|
|
88
|
+
if ('folder' in data) options.folder = parseDialogPath(data['folder'], 'dialog folder')
|
|
89
|
+
if ('name' in data) {
|
|
90
|
+
if (mode !== 'save_file') throw new ProtocolError('Only a save dialog accepts a suggested name')
|
|
91
|
+
const name = line(data['name'], 'suggested name', MAX_DIALOG_NAME_BYTES)
|
|
92
|
+
if (name.includes('/') || name === '.' || name === '..')
|
|
93
|
+
throw new ProtocolError('Suggested name must be one path component')
|
|
94
|
+
options.name = name
|
|
95
|
+
}
|
|
96
|
+
if ('filters' in data) {
|
|
97
|
+
const filters = data['filters']
|
|
98
|
+
if (!Array.isArray(filters) || filters.length > MAX_DIALOG_FILTERS)
|
|
99
|
+
throw new ProtocolError('A file dialog accepts at most 16 filters')
|
|
100
|
+
if (filters.length && mode === 'open_directory')
|
|
101
|
+
throw new ProtocolError('A folder dialog does not accept file filters')
|
|
102
|
+
if (filters.length) options.filters = filters.map(filter)
|
|
103
|
+
}
|
|
104
|
+
return options
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function parseFileDialogResult(value: unknown): FileDialogResult {
|
|
108
|
+
const data = object(value)
|
|
109
|
+
switch (data['kind']) {
|
|
110
|
+
case 'selected': {
|
|
111
|
+
keys(data, ['kind', 'paths'])
|
|
112
|
+
const paths = data['paths']
|
|
113
|
+
if (!Array.isArray(paths) || paths.length < 1 || paths.length > MAX_DIALOG_PATHS)
|
|
114
|
+
throw new ProtocolError('A dialog selection holds between 1 and 256 paths')
|
|
115
|
+
return { kind: 'selected', paths: paths.map((path) => parseDialogPath(path)) }
|
|
116
|
+
}
|
|
117
|
+
case 'cancelled':
|
|
118
|
+
keys(data, ['kind'])
|
|
119
|
+
return { kind: 'cancelled' }
|
|
120
|
+
case 'failed': {
|
|
121
|
+
keys(data, ['kind', 'code'])
|
|
122
|
+
const code = (['native_failure', 'payload_too_large'] as const).find(
|
|
123
|
+
(candidate) => candidate === data['code']
|
|
124
|
+
)
|
|
125
|
+
if (!code) throw new ProtocolError('Unknown file dialog failure')
|
|
126
|
+
return { kind: 'failed', code }
|
|
127
|
+
}
|
|
128
|
+
default:
|
|
129
|
+
throw new ProtocolError('Unknown file dialog result')
|
|
130
|
+
}
|
|
131
|
+
}
|
package/src/drops.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { ProtocolError } from './framing.ts'
|
|
2
|
+
import { 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
|
+
export function parseDroppedItems(value: unknown): DroppedItem[] {
|
|
37
|
+
if (!Array.isArray(value) || value.length < 1 || value.length > MAX_DROP_ITEMS)
|
|
38
|
+
throw new ProtocolError('A drop must deliver between 1 and ' + MAX_DROP_ITEMS + ' items')
|
|
39
|
+
return value.map((entry) => {
|
|
40
|
+
const data = object(entry)
|
|
41
|
+
keys(data, ['name'])
|
|
42
|
+
const name = string(data['name'], 'Dropped item name', MAX_DROP_NAME_BYTES)
|
|
43
|
+
if (!name || /\p{Cc}/u.test(name))
|
|
44
|
+
throw new ProtocolError('Dropped item name must be nonempty without control characters')
|
|
45
|
+
return { name }
|
|
46
|
+
})
|
|
47
|
+
}
|