@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 +21 -0
- package/README.md +4 -2
- package/bun-native-source.json +75 -0
- package/package.json +26 -4
- package/src/app-menu.ts +217 -0
- package/src/dialogs.ts +146 -0
- package/src/drops.ts +49 -0
- package/src/elements.ts +473 -0
- package/src/files.ts +295 -0
- package/src/framing.ts +109 -0
- package/src/index.ts +136 -0
- package/src/message-dialogs.ts +131 -0
- package/src/messages.ts +1490 -0
- package/src/processes.ts +263 -0
- package/src/services.ts +208 -0
- package/src/values.ts +61 -0
package/src/processes.ts
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
import { ProtocolError } from './framing.ts'
|
|
2
|
+
import { parseChunk, parseFilePath, parseRootId, parseRootPath } from './files.ts'
|
|
3
|
+
import { fields, id, integer, keys, object } from './values.ts'
|
|
4
|
+
|
|
5
|
+
export const MAX_PROCESS_EXECUTABLES = 8
|
|
6
|
+
export const MAX_PROCESSES = 8
|
|
7
|
+
export const MAX_PROCESS_ARGS = 64
|
|
8
|
+
export const MAX_PROCESS_ENV = 64
|
|
9
|
+
export const MAX_PROCESS_OUTPUT_CREDIT = 4
|
|
10
|
+
const MAX_PROCESS_ARG_BYTES = 4_096
|
|
11
|
+
const MAX_PROCESS_ENV_NAME_BYTES = 128
|
|
12
|
+
const MAX_PROCESS_ENV_VALUE_BYTES = 4_096
|
|
13
|
+
const MAX_PROCESS_LAUNCH_BYTES = 32_768
|
|
14
|
+
const MAX_TERMINAL_DIMENSION = 1_000
|
|
15
|
+
const PROCESS_ARGUMENT = '--bun-native-process='
|
|
16
|
+
const PROCESS_LIMITS_ARGUMENT = '--bun-native-process-limits='
|
|
17
|
+
const MAX_PROCESS_CPU_SECONDS = 604_800
|
|
18
|
+
const MIN_PROCESS_MEMORY_BYTES = 2 ** 20
|
|
19
|
+
const MAX_PROCESS_MEMORY_BYTES = 2 ** 48
|
|
20
|
+
const MIN_PROCESS_OPEN_FILES = 3
|
|
21
|
+
const MAX_PROCESS_OPEN_FILES = 2 ** 20
|
|
22
|
+
const encoder = new TextEncoder()
|
|
23
|
+
const bytes = (value: string) => encoder.encode(value).byteLength
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Resource limits every child started from one executable entry runs under.
|
|
27
|
+
* A platform that cannot enforce a requested limit refuses the spawn.
|
|
28
|
+
*/
|
|
29
|
+
export interface ProcessLimits {
|
|
30
|
+
/** CPU time (POSIX `RLIMIT_CPU`, Windows per-process job time). */
|
|
31
|
+
cpu_seconds?: number
|
|
32
|
+
/** Memory (Linux `RLIMIT_AS`, Windows per-process job memory). */
|
|
33
|
+
memory_bytes?: number
|
|
34
|
+
/** Open descriptors (POSIX `RLIMIT_NOFILE`). */
|
|
35
|
+
open_files?: number
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface ProcessExecutable {
|
|
39
|
+
id: string
|
|
40
|
+
executable: string
|
|
41
|
+
limits?: ProcessLimits
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const LIMIT_NAMES = ['cpu_seconds', 'memory_bytes', 'open_files'] as const
|
|
45
|
+
const LIMIT_BOUNDS = {
|
|
46
|
+
cpu_seconds: [1, MAX_PROCESS_CPU_SECONDS],
|
|
47
|
+
memory_bytes: [MIN_PROCESS_MEMORY_BYTES, MAX_PROCESS_MEMORY_BYTES],
|
|
48
|
+
open_files: [MIN_PROCESS_OPEN_FILES, MAX_PROCESS_OPEN_FILES],
|
|
49
|
+
} as const
|
|
50
|
+
|
|
51
|
+
const PROCESS_LIMITS_KEYS = fields(...LIMIT_NAMES)
|
|
52
|
+
|
|
53
|
+
function parseProcessLimits(value: unknown): ProcessLimits {
|
|
54
|
+
const data = object(value)
|
|
55
|
+
keys(data, PROCESS_LIMITS_KEYS)
|
|
56
|
+
const limits: ProcessLimits = {}
|
|
57
|
+
for (const name of LIMIT_NAMES)
|
|
58
|
+
if (name in data) {
|
|
59
|
+
const [min, max] = LIMIT_BOUNDS[name]
|
|
60
|
+
limits[name] = integer(data[name], 'process ' + name, min, max)
|
|
61
|
+
}
|
|
62
|
+
if (Object.keys(limits).length === 0)
|
|
63
|
+
throw new ProtocolError('Process limits must set at least one limit')
|
|
64
|
+
return limits
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** `cpu_seconds=N,memory_bytes=N,open_files=N`, only the set limits, in that order. */
|
|
68
|
+
export function processLimitsArgument(limits: ProcessLimits): string {
|
|
69
|
+
const parsed = parseProcessLimits(limits)
|
|
70
|
+
return LIMIT_NAMES.filter((name) => parsed[name] !== undefined)
|
|
71
|
+
.map((name) => name + '=' + parsed[name])
|
|
72
|
+
.join(',')
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Parses `processLimitsArgument`'s form strictly, as the native host does. */
|
|
76
|
+
export function parseProcessLimitsArgument(text: string): ProcessLimits {
|
|
77
|
+
const data: Record<string, number> = {}
|
|
78
|
+
let last = -1
|
|
79
|
+
for (const part of text.split(',')) {
|
|
80
|
+
const match = /^([a-z_]+)=(0|[1-9][0-9]{0,19})$/.exec(part)
|
|
81
|
+
const order = LIMIT_NAMES.indexOf(match?.[1] as (typeof LIMIT_NAMES)[number])
|
|
82
|
+
if (!match || order <= last)
|
|
83
|
+
throw new ProtocolError('Process limits must be cpu_seconds=N,memory_bytes=N,open_files=N')
|
|
84
|
+
last = order
|
|
85
|
+
data[match[1]!] = Number(match[2])
|
|
86
|
+
}
|
|
87
|
+
return parseProcessLimits(data)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface TerminalSize {
|
|
91
|
+
columns: number
|
|
92
|
+
rows: number
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export interface ProcessLaunch {
|
|
96
|
+
executable: string
|
|
97
|
+
args: string[]
|
|
98
|
+
env: Record<string, string>
|
|
99
|
+
cwd?: { grant_id: number; path: string }
|
|
100
|
+
terminal?: TerminalSize
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export const PROCESS_SIGNALS = Object.freeze(['interrupt', 'terminate', 'kill', 'hangup'] as const)
|
|
104
|
+
export type ProcessSignal = (typeof PROCESS_SIGNALS)[number]
|
|
105
|
+
export type ProcessStream = 'stdout' | 'stderr' | 'terminal'
|
|
106
|
+
export type ProcessExit = { code: number } | { signal: number }
|
|
107
|
+
|
|
108
|
+
const EXECUTABLE_KEYS = fields('id', 'executable', 'limits')
|
|
109
|
+
|
|
110
|
+
function executable(value: unknown): ProcessExecutable {
|
|
111
|
+
const data = object(value)
|
|
112
|
+
keys(data, EXECUTABLE_KEYS)
|
|
113
|
+
const path = parseRootPath(data['executable'])
|
|
114
|
+
// `/` and `X:\` are the only normalized paths ending in a separator.
|
|
115
|
+
if (path.endsWith('/') || path.endsWith('\\'))
|
|
116
|
+
throw new ProtocolError('Process executable must name a file')
|
|
117
|
+
const entry: ProcessExecutable = { id: parseRootId(data['id']), executable: path }
|
|
118
|
+
if ('limits' in data) entry.limits = parseProcessLimits(data['limits'])
|
|
119
|
+
return entry
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function parseProcessExecutables(value: unknown): ProcessExecutable[] {
|
|
123
|
+
if (!Array.isArray(value) || value.length > MAX_PROCESS_EXECUTABLES)
|
|
124
|
+
throw new ProtocolError(
|
|
125
|
+
'At most ' + MAX_PROCESS_EXECUTABLES + ' process executables may be granted'
|
|
126
|
+
)
|
|
127
|
+
const entries = value.map(executable)
|
|
128
|
+
if (new Set(entries.map((entry) => entry.id)).size !== entries.length)
|
|
129
|
+
throw new ProtocolError('Process executable IDs must be unique')
|
|
130
|
+
return entries
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* The reserved native launch arguments for one validated executable: the
|
|
135
|
+
* executable itself, then its limits when it has any.
|
|
136
|
+
*/
|
|
137
|
+
export function processArguments(value: ProcessExecutable): string[] {
|
|
138
|
+
const entry = executable(value)
|
|
139
|
+
const argv = [PROCESS_ARGUMENT + entry.id + ':' + entry.executable]
|
|
140
|
+
if (entry.limits)
|
|
141
|
+
argv.push(PROCESS_LIMITS_ARGUMENT + entry.id + ':' + processLimitsArgument(entry.limits))
|
|
142
|
+
return argv
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function processesFromArguments(argv: readonly string[]): ProcessExecutable[] {
|
|
146
|
+
// One pass, as the host parses them: limits name an executable granted by
|
|
147
|
+
// an earlier argument, at most once.
|
|
148
|
+
const entries: ProcessExecutable[] = []
|
|
149
|
+
for (const argument of argv) {
|
|
150
|
+
if (argument.startsWith(PROCESS_ARGUMENT)) {
|
|
151
|
+
const [entry, ...path] = argument.slice(PROCESS_ARGUMENT.length).split(':')
|
|
152
|
+
entries.push({ id: entry!, executable: path.join(':') })
|
|
153
|
+
} else if (argument.startsWith(PROCESS_LIMITS_ARGUMENT)) {
|
|
154
|
+
const text = argument.slice(PROCESS_LIMITS_ARGUMENT.length)
|
|
155
|
+
const separator = text.indexOf(':')
|
|
156
|
+
const entry = entries.find((item) => item.id === text.slice(0, separator))
|
|
157
|
+
if (separator < 0 || !entry || entry.limits)
|
|
158
|
+
throw new ProtocolError('Process limits must name one granted executable once')
|
|
159
|
+
entry.limits = parseProcessLimitsArgument(text.slice(separator + 1))
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return parseProcessExecutables(entries)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function plain(value: unknown, limit: number, field: string): string {
|
|
166
|
+
if (
|
|
167
|
+
typeof value !== 'string' ||
|
|
168
|
+
!value.isWellFormed() ||
|
|
169
|
+
value.includes('\0') ||
|
|
170
|
+
bytes(value) > limit
|
|
171
|
+
)
|
|
172
|
+
throw new ProtocolError(field + ' must be a NUL-free string of at most ' + limit + ' bytes')
|
|
173
|
+
return value
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const TERMINAL_SIZE_KEYS = fields('columns', 'rows')
|
|
177
|
+
|
|
178
|
+
export function parseTerminalSize(value: unknown): TerminalSize {
|
|
179
|
+
const data = object(value)
|
|
180
|
+
keys(data, TERMINAL_SIZE_KEYS)
|
|
181
|
+
return {
|
|
182
|
+
columns: integer(data['columns'], 'terminal columns', 1, MAX_TERMINAL_DIMENSION),
|
|
183
|
+
rows: integer(data['rows'], 'terminal rows', 1, MAX_TERMINAL_DIMENSION),
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function parseEnvName(value: string): string {
|
|
188
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(value) || value.length > MAX_PROCESS_ENV_NAME_BYTES)
|
|
189
|
+
throw new ProtocolError(
|
|
190
|
+
'Environment names must be ASCII letters, digits and "_", not starting with a digit'
|
|
191
|
+
)
|
|
192
|
+
return value
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const PROCESS_LAUNCH_KEYS = fields('executable', 'args', 'env', 'cwd', 'terminal')
|
|
196
|
+
const PROCESS_CWD_KEYS = fields('grant_id', 'path')
|
|
197
|
+
|
|
198
|
+
export function parseProcessLaunch(value: unknown): ProcessLaunch {
|
|
199
|
+
const data = object(value)
|
|
200
|
+
keys(data, PROCESS_LAUNCH_KEYS)
|
|
201
|
+
const args = data['args']
|
|
202
|
+
if (!Array.isArray(args) || args.length > MAX_PROCESS_ARGS)
|
|
203
|
+
throw new ProtocolError('Process arguments must be an array of at most ' + MAX_PROCESS_ARGS)
|
|
204
|
+
const parsedArgs = args.map((item) => plain(item, MAX_PROCESS_ARG_BYTES, 'Process argument'))
|
|
205
|
+
const env = object(data['env'])
|
|
206
|
+
const names = Object.keys(env)
|
|
207
|
+
if (names.length > MAX_PROCESS_ENV)
|
|
208
|
+
throw new ProtocolError('At most ' + MAX_PROCESS_ENV + ' environment variables may be set')
|
|
209
|
+
const parsedEnv: Record<string, string> = {}
|
|
210
|
+
for (const name of names)
|
|
211
|
+
parsedEnv[parseEnvName(name)] = plain(
|
|
212
|
+
env[name],
|
|
213
|
+
MAX_PROCESS_ENV_VALUE_BYTES,
|
|
214
|
+
'Environment value'
|
|
215
|
+
)
|
|
216
|
+
const total =
|
|
217
|
+
parsedArgs.reduce((sum, item) => sum + bytes(item), 0) +
|
|
218
|
+
names.reduce((sum, name) => sum + bytes(name) + bytes(parsedEnv[name]!), 0)
|
|
219
|
+
if (total > MAX_PROCESS_LAUNCH_BYTES)
|
|
220
|
+
throw new ProtocolError('Process arguments and environment exceed 32768 bytes')
|
|
221
|
+
const launch: ProcessLaunch = {
|
|
222
|
+
executable: parseRootId(data['executable']),
|
|
223
|
+
args: parsedArgs,
|
|
224
|
+
env: parsedEnv,
|
|
225
|
+
}
|
|
226
|
+
if ('cwd' in data) {
|
|
227
|
+
const cwd = object(data['cwd'])
|
|
228
|
+
keys(cwd, PROCESS_CWD_KEYS)
|
|
229
|
+
launch.cwd = { grant_id: id(cwd['grant_id']), path: parseFilePath(cwd['path']) }
|
|
230
|
+
}
|
|
231
|
+
if ('terminal' in data) launch.terminal = parseTerminalSize(data['terminal'])
|
|
232
|
+
return launch
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export function parseProcessSignal(value: unknown): ProcessSignal {
|
|
236
|
+
if (!PROCESS_SIGNALS.includes(value as ProcessSignal))
|
|
237
|
+
throw new ProtocolError('Unknown process signal')
|
|
238
|
+
return value as ProcessSignal
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export function parseProcessStream(value: unknown): ProcessStream {
|
|
242
|
+
if (value !== 'stdout' && value !== 'stderr' && value !== 'terminal')
|
|
243
|
+
throw new ProtocolError('Unknown process stream')
|
|
244
|
+
return value
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export function parseProcessOutput(value: unknown): string {
|
|
248
|
+
const data = parseChunk(value)
|
|
249
|
+
if (!data) throw new ProtocolError('Process output chunks are nonempty')
|
|
250
|
+
return data
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const PROCESS_EXIT_KEYS = fields('code', 'signal')
|
|
254
|
+
|
|
255
|
+
export function parseProcessExit(value: unknown): ProcessExit {
|
|
256
|
+
const data = object(value)
|
|
257
|
+
keys(data, PROCESS_EXIT_KEYS)
|
|
258
|
+
if ('code' in data && !('signal' in data))
|
|
259
|
+
return { code: integer(data['code'], 'exit code', 0, 255) }
|
|
260
|
+
if ('signal' in data && !('code' in data))
|
|
261
|
+
return { signal: integer(data['signal'], 'exit signal', 1, 64) }
|
|
262
|
+
throw new ProtocolError('A process exit has exactly one exit code or signal')
|
|
263
|
+
}
|
package/src/services.ts
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { ProtocolError } from './framing.ts'
|
|
2
|
+
import { fields, keys, number, object, string } from './values.ts'
|
|
3
|
+
import { parseFilesystemRoots, type FilesystemRoot } from './files.ts'
|
|
4
|
+
import { parseProcessExecutables, type ProcessExecutable } from './processes.ts'
|
|
5
|
+
|
|
6
|
+
// Worst-case JSON escaping still leaves room within the 64KiB frame.
|
|
7
|
+
export const MAX_CLIPBOARD_BYTES = 8_192
|
|
8
|
+
export const MAX_SERVICE_REQUESTS = 8
|
|
9
|
+
export const MAX_GLOBAL_SHORTCUTS = 16
|
|
10
|
+
export const MAX_NOTIFICATIONS = 32
|
|
11
|
+
export const MAX_NOTIFICATION_TITLE_BYTES = 256
|
|
12
|
+
export const MAX_NOTIFICATION_BODY_BYTES = 2_048
|
|
13
|
+
|
|
14
|
+
export interface ServicePolicy {
|
|
15
|
+
window_display: boolean
|
|
16
|
+
clipboard_read: boolean
|
|
17
|
+
clipboard_write: boolean
|
|
18
|
+
global_shortcuts: boolean
|
|
19
|
+
notifications: boolean
|
|
20
|
+
/** Owned targets that receive external file drops as untrusted names. */
|
|
21
|
+
drag_drop: boolean
|
|
22
|
+
/** Directories a window may open as scoped grants, in launch order. */
|
|
23
|
+
filesystem_roots: FilesystemRoot[]
|
|
24
|
+
/** Executables a window may start as owned processes, in launch order. */
|
|
25
|
+
processes: ProcessExecutable[]
|
|
26
|
+
/** Optional on the wire, so existing peers stay exact: absence is a denial. */
|
|
27
|
+
file_dialogs?: boolean
|
|
28
|
+
/** Optional like `file_dialogs`: the application menu bar. */
|
|
29
|
+
app_menu?: boolean
|
|
30
|
+
/** Optional like `file_dialogs`: OS message dialogs. */
|
|
31
|
+
task_dialogs?: boolean
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
type Characters<S extends string> = S extends `${infer Head}${infer Tail}`
|
|
35
|
+
? Head | Characters<Tail>
|
|
36
|
+
: never
|
|
37
|
+
|
|
38
|
+
/** A layout-level key name: a-z, 0-9 or f1-f12. */
|
|
39
|
+
export type GlobalShortcutKey =
|
|
40
|
+
| Characters<'abcdefghijklmnopqrstuvwxyz0123456789'>
|
|
41
|
+
| `f${1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12}`
|
|
42
|
+
|
|
43
|
+
/** The closed set of layout-level keys a global shortcut may name. */
|
|
44
|
+
export const GLOBAL_SHORTCUT_KEYS = Object.freeze([
|
|
45
|
+
...'abcdefghijklmnopqrstuvwxyz0123456789',
|
|
46
|
+
...Array.from({ length: 12 }, (_, index) => `f${index + 1}`),
|
|
47
|
+
] as GlobalShortcutKey[]) as readonly GlobalShortcutKey[]
|
|
48
|
+
|
|
49
|
+
export interface ShortcutModifiers {
|
|
50
|
+
control: boolean
|
|
51
|
+
alt: boolean
|
|
52
|
+
shift: boolean
|
|
53
|
+
platform: boolean
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const SHORTCUT_MODIFIERS_KEYS = fields('control', 'alt', 'shift', 'platform')
|
|
57
|
+
|
|
58
|
+
export function parseShortcutModifiers(value: unknown): ShortcutModifiers {
|
|
59
|
+
const data = object(value)
|
|
60
|
+
keys(data, SHORTCUT_MODIFIERS_KEYS)
|
|
61
|
+
const flag = (key: keyof ShortcutModifiers) => {
|
|
62
|
+
if (typeof data[key] !== 'boolean')
|
|
63
|
+
throw new ProtocolError('Global shortcut modifiers must state each modifier explicitly')
|
|
64
|
+
return data[key]
|
|
65
|
+
}
|
|
66
|
+
const modifiers = {
|
|
67
|
+
control: flag('control'),
|
|
68
|
+
alt: flag('alt'),
|
|
69
|
+
shift: flag('shift'),
|
|
70
|
+
platform: flag('platform'),
|
|
71
|
+
}
|
|
72
|
+
if (!modifiers.control && !modifiers.alt && !modifiers.platform)
|
|
73
|
+
throw new ProtocolError('Global shortcut requires control, alt or platform')
|
|
74
|
+
return modifiers
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function parseShortcutKey(value: unknown): string {
|
|
78
|
+
if (typeof value !== 'string' || !(GLOBAL_SHORTCUT_KEYS as readonly string[]).includes(value))
|
|
79
|
+
throw new ProtocolError('Global shortcut key must be one of a-z, 0-9 or f1-f12')
|
|
80
|
+
return value
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Plain-text notification content: one nonempty title line and a body that
|
|
84
|
+
* may hold line feeds but no other control characters. Neither is markup. */
|
|
85
|
+
export function parseNotificationContent(title: unknown, body: unknown) {
|
|
86
|
+
const parsedTitle = string(title, 'notification title', MAX_NOTIFICATION_TITLE_BYTES)
|
|
87
|
+
if (!parsedTitle || /\p{Cc}/u.test(parsedTitle))
|
|
88
|
+
throw new ProtocolError('Notification title must be nonempty and contain no control characters')
|
|
89
|
+
const parsedBody = string(body, 'notification body', MAX_NOTIFICATION_BODY_BYTES)
|
|
90
|
+
if (/[^\P{Cc}\n]/u.test(parsedBody))
|
|
91
|
+
throw new ProtocolError(
|
|
92
|
+
'Notification body may contain no control characters other than line feeds'
|
|
93
|
+
)
|
|
94
|
+
return { title: parsedTitle, body: parsedBody }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export const NOTIFICATION_DISMISSALS = Object.freeze(['expired', 'dismissed', 'closed'] as const)
|
|
98
|
+
export type NotificationDismissal = (typeof NOTIFICATION_DISMISSALS)[number]
|
|
99
|
+
|
|
100
|
+
export function parseNotificationDismissal(value: unknown): NotificationDismissal {
|
|
101
|
+
if (!NOTIFICATION_DISMISSALS.includes(value as NotificationDismissal))
|
|
102
|
+
throw new ProtocolError('Unknown notification dismissal reason')
|
|
103
|
+
return value as NotificationDismissal
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export type ClipboardText = { kind: 'empty' } | { kind: 'text'; text: string }
|
|
107
|
+
|
|
108
|
+
const CLIPBOARD_TEXT_EMPTY_KEYS = fields('kind')
|
|
109
|
+
const CLIPBOARD_TEXT_READ_KEYS = fields('kind', 'text')
|
|
110
|
+
|
|
111
|
+
export function parseClipboardText(value: unknown): ClipboardText {
|
|
112
|
+
const data = object(value)
|
|
113
|
+
if (data['kind'] === 'empty') {
|
|
114
|
+
keys(data, CLIPBOARD_TEXT_EMPTY_KEYS)
|
|
115
|
+
return { kind: 'empty' }
|
|
116
|
+
}
|
|
117
|
+
keys(data, CLIPBOARD_TEXT_READ_KEYS)
|
|
118
|
+
if (data['kind'] !== 'text') throw new ProtocolError('Unknown clipboard text result')
|
|
119
|
+
return { kind: 'text', text: string(data['text'], 'clipboard text', MAX_CLIPBOARD_BYTES) }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
interface DisplayBounds {
|
|
123
|
+
x: number
|
|
124
|
+
y: number
|
|
125
|
+
width: number
|
|
126
|
+
height: number
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export interface DisplaySnapshot {
|
|
130
|
+
bounds: DisplayBounds
|
|
131
|
+
scale_factor: number
|
|
132
|
+
/** Work area in the logical coordinates of `bounds` (`display_details_v1` only). */
|
|
133
|
+
visible_bounds?: DisplayBounds
|
|
134
|
+
/** Platform display identifier, lowercase hyphenated (`display_identity_v1` only). */
|
|
135
|
+
display_uuid?: string
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const DISPLAY_UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
|
|
139
|
+
|
|
140
|
+
const DISPLAY_BOUNDS_KEYS = fields('x', 'y', 'width', 'height')
|
|
141
|
+
|
|
142
|
+
function displayBounds(value: unknown, name: string): DisplayBounds {
|
|
143
|
+
const bounds = object(value)
|
|
144
|
+
keys(bounds, DISPLAY_BOUNDS_KEYS)
|
|
145
|
+
return {
|
|
146
|
+
x: number(bounds['x'], name + ' x', -1_048_576, 1_048_576),
|
|
147
|
+
y: number(bounds['y'], name + ' y', -1_048_576, 1_048_576),
|
|
148
|
+
width: number(bounds['width'], name + ' width', Number.MIN_VALUE, 1_048_576),
|
|
149
|
+
height: number(bounds['height'], name + ' height', Number.MIN_VALUE, 1_048_576),
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const SERVICE_POLICY_KEYS = fields(
|
|
154
|
+
'window_display',
|
|
155
|
+
'clipboard_read',
|
|
156
|
+
'clipboard_write',
|
|
157
|
+
'global_shortcuts',
|
|
158
|
+
'notifications',
|
|
159
|
+
'drag_drop',
|
|
160
|
+
'filesystem_roots',
|
|
161
|
+
'processes',
|
|
162
|
+
'file_dialogs',
|
|
163
|
+
'app_menu',
|
|
164
|
+
'task_dialogs'
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
export function parseServicePolicy(value: unknown): ServicePolicy {
|
|
168
|
+
const data = object(value)
|
|
169
|
+
keys(data, SERVICE_POLICY_KEYS)
|
|
170
|
+
const grant = (key: keyof ServicePolicy) => {
|
|
171
|
+
if (typeof data[key] !== 'boolean')
|
|
172
|
+
throw new ProtocolError('Native service policy must explicitly grant or deny each operation')
|
|
173
|
+
return data[key]
|
|
174
|
+
}
|
|
175
|
+
return {
|
|
176
|
+
window_display: grant('window_display'),
|
|
177
|
+
clipboard_read: grant('clipboard_read'),
|
|
178
|
+
clipboard_write: grant('clipboard_write'),
|
|
179
|
+
global_shortcuts: grant('global_shortcuts'),
|
|
180
|
+
notifications: grant('notifications'),
|
|
181
|
+
drag_drop: grant('drag_drop'),
|
|
182
|
+
filesystem_roots: parseFilesystemRoots(data['filesystem_roots']),
|
|
183
|
+
processes: parseProcessExecutables(data['processes']),
|
|
184
|
+
...('file_dialogs' in data ? { file_dialogs: grant('file_dialogs') } : {}),
|
|
185
|
+
...('app_menu' in data ? { app_menu: grant('app_menu') } : {}),
|
|
186
|
+
...('task_dialogs' in data ? { task_dialogs: grant('task_dialogs') } : {}),
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const DISPLAY_SNAPSHOT_KEYS = fields('bounds', 'scale_factor', 'visible_bounds', 'display_uuid')
|
|
191
|
+
|
|
192
|
+
export function parseDisplaySnapshot(value: unknown): DisplaySnapshot {
|
|
193
|
+
const data = object(value)
|
|
194
|
+
keys(data, DISPLAY_SNAPSHOT_KEYS)
|
|
195
|
+
const snapshot: DisplaySnapshot = {
|
|
196
|
+
bounds: displayBounds(data['bounds'], 'display'),
|
|
197
|
+
scale_factor: number(data['scale_factor'], 'display scale factor', 0.125, 16),
|
|
198
|
+
}
|
|
199
|
+
if ('visible_bounds' in data)
|
|
200
|
+
snapshot.visible_bounds = displayBounds(data['visible_bounds'], 'display work area')
|
|
201
|
+
if ('display_uuid' in data) {
|
|
202
|
+
const uuid = string(data['display_uuid'], 'display UUID', 36)
|
|
203
|
+
if (!DISPLAY_UUID.test(uuid))
|
|
204
|
+
throw new ProtocolError('Native display identity must be a lowercase hyphenated UUID')
|
|
205
|
+
snapshot.display_uuid = uuid
|
|
206
|
+
}
|
|
207
|
+
return snapshot
|
|
208
|
+
}
|
package/src/values.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { ProtocolError } from './framing.ts'
|
|
2
|
+
|
|
3
|
+
export const MAX_TEXT_BYTES = 16_384
|
|
4
|
+
export const MAX_TITLE_BYTES = 256
|
|
5
|
+
const encoder = new TextEncoder()
|
|
6
|
+
|
|
7
|
+
export function object(value: unknown): Record<string, unknown> {
|
|
8
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value))
|
|
9
|
+
throw new ProtocolError('Message must be an object')
|
|
10
|
+
return value as Record<string, unknown>
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** A message's accepted property names, hoisted to module scope so validation allocates nothing. */
|
|
14
|
+
export function fields(...names: readonly string[]): ReadonlySet<string> {
|
|
15
|
+
return new Set(names)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Reject own enumerable properties outside `allowed`; inherited keys are ignored. */
|
|
19
|
+
export function keys(value: Record<string, unknown>, allowed: ReadonlySet<string>): void {
|
|
20
|
+
for (const key of Object.keys(value))
|
|
21
|
+
if (!allowed.has(key)) throw new ProtocolError('Unknown message property: ' + key)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function number(value: unknown, field: string, min: number, max: number): number {
|
|
25
|
+
if (typeof value !== 'number' || !Number.isFinite(value) || value < min || value > max)
|
|
26
|
+
throw new ProtocolError(field + ' must be finite and between ' + min + ' and ' + max)
|
|
27
|
+
return value
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function integer(
|
|
31
|
+
value: unknown,
|
|
32
|
+
field: string,
|
|
33
|
+
min = 0,
|
|
34
|
+
max = Number.MAX_SAFE_INTEGER
|
|
35
|
+
): number {
|
|
36
|
+
const result = number(value, field, min, max)
|
|
37
|
+
if (!Number.isSafeInteger(result)) throw new ProtocolError(field + ' must be a safe integer')
|
|
38
|
+
return result
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function id(value: unknown): number {
|
|
42
|
+
return integer(value, 'Identifier', 1)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* UTF-8 length bound without encoding when the UTF-16 length decides it: each
|
|
47
|
+
* code unit encodes to one to three bytes (a surrogate pair to four).
|
|
48
|
+
*/
|
|
49
|
+
function withinBytes(value: string, limit: number): boolean {
|
|
50
|
+
if (value.length > limit) return false
|
|
51
|
+
if (value.length * 3 <= limit) return true
|
|
52
|
+
return encoder.encode(value).byteLength <= limit
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function string(value: unknown, field: string, limit = MAX_TEXT_BYTES): string {
|
|
56
|
+
if (typeof value !== 'string' || !withinBytes(value, limit))
|
|
57
|
+
throw new ProtocolError(field + ' must be a string within ' + limit + ' UTF-8 bytes')
|
|
58
|
+
if (!value.isWellFormed()) throw new ProtocolError(field + ' contains an unpaired surrogate')
|
|
59
|
+
if (value.includes('\0')) throw new ProtocolError(field + ' contains a NUL character')
|
|
60
|
+
return value
|
|
61
|
+
}
|