@vestra-player/sdk 1.6.18 → 1.6.19

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 (61) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/dist/docs-tree.json.gz +0 -0
  3. package/dist/plugin/appearance.js +1 -1
  4. package/dist/plugin/bindings.js +1 -1
  5. package/dist/plugin/commands.js +3 -2
  6. package/dist/plugin/connection.d.ts +119 -0
  7. package/dist/plugin/connection.js +402 -0
  8. package/dist/plugin/drops.d.ts +3 -3
  9. package/dist/plugin/drops.js +1 -1
  10. package/dist/plugin/errors.d.ts +9 -0
  11. package/dist/plugin/errors.js +35 -0
  12. package/dist/plugin/filters.js +1 -1
  13. package/dist/plugin/index.d.ts +1 -1
  14. package/dist/plugin/index.js +3 -1
  15. package/dist/plugin/language.js +1 -1
  16. package/dist/plugin/latest.d.ts +12 -0
  17. package/dist/plugin/latest.js +52 -0
  18. package/dist/plugin/local.js +1 -2
  19. package/dist/plugin/markdown.d.ts +2 -2
  20. package/dist/plugin/markdown.js +1 -1
  21. package/dist/plugin/media.js +1 -1
  22. package/dist/plugin/osd.js +2 -2
  23. package/dist/plugin/packages.js +1 -1
  24. package/dist/plugin/passes.d.ts +111 -0
  25. package/dist/plugin/passes.js +341 -0
  26. package/dist/plugin/player.d.ts +2 -3
  27. package/dist/plugin/player.js +2 -7
  28. package/dist/plugin/resolvers.js +1 -1
  29. package/dist/plugin/runtime.d.ts +24 -209
  30. package/dist/plugin/runtime.js +34 -770
  31. package/dist/plugin/scope.d.ts +2 -1
  32. package/dist/plugin/scope.js +5 -2
  33. package/dist/plugin/services/dialogs.d.ts +3 -0
  34. package/dist/plugin/services/dialogs.js +62 -0
  35. package/dist/plugin/services.js +1 -1
  36. package/dist/plugin/settings.js +2 -2
  37. package/dist/plugin/shaders.js +2 -1
  38. package/dist/plugin/stream.js +1 -2
  39. package/dist/plugin/subtitles.js +2 -2
  40. package/dist/plugin/testing/transport.js +1 -1
  41. package/dist/plugin/timeline.js +2 -1
  42. package/dist/plugin/view/base.d.ts +29 -0
  43. package/dist/plugin/view/base.js +88 -0
  44. package/dist/plugin/view/binder.d.ts +22 -0
  45. package/dist/plugin/view/binder.js +57 -0
  46. package/dist/plugin/view/image.d.ts +21 -0
  47. package/dist/plugin/view/image.js +60 -0
  48. package/dist/plugin/view/layout.d.ts +47 -0
  49. package/dist/plugin/view/layout.js +171 -0
  50. package/dist/plugin/view/panel.d.ts +3 -0
  51. package/dist/plugin/view/panel.js +178 -0
  52. package/dist/plugin/view/window.d.ts +3 -0
  53. package/dist/plugin/view/window.js +99 -0
  54. package/dist/plugin/view/writer.d.ts +58 -0
  55. package/dist/plugin/view/writer.js +634 -0
  56. package/dist/plugin/view.js +2 -1
  57. package/dist/plugin/window.js +1 -1
  58. package/llms.txt +1 -1
  59. package/package.json +2 -2
  60. package/dist/plugin/ui.d.ts +0 -74
  61. package/dist/plugin/ui.js +0 -1234
