@standardagents/code-web 0.14.1-issue.663.fd4c3f → 0.14.4-branch.9e48e914.ee06ef

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.
Files changed (37) hide show
  1. package/README.md +9 -1
  2. package/dist/build.json +1 -1
  3. package/dist/licenses/jco-transpile.txt +220 -0
  4. package/dist/manifest.json +1 -1
  5. package/dist/manifest.sig +1 -1
  6. package/dist/pkg/standard_web.d.ts +44 -6
  7. package/dist/pkg/standard_web.js +292 -20
  8. package/dist/pkg/standard_web_bg.wasm +0 -0
  9. package/dist/pkg/standard_web_bg.wasm.d.ts +11 -6
  10. package/dist/plugins/clock-cells/clock_cells.core.wasm +0 -0
  11. package/dist/plugins/clock-cells/clock_cells.wasm +0 -0
  12. package/dist/plugins/clock-cells/standard-plugin.json +11 -0
  13. package/dist/plugins/host/transpile.js +51 -0
  14. package/dist/plugins/host/ui-jspi.js +14851 -0
  15. package/dist/plugins/host/ui.core2.wasm +0 -0
  16. package/dist/plugins/host/ui.core3.wasm +0 -0
  17. package/dist/plugins/host/ui.js +14675 -0
  18. package/dist/plugins/host/world.js +172 -0
  19. package/dist/plugins/jco/cli.js +141 -0
  20. package/dist/plugins/jco/common.js +16 -0
  21. package/dist/plugins/jco/config.js +7 -0
  22. package/dist/plugins/jco/environment.js +24 -0
  23. package/dist/plugins/jco/filesystem.js +339 -0
  24. package/dist/plugins/jco/in-memory-filesystem.js +620 -0
  25. package/dist/plugins/jco/io.js +366 -0
  26. package/dist/plugins/jco/js-component-bindgen-component.core.wasm +0 -0
  27. package/dist/plugins/jco/js-component-bindgen-component.core2.wasm +0 -0
  28. package/dist/plugins/jco/js-component-bindgen-component.js +14272 -0
  29. package/dist/plugins/jco/opfs-filesystem.js +245 -0
  30. package/dist/plugins/jco/random.js +51 -0
  31. package/dist/plugins/plasma-pixels/plasma_pixels.core.wasm +0 -0
  32. package/dist/plugins/plasma-pixels/plasma_pixels.wasm +0 -0
  33. package/dist/plugins/plasma-pixels/standard-plugin.json +11 -0
  34. package/dist/plugins/runtime.js +1044 -0
  35. package/dist/plugins/samples.json +81 -0
  36. package/dist/plugins/worker.js +411 -0
  37. package/package.json +8 -2
