@vestra-player/sdk 1.6.17 → 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.
- package/CHANGELOG.md +24 -0
- package/dist/docs-tree.json.gz +0 -0
- package/dist/plugin/appearance.js +1 -1
- package/dist/plugin/bindings.js +1 -1
- package/dist/plugin/commands.js +3 -2
- package/dist/plugin/connection.d.ts +119 -0
- package/dist/plugin/connection.js +402 -0
- package/dist/plugin/drops.d.ts +3 -3
- package/dist/plugin/drops.js +1 -1
- package/dist/plugin/errors.d.ts +9 -0
- package/dist/plugin/errors.js +35 -0
- package/dist/plugin/filters.js +1 -1
- package/dist/plugin/index.d.ts +1 -1
- package/dist/plugin/index.js +3 -1
- package/dist/plugin/language.js +1 -1
- package/dist/plugin/latest.d.ts +12 -0
- package/dist/plugin/latest.js +52 -0
- package/dist/plugin/local.js +1 -2
- package/dist/plugin/markdown.d.ts +2 -2
- package/dist/plugin/markdown.js +1 -1
- package/dist/plugin/media.js +1 -1
- package/dist/plugin/osd.js +2 -2
- package/dist/plugin/packages.js +1 -1
- package/dist/plugin/passes.d.ts +111 -0
- package/dist/plugin/passes.js +341 -0
- package/dist/plugin/player.d.ts +2 -3
- package/dist/plugin/player.js +2 -7
- package/dist/plugin/resolvers.js +1 -1
- package/dist/plugin/runtime.d.ts +26 -185
- package/dist/plugin/runtime.js +35 -755
- package/dist/plugin/scope.d.ts +2 -1
- package/dist/plugin/scope.js +5 -2
- package/dist/plugin/services/dialogs.d.ts +3 -0
- package/dist/plugin/services/dialogs.js +62 -0
- package/dist/plugin/services.js +1 -1
- package/dist/plugin/settings.js +2 -2
- package/dist/plugin/shaders.js +2 -1
- package/dist/plugin/stream.js +1 -2
- package/dist/plugin/subtitles.js +2 -2
- package/dist/plugin/testing/commands.d.ts +65 -0
- package/dist/plugin/testing/commands.js +412 -0
- package/dist/plugin/testing/core-actions.d.ts +39 -0
- package/dist/plugin/testing/core-actions.js +371 -0
- package/dist/plugin/testing/declarations.d.ts +37 -0
- package/dist/plugin/testing/declarations.js +80 -0
- package/dist/plugin/testing/i18n.d.ts +28 -0
- package/dist/plugin/testing/i18n.js +107 -0
- package/dist/plugin/testing/index.d.ts +3140 -0
- package/dist/plugin/testing/index.js +2796 -0
- package/dist/plugin/testing/internals.d.ts +76 -0
- package/dist/plugin/testing/internals.js +60 -0
- package/dist/plugin/testing/jobs.d.ts +9 -0
- package/dist/plugin/testing/jobs.js +304 -0
- package/dist/plugin/testing/layout.d.ts +85 -0
- package/dist/plugin/testing/layout.js +622 -0
- package/dist/plugin/testing/media.d.ts +55 -0
- package/dist/plugin/testing/media.js +615 -0
- package/dist/plugin/testing/osd.d.ts +4 -0
- package/dist/plugin/testing/osd.js +57 -0
- package/dist/plugin/testing/processing.d.ts +56 -0
- package/dist/plugin/testing/processing.js +491 -0
- package/dist/plugin/testing/services.d.ts +50 -0
- package/dist/plugin/testing/services.js +466 -0
- package/dist/plugin/testing/transport.d.ts +29 -0
- package/dist/plugin/testing/transport.js +140 -0
- package/dist/plugin/testing.d.ts +1 -3140
- package/dist/plugin/testing.js +1 -6203
- package/dist/plugin/timeline.js +2 -1
- package/dist/plugin/view/base.d.ts +29 -0
- package/dist/plugin/view/base.js +88 -0
- package/dist/plugin/view/binder.d.ts +22 -0
- package/dist/plugin/view/binder.js +57 -0
- package/dist/plugin/view/image.d.ts +21 -0
- package/dist/plugin/view/image.js +60 -0
- package/dist/plugin/view/layout.d.ts +47 -0
- package/dist/plugin/view/layout.js +171 -0
- package/dist/plugin/view/panel.d.ts +3 -0
- package/dist/plugin/view/panel.js +178 -0
- package/dist/plugin/view/window.d.ts +3 -0
- package/dist/plugin/view/window.js +99 -0
- package/dist/plugin/view/writer.d.ts +58 -0
- package/dist/plugin/view/writer.js +634 -0
- package/dist/plugin/view.js +2 -1
- package/dist/plugin/window.js +1 -1
- package/llms.txt +1 -1
- package/package.json +6 -4
- package/dist/plugin/ui.d.ts +0 -74
- 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
|
-
};
|