@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.
- package/README.md +9 -1
- package/dist/build.json +1 -1
- package/dist/licenses/jco-transpile.txt +220 -0
- package/dist/manifest.json +1 -1
- package/dist/manifest.sig +1 -1
- package/dist/pkg/standard_web.d.ts +44 -6
- package/dist/pkg/standard_web.js +292 -20
- package/dist/pkg/standard_web_bg.wasm +0 -0
- package/dist/pkg/standard_web_bg.wasm.d.ts +11 -6
- package/dist/plugins/clock-cells/clock_cells.core.wasm +0 -0
- package/dist/plugins/clock-cells/clock_cells.wasm +0 -0
- package/dist/plugins/clock-cells/standard-plugin.json +11 -0
- package/dist/plugins/host/transpile.js +51 -0
- package/dist/plugins/host/ui-jspi.js +14851 -0
- package/dist/plugins/host/ui.core2.wasm +0 -0
- package/dist/plugins/host/ui.core3.wasm +0 -0
- package/dist/plugins/host/ui.js +14675 -0
- package/dist/plugins/host/world.js +172 -0
- package/dist/plugins/jco/cli.js +141 -0
- package/dist/plugins/jco/common.js +16 -0
- package/dist/plugins/jco/config.js +7 -0
- package/dist/plugins/jco/environment.js +24 -0
- package/dist/plugins/jco/filesystem.js +339 -0
- package/dist/plugins/jco/in-memory-filesystem.js +620 -0
- package/dist/plugins/jco/io.js +366 -0
- package/dist/plugins/jco/js-component-bindgen-component.core.wasm +0 -0
- package/dist/plugins/jco/js-component-bindgen-component.core2.wasm +0 -0
- package/dist/plugins/jco/js-component-bindgen-component.js +14272 -0
- package/dist/plugins/jco/opfs-filesystem.js +245 -0
- package/dist/plugins/jco/random.js +51 -0
- package/dist/plugins/plasma-pixels/plasma_pixels.core.wasm +0 -0
- package/dist/plugins/plasma-pixels/plasma_pixels.wasm +0 -0
- package/dist/plugins/plasma-pixels/standard-plugin.json +11 -0
- package/dist/plugins/runtime.js +1044 -0
- package/dist/plugins/samples.json +81 -0
- package/dist/plugins/worker.js +411 -0
- 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
|
+
}
|