package/dist/plugin/ui.js DELETED
@@ -1,1234 +0,0 @@
1
- /** Tool windows, docked panels and system dialogs. */
2
- import { shownText, templateArgs } from "./osd.js";
3
- import { isTextValue } from "../i18n.js";
4
- import { readFileSync } from "node:fs";
5
- import { basename, extname, resolve } from "node:path";
6
- import { failure, Latest, quiet } from "./runtime.js";
7
- import { dropsOf } from "./drops.js";
8
- import { MarkdownHandle, markdown, remoteImageFile } from "./markdown.js";
9
- import { HostError } from "../wire.js";
10
- import { countRow, diff, keyOf, CHUNK_BYTES, chunkOps, PATCH_FEATURE, prefixWithin, jsonBytes, rowBytes, replay, withinLimits, } from "./patch.js";
11
- import { UI_VALUE_LIMITS, valueLimitProblem } from "./values.js";
12
- /** Slint names are kebab-case; plugin code may use camelCase. */
13
- export const kebab = (name) => name.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`);
14
- const encode = (props) => Object.fromEntries(Object.entries(props).map(([k, v]) => [kebab(k), v]));
15
- let images = 0;
16
- /**
17
- * `plugin.ui.image`. The wire form (`{ $vestraImage: path, ref }`) is an
18
- * internal detail; `ref` makes every call a new value, so a file rewritten
19
- * under the same path is sent (and decoded) again. The player checks the
20
- * path on every use, so a hand-made wire value gains nothing.
21
- */
22
- export class ImageHandle {
23
- path;
24
- __vestraImage = true;
25
- #scope;
26
- #ref = ++images;
27
- #maxSide;
28
- constructor(scope, path, maxSide) {
29
- this.#scope = scope;
30
- this.path = path;
31
- this.#maxSide = maxSide;
32
- }
33
- /** Released with its scope: writing it is `invalid`. */
34
- get released() {
35
- return this.#scope.disposed;
36
- }
37
- toJSON() {
38
- // `maxSide` only when given: players before 0.5.1 refuse the field.
39
- return this.#maxSide === undefined
40
- ? { $vestraImage: this.path, ref: this.#ref }
41
- : { $vestraImage: this.path, ref: this.#ref, maxSide: this.#maxSide };
42
- }
43
- }
44
- /** `ImageOptions.maxSide`, checked: an integer 1–4096 or absent. */
45
- export function imageSide(options) {
46
- const side = options?.maxSide;
47
- if (side === undefined)
48
- return undefined;
49
- if (!Number.isInteger(side) || side < 1 || side > 4096)
50
- throw failure("invalid", "maxSide must be an integer 1–4096");
51
- return side;
52
- }
53
- /**
54
- * A property value as sent: images and documents in wire form, at any depth
55
- * (released ones refused wherever they are), like the player.
56
- */
57
- function wireValue(value) {
58
- if (value instanceof MarkdownHandle) {
59
- if (value.released)
60
- throw failure("invalid", "Markdown document was released with the scope that created it");
61
- return value.toJSON();
62
- }
63
- if (value instanceof ImageHandle) {
64
- if (value.released)
65
- throw failure("invalid", `image ${value.path} was released with the scope that created it`);
66
- return value.toJSON();
67
- }
68
- // Array.from: a hole is `null`, as `JSON.stringify` writes it.
69
- if (Array.isArray(value))
70
- return Array.from(value, (item) => wireValue(item ?? null));
71
- if (value !== null && typeof value === "object") {
72
- const out = {};
73
- for (const [key, item] of Object.entries(value))
74
- out[key] = wireValue(item);
75
- return out;
76
- }
77
- return value;
78
- }
79
- /** Scalars compared by value. Non-finite numbers are JSON `null`: compared as JSON. */
80
- const isPrimitive = (value) => value === null ||
81
- typeof value === "string" ||
82
- typeof value === "boolean" ||
83
- (typeof value === "number" && Number.isFinite(value));
84
- /** A row as `JSON.stringify` writes it inside an array. */
85
- const encodeRow = (row) => JSON.stringify(row) ?? "null";
86
- /** What a row's encoding holds: a Markdown document, an image. */
87
- const HAS_MARKDOWN = 1;
88
- const HAS_IMAGE = 2;
89
- const marksOf = (enc) => (enc.includes('"$vestraMarkdown"') ? HAS_MARKDOWN : 0) |
90
- (enc.includes('"$vestraImage"') ? HAS_IMAGE : 0);
91
- /**
92
- * Row encodings by row object, shared by every property (a row's JSON does
93
- * not depend on where it is bound). Only rows that cannot change are kept
94
- * ({@link settled}): a plugin that mutates its rows in place sees no
95
- * difference, and one that keeps unchanged rows as the same deep-frozen
96
- * objects skips encoding them on every pass. Weak: a row the plugin drops
97
- * goes with its entry.
98
- */
99
- const settledRows = new WeakMap();
100
- /**
101
- * Frozen rows seen once. A frozen row is verified and kept on its second
102
- * pass, so rows frozen afresh on every pass cost one set entry, not a
103
- * verification each.
104
- */
105
- const seenRows = new WeakSet();
106
- /** Frozen rows that can still change (a mutable part, a getter): never kept. */
107
- const unsettledRows = new WeakSet();
108
- /**
109
- * `value` can never change what `JSON.stringify` writes for it: a primitive,
110
- * an image or document handle (their wire form is fixed when made), or a
111
- * frozen plain object or array whose own enumerable properties are data
112
- * properties (no getter, no own `toJSON`) holding such values. Freezing
113
- * fixes the properties and the prototype for good, so the answer holds as
114
- * long as the object lives.
115
- */
116
- function settled(value, depth) {
117
- if (value === null || typeof value !== "object")
118
- return typeof value !== "function";
119
- if (value instanceof ImageHandle || value instanceof MarkdownHandle)
120
- return true;
121
- if (depth > UI_VALUE_LIMITS.depth || !Object.isFrozen(value))
122
- return false;
123
- const proto = Object.getPrototypeOf(value);
124
- if (Array.isArray(value)
125
- ? proto !== Array.prototype
126
- : proto !== Object.prototype && proto !== null)
127
- return false;
128
- // An own `toJSON`, enumerable or not, is called by `JSON.stringify`.
129
- if (Object.prototype.hasOwnProperty.call(value, "toJSON"))
130
- return false;
131
- for (const key of Object.keys(value)) {
132
- const property = Object.getOwnPropertyDescriptor(value, key);
133
- if (!property || !("value" in property))
134
- return false;
135
- if (!settled(property.value, depth + 1))
136
- return false;
137
- }
138
- return true;
139
- }
140
- /** A row's encoding and marks, from {@link settledRows} when it is kept. */
141
- function rowEncoding(row, cache) {
142
- if (cache && row !== null && typeof row === "object") {
143
- // A kept row is frozen: looked up first, `isFrozen` only on a miss.
144
- const kept = settledRows.get(row);
145
- if (kept)
146
- return kept;
147
- }
148
- if (cache &&
149
- row !== null &&
150
- typeof row === "object" &&
151
- Object.isFrozen(row)) {
152
- const enc = encodeRow(row);
153
- const entry = { enc, marks: marksOf(enc) };
154
- if (!seenRows.has(row))
155
- seenRows.add(row);
156
- else if (!unsettledRows.has(row)) {
157
- if (settled(row, 1))
158
- settledRows.set(row, entry);
159
- else
160
- unsettledRows.add(row);
161
- }
162
- return entry;
163
- }
164
- const enc = encodeRow(row);
165
- return { enc, marks: marksOf(enc) };
166
- }
167
- /** Each row of an array as `JSON.stringify` writes it; a hole is `null`. */
168
- function encodeRows(rows) {
169
- // A `toJSON` added to the shared prototypes would change any row's JSON:
170
- // then nothing is taken from (or kept in) the cache.
171
- const cache = !("toJSON" in Object.prototype) && !("toJSON" in Array.prototype);
172
- const enc = [];
173
- const marks = new Uint8Array(rows.length);
174
- let markdown = false;
175
- for (let i = 0; i < rows.length; i++) {
176
- const row = rowEncoding(rows[i], cache);
177
- enc[i] = row.enc;
178
- marks[i] = row.marks;
179
- if (row.marks & HAS_MARKDOWN)
180
- markdown = true;
181
- }
182
- return { enc, marks, markdown, cache };
183
- }
184
- /** A row's limit counts, kept with its encoding when the row cannot change. */
185
- function rowCount(row, cache) {
186
- const kept = cache && row !== null && typeof row === "object"
187
- ? settledRows.get(row)
188
- : undefined;
189
- if (!kept)
190
- return countRow(row);
191
- return (kept.count ??= countRow(row));
192
- }
193
- function sameRows(a, b) {
194
- if (a.length !== b.length)
195
- return false;
196
- for (let i = 0; i < a.length; i++)
197
- if (a[i] !== b[i])
198
- return false;
199
- return true;
200
- }
201
- /** An initial property (wire form) as sent. */
202
- function sentOf(value, patches) {
203
- if (isPrimitive(value))
204
- return { kind: "value", value };
205
- if (Array.isArray(value) && patches) {
206
- const keyed = keyOf(value);
207
- return {
208
- kind: "rows",
209
- rows: {
210
- enc: encodeRows(value).enc,
211
- field: keyed?.field ?? null,
212
- keys: keyed?.keys ?? null,
213
- counts: [],
214
- },
215
- };
216
- }
217
- return { kind: "json", enc: JSON.stringify(value) };
218
- }
219
- /**
220
- * A write sent in parts stopped because its view closed or showed its
221
- * initial properties again: not a failure.
222
- */
223
- class Abandoned extends Error {
224
- }
225
- /** The wire key of a Markdown document: rows holding one are never sent in parts. */ const MARKDOWN_KEY = '"$vestraMarkdown"';
226
- /** A row larger than one request can carry (`limit`). */
227
- const rowTooLarge = (key, bytes) => new HostError("limit", `${key}: a row of ${bytes} bytes is larger than one request takes (${CHUNK_BYTES} bytes)`);
228
- /** Refuse a patch carrying a row larger than one request, before sending it. */
229
- function tooLarge(key, ops) {
230
- for (const op of ops) {
231
- const rows = op.op === "set" ? [op.row] : op.op === "insert" ? op.rows : [];
232
- for (const row of rows) {
233
- const bytes = rowBytes(row);
234
- if (bytes > CHUNK_BYTES)
235
- throw rowTooLarge(key, bytes);
236
- }
237
- }
238
- }
239
- /**
240
- * The row patch from `sent` to `next` (the bound `rows`), in wire form, and
241
- * the new rows' limit counts; `null` when the whole value is to be sent:
242
- * nothing to type the rows by (no rows sent), a Markdown document in a row
243
- * (the player merges documents in place), too many operations, a value over
244
- * the limits (the player refuses it whole, as before), or no change (an
245
- * explicit `set()` of the same value).
246
- */
247
- function rowPatch(sent, next, rows) {
248
- if (sent.enc.length === 0)
249
- return null;
250
- if (next.markdown)
251
- return null;
252
- const nextKeyed = sent.field === null ? null : next.keyed();
253
- const keyed = nextKeyed !== null && nextKeyed.field === sent.field;
254
- const plan = diff({ enc: sent.enc, keys: keyed ? sent.keys : null }, { enc: next.enc, keys: keyed ? nextKeyed.keys : null });
255
- if (!plan || plan.ops.length === 0)
256
- return null;
257
- // A released image is refused wherever it is, as in a whole write: the
258
- // rows the patch does not carry are checked too (those holding images).
259
- for (let j = 0; j < rows.length; j++)
260
- if (plan.from[j] >= 0 && next.marks[j] & HAS_IMAGE)
261
- wireValue(rows[j] ?? null);
262
- const counts = next.enc.map((_, j) => {
263
- const from = plan.from[j];
264
- return ((from >= 0 ? sent.counts[from] : undefined) ??
265
- rowCount(rows[j], next.cache));
266
- });
267
- if (!withinLimits(counts))
268
- return null;
269
- // The ops must give exactly the new rows (a guard against this diff's bugs).
270
- if (!sameRows(replay(sent.enc, plan.ops, next.enc), next.enc))
271
- return null;
272
- const ops = plan.ops.map((op) => {
273
- switch (op.op) {
274
- case "set":
275
- return { op: "set", at: op.at, row: wireValue(rows[op.row] ?? null) };
276
- case "insert":
277
- return {
278
- op: "insert",
279
- at: op.at,
280
- rows: op.rows.map((j) => wireValue(rows[j] ?? null)),
281
- };
282
- default:
283
- return op;
284
- }
285
- });
286
- return { ops, counts };
287
- }
288
- export const ui = (scope) => ({
289
- image(path, options) {
290
- if (typeof path !== "string" || !path)
291
- throw failure("invalid", "ui.image needs a file path");
292
- return new ImageHandle(scope, path, imageSide(options));
293
- },
294
- async remoteImage(url, options) {
295
- if (typeof url !== "string" || !url.startsWith("https://"))
296
- throw failure("invalid", "ui.remoteImage needs an https: address");
297
- const side = imageSide(options);
298
- const file = await remoteImageFile(scope.runtime, url);
299
- return new ImageHandle(scope, file, side);
300
- },
301
- markdown: (text, options) => markdown(scope, text, options),
302
- });
303
- /** Property and callback plumbing shared by windows, panels and settings pages. */
304
- export class ViewBase {
305
- scope;
306
- handlers = new Map();
307
- #values = new Map();
308
- /** Each binding, with where the plugin added it (for the player's log). */
309
- #binders = new Map();
310
- /** What the host currently shows, as far as this side knows. */
311
- #sent = new Map();
312
- /**
313
- * Row patches (player feature `ui.patch`): each property's last write the
314
- * player confirmed, by `seq` (0: the initial properties). A patch applies
315
- * on it; a property without one is sent whole.
316
- */
317
- #bases = new Map();
318
- /**
319
- * Patches of a property answered `stale` in a row: after two, the
320
- * property is sent whole until the view shows its initial properties again.
321
- */
322
- #stales = new Map();
323
- #seq = 0;
324
- /** Counts the times the view showed its initial properties (`shown`). */
325
- #generation = 0;
326
- /** Written by `set()`: sent even if equal to what was sent before. */
327
- #forced = new Set();
328
- /** The last value of each property whose write failed; not retried until it changes. */
329
- #failed = new Map();
330
- #backoff = 0;
331
- /** A write failed and none succeeded since: further failures stay quiet. */
332
- #broken = false;
333
- #retry;
334
- #flushing = false;
335
- #binding;
336
- closed = false;
337
- constructor(scope) {
338
- this.scope = scope;
339
- this.#binding = scope.runtime.bind(() => this.evaluate());
340
- }
341
- /** The player takes row patches (and `seq` on whole writes). */
342
- get #patches() {
343
- return this.scope.runtime.features.has(PATCH_FEATURE);
344
- }
345
- /**
346
- * Initial properties in wire form. A released image is left out (the
347
- * property keeps its default) and reported, like a failed write. With a
348
- * player that takes row patches, the arrays of one request carry at most
349
- * {@link CHUNK_BYTES} of rows together (SDK 1.5.46): an array over that
350
- * goes with its first rows, and the first flush sends the rest as patches.
351
- * An array over the value limits is refused here, as the player would
352
- * refuse it whole: left out and reported (a panel, a settings page), or,
353
- * with `refuse` (a window's `show`), thrown.
354
- */
355
- wired(values, refuse = false) {
356
- const out = {};
357
- let room = CHUNK_BYTES;
358
- for (const [key, value] of Object.entries(values)) {
359
- let wire;
360
- try {
361
- wire = wireValue(value);
362
- }
363
- catch (error) {
364
- this.#failed.set(key, JSON.stringify(value));
365
- this.scope.report(error);
366
- continue;
367
- }
368
- if (this.#patches && Array.isArray(wire)) {
369
- const sizes = wire.map(rowBytes);
370
- const total = sizes.reduce((a, b) => a + b, 0);
371
- if (total > room && !JSON.stringify(wire).includes(MARKDOWN_KEY)) {
372
- const largest = sizes.reduce((a, b) => Math.max(a, b), 0);
373
- const problem = valueLimitProblem(wire);
374
- if (problem || largest > CHUNK_BYTES) {
375
- const error = problem
376
- ? new HostError("limit", `${key}: ${problem}`)
377
- : rowTooLarge(key, largest);
378
- if (refuse)
379
- throw error;
380
- this.#failed.set(key, JSON.stringify(value));
381
- this.scope.report(error);
382
- continue;
383
- }
384
- const count = room > 0 ? prefixWithin(sizes, room) : 0;
385
- wire = wire.slice(0, count);
386
- room -= sizes.slice(0, count).reduce((a, b) => a + b, 0);
387
- }
388
- else
389
- room -= total;
390
- }
391
- out[key] = wire;
392
- }
393
- return out;
394
- }
395
- on(callback, handler) {
396
- const name = kebab(callback);
397
- this.handlers.set(name, handler);
398
- return this.scope.onDispose(() => {
399
- if (this.handlers.get(name) === handler)
400
- this.handlers.delete(name);
401
- });
402
- }
403
- set(props) {
404
- for (const [key, value] of Object.entries(encode(props))) {
405
- this.#values.set(key, value);
406
- this.#forced.add(key);
407
- this.#failed.delete(key); // an explicit write is tried again (after any back-off)
408
- }
409
- this.flush();
410
- }
411
- bind(props) {
412
- // `token` names this binding of this view in the runtime's read
413
- // tracking: the same function bound to two views is two parts.
414
- this.#binders.set(props, {
415
- site: this.scope.runtime.callSite(),
416
- token: {},
417
- });
418
- this.scope.update();
419
- return this.scope.onDispose(() => {
420
- this.#binders.delete(props);
421
- });
422
- }
423
- /**
424
- * Current desired properties (bound values win over earlier `set`). A
425
- * precise pass (`Runtime.part`) skips a bind function that read none of
426
- * the changed state: its last values stay.
427
- */
428
- snapshot() {
429
- const runtime = this.scope.runtime;
430
- for (const [binder, { site, token }] of this.#binders) {
431
- try {
432
- // Encoded inside the part: what encoding reads counts as the
433
- // function's (a state object it returned).
434
- const result = runtime.part(token, () => encode(runtime.reader("a view's bind", site, binder)));
435
- if (!result)
436
- continue;
437
- for (const [key, value] of Object.entries(result.value))
438
- this.#values.set(key, value);
439
- }
440
- catch (error) {
441
- this.scope.report(error);
442
- }
443
- }
444
- return Object.fromEntries(this.#values);
445
- }
446
- evaluate() {
447
- if (this.closed)
448
- return;
449
- this.snapshot();
450
- this.flush();
451
- }
452
- /** The host already shows these values (e.g. sent as initial properties). */
453
- shown(values) {
454
- this.#generation++;
455
- const patches = this.#patches;
456
- this.#sent = new Map(Object.entries(values).map(([k, v]) => [k, sentOf(v, patches)]));
457
- this.#bases = new Map(patches ? Object.keys(values).map((key) => [key, 0]) : []);
458
- this.#stales.clear();
459
- this.#forced.clear();
460
- for (const key of Object.keys(values))
461
- this.#failed.delete(key);
462
- }
463
- /** `value` encoded, and whether it differs from what was sent. */
464
- #encode(key, value) {
465
- const sent = this.#sent.get(key);
466
- if (isPrimitive(value)) {
467
- // Compared as it is: no `JSON.stringify` for the scalars of a pass.
468
- const changed = sent?.kind === "value"
469
- ? sent.value !== value
470
- : sent?.kind !== "json" || sent.enc !== JSON.stringify(value);
471
- return {
472
- key,
473
- value,
474
- changed,
475
- signature: () => JSON.stringify(value),
476
- sent: () => ({ kind: "value", value }),
477
- };
478
- }
479
- if (Array.isArray(value) && this.#patches) {
480
- // Row by row: the same work as one `JSON.stringify`, and the rows to
481
- // diff against those last sent. Rows that cannot change (deep-frozen
482
- // objects bound again, SDK 1.5.47) are taken from `settledRows`.
483
- const encoded = encodeRows(value);
484
- const { enc } = encoded;
485
- const signature = () => `[${enc.join(",")}]`;
486
- const changed = sent?.kind === "rows"
487
- ? !sameRows(sent.rows.enc, enc)
488
- : sent?.kind !== "json" || sent.enc !== signature();
489
- // Keys only for a write: an unchanged array costs its encoding alone.
490
- let keys;
491
- const keyed = () => (keys === undefined ? (keys = keyOf(value)) : keys);
492
- return {
493
- key,
494
- value,
495
- changed,
496
- signature,
497
- rows: { ...encoded, keyed },
498
- sent: (counts = []) => ({
499
- kind: "rows",
500
- rows: {
501
- enc,
502
- field: keyed()?.field ?? null,
503
- keys: keyed()?.keys ?? null,
504
- counts,
505
- },
506
- }),
507
- };
508
- }
509
- const enc = JSON.stringify(value);
510
- return {
511
- key,
512
- value,
513
- changed: sent?.kind !== "json" || sent.enc !== enc,
514
- signature: () => enc,
515
- sent: () => ({ kind: "json", enc }),
516
- };
517
- }
518
- /**
519
- * Write one property: a row patch when the player takes them and one
520
- * applies (else the whole value); a `stale` patch is followed by the whole
521
- * value at once. The base is forgotten on any failure, so the next write
522
- * of the property is whole. A write larger than one request
523
- * ({@link CHUNK_BYTES} of rows, SDK 1.5.46) is sent in parts: a whole
524
- * array as its first rows and `insert` patches of the rest, a patch as
525
- * several, each applying on the one before.
526
- */
527
- async #send(pending) {
528
- const { key, value } = pending;
529
- if (!this.#patches) {
530
- await this.write(key, { value: wireValue(value) });
531
- return pending.sent();
532
- }
533
- const generation = this.#generation;
534
- const base = this.#bases.get(key);
535
- this.#bases.delete(key);
536
- const sent = this.#sent.get(key);
537
- let plan = base !== undefined &&
538
- sent?.kind === "rows" &&
539
- pending.rows &&
540
- (this.#stales.get(key) ?? 0) < 2
541
- ? rowPatch(sent.rows, pending.rows, value)
542
- : null;
543
- // A patch sent in several never passes the row limit midway (the diff
544
- // removes before it inserts; checked anyway); otherwise whole.
545
- if (plan && sent?.kind === "rows") {
546
- let length = sent.rows.enc.length;
547
- for (const op of plan.ops) {
548
- if (op.op === "insert")
549
- length += op.rows.length;
550
- else if (op.op === "remove")
551
- length -= op.count;
552
- if (length > UI_VALUE_LIMITS.arrayRows) {
553
- plan = null;
554
- break;
555
- }
556
- }
557
- }
558
- if (plan) {
559
- tooLarge(key, plan.ops);
560
- const [first, ...rest] = chunkOps(plan.ops);
561
- const seq = ++this.#seq;
562
- let applied = false;
563
- try {
564
- await this.write(key, { patch: { base: base, seq, ops: first } });
565
- applied = true;
566
- }
567
- catch (error) {
568
- if (!(error instanceof HostError && error.code === "stale"))
569
- throw error;
570
- this.#stales.set(key, (this.#stales.get(key) ?? 0) + 1);
571
- }
572
- if (applied) {
573
- this.#stales.delete(key);
574
- await this.#parts(key, seq, rest, false, generation);
575
- return pending.sent(plan.counts);
576
- }
577
- }
578
- const seq = ++this.#seq;
579
- // Rows holding Markdown documents go whole, as before: the player
580
- // merges documents in place and takes no parts of them.
581
- if (Array.isArray(value) && pending.rows && !pending.rows.markdown) {
582
- const sizes = pending.rows.enc.map(jsonBytes);
583
- const count = prefixWithin(sizes, CHUNK_BYTES);
584
- if (count < value.length) {
585
- const rows = value;
586
- // Over the limits, or a row larger than one request: refused here,
587
- // as the player would refuse the whole value, before any part is
588
- // sent (a part refused midway would leave the list half sent).
589
- const problem = valueLimitProblem(wireValue(value));
590
- if (problem)
591
- throw new HostError("limit", `${key}: ${problem}`);
592
- const largest = sizes.reduce((a, b) => Math.max(a, b), 0);
593
- if (largest > CHUNK_BYTES)
594
- throw rowTooLarge(key, largest);
595
- // Every part but the last carries `more`: the player holds them
596
- // and writes the whole value at the last, so a list it shows is
597
- // written once (player 0.6.0).
598
- await this.write(key, {
599
- value: wireValue(rows.slice(0, count)),
600
- seq,
601
- more: true,
602
- });
603
- const insert = {
604
- op: "insert",
605
- at: count,
606
- rows: rows.slice(count).map((row) => wireValue(row ?? null)),
607
- };
608
- await this.#parts(key, seq, chunkOps([insert]), true, generation);
609
- return pending.sent();
610
- }
611
- }
612
- await this.write(key, { value: wireValue(value), seq });
613
- this.#bases.set(key, seq);
614
- return pending.sent();
615
- }
616
- /**
617
- * The rest of a write sent in parts: each patch applies on the one
618
- * before (`base`), and the property's base is the last one's; with
619
- * `more`, every part but the last says so. A failure leaves no base: the
620
- * next write of the property is whole. When the view closed or showed its
621
- * initial properties again meanwhile (`generation`), the rest is dropped
622
- * quietly ({@link Abandoned}): the view's next flush sends what it shows.
623
- */
624
- async #parts(key, base, groups, more, generation) {
625
- let last = base;
626
- for (const [i, ops] of groups.entries()) {
627
- if (!this.live() || this.#generation !== generation)
628
- throw new Abandoned();
629
- const seq = ++this.#seq;
630
- try {
631
- await this.write(key, {
632
- patch: { base: last, seq, ops },
633
- ...(more && i < groups.length - 1 ? { more: true } : {}),
634
- });
635
- }
636
- catch (error) {
637
- // Refused because the view went meanwhile: not a failure.
638
- if (!this.live() || this.#generation !== generation)
639
- throw new Abandoned();
640
- throw error;
641
- }
642
- last = seq;
643
- }
644
- this.#bases.set(key, last);
645
- }
646
- /**
647
- * Send what changed. A bound value is sent when it differs from the last one
648
- * sent; a `set()` value is always sent once. Nothing is resent merely
649
- * because a callback arrived, so text the user typed into an in-out
650
- * property survives other callbacks (call `set()` to pull a control back).
651
- * A failed bound value is not repeated until it changes; an explicit `set()`
652
- * is, after a back-off (0.25 s doubling to 30 s). Only the first failure of
653
- * a failing streak is reported, so an error handler that writes a status to
654
- * a broken view cannot spin.
655
- */
656
- flush() {
657
- if (this.#flushing || this.#retry || !this.live())
658
- return;
659
- this.#flushing = true;
660
- void (async () => {
661
- let failed = false;
662
- let failure;
663
- try {
664
- let pending;
665
- do {
666
- pending = [];
667
- for (const [key, value] of this.#values) {
668
- const encoded = this.#encode(key, value);
669
- const failedAs = this.#failed.get(key);
670
- if (failedAs !== undefined && failedAs === encoded.signature())
671
- continue;
672
- if (this.#forced.has(key) || encoded.changed)
673
- pending.push(encoded);
674
- }
675
- for (const encoded of pending) {
676
- if (!this.live())
677
- return;
678
- const key = encoded.key;
679
- this.#forced.delete(key);
680
- try {
681
- this.#sent.set(key, await this.#send(encoded));
682
- this.#failed.delete(key);
683
- this.#backoff = 0;
684
- this.#broken = false;
685
- }
686
- catch (error) {
687
- // The view closed or showed its initial properties again while
688
- // a write was sent in parts: nothing failed (the next flush
689
- // sends what the view shows).
690
- if (error instanceof Abandoned) {
691
- queueMicrotask(() => this.flush());
692
- return;
693
- }
694
- failed = true;
695
- // Report the start of a failing streak only.
696
- if (!this.#broken)
697
- failure ??= error;
698
- this.#broken = true;
699
- this.#failed.set(key, encoded.signature());
700
- }
701
- }
702
- } while (pending.length && !failed && this.live());
703
- }
704
- finally {
705
- this.#flushing = false;
706
- }
707
- if (!failed)
708
- return;
709
- this.#backoff = Math.min(Math.max(250, this.#backoff * 2), 30_000);
710
- this.#retry = setTimeout(() => {
711
- this.#retry = undefined;
712
- this.flush();
713
- }, this.#backoff);
714
- this.#retry.unref?.();
715
- if (failure !== undefined)
716
- this.scope.report(failure);
717
- })();
718
- }
719
- dispatch(callback, args) {
720
- if (this.closed)
721
- return;
722
- const handler = this.handlers.get(callback);
723
- if (handler)
724
- this.scope.run(handler, ...args);
725
- else
726
- this.scope.update();
727
- }
728
- async release() {
729
- this.closed = true;
730
- clearTimeout(this.#retry);
731
- await this.#binding.dispose();
732
- }
733
- }
734
- class WindowHandle extends ViewBase {
735
- #id;
736
- #file;
737
- #opened = false;
738
- #queue = Promise.resolve();
739
- constructor(scope, file, id) {
740
- if (scope.runtime.views.has(id))
741
- throw failure("conflict", `window ${id} already exists; pass a distinct id`);
742
- super(scope);
743
- this.#file = file;
744
- this.#id = id;
745
- scope.runtime.views.set(id, (callback, args) => this.dispatch(callback, args));
746
- // The user closed it with the title bar: the host already dropped it.
747
- this.#closedByUser = scope.runtime.raw("ui.closed", (data) => {
748
- if (data.view !== this.#id || !this.#opened)
749
- return;
750
- this.#opened = false;
751
- for (const listener of [...this.#closeListeners])
752
- this.scope.run(listener);
753
- this.scope.update();
754
- });
755
- scope.add(this);
756
- }
757
- #closeListeners = new Set();
758
- #closedByUser;
759
- onClose(listener) {
760
- this.#closeListeners.add(listener);
761
- return this.scope.onDispose(() => {
762
- this.#closeListeners.delete(listener);
763
- });
764
- }
765
- get opened() {
766
- return this.#opened;
767
- }
768
- live() {
769
- return this.#opened && !this.closed;
770
- }
771
- write(property, write) {
772
- return this.scope.runtime.host.ui.set(this.#id, property, write);
773
- }
774
- on(callback, handler) {
775
- // The host forwards only callbacks named when the window opens.
776
- if (this.#opened && !this.handlers.has(kebab(callback)))
777
- throw new Error(`bind window handler ${callback} before the first show()`);
778
- return super.on(callback, handler);
779
- }
780
- /** Window RPCs run strictly in order; a failure does not block later calls. */
781
- #serial(task) {
782
- const next = this.#queue.then(task, task);
783
- this.#queue = next.catch(() => { });
784
- return next;
785
- }
786
- show() {
787
- return this.#serial(async () => {
788
- if (this.closed)
789
- return;
790
- const ui = this.scope.runtime.host.ui;
791
- if (this.#opened)
792
- return ui.show(this.#id, true);
793
- const initial = this.wired(this.snapshot(), true);
794
- await ui.open(this.#id, this.#file, initial, [...this.handlers.keys()]);
795
- this.#opened = true;
796
- this.shown(initial);
797
- this.flush();
798
- });
799
- }
800
- hide() {
801
- return this.#serial(async () => {
802
- if (this.#opened)
803
- await this.scope.runtime.host.ui.show(this.#id, false);
804
- });
805
- }
806
- close() {
807
- return this.#serial(async () => {
808
- if (!this.#opened)
809
- return;
810
- this.#opened = false;
811
- await this.scope.runtime.host.ui.close(this.#id);
812
- });
813
- }
814
- async dispose() {
815
- if (this.closed)
816
- return;
817
- this.scope.forget(this);
818
- await this.close().catch(() => { });
819
- await this.release();
820
- await this.#closedByUser.dispose();
821
- this.#closeListeners.clear();
822
- this.scope.runtime.views.delete(this.#id);
823
- }
824
- }
825
- /** Find `export component X` / `export global Y` so most mounts need no names. */
826
- export function exported(directory, file, kind) {
827
- const text = readFileSync(resolve(directory, file), "utf8");
828
- const names = [
829
- ...text.matchAll(new RegExp(`export\\s+${kind}\\s+([\\w-]+)`, "g")),
830
- ].map((m) => m[1]);
831
- if (names.length !== 1)
832
- throw new Error(`${file}: name the ${kind} to mount (found ${names.length})`);
833
- return names[0];
834
- }
835
- class PanelHandle extends ViewBase {
836
- ready;
837
- #slot;
838
- #mounted = false;
839
- #locks = { focused: false, dragging: false, popupOpen: false };
840
- #interaction;
841
- constructor(scope, slot, file, options) {
842
- // One panel per slot (SDK 1.6.15, player 0.7.6; a player before 0.7.6
843
- // refuses a second panel with `conflict`).
844
- if (scope.runtime.views.has(`panel:${slot}`))
845
- throw new Error(`this plugin already has a panel in the ${slot}`);
846
- super(scope);
847
- this.#slot = slot;
848
- const runtime = scope.runtime;
849
- runtime.views.set(`panel:${slot}`, (callback, args) => this.dispatch(callback, args));
850
- this.#interaction = new Latest((locks) => runtime.host.ui.interaction(locks, this.#named()));
851
- const component = options.component ?? exported(runtime.directory, file, "component");
852
- const global = options.global ?? exported(runtime.directory, file, "global");
853
- scope.add(this);
854
- this.ready = runtime
855
- .starting((async () => {
856
- // Let handlers and bindings declared right after mount() join in.
857
- await Promise.resolve();
858
- const properties = this.wired(this.snapshot());
859
- await runtime.host.ui.mount({
860
- slot,
861
- file,
862
- component,
863
- global,
864
- ...(options.title === undefined ? {} : { title: options.title }),
865
- properties,
866
- });
867
- this.#mounted = true;
868
- this.shown(properties);
869
- this.flush();
870
- // The position's state changed: refresh it if this plugin reads it.
871
- await layoutOf(runtime).refresh();
872
- })(), (error) => scope.report(error))
873
- .then(() => { });
874
- quiet(this.ready);
875
- }
876
- live() {
877
- return this.#mounted && !this.closed;
878
- }
879
- /**
880
- * The slot to name in a request about this panel: only while the plugin
881
- * has panels in more than one slot (the player then needs it); a plugin
882
- * with one panel sends what it always sent.
883
- */
884
- #named() {
885
- const panels = [...this.scope.runtime.views.keys()].filter((key) => key.startsWith("panel:"));
886
- return panels.length > 1 ? this.#slot : undefined;
887
- }
888
- write(property, write) {
889
- return this.scope.runtime.host.ui.setMount(property, write, this.#named());
890
- }
891
- get state() {
892
- const service = layoutOf(this.scope.runtime);
893
- service.start();
894
- return this.live()
895
- ? panelState(service.state, this.#slot, this.scope.runtime.manifest.id)
896
- : null;
897
- }
898
- onChange(listener) {
899
- const scope = this.scope;
900
- let last = JSON.stringify(this.state);
901
- const watch = layoutOf(scope.runtime).watch(() => {
902
- const state = this.state;
903
- const next = JSON.stringify(state);
904
- if (next === last)
905
- return;
906
- last = next;
907
- scope.run(listener, state);
908
- });
909
- return scope.add({
910
- dispose: async () => {
911
- scope.forget(watch);
912
- await watch.dispose();
913
- },
914
- });
915
- }
916
- async show() {
917
- await this.ready;
918
- if (this.#slot === "backdrop")
919
- throw failure("invalid", "the player shows the backdrop by itself");
920
- await layoutOf(this.scope.runtime).set(this.#slot, {
921
- active: this.scope.runtime.manifest.id,
922
- visible: true,
923
- });
924
- }
925
- async toggle() {
926
- await this.ready;
927
- if (this.#slot === "backdrop")
928
- throw failure("invalid", "the player shows the backdrop by itself");
929
- await layoutOf(this.scope.runtime).read();
930
- const state = this.state;
931
- // A switch, the same in every mode (user ruling 2026-11-08): off when
932
- // on and current, else on at this tab. `reveal: false`: in fullscreen
933
- // nothing pops out, the right edge reveals it (player 0.7.6; older
934
- // players ignore it and pop it out).
935
- if (state?.preferred && state.active)
936
- await layoutOf(this.scope.runtime).set(this.#slot, { visible: false });
937
- else
938
- await layoutOf(this.scope.runtime).set(this.#slot, {
939
- active: this.scope.runtime.manifest.id,
940
- visible: true,
941
- reveal: false,
942
- });
943
- }
944
- async showMenu(items, at) {
945
- await this.ready;
946
- await this.scope.runtime.host.ui.menu(items.map((item) => item?.id ?? null), at.x, at.y, this.#named());
947
- }
948
- onDrop(listener) {
949
- const watch = dropsOf(this.scope.runtime).listen(this.scope, (event) => this.scope.run(listener, event));
950
- return this.scope.add({ dispose: () => watch.dispose() });
951
- }
952
- setInteraction(locks) {
953
- Object.assign(this.#locks, locks);
954
- this.#interaction
955
- .push({ ...this.#locks })
956
- .catch((error) => this.scope.report(error));
957
- }
958
- async dispose() {
959
- if (this.closed)
960
- return;
961
- this.scope.forget(this);
962
- this.#interaction.stop();
963
- if (this.#mounted)
964
- await this.scope.runtime.host.ui.unmount(this.#named()).catch(() => { });
965
- this.#mounted = false;
966
- await this.release();
967
- this.scope.runtime.views.delete(`panel:${this.#slot}`);
968
- // Listeners of `onChange` hear `null`.
969
- this.scope.runtime.invalidate();
970
- await layoutOf(this.scope.runtime).refresh();
971
- }
972
- }
973
- /** One panel's view of its position (`Panel.state`). */
974
- function panelState(layout, slot, plugin) {
975
- if (slot === "backdrop")
976
- return (layout.backdrop && {
977
- active: true,
978
- preferred: true,
979
- effective: layout.backdrop.effective,
980
- reason: null,
981
- detached: false,
982
- });
983
- const state = layout[slot];
984
- if (!state)
985
- return null;
986
- // Players without tabs (no `panels`): the position holds this panel only.
987
- const panels = layout.tabs[slot];
988
- const own = panels?.find((panel) => panel.plugin === plugin);
989
- if (panels && !own)
990
- return null;
991
- const active = own?.active ?? true;
992
- return {
993
- active,
994
- preferred: state.preferred,
995
- effective: state.effective && active,
996
- reason: active ? state.reason : "otherTab",
997
- detached: state.detached,
998
- };
999
- }
1000
- export const windows = (scope) => ({
1001
- create: (file, options) => new WindowHandle(scope, file, options?.id ?? basename(file, extname(file))),
1002
- });
1003
- /**
1004
- * Both positions, one per plugin: read with `layout.state` after every
1005
- * `window.state` event (one read in flight; events during it cause one more)
1006
- * and after this plugin's own changes. Started on first use.
1007
- */
1008
- class LayoutService {
1009
- #runtime;
1010
- state = {
1011
- bottom: null,
1012
- sidebar: null,
1013
- adjust: null,
1014
- tabs: { bottom: null, sidebar: null, adjust: null },
1015
- backdrop: null,
1016
- };
1017
- #listeners = new Set();
1018
- #started = false;
1019
- #reading = false;
1020
- #again = false;
1021
- constructor(runtime) {
1022
- this.#runtime = runtime;
1023
- }
1024
- /** Read again, but only once something in this plugin uses the state. */
1025
- async refresh() {
1026
- if (this.#started)
1027
- await this.read();
1028
- }
1029
- start() {
1030
- if (this.#started || !this.#runtime.can("ui.mount"))
1031
- return;
1032
- this.#started = true;
1033
- // A mirror: panels listen through `watch`.
1034
- this.#runtime.on("window.state", () => void this.read(), "events", true);
1035
- void this.read();
1036
- }
1037
- watch(listener) {
1038
- this.start();
1039
- this.#listeners.add(listener);
1040
- const held = this.#runtime.hold("window.state");
1041
- return {
1042
- dispose: async () => {
1043
- this.#listeners.delete(listener);
1044
- await held.dispose();
1045
- },
1046
- };
1047
- }
1048
- async read() {
1049
- if (this.#reading) {
1050
- this.#again = true;
1051
- return;
1052
- }
1053
- this.#reading = true;
1054
- try {
1055
- do {
1056
- this.#again = false;
1057
- // "pending" until the host confirmed the layout: keep the last value.
1058
- const raw = await this.#runtime.host.layout.state().catch(() => null);
1059
- if (!raw)
1060
- continue;
1061
- const view = (slot) => slot && {
1062
- preferred: slot.preferred,
1063
- effective: slot.effective,
1064
- reason: slot.reason,
1065
- detached: slot.detached,
1066
- };
1067
- const tabs = (slot) => slot?.panels
1068
- ? Object.freeze(slot.panels.map((panel) => Object.freeze({
1069
- plugin: panel.plugin,
1070
- title: panel.title,
1071
- active: panel.owner === slot.owner,
1072
- })))
1073
- : null;
1074
- const adjust = raw.adjust ?? null;
1075
- const next = {
1076
- bottom: view(raw.bottom),
1077
- sidebar: view(raw.sidebar),
1078
- adjust: view(adjust),
1079
- tabs: {
1080
- bottom: tabs(raw.bottom),
1081
- sidebar: tabs(raw.sidebar),
1082
- adjust: tabs(adjust),
1083
- },
1084
- backdrop: raw.backdrop
1085
- ? Object.freeze({ effective: raw.backdrop.effective === true })
1086
- : null,
1087
- };
1088
- if (JSON.stringify(next) === JSON.stringify(this.state))
1089
- continue;
1090
- this.state = next;
1091
- for (const listener of [...this.#listeners])
1092
- listener(next);
1093
- this.#runtime.invalidate();
1094
- } while (this.#again);
1095
- }
1096
- finally {
1097
- this.#reading = false;
1098
- }
1099
- }
1100
- async set(slot, change) {
1101
- this.start();
1102
- await this.#runtime.host.layout.set({ slot, ...change });
1103
- await this.read();
1104
- }
1105
- }
1106
- const layoutOf = (runtime) => runtime.service("layout", () => new LayoutService(runtime));
1107
- function slot(scope, name) {
1108
- const service = () => layoutOf(scope.runtime);
1109
- return {
1110
- get state() {
1111
- service().start();
1112
- return service().state[name];
1113
- },
1114
- get panels() {
1115
- service().start();
1116
- return service().state.tabs[name];
1117
- },
1118
- onChange(listener) {
1119
- const seen = (layout) => JSON.stringify([layout[name], layout.tabs[name]]);
1120
- let last = seen(service().state);
1121
- const watch = service().watch((layout) => {
1122
- const next = seen(layout);
1123
- if (next === last)
1124
- return;
1125
- last = next;
1126
- scope.run(listener, layout[name]);
1127
- });
1128
- return scope.add({
1129
- dispose: async () => {
1130
- scope.forget(watch);
1131
- await watch.dispose();
1132
- },
1133
- });
1134
- },
1135
- setVisible: (visible) => service().set(name, { visible }),
1136
- async toggle() {
1137
- await service().read();
1138
- const current = service().state[name];
1139
- // A switch (see Panel.toggle): nothing pops out in fullscreen.
1140
- await service().set(name, {
1141
- visible: !(current?.preferred ?? true),
1142
- reveal: false,
1143
- });
1144
- },
1145
- setDetached: (detached) => service().set(name, { detached }),
1146
- activate: (plugin) => service().set(name, { active: plugin }),
1147
- };
1148
- }
1149
- function backdrop(scope) {
1150
- const service = () => layoutOf(scope.runtime);
1151
- return {
1152
- get state() {
1153
- service().start();
1154
- return service().state.backdrop;
1155
- },
1156
- onChange(listener) {
1157
- let last = JSON.stringify(service().state.backdrop);
1158
- const watch = service().watch((layout) => {
1159
- const next = JSON.stringify(layout.backdrop);
1160
- if (next === last)
1161
- return;
1162
- last = next;
1163
- scope.run(listener, layout.backdrop);
1164
- });
1165
- return scope.add({
1166
- dispose: async () => {
1167
- scope.forget(watch);
1168
- await watch.dispose();
1169
- },
1170
- });
1171
- },
1172
- };
1173
- }
1174
- export const panels = (scope) => ({
1175
- mount: (position, file, options = {}) => new PanelHandle(scope, position, file, options),
1176
- bottom: slot(scope, "bottom"),
1177
- sidebar: slot(scope, "sidebar"),
1178
- adjust: slot(scope, "adjust"),
1179
- backdrop: backdrop(scope),
1180
- });
1181
- export const dialogs = (scope) => {
1182
- const open = async (kind, options = {}) => {
1183
- const result = await scope.runtime.host.dialogs.open({
1184
- kind,
1185
- ...(options.title === undefined
1186
- ? {}
1187
- : { title: shownText(options.title, undefined, "title") }),
1188
- ...(options.fileName === undefined
1189
- ? {}
1190
- : { fileName: shownText(options.fileName, undefined, "fileName") }),
1191
- });
1192
- return result.cancelled ? [] : result.paths;
1193
- };
1194
- return {
1195
- async confirm(options) {
1196
- const { message, detail, confirm, cancel, destructive, checkbox, args } = options ?? {};
1197
- const values = templateArgs(args);
1198
- // A string's length is checked here; a text value's (shown) length by
1199
- // the player.
1200
- const length = (text, min, max) => isTextValue(text) ||
1201
- (typeof text === "string" &&
1202
- [...text].length >= min &&
1203
- [...text].length <= max);
1204
- if (!length(message, 1, 200) ||
1205
- !length(confirm, 1, 40) ||
1206
- (detail !== undefined && !length(detail, 0, 2000)) ||
1207
- (cancel !== undefined && !length(cancel, 1, 40)) ||
1208
- (checkbox !== undefined && !length(checkbox, 1, 40)) ||
1209
- (destructive !== undefined && typeof destructive !== "boolean"))
1210
- throw failure("invalid", "confirm needs message 1–200, confirm/cancel/checkbox 1–40, detail ≤ 2000 characters");
1211
- // `args` fills the string templates; a text value carries its own.
1212
- const shown = (text, what) => shownText(text, undefined, what);
1213
- const answer = await scope.runtime.host.dialogs.confirm({
1214
- message: shown(message, "message"),
1215
- confirm: shown(confirm, "confirm"),
1216
- ...(detail === undefined ? {} : { detail: shown(detail, "detail") }),
1217
- ...(cancel === undefined ? {} : { cancel: shown(cancel, "cancel") }),
1218
- ...(destructive === undefined ? {} : { destructive }),
1219
- ...(checkbox === undefined
1220
- ? {}
1221
- : { checkbox: shown(checkbox, "checkbox") }),
1222
- ...(values === undefined ? {} : { args: values }),
1223
- });
1224
- return {
1225
- confirmed: answer.confirmed === true,
1226
- checked: answer.checked === true,
1227
- };
1228
- },
1229
- openFile: async (options) => (await open("openFile", options))[0] ?? null,
1230
- openFiles: (options) => open("openFiles", options),
1231
- openDirectory: async (options) => (await open("openDirectory", options))[0] ?? null,
1232
- saveFile: async (options) => (await open("saveFile", options))[0] ?? null,
1233
- };
1234
- };