@@ -0,0 +1,1044 @@
1
+ // The browser plugin host inside a plugin's Worker: the WIT imports of the
2
+ // `ui-plugin` world (wit/standard-plugin/plugin.wit) implemented against the
3
+ // same rules as the native host (crates/standard-plugin-host), and the copy
4
+ // of committed surface slots into the buffers the viewer samples.
5
+ //
6
+ // Nothing here touches the DOM or the network; worker.js wires it to a
7
+ // transpiled component and to the viewer's messages, and the tests drive it
8
+ // directly.
9
+
10
+ /** Bytes before slot 0 of a plugin's region: eight u32 header words. */
11
+ export const HEADER_LEN = 32
12
+ /** Bytes per cell in the cells model. */
13
+ export const CELL_LEN = 16
14
+ /** Bytes per pixel in the pixels model. */
15
+ export const PIXEL_LEN = 4
16
+ /** The largest surface either model addresses, in cells. */
17
+ export const MAX_COLS = 1024
18
+ export const MAX_ROWS = 512
19
+ /** Bytes before each output slot's data: `seq`, `gen` (u32 each), padding. */
20
+ export const SLOT_HEADER = 16
21
+ /** Output slots per surface: the viewer holds one, the worker writes another. */
22
+ export const RING_SLOTS = 3
23
+ /** Unpublished commits' rectangles merged past this become a full copy. */
24
+ export const MAX_MERGED_RECTS = 64
25
+
26
+ const MODELS = { cells: 0, pixels: 1 }
27
+ /** Anchors instanced per pane, per machine or per project. */
28
+ const INSTANCED = ['pane.footer', 'pane.header', 'machine.after', 'project.after']
29
+ /** Anchors shown only when opened. */
30
+ const OPENS = ['stage', 'panel.popover']
31
+ /** The xterm defaults of the 16 ANSI colours. */
32
+ const XTERM_PALETTE = [
33
+ 0x000000, 0xcd0000, 0x00cd00, 0xcdcd00, 0x0000ee, 0xcd00cd, 0x00cdcd, 0xe5e5e5,
34
+ 0x7f7f7f, 0xff0000, 0x00ff00, 0xffff00, 0x5c5cff, 0xff00ff, 0x00ffff, 0xffffff,
35
+ ]
36
+
37
+ /** A manifest's `model`: one model, or several in preference order. */
38
+ export function modelsOf(decl) {
39
+ return Array.isArray(decl.model) ? decl.model : [decl.model]
40
+ }
41
+
42
+ // ── grants (crates/standard-plugin-host/src/grants.rs) ─────────────────────
43
+
44
+ function covers(granted, needed) {
45
+ if (granted === needed) return true
46
+ const split = granted.indexOf(':')
47
+ const neededSplit = needed.indexOf(':')
48
+ if (split < 0 || neededSplit < 0) return false
49
+ if (granted.slice(0, split) !== needed.slice(0, neededSplit)) return false
50
+ const pattern = granted.slice(split + 1)
51
+ if (!pattern.endsWith('*')) return false
52
+ const prefix = pattern.slice(0, -1)
53
+ return prefix === '' || needed.slice(neededSplit + 1).startsWith(prefix)
54
+ }
55
+
56
+ /** The approved grants of one plugin. */
57
+ export class Grants {
58
+ constructor(pluginId, granted) {
59
+ this.pluginId = pluginId
60
+ this.granted = [...new Set(granted)]
61
+ }
62
+
63
+ allows(needed) {
64
+ return this.granted.some(granted => covers(granted, needed))
65
+ }
66
+
67
+ /** The missing grant, or `null` when `needed` is covered. */
68
+ check(needed) {
69
+ return this.allows(needed) ? null : needed
70
+ }
71
+
72
+ /** Whether a `url.open:` grant covers `host` (lowercase). */
73
+ allowsUrlOpen(host) {
74
+ return this.granted.some(granted => granted.startsWith('url.open:')
75
+ && hostPatternCovers(granted.slice('url.open:'.length), host))
76
+ }
77
+
78
+ /** A key, prefix or event name inside `<id>.` needs no grant. */
79
+ isOwnNamespace(subject) {
80
+ return subject.startsWith(this.pluginId) && subject[this.pluginId.length] === '.'
81
+ }
82
+
83
+ checkNamespaced(kind, subject) {
84
+ return this.isOwnNamespace(subject) ? null : this.check(`${kind}:${subject}`)
85
+ }
86
+ }
87
+
88
+ /** Whether host pattern `pattern` (`url.open:<pattern>`) covers `host`
89
+ * (`standard_plugin_manifest::grants`): the host, `*.<domain>` for the
90
+ * domain and its subdomains, `*` for any. */
91
+ function hostPatternCovers(pattern, host) {
92
+ if (pattern === '*') return true
93
+ if (pattern.startsWith('*.')) {
94
+ const domain = pattern.slice(2).toLowerCase()
95
+ return host === domain || host.endsWith(`.${domain}`)
96
+ }
97
+ return host === pattern.toLowerCase()
98
+ }
99
+
100
+ /** The longest URL `url.open` takes, and how long after input it may. */
101
+ export const URL_MAX_BYTES = 2048
102
+ export const URL_OPEN_WINDOW_MS = 1000
103
+
104
+ /** The lowercase host of a URL `url.open` accepts, or `null`
105
+ * (`standard_plugin_manifest::url::https_host`): plain `https://`, no
106
+ * credentials, a host of letters, digits, dots and dashes, no whitespace
107
+ * or control characters. */
108
+ export function httpsHost(url) {
109
+ if (typeof url !== 'string' || new TextEncoder().encode(url).length > URL_MAX_BYTES) return null
110
+ // eslint-disable-next-line no-control-regex
111
+ if (/[\s\u0000-\u001f\u007f-\u009f]/u.test(url)) return null
112
+ if (url.slice(0, 8).toLowerCase() !== 'https://') return null
113
+ const authority = url.slice(8).split(/[/?#]/)[0]
114
+ if (authority.includes('@')) return null
115
+ let host = authority
116
+ const colon = authority.lastIndexOf(':')
117
+ if (colon >= 0) {
118
+ const port = authority.slice(colon + 1)
119
+ if (!/^[0-9]+$/.test(port)) return null
120
+ host = authority.slice(0, colon)
121
+ }
122
+ if (!/^[A-Za-z0-9.-]+$/.test(host)) return null
123
+ return host.toLowerCase()
124
+ }
125
+
126
+ /** The grants a manifest carries: `{ "<grant>": "<reason>" }` or a list. */
127
+ export function manifestGrants(manifest) {
128
+ const grants = manifest?.grants ?? {}
129
+ return Array.isArray(grants) ? grants.map(String) : Object.keys(grants)
130
+ }
131
+
132
+ // ── layout (crates/standard-plugin-host/src/buffer.rs) ─────────────────────
133
+
134
+ /** The geometry a surface gets: cells, clamped, with its pixel size. */
135
+ export function geometry(cols, rows, cellPxW, cellPxH) {
136
+ cols = Math.min(cols >>> 0, MAX_COLS)
137
+ rows = Math.min(rows >>> 0, MAX_ROWS)
138
+ return { cols, rows, pxW: cols * cellPxW, pxH: rows * cellPxH, cellPxW, cellPxH }
139
+ }
140
+
141
+ export function geometryEmpty(g) {
142
+ return !g || g.cols === 0 || g.rows === 0
143
+ }
144
+
145
+ export function sameGeometry(a, b) {
146
+ return a.cols === b.cols && a.rows === b.rows && a.pxW === b.pxW && a.pxH === b.pxH
147
+ && a.cellPxW === b.cellPxW && a.cellPxH === b.cellPxH
148
+ }
149
+
150
+ /** `surface.region-layout` for a model and geometry. */
151
+ export function regionLayout(model, g) {
152
+ const slotLen = model === 'cells' ? g.cols * g.rows * CELL_LEN : g.pxW * g.pxH * PIXEL_LEN
153
+ return { len: HEADER_LEN + 2 * slotLen, headerLen: HEADER_LEN, slotLen, model, geometry: g }
154
+ }
155
+
156
+ /** Whether a region header describes exactly this layout (seq aside). */
157
+ export function headerMatches(layout, bytes) {
158
+ if (bytes.byteLength < HEADER_LEN) return false
159
+ const view = new DataView(bytes.buffer, bytes.byteOffset, HEADER_LEN)
160
+ const g = layout.geometry
161
+ const expected = [MODELS[layout.model], g.cols, g.rows, g.pxW, g.pxH, g.cellPxW, g.cellPxH]
162
+ return expected.every((word, index) => view.getUint32((index + 1) * 4, true) === word)
163
+ }
164
+
165
+ /** The model's addressable size: cells or pixels. */
166
+ export function extent(model, g) {
167
+ return model === 'cells' ? [g.cols, g.rows] : [g.pxW, g.pxH]
168
+ }
169
+
170
+ /** The part of `[x, y, w, h]` inside `width` by `height`, or `null`. */
171
+ export function clampRect(rect, width, height) {
172
+ const [x, y, w, h] = rect
173
+ const right = Math.min(x + w, width)
174
+ const bottom = Math.min(y + h, height)
175
+ return x < right && y < bottom ? [x, y, right - x, bottom - y] : null
176
+ }
177
+
178
+ /** Bytes per unit of a rectangle (a cell or a pixel). */
179
+ function unitLen(model) {
180
+ return model === 'cells' ? CELL_LEN : PIXEL_LEN
181
+ }
182
+
183
+ /**
184
+ * Copies `rects` of one slot image from `source` (at `sourceOffset`) into
185
+ * `target` (at `targetOffset`). Both images have the same layout: `width`
186
+ * units per row, `unit` bytes per unit. Rows a rectangle spans whole are one
187
+ * copy.
188
+ */
189
+ export function copyRects(source, sourceOffset, target, targetOffset, rects, width, unit) {
190
+ const stride = width * unit
191
+ let bytes = 0
192
+ for (const [x, y, w, h] of rects) {
193
+ if (x === 0 && w === width) {
194
+ const start = y * stride
195
+ const length = h * stride
196
+ target.set(source.subarray(sourceOffset + start, sourceOffset + start + length), targetOffset + start)
197
+ bytes += length
198
+ continue
199
+ }
200
+ const length = w * unit
201
+ for (let row = y; row < y + h; row++) {
202
+ const start = row * stride + x * unit
203
+ target.set(source.subarray(sourceOffset + start, sourceOffset + start + length), targetOffset + start)
204
+ bytes += length
205
+ }
206
+ }
207
+ return bytes
208
+ }
209
+
210
+ /** Rectangles accumulated across commits: a list, or everything. */
211
+ export class Dirty {
212
+ constructor(full = false) {
213
+ this.full = full
214
+ this.rects = []
215
+ }
216
+
217
+ add(rects, full) {
218
+ if (this.full) return
219
+ if (full) {
220
+ this.full = true
221
+ this.rects = []
222
+ return
223
+ }
224
+ this.rects.push(...rects)
225
+ if (this.rects.length > MAX_MERGED_RECTS) {
226
+ this.full = true
227
+ this.rects = []
228
+ }
229
+ }
230
+
231
+ empty() {
232
+ return !this.full && this.rects.length === 0
233
+ }
234
+
235
+ /** The rectangles to copy, clamped; the whole image when full. */
236
+ resolve(width, height) {
237
+ if (this.full) return [[0, 0, width, height]]
238
+ return this.rects.map(rect => clampRect(rect, width, height)).filter(Boolean)
239
+ }
240
+
241
+ take() {
242
+ const taken = { full: this.full, rects: this.rects }
243
+ this.full = false
244
+ this.rects = []
245
+ return taken
246
+ }
247
+ }
248
+
249
+ // ── the viewer's buffers for one surface ────────────────────────────────────
250
+
251
+ /**
252
+ * The output of one surface at one size: `RING_SLOTS` buffers of
253
+ * `SLOT_HEADER + slot-len` bytes each, the viewer's to sample. A slot is
254
+ * either the worker's (it may write it) or the viewer's (published, and
255
+ * never written until the viewer releases it), so the viewer never reads a
256
+ * slot being written. Each slot holds a whole image: before a slot is
257
+ * published, everything that changed since its content was written is
258
+ * copied into it from the plugin's committed slot.
259
+ *
260
+ * With `SharedArrayBuffer`s both sides keep references and only the
261
+ * ownership moves (in messages); with plain `ArrayBuffer`s the buffer itself
262
+ * is transferred each way.
263
+ */
264
+ export class SurfaceOutput {
265
+ constructor({ model, geometry: g, gen, buffers, shared }) {
266
+ this.model = model
267
+ this.geometry = g
268
+ this.gen = gen
269
+ this.shared = shared
270
+ this.layout = regionLayout(model, g)
271
+ this.width = extent(model, g)[0]
272
+ this.height = extent(model, g)[1]
273
+ this.slots = buffers.map(buffer => ({ buffer, owned: true, missing: new Dirty(true) }))
274
+ this.seq = 0
275
+ /** What changed since the last publish: what the viewer must redraw.
276
+ * The viewer has nothing at this size yet, so the first publish is
277
+ * whole. */
278
+ this.unpublished = new Dirty(true)
279
+ /** A commit that found no free slot waits here for a release. */
280
+ this.deferred = null
281
+ }
282
+
283
+ /**
284
+ * Publishes one commit. `source()` returns the plugin's memory as bytes
285
+ * and the offset of its committed slot, read at copy time (linear memory
286
+ * may have grown since). Returns the message fields and the buffer to
287
+ * hand over, or `null` when every slot is the viewer's (the commit waits
288
+ * for a release and carries into the next publish).
289
+ */
290
+ publish(source, rects, full) {
291
+ for (const slot of this.slots) slot.missing.add(rects, full)
292
+ this.unpublished.add(rects, full)
293
+ const index = this.slots.findIndex(slot => slot.owned && slot.buffer)
294
+ if (index < 0) {
295
+ this.deferred = source
296
+ return null
297
+ }
298
+ this.deferred = null
299
+ return this.write(index, source)
300
+ }
301
+
302
+ write(index, source) {
303
+ const slot = this.slots[index]
304
+ const { bytes, offset } = source()
305
+ const data = new Uint8Array(slot.buffer, SLOT_HEADER, this.layout.slotLen)
306
+ const words = new Int32Array(slot.buffer, 0, 2)
307
+ // Invalid while written: a viewer that ever looked here would see a
308
+ // sequence number that matches no commit.
309
+ Atomics.store(words, 0, 0)
310
+ const rects = slot.missing.resolve(this.width, this.height)
311
+ const copied = copyRects(bytes, offset, data, 0, rects, this.width, unitLen(this.model))
312
+ slot.missing = new Dirty()
313
+ this.seq = (this.seq + 1) >>> 0 || 1
314
+ Atomics.store(words, 1, this.gen)
315
+ Atomics.store(words, 0, this.seq)
316
+ slot.owned = false
317
+ const changed = this.unpublished.take()
318
+ const buffer = slot.buffer
319
+ if (!this.shared) slot.buffer = null
320
+ return {
321
+ message: {
322
+ gen: this.gen,
323
+ slot: index,
324
+ seq: this.seq,
325
+ full: changed.full,
326
+ rects: changed.full ? [] : changed.rects.map(rect => clampRect(rect, this.width, this.height)).filter(Boolean),
327
+ },
328
+ buffer: this.shared ? null : buffer,
329
+ copied,
330
+ }
331
+ }
332
+
333
+ /** The viewer is done with `index`. Publishes a waiting commit, if any. */
334
+ release(index, buffer) {
335
+ const slot = this.slots[index]
336
+ if (!slot || slot.owned) return null
337
+ if (!this.shared) {
338
+ if (!buffer || buffer.byteLength !== SLOT_HEADER + this.layout.slotLen) return null
339
+ slot.buffer = buffer
340
+ }
341
+ slot.owned = true
342
+ if (!this.deferred) return null
343
+ const source = this.deferred
344
+ this.deferred = null
345
+ return this.write(index, source)
346
+ }
347
+ }
348
+
349
+ // ── the host imports ─────────────────────────────────────────────────────────
350
+
351
+ function err(tag, val) {
352
+ return val === undefined ? { tag: 'err', val: { tag } } : { tag: 'err', val: { tag, val } }
353
+ }
354
+
355
+ const VALUE_MAX_BYTES = 256 * 1024
356
+
357
+ /** `send`s and `call-async`s of one plugin in flight at once. */
358
+ export const MAX_CALLS_IN_FLIGHT = 32
359
+
360
+ /** A call the viewer puts to the account; `timeoutMs` absent is the default. */
361
+ function callOf(method, payload, to, timeoutMs) {
362
+ const call = { op: 'call', method, payload, to: to ?? { tag: 'singleton' } }
363
+ if (timeoutMs !== undefined && timeoutMs !== null) call.timeoutMs = timeoutMs >>> 0
364
+ return call
365
+ }
366
+
367
+ /** What a call that needs the account answers in a browser without JSPI. */
368
+ export const NEEDS_JSPI = 'unsupported: this browser cannot wait for the account (no JSPI); see docs/plugin-runtime.md'
369
+
370
+ /** The event a failed fire-and-forget write reports (the mirror mode). */
371
+ export const WRITE_FAILED_EVENT = 'system.plugin.write-failed'
372
+
373
+ /** A viewer's `{ ok }` / `{ err: { tag, val? } }` reply as the WIT result. */
374
+ export function witResult(reply) {
375
+ if (reply && reply.err) return { tag: 'err', val: reply.err }
376
+ return reply?.ok ?? undefined
377
+ }
378
+
379
+ /**
380
+ * What a plugin reads of the account when the browser cannot wait for it:
381
+ * the values under the prefixes it may read, the secrets its grants name,
382
+ * kept current by the viewer (`{ t: 'mirror' }`). `complete` lists the
383
+ * prefixes whose every key is here; a key under another prefix may exist
384
+ * unseen.
385
+ */
386
+ export class Mirror {
387
+ constructor({ values = {}, complete = [], secrets = {} } = {}) {
388
+ this.values = new Map(Object.entries(values))
389
+ this.complete = [...complete]
390
+ this.secrets = new Map(Object.entries(secrets))
391
+ }
392
+
393
+ apply(values) {
394
+ for (const [key, value] of Object.entries(values)) {
395
+ if (value === null) this.values.delete(key)
396
+ else this.values.set(key, value)
397
+ }
398
+ }
399
+
400
+ covers(key) {
401
+ return this.complete.some(prefix => key.startsWith(prefix))
402
+ }
403
+ }
404
+
405
+ /**
406
+ * One plugin instance's host: grants, the account interfaces, the
407
+ * viewer's view state, and the surfaces. `post(message, buffer)` sends to
408
+ * the viewer; `memory()` returns the instance's linear memory once it
409
+ * exists.
410
+ *
411
+ * `grants` are the grants the plugin runs with (its manifest's, and for an
412
+ * account install only those the account approved); the manifest's when
413
+ * absent. `account` selects the account interfaces:
414
+ *
415
+ * - absent: in memory, as the native `InMemoryProvider` (the demo samples);
416
+ * - `{ mode: 'jspi', ask }`: every call goes to the account through the
417
+ * viewer (`ask(call)` resolves with its `{ ok } | { err }`), and the
418
+ * import suspends the plugin until it answers;
419
+ * - `{ mode: 'mirror', ask, mirror }`: reads answer from the mirror, writes
420
+ * go out without waiting and a refusal arrives later as the
421
+ * `system.plugin.write-failed` event, calls and claims are unsupported.
422
+ *
423
+ * `deliver(event)` queues an event for the plugin (the Worker calls it
424
+ * between exports).
425
+ */
426
+ export class PluginHost {
427
+ constructor({ manifest, grants, config = '{}', view, shared, post, memory, account = null, deliver = () => {} }) {
428
+ this.id = manifest.id
429
+ this.grants = new Grants(manifest.id, grants ?? manifestGrants(manifest))
430
+ this.config = config
431
+ this.view = view
432
+ this.shared = shared
433
+ this.post = post
434
+ this.memory = memory
435
+ this.account = account
436
+ this.deliver = deliver
437
+ this.accountState = { machines: [], projects: [], panes: [] }
438
+ this.accountWatched = false
439
+ this.values = new Map()
440
+ this.secrets = new Map()
441
+ this.claims = new Set()
442
+ this.published = []
443
+ this.denials = 0
444
+ this.lastDenied = null
445
+ /** The region the next `attach` names: set by the core-import shim. */
446
+ this.attaching = null
447
+ /** The plugin is handling a user gesture: `surface.open` may run. */
448
+ this.gesture = false
449
+ /** When the viewer last delivered user input, until `url.open` used it. */
450
+ this.inputAt = null
451
+ /** `call-async` ids, unique for this instance's life. */
452
+ this.nextCallId = 1n
453
+ this.callsInFlight = 0
454
+ this.surfaces = new Map((manifest.surfaces ?? []).map(decl => [decl.id, this.newSurface(decl, null)]))
455
+ }
456
+
457
+ newSurface(decl, owner) {
458
+ const perMachine = decl.anchor === 'machine.after'
459
+ const perProject = decl.anchor === 'project.after'
460
+ return {
461
+ decl,
462
+ pane: perMachine || perProject ? null : owner,
463
+ machine: perMachine ? owner : null,
464
+ project: perProject ? owner : null,
465
+ requested: null,
466
+ // The viewer names the model it picked with every resize.
467
+ model: modelsOf(decl)[0],
468
+ geometry: geometry(0, 0, 0, 0),
469
+ region: null,
470
+ pendingRegion: null,
471
+ committed: false,
472
+ output: null,
473
+ }
474
+ }
475
+
476
+ /** A surface by id; a pane instance (`<surface>@<pane>`) of a footer or
477
+ * header, a machine instance (`<surface>@<machine>`) of a machine row or
478
+ * a project instance (`<surface>@<project>`) of a project row, is
479
+ * created the first time the viewer sizes it. */
480
+ surface(id, create = false) {
481
+ const known = this.surfaces.get(id)
482
+ if (known || !create) return known
483
+ const at = id.indexOf('@')
484
+ const template = at > 0 ? this.surfaces.get(id.slice(0, at)) : null
485
+ if (!template || template.pane != null || template.machine != null || template.project != null
486
+ || !INSTANCED.includes(template.decl.anchor)) return undefined
487
+ const instance = this.newSurface(template.decl, id.slice(at + 1))
488
+ this.surfaces.set(id, instance)
489
+ return instance
490
+ }
491
+
492
+ denied(grant) {
493
+ this.denials += 1
494
+ this.lastDenied = grant
495
+ this.post({ t: 'denied', grant })
496
+ return err('grant-denied', grant)
497
+ }
498
+
499
+ check(grant) {
500
+ return this.grants.check(grant)
501
+ }
502
+
503
+ namespaced(kind, subject) {
504
+ return this.grants.checkNamespaced(kind, subject)
505
+ }
506
+
507
+ /** The viewer resized a surface: the ring for its new size. */
508
+ resize(id, g, gen, buffers, model) {
509
+ const surface = this.surface(id, true)
510
+ if (!surface) return
511
+ if (model && model !== surface.model) {
512
+ // Another model: the old region describes nothing the viewer reads.
513
+ surface.model = model
514
+ surface.region = null
515
+ surface.pendingRegion = null
516
+ surface.committed = false
517
+ }
518
+ surface.geometry = g
519
+ surface.output = geometryEmpty(g) ? null : new SurfaceOutput({
520
+ model: surface.model, geometry: g, gen, buffers, shared: this.shared,
521
+ })
522
+ }
523
+
524
+ /** The viewer is done with a slot it held. */
525
+ release(id, gen, slot, buffer) {
526
+ const surface = this.surfaces.get(id)
527
+ const output = surface?.output
528
+ if (!output || output.gen !== gen) return
529
+ this.send(id, output.release(slot, buffer))
530
+ }
531
+
532
+ send(id, published) {
533
+ if (!published) return
534
+ this.post({ t: 'commit', surface: id, ...published.message }, published.buffer)
535
+ }
536
+
537
+ layout(id) {
538
+ const surface = this.surfaces.get(id)
539
+ if (!surface) return err('invalid', `no surface ${JSON.stringify(id)} in the manifest`)
540
+ return regionLayout(surface.model, surface.geometry)
541
+ }
542
+
543
+ attach(id, region) {
544
+ const pointer = this.attaching
545
+ this.attaching = null
546
+ const surface = this.surfaces.get(id)
547
+ if (!surface) return err('invalid', `no surface ${JSON.stringify(id)} in the manifest`)
548
+ const layout = regionLayout(surface.model, surface.geometry)
549
+ if (geometryEmpty(surface.geometry)) return err('invalid', `surface ${JSON.stringify(id)} has no size yet`)
550
+ if (!pointer || pointer.len !== region.byteLength) return err('invalid', 'the region is not in linear memory')
551
+ if (region.byteLength < layout.len) {
552
+ return err('invalid', `region of ${region.byteLength} bytes is smaller than the ${layout.len} the layout needs`)
553
+ }
554
+ if (!headerMatches(layout, region)) return err('invalid', 'region header does not describe the current layout')
555
+ const bound = { base: pointer.ptr, len: pointer.len, layout }
556
+ if (surface.region) surface.pendingRegion = bound
557
+ else surface.region = bound
558
+ return undefined
559
+ }
560
+
561
+ commit(id, slot, dirty) {
562
+ const surface = this.surfaces.get(id)
563
+ if (!surface) return err('invalid', `no surface ${JSON.stringify(id)} in the manifest`)
564
+ if (!surface.region && !surface.pendingRegion) return err('invalid', `surface ${JSON.stringify(id)} has no region attached`)
565
+ if (slot > 1) return err('invalid', 'slot must be 0 or 1')
566
+ let full = Boolean(surface.pendingRegion) || !surface.committed
567
+ const rects = dirty.map(rect => [rect.x, rect.y, rect.w, rect.h])
568
+ if (!full && rects.length === 0) return undefined
569
+ if (surface.pendingRegion) {
570
+ // The first commit after an attach is at the new region's size.
571
+ surface.region = surface.pendingRegion
572
+ surface.pendingRegion = null
573
+ }
574
+ surface.committed = true
575
+ const region = surface.region
576
+ const output = surface.output
577
+ // A region at another size than the viewer's buffers (a commit racing
578
+ // a resize) is never copied: the viewer keeps its last frame until the
579
+ // plugin commits at the new size.
580
+ if (!output || !sameGeometry(region.layout.geometry, output.geometry)) return undefined
581
+ const offset = region.base + HEADER_LEN + slot * region.layout.slotLen
582
+ const memory = this.memory
583
+ const source = () => ({ bytes: new Uint8Array(memory().buffer), offset })
584
+ if (offset + region.layout.slotLen > memory().buffer.byteLength) {
585
+ return err('invalid', 'the region lies outside linear memory')
586
+ }
587
+ this.send(id, output.publish(source, rects, full))
588
+ return undefined
589
+ }
590
+
591
+ /** `surface.open`: a stage or a popover, and only during a user gesture. */
592
+ open(id) {
593
+ const surface = this.surfaces.get(id)
594
+ if (!surface) return err('invalid', `no surface ${JSON.stringify(id)} in the manifest`)
595
+ if (!OPENS.includes(surface.decl.anchor)) {
596
+ return err('invalid', `surface ${JSON.stringify(id)} is placed by the viewer; only a stage or a popover opens`)
597
+ }
598
+ if (!this.gesture) return err('invalid', 'surface.open needs a user gesture: a key, paste or press on one of the plugin\'s surfaces, or one of its commands')
599
+ this.post({ t: 'surface', surface: id, open: true })
600
+ return undefined
601
+ }
602
+
603
+ /** User input reached the plugin: it may open one URL now or shortly. */
604
+ noteInput() {
605
+ this.inputAt = performance.now()
606
+ }
607
+
608
+ /** `url.open`: an https URL on a granted host, on user input, once per
609
+ * input. The viewer checks all of it again and opens the tab. */
610
+ openUrl(url) {
611
+ const host = httpsHost(url)
612
+ if (host == null) {
613
+ return err('invalid', 'url.open takes a plain https:// URL with a host and no whitespace')
614
+ }
615
+ if (!this.grants.allowsUrlOpen(host)) return this.denied(`url.open:${host}`)
616
+ const at = this.inputAt
617
+ if (at == null || (!this.gesture && performance.now() - at > URL_OPEN_WINDOW_MS)) {
618
+ return err('invalid', 'url.open needs user input: a key, paste or press on one of the plugin\'s surfaces, or one of its commands, within the last second')
619
+ }
620
+ this.inputAt = null
621
+ this.post({ t: 'open-url', url })
622
+ return undefined
623
+ }
624
+
625
+ /** `surface.request-size`: bounded here as natively; the viewer clamps it
626
+ * where it places the surface and answers with a resize. */
627
+ requestSize(id, cols, rows) {
628
+ // An instance the viewer has not sized yet (a machine or project row at
629
+ // zero rows) takes a request too, as natively.
630
+ const surface = this.surface(id, true)
631
+ if (!surface) return err('invalid', `no surface ${JSON.stringify(id)} in the manifest`)
632
+ const requested = [Math.min(cols >>> 0, MAX_COLS), Math.min(rows >>> 0, MAX_ROWS)]
633
+ if (surface.requested?.[0] !== requested[0] || surface.requested?.[1] !== requested[1]) {
634
+ surface.requested = requested
635
+ this.post({ t: 'request-size', surface: id, cols: requested[0], rows: requested[1] })
636
+ }
637
+ return undefined
638
+ }
639
+
640
+ close(id) {
641
+ if (!this.surfaces.get(id)) return err('invalid', `no surface ${JSON.stringify(id)} in the manifest`)
642
+ this.post({ t: 'surface', surface: id, open: false })
643
+ return undefined
644
+ }
645
+
646
+ detach(id) {
647
+ const surface = this.surfaces.get(id)
648
+ if (!surface) return err('invalid', `no surface ${JSON.stringify(id)} in the manifest`)
649
+ surface.region = null
650
+ surface.pendingRegion = null
651
+ surface.committed = false
652
+ return undefined
653
+ }
654
+
655
+ /** Sends one account call and answers its WIT result (JSPI mode). */
656
+ ask(call) {
657
+ return this.account.ask(call).then(witResult, error => err('unavailable', String(error?.message ?? error)))
658
+ }
659
+
660
+ /**
661
+ * A write without waiting (the mirror mode): the call goes out and a
662
+ * refusal reaches the plugin later as `system.plugin.write-failed`.
663
+ * `undo` puts back what the write changed locally.
664
+ */
665
+ tell(call, undo = () => {}) {
666
+ this.account.ask(call).then((reply) => {
667
+ if (!reply?.err) return
668
+ undo()
669
+ this.deliver({
670
+ tag: 'plugin-event',
671
+ name: WRITE_FAILED_EVENT,
672
+ payload: JSON.stringify({ call: call.op, key: call.key ?? call.name ?? call.prefix ?? call.pattern ?? null, error: reply.err }),
673
+ })
674
+ }, () => {})
675
+ return undefined
676
+ }
677
+
678
+ /**
679
+ * A `send` or `call-async`: goes to the account at once and hands its
680
+ * outcome (`{ tag: 'ok' | 'err', val }`) to `done` when it arrives. At
681
+ * most `MAX_CALLS_IN_FLIGHT` at once, as natively; `rate-limited` past
682
+ * that. Returns an error result, or `undefined` once sent.
683
+ */
684
+ startCall(call, done) {
685
+ if (this.callsInFlight >= MAX_CALLS_IN_FLIGHT) return err('rate-limited')
686
+ this.callsInFlight += 1
687
+ const settle = (outcome) => {
688
+ this.callsInFlight -= 1
689
+ done(outcome)
690
+ }
691
+ this.account.ask(call).then(
692
+ reply => settle(reply?.err ? { tag: 'err', val: reply.err } : { tag: 'ok', val: String(reply?.ok ?? 'null') }),
693
+ error => settle({ tag: 'err', val: { tag: 'unavailable', val: String(error?.message ?? error) } }),
694
+ )
695
+ return undefined
696
+ }
697
+
698
+ /** Whether calls go to the account (an installed plugin). */
699
+ get onAccount() {
700
+ return this.account !== null
701
+ }
702
+
703
+ get waits() {
704
+ return this.account?.mode === 'jspi'
705
+ }
706
+
707
+ /** A read from the mirror: the value, none, or `unavailable` when the
708
+ * key may exist beyond what the mirror holds. */
709
+ mirrored(key) {
710
+ const mirror = this.account.mirror
711
+ if (mirror.values.has(key)) return mirror.values.get(key)
712
+ if (mirror.covers(key)) return undefined
713
+ this.account.ask({ op: 'values.fetch', key }).catch(() => {})
714
+ return err('unavailable', `${key} is not mirrored in this browser yet; ${NEEDS_JSPI}`)
715
+ }
716
+
717
+ /** A value write in the mirror mode: applied locally at once. */
718
+ mirrorWrite(call, key, value) {
719
+ const values = this.account.mirror.values
720
+ const before = values.has(key) ? values.get(key) : null
721
+ if (value === null) values.delete(key)
722
+ else values.set(key, value)
723
+ return this.tell(call, () => {
724
+ // Only if nothing newer arrived since.
725
+ if ((values.get(key) ?? null) !== value) return
726
+ if (before === null) values.delete(key)
727
+ else values.set(key, before)
728
+ })
729
+ }
730
+
731
+ /** The import object a `ui-plugin` component instantiates with. */
732
+ imports() {
733
+ const host = this
734
+ const denial = grant => (grant ? host.denied(grant) : null)
735
+ return {
736
+ 'standard:plugin/types': {},
737
+ 'standard:plugin/event-types': {},
738
+ 'standard:plugin/values': {
739
+ get(key) {
740
+ const denied = denial(host.namespaced('values.read', key))
741
+ if (denied) return denied
742
+ if (!host.onAccount) return host.values.get(key)
743
+ if (host.waits) return host.ask({ op: 'values.get', key })
744
+ return host.mirrored(key)
745
+ },
746
+ set(key, value) {
747
+ const denied = denial(host.namespaced('values.write', key))
748
+ if (denied) return denied
749
+ if (value.length > VALUE_MAX_BYTES) return err('invalid', 'value over 256 KiB')
750
+ if (!host.onAccount) {
751
+ host.values.set(key, value)
752
+ return undefined
753
+ }
754
+ const call = { op: 'values.set', key, value }
755
+ return host.waits ? host.ask(call) : host.mirrorWrite(call, key, value)
756
+ },
757
+ delete(key) {
758
+ const denied = denial(host.namespaced('values.write', key))
759
+ if (denied) return denied
760
+ if (!host.onAccount) {
761
+ host.values.delete(key)
762
+ return undefined
763
+ }
764
+ const call = { op: 'values.delete', key }
765
+ return host.waits ? host.ask(call) : host.mirrorWrite(call, key, null)
766
+ },
767
+ keys(prefix) {
768
+ const denied = denial(host.namespaced('values.read', prefix))
769
+ if (denied) return denied
770
+ const listed = values => [...values.keys()].filter(key => key.startsWith(prefix)).sort()
771
+ if (!host.onAccount) return listed(host.values)
772
+ if (host.waits) return host.ask({ op: 'values.keys', prefix })
773
+ const mirror = host.account.mirror
774
+ if (!mirror.covers(prefix)) return err('unavailable', `keys under ${prefix} are not all mirrored; ${NEEDS_JSPI}`)
775
+ return listed(mirror.values)
776
+ },
777
+ watch(prefix) {
778
+ const denied = denial(host.namespaced('values.read', prefix))
779
+ if (denied) return denied
780
+ // The viewer's router passes on only watched keys; nothing waits.
781
+ if (host.onAccount) host.account.ask({ op: 'values.watch', prefix }).catch(() => {})
782
+ return undefined
783
+ },
784
+ },
785
+ 'standard:plugin/live': {
786
+ publish(key, payload) {
787
+ const denied = denial(host.namespaced('live.publish', key))
788
+ if (denied) return denied
789
+ if (!host.onAccount) {
790
+ host.published.push(['live', key, payload])
791
+ return undefined
792
+ }
793
+ const call = { op: 'live.publish', key, payload }
794
+ return host.waits ? host.ask(call) : host.tell(call)
795
+ },
796
+ delete(key) {
797
+ const denied = denial(host.namespaced('live.publish', key))
798
+ if (denied) return denied
799
+ if (!host.onAccount) {
800
+ host.published.push(['live-delete', key, ''])
801
+ return undefined
802
+ }
803
+ const call = { op: 'live.delete', key }
804
+ return host.waits ? host.ask(call) : host.tell(call)
805
+ },
806
+ subscribe(prefix) {
807
+ const denied = denial(host.namespaced('live.subscribe', prefix))
808
+ if (denied || !host.onAccount) return denied ?? undefined
809
+ const call = { op: 'live.subscribe', prefix, on: true }
810
+ return host.waits ? host.ask(call) : host.tell(call)
811
+ },
812
+ unsubscribe(prefix) {
813
+ const denied = denial(host.namespaced('live.subscribe', prefix))
814
+ if (denied || !host.onAccount) return denied ?? undefined
815
+ const call = { op: 'live.subscribe', prefix, on: false }
816
+ return host.waits ? host.ask(call) : host.tell(call)
817
+ },
818
+ },
819
+ 'standard:plugin/events': {
820
+ emit(name, payload, to) {
821
+ if (name.startsWith('system.')) return host.denied(`events.emit:${name}`)
822
+ const denied = denial(host.namespaced('events.emit', name))
823
+ if (denied) return denied
824
+ if (!host.onAccount) {
825
+ host.published.push(['event', name, payload])
826
+ return undefined
827
+ }
828
+ const call = { op: 'events.emit', name, payload, to: to ?? { tag: 'all' } }
829
+ return host.waits ? host.ask(call) : host.tell(call)
830
+ },
831
+ on(pattern) {
832
+ const denied = denial(host.namespaced('events.on', pattern))
833
+ if (denied || !host.onAccount) return denied ?? undefined
834
+ const call = { op: 'events.listen', pattern, on: true }
835
+ return host.waits ? host.ask(call) : host.tell(call)
836
+ },
837
+ off(pattern) {
838
+ const denied = denial(host.namespaced('events.on', pattern))
839
+ if (denied || !host.onAccount) return denied ?? undefined
840
+ const call = { op: 'events.listen', pattern, on: false }
841
+ return host.waits ? host.ask(call) : host.tell(call)
842
+ },
843
+ },
844
+ 'standard:plugin/calls': {
845
+ call(method, payload, to, timeoutMs) {
846
+ const denied = denial(host.check(`call:${host.id}`))
847
+ if (denied) return denied
848
+ if (!host.onAccount) return err('unavailable', 'companion calls arrive with the account service')
849
+ if (!host.waits) return err('unavailable', NEEDS_JSPI)
850
+ return host.ask(callOf(method, payload, to, timeoutMs))
851
+ },
852
+ // Neither waits, with or without JSPI: the call goes out and the
853
+ // plugin carries on.
854
+ send(method, payload, to) {
855
+ const denied = denial(host.check(`call:${host.id}`))
856
+ if (denied) return denied
857
+ if (!host.onAccount) return err('unavailable', 'companion calls arrive with the account service')
858
+ return host.startCall(callOf(method, payload, to, undefined), () => {})
859
+ },
860
+ callAsync(method, payload, to, timeoutMs) {
861
+ const denied = denial(host.check(`call:${host.id}`))
862
+ if (denied) return denied
863
+ if (!host.onAccount) return err('unavailable', 'companion calls arrive with the account service')
864
+ const id = host.nextCallId
865
+ const started = host.startCall(callOf(method, payload, to, timeoutMs), (outcome) => {
866
+ host.deliver({ tag: 'call-result', id, outcome })
867
+ })
868
+ if (started) return started
869
+ host.nextCallId += 1n
870
+ return id
871
+ },
872
+ },
873
+ 'standard:plugin/config': {
874
+ get() {
875
+ return host.config
876
+ },
877
+ },
878
+ 'standard:plugin/secrets': {
879
+ get(name) {
880
+ const denied = denial(host.check(`secret:${name}`))
881
+ if (denied) return denied
882
+ if (!host.onAccount) return host.secrets.get(name)
883
+ if (host.waits) return host.ask({ op: 'secret.get', name })
884
+ const secrets = host.account.mirror.secrets
885
+ return secrets.has(name) ? (secrets.get(name) ?? undefined) : err('unavailable', NEEDS_JSPI)
886
+ },
887
+ },
888
+ // Claims live in the plugin's own namespace: no grant. In memory one
889
+ // instance holds every claim it takes, as the native in-memory host.
890
+ 'standard:plugin/claims': {
891
+ claim(key, ttlMs) {
892
+ if (!host.onAccount) {
893
+ host.claims.add(key)
894
+ return true
895
+ }
896
+ if (!host.waits) return err('unavailable', NEEDS_JSPI)
897
+ return host.ask({ op: 'claim', key, ttlMs: ttlMs >>> 0 })
898
+ },
899
+ // The holder releases; releasing a claim this session does not
900
+ // hold does nothing. Without JSPI the release is sent and not awaited.
901
+ release(key) {
902
+ if (!host.onAccount) {
903
+ host.claims.delete(key)
904
+ return undefined
905
+ }
906
+ if (host.waits) host.ask({ op: 'claim.release', key })
907
+ else host.tell({ op: 'claim.release', key })
908
+ return undefined
909
+ },
910
+ },
911
+ // The plugin's own report; the viewer lists it in the Plugins view.
912
+ 'standard:plugin/health': {
913
+ set(state, message) {
914
+ host.post({ t: 'health', state, message: String(message).slice(0, 200) })
915
+ },
916
+ },
917
+ 'standard:plugin/account': {
918
+ state() {
919
+ const denied = denial(host.check('account.read'))
920
+ if (denied) return denied
921
+ const state = host.accountState
922
+ // `generation` is a u64: the glue lowers it from a BigInt.
923
+ return { ...state, panes: (state.panes ?? []).map(pane => ({ ...pane, generation: BigInt(pane.generation ?? 0) })) }
924
+ },
925
+ watch() {
926
+ const denied = denial(host.check('account.read'))
927
+ if (denied) return denied
928
+ host.accountWatched = true
929
+ return undefined
930
+ },
931
+ },
932
+ 'standard:plugin/surface': {
933
+ layout: id => host.layout(id),
934
+ attach: (id, region) => host.attach(id, region),
935
+ commit: (id, slot, dirty) => host.commit(id, slot, dirty),
936
+ detach: id => host.detach(id),
937
+ open: id => host.open(id),
938
+ close: id => host.close(id),
939
+ requestSize: (id, cols, rows) => host.requestSize(id, cols, rows),
940
+ },
941
+ 'standard:plugin/url': {
942
+ open: url => host.openUrl(url),
943
+ },
944
+ 'standard:plugin/view': {
945
+ isDriving: () => Boolean(host.view.driving),
946
+ theme() {
947
+ const [fg, bg, recedeFg, recedeBg, accent] = host.view.theme
948
+ const palette = Array.isArray(host.view.palette) && host.view.palette.length === 16
949
+ ? host.view.palette
950
+ : XTERM_PALETTE
951
+ return { fg, bg, recedeFg, recedeBg, accent, palette }
952
+ },
953
+ surfaceMachine: id => host.surfaces.get(id)?.machine ?? undefined,
954
+ surfaceProject: id => host.surfaces.get(id)?.project ?? undefined,
955
+ surfaceTint(id) {
956
+ const surface = host.surfaces.get(id)
957
+ const tints = host.view.tints ?? {}
958
+ if (!surface) return undefined
959
+ if (surface.machine != null) return tints[surface.machine] ?? undefined
960
+ if (surface.project != null) return tints[surface.project] ?? undefined
961
+ if (surface.pane == null) return undefined
962
+ const pane = (host.accountState.panes ?? []).find(entry => entry.id === surface.pane)
963
+ if (!pane) return undefined
964
+ return (pane.project != null ? tints[pane.project] : undefined) ?? tints[pane.machine] ?? undefined
965
+ },
966
+ identityTint: id => (host.view.tints ?? {})[id] ?? undefined,
967
+ capabilities() {
968
+ const [cellPxW, cellPxH] = host.view.cellPx ?? [0, 0]
969
+ const pixelScale = Math.max(1, host.view.pixelScale >>> 0)
970
+ const scaled = size => (size === 0 ? 0 : Math.max(1, Math.floor(size / pixelScale)))
971
+ return {
972
+ graphics: Boolean(host.view.graphics),
973
+ cellPxW: scaled(cellPxW),
974
+ cellPxH: scaled(cellPxH),
975
+ fps: host.view.fps >>> 0,
976
+ pixelScale,
977
+ }
978
+ },
979
+ focusedPane: () => host.view.focusedPane ?? undefined,
980
+ machineId: () => host.view.machineId ?? undefined,
981
+ instanceId: () => String(host.view.instanceId ?? ''),
982
+ // The browser's own clock and zone, read when the plugin asks.
983
+ wallMs: () => BigInt(Date.now()),
984
+ utcOffsetMinutes: () => -new Date().getTimezoneOffset(),
985
+ timeZone: () => Intl.DateTimeFormat().resolvedOptions().timeZone || undefined,
986
+ surfacePane(id) {
987
+ const pane = host.surfaces.get(id)?.pane
988
+ if (pane == null) return undefined
989
+ const known = (host.accountState.panes ?? []).find(entry => entry.id === pane)
990
+ return { id: pane, generation: BigInt(known?.generation ?? 0) }
991
+ },
992
+ // The viewer's clock is its own; 0 means "the next paced frame".
993
+ requestFrame: () => host.post({ t: 'wake', at: 0 }),
994
+ wakeAt: at => host.post({ t: 'wake', at: Number(at) }),
995
+ },
996
+ }
997
+ }
998
+ }
999
+
1000
+ /** The `event` export's value for a viewer event message. */
1001
+ export function witEvent(event) {
1002
+ switch (event.tag) {
1003
+ case 'resize': return { tag: 'resize', val: [event.surface, event.geometry] }
1004
+ case 'visibility': return { tag: 'visibility', val: [event.surface, Boolean(event.visible)] }
1005
+ case 'driving': return { tag: 'driving', val: Boolean(event.driving) }
1006
+ case 'capabilities-changed': return { tag: 'capabilities-changed' }
1007
+ case 'theme-changed': return { tag: 'theme-changed' }
1008
+ case 'focus': return { tag: 'focus', val: [event.surface, Boolean(event.focused)] }
1009
+ case 'command': return { tag: 'command', val: event.id }
1010
+ // A `call-async` finished (the Worker's own delivery).
1011
+ case 'call-result': return { tag: 'call-result', val: { id: BigInt(event.id), outcome: event.outcome } }
1012
+ // The account's pushes, routed to this plugin only by the viewer.
1013
+ case 'value-changed': return { tag: 'value-changed', val: [event.key, event.value ?? undefined] }
1014
+ case 'live': return { tag: 'live', val: [event.key, event.value] }
1015
+ case 'live-deleted': return { tag: 'live-deleted', val: event.key }
1016
+ case 'plugin-event': return { tag: 'plugin-event', val: [event.name, event.payload] }
1017
+ case 'account-changed': return { tag: 'account-changed' }
1018
+ case 'key': return {
1019
+ tag: 'key',
1020
+ val: {
1021
+ surface: event.surface,
1022
+ code: event.code,
1023
+ text: event.text ?? undefined,
1024
+ modifiers: event.modifiers >>> 0,
1025
+ phase: event.phase,
1026
+ },
1027
+ }
1028
+ case 'paste': return { tag: 'paste', val: [event.surface, event.text] }
1029
+ case 'pointer': return {
1030
+ tag: 'pointer',
1031
+ val: {
1032
+ surface: event.surface,
1033
+ x: event.x >>> 0,
1034
+ y: event.y >>> 0,
1035
+ col: event.col >>> 0,
1036
+ row: event.row >>> 0,
1037
+ button: event.button & 0xff,
1038
+ kind: event.kind,
1039
+ modifiers: event.modifiers >>> 0,
1040
+ },
1041
+ }
1042
+ default: throw new Error(`unknown event ${event.tag}`)
1043
+ }
1044
+ }