@vestra-player/sdk 1.5.43 → 1.5.45

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/dist/plugin/ui.js CHANGED
@@ -6,6 +6,8 @@ import { basename, extname, resolve } from "node:path";
6
6
  import { failure, Latest, quiet } from "./runtime.js";
7
7
  import { dropsOf } from "./drops.js";
8
8
  import { MarkdownHandle, markdown, remoteImageFile } from "./markdown.js";
9
+ import { HostError } from "../wire.js";
10
+ import { countRow, diff, keyOf, PATCH_FEATURE, replay, withinLimits, } from "./patch.js";
9
11
  /** Slint names are kebab-case; plugin code may use camelCase. */
10
12
  export const kebab = (name) => name.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`);
11
13
  const encode = (props) => Object.fromEntries(Object.entries(props).map(([k, v]) => [kebab(k), v]));
@@ -62,8 +64,9 @@ function wireValue(value) {
62
64
  throw failure("invalid", `image ${value.path} was released with the scope that created it`);
63
65
  return value.toJSON();
64
66
  }
67
+ // Array.from: a hole is `null`, as `JSON.stringify` writes it.
65
68
  if (Array.isArray(value))
66
- return value.map((item) => wireValue(item));
69
+ return Array.from(value, (item) => wireValue(item ?? null));
67
70
  if (value !== null && typeof value === "object") {
68
71
  const out = {};
69
72
  for (const [key, item] of Object.entries(value))
@@ -72,6 +75,89 @@ function wireValue(value) {
72
75
  }
73
76
  return value;
74
77
  }
78
+ /** Scalars compared by value. Non-finite numbers are JSON `null`: compared as JSON. */
79
+ const isPrimitive = (value) => value === null ||
80
+ typeof value === "string" ||
81
+ typeof value === "boolean" ||
82
+ (typeof value === "number" && Number.isFinite(value));
83
+ /** A row as `JSON.stringify` writes it inside an array. */
84
+ const encodeRow = (row) => JSON.stringify(row) ?? "null";
85
+ /** Each row of an array as `JSON.stringify` writes it; a hole is `null`. */
86
+ const encodeRows = (rows) => Array.from(rows, encodeRow);
87
+ function sameRows(a, b) {
88
+ if (a.length !== b.length)
89
+ return false;
90
+ for (let i = 0; i < a.length; i++)
91
+ if (a[i] !== b[i])
92
+ return false;
93
+ return true;
94
+ }
95
+ /** An initial property (wire form) as sent. */
96
+ function sentOf(value, patches) {
97
+ if (isPrimitive(value))
98
+ return { kind: "value", value };
99
+ if (Array.isArray(value) && patches) {
100
+ const keyed = keyOf(value);
101
+ return {
102
+ kind: "rows",
103
+ rows: {
104
+ enc: encodeRows(value),
105
+ field: keyed?.field ?? null,
106
+ keys: keyed?.keys ?? null,
107
+ counts: [],
108
+ },
109
+ };
110
+ }
111
+ return { kind: "json", enc: JSON.stringify(value) };
112
+ }
113
+ /**
114
+ * The row patch from `sent` to `next` (the bound `rows`), in wire form, and
115
+ * the new rows' limit counts; `null` when the whole value is to be sent:
116
+ * nothing to type the rows by (no rows sent), a Markdown document in a row
117
+ * (the player merges documents in place), too many operations, a value over
118
+ * the limits (the player refuses it whole, as before), or no change (an
119
+ * explicit `set()` of the same value).
120
+ */
121
+ function rowPatch(sent, next, rows) {
122
+ if (sent.enc.length === 0)
123
+ return null;
124
+ if (next.enc.some((row) => row.includes('"$vestraMarkdown"')))
125
+ return null;
126
+ const nextKeyed = sent.field === null ? null : next.keyed();
127
+ const keyed = nextKeyed !== null && nextKeyed.field === sent.field;
128
+ const plan = diff({ enc: sent.enc, keys: keyed ? sent.keys : null }, { enc: next.enc, keys: keyed ? nextKeyed.keys : null });
129
+ if (!plan || plan.ops.length === 0)
130
+ return null;
131
+ // A released image is refused wherever it is, as in a whole write: the
132
+ // rows the patch does not carry are checked too (those holding images).
133
+ for (let j = 0; j < rows.length; j++)
134
+ if (plan.from[j] >= 0 && next.enc[j].includes('"$vestraImage"'))
135
+ wireValue(rows[j] ?? null);
136
+ const counts = next.enc.map((_, j) => {
137
+ const from = plan.from[j];
138
+ return (from >= 0 ? sent.counts[from] : undefined) ?? countRow(rows[j]);
139
+ });
140
+ if (!withinLimits(counts))
141
+ return null;
142
+ // The ops must give exactly the new rows (a guard against this diff's bugs).
143
+ if (!sameRows(replay(sent.enc, plan.ops, next.enc), next.enc))
144
+ return null;
145
+ const ops = plan.ops.map((op) => {
146
+ switch (op.op) {
147
+ case "set":
148
+ return { op: "set", at: op.at, row: wireValue(rows[op.row] ?? null) };
149
+ case "insert":
150
+ return {
151
+ op: "insert",
152
+ at: op.at,
153
+ rows: op.rows.map((j) => wireValue(rows[j] ?? null)),
154
+ };
155
+ default:
156
+ return op;
157
+ }
158
+ });
159
+ return { ops, counts };
160
+ }
75
161
  export const ui = (scope) => ({
76
162
  image(path, options) {
77
163
  if (typeof path !== "string" || !path)
@@ -96,6 +182,18 @@ export class ViewBase {
96
182
  #binders = new Map();
97
183
  /** What the host currently shows, as far as this side knows. */
98
184
  #sent = new Map();
185
+ /**
186
+ * Row patches (player feature `ui.patch`): each property's last write the
187
+ * player confirmed, by `seq` (0: the initial properties). A patch applies
188
+ * on it; a property without one is sent whole.
189
+ */
190
+ #bases = new Map();
191
+ /**
192
+ * Patches of a property answered `stale` in a row: after two, the
193
+ * property is sent whole until the view shows its initial properties again.
194
+ */
195
+ #stales = new Map();
196
+ #seq = 0;
99
197
  /** Written by `set()`: sent even if equal to what was sent before. */
100
198
  #forced = new Set();
101
199
  /** The last value of each property whose write failed; not retried until it changes. */
@@ -111,6 +209,10 @@ export class ViewBase {
111
209
  this.scope = scope;
112
210
  this.#binding = scope.runtime.bind(() => this.evaluate());
113
211
  }
212
+ /** The player takes row patches (and `seq` on whole writes). */
213
+ get #patches() {
214
+ return this.scope.runtime.features.has(PATCH_FEATURE);
215
+ }
114
216
  /**
115
217
  * Initial properties in wire form. A released image is left out (the
116
218
  * property keeps its default) and reported, like a failed write.
@@ -145,18 +247,32 @@ export class ViewBase {
145
247
  this.flush();
146
248
  }
147
249
  bind(props) {
148
- this.#binders.set(props, this.scope.runtime.callSite());
250
+ // `token` names this binding of this view in the runtime's read
251
+ // tracking: the same function bound to two views is two parts.
252
+ this.#binders.set(props, {
253
+ site: this.scope.runtime.callSite(),
254
+ token: {},
255
+ });
149
256
  this.scope.update();
150
257
  return this.scope.onDispose(() => {
151
258
  this.#binders.delete(props);
152
259
  });
153
260
  }
154
- /** Current desired properties (bound values win over earlier `set`). */
261
+ /**
262
+ * Current desired properties (bound values win over earlier `set`). A
263
+ * precise pass (`Runtime.part`) skips a bind function that read none of
264
+ * the changed state: its last values stay.
265
+ */
155
266
  snapshot() {
156
- for (const [binder, site] of this.#binders) {
267
+ const runtime = this.scope.runtime;
268
+ for (const [binder, { site, token }] of this.#binders) {
157
269
  try {
158
- const props = this.scope.runtime.reader("a view's bind", site, binder);
159
- for (const [key, value] of Object.entries(encode(props)))
270
+ // Encoded inside the part: what encoding reads counts as the
271
+ // function's (a state object it returned).
272
+ const result = runtime.part(token, () => encode(runtime.reader("a view's bind", site, binder)));
273
+ if (!result)
274
+ continue;
275
+ for (const [key, value] of Object.entries(result.value))
160
276
  this.#values.set(key, value);
161
277
  }
162
278
  catch (error) {
@@ -173,11 +289,107 @@ export class ViewBase {
173
289
  }
174
290
  /** The host already shows these values (e.g. sent as initial properties). */
175
291
  shown(values) {
176
- this.#sent = new Map(Object.entries(values).map(([k, v]) => [k, JSON.stringify(v)]));
292
+ const patches = this.#patches;
293
+ this.#sent = new Map(Object.entries(values).map(([k, v]) => [k, sentOf(v, patches)]));
294
+ this.#bases = new Map(patches ? Object.keys(values).map((key) => [key, 0]) : []);
295
+ this.#stales.clear();
177
296
  this.#forced.clear();
178
297
  for (const key of Object.keys(values))
179
298
  this.#failed.delete(key);
180
299
  }
300
+ /** `value` encoded, and whether it differs from what was sent. */
301
+ #encode(key, value) {
302
+ const sent = this.#sent.get(key);
303
+ if (isPrimitive(value)) {
304
+ // Compared as it is: no `JSON.stringify` for the scalars of a pass.
305
+ const changed = sent?.kind === "value"
306
+ ? sent.value !== value
307
+ : sent?.kind !== "json" || sent.enc !== JSON.stringify(value);
308
+ return {
309
+ key,
310
+ value,
311
+ changed,
312
+ signature: () => JSON.stringify(value),
313
+ sent: () => ({ kind: "value", value }),
314
+ };
315
+ }
316
+ if (Array.isArray(value) && this.#patches) {
317
+ // Row by row: the same work as one `JSON.stringify`, and the rows to
318
+ // diff against those last sent.
319
+ const enc = encodeRows(value);
320
+ const signature = () => `[${enc.join(",")}]`;
321
+ const changed = sent?.kind === "rows"
322
+ ? !sameRows(sent.rows.enc, enc)
323
+ : sent?.kind !== "json" || sent.enc !== signature();
324
+ // Keys only for a write: an unchanged array costs its encoding alone.
325
+ let keys;
326
+ const keyed = () => (keys === undefined ? (keys = keyOf(value)) : keys);
327
+ return {
328
+ key,
329
+ value,
330
+ changed,
331
+ signature,
332
+ rows: { enc, keyed },
333
+ sent: (counts = []) => ({
334
+ kind: "rows",
335
+ rows: {
336
+ enc,
337
+ field: keyed()?.field ?? null,
338
+ keys: keyed()?.keys ?? null,
339
+ counts,
340
+ },
341
+ }),
342
+ };
343
+ }
344
+ const enc = JSON.stringify(value);
345
+ return {
346
+ key,
347
+ value,
348
+ changed: sent?.kind !== "json" || sent.enc !== enc,
349
+ signature: () => enc,
350
+ sent: () => ({ kind: "json", enc }),
351
+ };
352
+ }
353
+ /**
354
+ * Write one property: a row patch when the player takes them and one
355
+ * applies (else the whole value); a `stale` patch is followed by the whole
356
+ * value at once. The base is forgotten on any failure, so the next write
357
+ * of the property is whole.
358
+ */
359
+ async #send(pending) {
360
+ const { key, value } = pending;
361
+ if (!this.#patches) {
362
+ await this.write(key, { value: wireValue(value) });
363
+ return pending.sent();
364
+ }
365
+ const base = this.#bases.get(key);
366
+ this.#bases.delete(key);
367
+ const sent = this.#sent.get(key);
368
+ const plan = base !== undefined &&
369
+ sent?.kind === "rows" &&
370
+ pending.rows &&
371
+ (this.#stales.get(key) ?? 0) < 2
372
+ ? rowPatch(sent.rows, pending.rows, value)
373
+ : null;
374
+ if (plan) {
375
+ const seq = ++this.#seq;
376
+ try {
377
+ await this.write(key, { patch: { base: base, seq, ops: plan.ops } });
378
+ this.#bases.set(key, seq);
379
+ this.#stales.delete(key);
380
+ return pending.sent(plan.counts);
381
+ }
382
+ catch (error) {
383
+ if (!(error instanceof HostError && error.code === "stale"))
384
+ throw error;
385
+ this.#stales.set(key, (this.#stales.get(key) ?? 0) + 1);
386
+ }
387
+ }
388
+ const seq = ++this.#seq;
389
+ await this.write(key, { value: wireValue(value), seq });
390
+ this.#bases.set(key, seq);
391
+ return pending.sent();
392
+ }
181
393
  /**
182
394
  * Send what changed. A bound value is sent when it differs from the last one
183
395
  * sent; a `set()` value is always sent once. Nothing is resent merely
@@ -198,20 +410,22 @@ export class ViewBase {
198
410
  try {
199
411
  let pending;
200
412
  do {
201
- pending = [...this.#values].filter(([key, value]) => {
202
- const encoded = JSON.stringify(value);
203
- if (this.#failed.get(key) === encoded)
204
- return false;
205
- return this.#forced.has(key) || this.#sent.get(key) !== encoded;
206
- });
207
- for (const [key, value] of pending) {
413
+ pending = [];
414
+ for (const [key, value] of this.#values) {
415
+ const encoded = this.#encode(key, value);
416
+ const failedAs = this.#failed.get(key);
417
+ if (failedAs !== undefined && failedAs === encoded.signature())
418
+ continue;
419
+ if (this.#forced.has(key) || encoded.changed)
420
+ pending.push(encoded);
421
+ }
422
+ for (const encoded of pending) {
208
423
  if (!this.live())
209
424
  return;
210
- const encoded = JSON.stringify(value);
425
+ const key = encoded.key;
211
426
  this.#forced.delete(key);
212
427
  try {
213
- await this.write(key, wireValue(value));
214
- this.#sent.set(key, encoded);
428
+ this.#sent.set(key, await this.#send(encoded));
215
429
  this.#failed.delete(key);
216
430
  this.#backoff = 0;
217
431
  this.#broken = false;
@@ -222,7 +436,7 @@ export class ViewBase {
222
436
  if (!this.#broken)
223
437
  failure ??= error;
224
438
  this.#broken = true;
225
- this.#failed.set(key, encoded);
439
+ this.#failed.set(key, encoded.signature());
226
440
  }
227
441
  }
228
442
  } while (pending.length && !failed && this.live());
@@ -294,8 +508,8 @@ class WindowHandle extends ViewBase {
294
508
  live() {
295
509
  return this.#opened && !this.closed;
296
510
  }
297
- write(property, value) {
298
- return this.scope.runtime.host.ui.set(this.#id, property, value);
511
+ write(property, write) {
512
+ return this.scope.runtime.host.ui.set(this.#id, property, write);
299
513
  }
300
514
  on(callback, handler) {
301
515
  // The host forwards only callbacks named when the window opens.
@@ -400,8 +614,8 @@ class PanelHandle extends ViewBase {
400
614
  live() {
401
615
  return this.#mounted && !this.closed;
402
616
  }
403
- write(property, value) {
404
- return this.scope.runtime.host.ui.setMount(property, value);
617
+ write(property, write) {
618
+ return this.scope.runtime.host.ui.setMount(property, write);
405
619
  }
406
620
  get state() {
407
621
  const service = layoutOf(this.scope.runtime);
package/dist/wire.d.ts CHANGED
@@ -56,6 +56,45 @@ export interface UiCallback {
56
56
  callback: string;
57
57
  args: Json[];
58
58
  }
59
+ /**
60
+ * One row operation of a patch, by index, applied to the result of the
61
+ * ones before (player 0.6.0, feature `ui.patch`). `move` takes `count` rows
62
+ * out at `from` and puts them back at `to`, an index of the list without
63
+ * them.
64
+ */
65
+ export type RowOp = {
66
+ op: "set";
67
+ at: number;
68
+ row: Json;
69
+ } | {
70
+ op: "insert";
71
+ at: number;
72
+ rows: Json[];
73
+ } | {
74
+ op: "remove";
75
+ at: number;
76
+ count: number;
77
+ } | {
78
+ op: "move";
79
+ from: number;
80
+ count: number;
81
+ to: number;
82
+ };
83
+ /**
84
+ * A property write: a whole value (with its `seq` to players that take
85
+ * patches), or a row patch of an array applying on the write numbered
86
+ * `base` (0: the initial properties).
87
+ */
88
+ export type PropertyWrite = {
89
+ value: Json;
90
+ seq?: number;
91
+ } | {
92
+ patch: {
93
+ base: number;
94
+ seq: number;
95
+ ops: RowOp[];
96
+ };
97
+ };
59
98
  /**
60
99
  * A definite refusal from the player (or the plugin runner), with a stable
61
100
  * `code`. Plugin code sees it as a `PluginError` (an `Error` with `code`);
@@ -481,7 +520,7 @@ export declare function createWire(transport: Transport): {
481
520
  global: string;
482
521
  properties: Record<string, Json>;
483
522
  }) => Promise<void>;
484
- set: (id: string, property: string, value: Json) => Promise<void>;
523
+ set: (id: string, property: string, write: PropertyWrite) => Promise<void>;
485
524
  unregister: (id: string) => Promise<void>;
486
525
  show: (id?: string) => Promise<void>;
487
526
  };
@@ -668,7 +707,7 @@ export declare function createWire(transport: Transport): {
668
707
  prepareMs: number;
669
708
  shellGeneration: number;
670
709
  }>;
671
- setMount: (property: string, value: Json) => Promise<void>;
710
+ setMount: (property: string, write: PropertyWrite) => Promise<void>;
672
711
  interaction: (locks: {
673
712
  focused: boolean;
674
713
  dragging: boolean;
@@ -677,7 +716,7 @@ export declare function createWire(transport: Transport): {
677
716
  unmount: () => Promise<void>;
678
717
  menu: (items: (string | null)[], x: number, y: number) => Promise<void>;
679
718
  open: (id: string, file: string, properties?: Record<string, Json>, callbacks?: string[]) => Promise<void>;
680
- set: (id: string, property: string, value: Json) => Promise<void>;
719
+ set: (id: string, property: string, write: PropertyWrite) => Promise<void>;
681
720
  show: (id: string, visible: boolean) => Promise<void>;
682
721
  close: (id: string) => Promise<void>;
683
722
  };
package/dist/wire.js CHANGED
@@ -125,7 +125,7 @@ export function createWire(transport) {
125
125
  /** Settings pages in the player's options window (`ui.settings`). */
126
126
  settings: {
127
127
  register: (options) => transport.request("settings.register", options),
128
- set: (id, property, value) => transport.request("settings.set", { id, property, value }),
128
+ set: (id, property, write) => transport.request("settings.set", { id, property, ...write }),
129
129
  unregister: (id) => transport.request("settings.unregister", { id }),
130
130
  show: (id) => transport.request("settings.show", id === undefined ? {} : { id }),
131
131
  },
@@ -321,12 +321,12 @@ export function createWire(transport) {
321
321
  base,
322
322
  }),
323
323
  mount: (options) => transport.request("ui.mount", options),
324
- setMount: (property, value) => transport.request("ui.setMount", { property, value }),
324
+ setMount: (property, write) => transport.request("ui.setMount", { property, ...write }),
325
325
  interaction: (locks) => transport.request("ui.interaction", locks),
326
326
  unmount: () => transport.request("ui.unmount"),
327
327
  menu: (items, x, y) => transport.request("ui.menu", { items, x, y }),
328
328
  open: (id, file, properties = {}, callbacks = []) => transport.request("ui.open", { id, file, properties, callbacks }),
329
- set: (id, property, value) => transport.request("ui.set", { id, property, value }),
329
+ set: (id, property, write) => transport.request("ui.set", { id, property, ...write }),
330
330
  show: (id, visible) => transport.request("ui.show", { id, visible }),
331
331
  close: (id) => transport.request("ui.close", { id }),
332
332
  },