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