@vestra-player/sdk 1.5.39 → 1.5.40

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 CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.5.40
4
+
5
+ **Who must change: nobody.** Additions only, `apiVersion` stays `vestra/1`. `plugin.when` needs player 0.5.21 (`"minPlayerVersion": "0.5.21"`); older players have no `plugin.when`. A plugin that does not use it sees no difference. What changes with the player's lazy activation (off by default; `VESTRA_LAZY=1`): a listener of `packages.changed`, `bindings.changed`, `appearance.changed` or `resolvers.fileTypes` no longer keeps a plugin running; the player stops it while idle and starts it again to deliver the event. `bindings.changed` (`plugin.bindings.onChange`) now fires only when the binding list changes, no longer when a plugin starts again with the same commands; nobody must change (the event carries no data, and the list is what it stands for).
6
+
7
+ - **`plugin.when`** (player 0.5.21): conditions on the player's own state for a command's `enabled` and `checked`, evaluated by the player, so a stopped plugin's menu items still follow playback. The vocabulary: `open`, `ready`, `playing`, `paused`, `stream`, `fullscreen`, `phase(...phases)`, `can(control)` (the names of `PlayerState.can`), `theme(id)`, `language(preference)`; combine with `and`, `or`, `not()`. `enabled: when.playing`, `checked: when.theme("vestra-light")`. With an `enabled` condition the player shows the command's `reason` only while the condition is false (or the command is busy), so a fixed reason is enough. A condition reads nothing in the plugin and keeps no listener; a function (`enabled: () => plugin.player.state.phase === "ready"`) keeps working and keeps the plugin running, as before. Wire form: `commands.register` and `commands.update` carry `when: { enabled?, checked? }` (one-key condition objects: `phase`, `paused`, `kind`, `can`, `fullscreen`, `theme`, `language`, `all`, `any`, `not`); the state fields they cover carry a placeholder. New types `When`, `Condition`; `Command.set` and `CommandOptions.enabled`/`checked` accept a condition.
8
+ - **Why a plugin keeps running.** The SDK tells the player everything that listens to an event and where the plugin added it (`events.subscribe { event, why: [...] }`, player 0.5.21 logs it; older players ignore it): `a listener added at main.ts:570`, `command "Snapshot" enabled is a function that reads plugin.player.state (main.ts:353) — use plugin.when`. The player's log names it when it keeps a plugin running, and for a development load (`--plugin`) at its start.
9
+ - **`TestHost`**: evaluates conditions as the player does (`FakeCommand.state` against `source`, `mainWindow.fullscreen`, `appearance` and `language`; `FakeCommand.when` is what was sent); `listeningWhy` (every reason each event of `listening` is listened to); `plugin.when.theme` and `language` refuse ids the player does not have (`TypeError`); `keepsRunning` (why the player would keep the plugin running: listeners of events other than the four above, a mounted panel, open windows). `vestra-plugin test` prints `keepsRunning` once per test file when it is not empty.
10
+
3
11
  ## 1.5.39
4
12
 
5
13
  **Who must change: nobody.** Additions only, `apiVersion` stays `vestra/1`, no new capability. The new API needs player 0.5.20 (`"minPlayerVersion": "0.5.20"`): older players refuse the new node kinds with `invalid` and the shader request as unknown, which shows as a `failed` handle. A plugin that uses none of it sees no difference; `plugin.video.render()` (its `shaders` included) behaves as before.
@@ -787,7 +787,11 @@ async function test(target) {
787
787
  run(
788
788
  process.execPath,
789
789
  ["--test", ...tests.map((file) => join(directory, file))],
790
- { cwd: target.directory },
790
+ // startPlugin says why a plugin would keep running while idle.
791
+ {
792
+ cwd: target.directory,
793
+ env: { ...process.env, VESTRA_PLUGIN_RESIDENCY: "1" },
794
+ },
791
795
  ) !== 0
792
796
  )
793
797
  fail(`${target.name}: tests failed`);
@@ -1,6 +1,7 @@
1
1
  /** Finite candidate command registry. Host authenticates owner; no plugin supplies an owner id. */
2
2
  import type { Json, Transport } from "./wire.ts";
3
3
  import type { ListJson, TextJson } from "./i18n.ts";
4
+ import type { ConditionJson } from "./plugin/when.ts";
4
5
  /**
5
6
  * The player's command registry caps, the same values as the player's own
6
7
  * table (the player's tests check that they agree). They are runaway guards,
@@ -238,6 +239,15 @@ export type CommandState = {
238
239
  /** Source text, or a text value (player 0.4.3). */
239
240
  reason: string | TextJson | ListJson | null;
240
241
  };
242
+ /**
243
+ * A command's conditions (SDK 1.5.40, player 0.5.21): `enabled` and
244
+ * `checked` evaluated by the player (`plugin.when`). Older players ignore
245
+ * the field and show the state as sent.
246
+ */
247
+ export type CommandWhen = {
248
+ enabled?: ConditionJson;
249
+ checked?: ConditionJson;
250
+ };
241
251
  export type CommandDeclaration = {
242
252
  /** Source text, or a text value (player 0.4.3). */
243
253
  title: string | TextJson;
@@ -266,12 +276,12 @@ export type CommandDeclaration = {
266
276
  };
267
277
  };
268
278
  export declare function commandClient(transport: Transport): {
269
- register: (id: string, revision: number, declaration: CommandDeclaration, state: CommandState) => Promise<{
279
+ register: (id: string, revision: number, declaration: CommandDeclaration, state: CommandState, when?: CommandWhen) => Promise<{
270
280
  revision: number;
271
281
  }>;
272
282
  /** Remove the command from the menu and the binding table; the id is free again. */
273
283
  unregister: (id: string) => Promise<void>;
274
- update: (id: string, revision: number, state: CommandState) => Promise<{
284
+ update: (id: string, revision: number, state: CommandState, when?: CommandWhen) => Promise<{
275
285
  revision: number;
276
286
  }>;
277
287
  };
package/dist/commands.js CHANGED
@@ -619,18 +619,20 @@ export const BUILTIN_COMMANDS = Object.freeze(BUILTIN_COMMAND_TABLE.trim()
619
619
  }));
620
620
  export function commandClient(transport) {
621
621
  return {
622
- register: (id, revision, declaration, state) => transport.request("commands.register", {
622
+ register: (id, revision, declaration, state, when) => transport.request("commands.register", {
623
623
  id,
624
624
  revision,
625
625
  declaration,
626
626
  state,
627
+ ...(when ? { when: when } : {}),
627
628
  }),
628
629
  /** Remove the command from the menu and the binding table; the id is free again. */
629
630
  unregister: (id) => transport.request("commands.unregister", { id }),
630
- update: (id, revision, state) => transport.request("commands.update", {
631
+ update: (id, revision, state, when) => transport.request("commands.update", {
631
632
  id,
632
633
  revision,
633
634
  state,
635
+ ...(when ? { when: when } : {}),
634
636
  }),
635
637
  };
636
638
  }
@@ -4,7 +4,20 @@ import { checkBindings } from "./bindings.js";
4
4
  import { BAR_BUTTON_ICONS, BAR_BUTTON_LIMITS, MENU_IDS } from "../commands.js";
5
5
  import { ImageHandle } from "./ui.js";
6
6
  import { isTextValue, textProblem } from "../i18n.js";
7
+ import { conditionJson, isCondition } from "./when.js";
7
8
  const value = (item) => typeof item === "function" ? item() : item;
9
+ /** A field's value, or `fallback` (and its condition noted) for a condition. */
10
+ function field(item, fallback, name, when) {
11
+ if (!isCondition(item))
12
+ return value(item);
13
+ when[name] = conditionJson(item);
14
+ return fallback;
15
+ }
16
+ /** How the player's log names a command: its title, else its id. */
17
+ const label = (options) => {
18
+ const title = options.title ?? options.path?.at(-1);
19
+ return `command "${typeof title === "string" ? title : options.id}"`;
20
+ };
8
21
  /** Validate options and build the wire declaration. */
9
22
  function declare(options) {
10
23
  const path = options.path ?? [];
@@ -88,13 +101,18 @@ class CommandHandle {
88
101
  #disposed = false;
89
102
  #registered = false;
90
103
  #pump;
104
+ #label;
105
+ /** Where the plugin added it, for the player's log. */
106
+ #where;
91
107
  #binding;
92
108
  constructor(scope, options) {
93
109
  const runtime = scope.runtime;
110
+ this.#where = runtime.callSite();
94
111
  if (runtime.commands.has(options.id))
95
112
  throw failure("conflict", `command ${options.id} is already declared`);
96
113
  this.id = options.id;
97
114
  this.#scope = scope;
115
+ this.#label = label(options);
98
116
  this.#spec = {
99
117
  enabled: options.enabled ?? true,
100
118
  checked: options.checked ?? null,
@@ -105,15 +123,15 @@ class CommandHandle {
105
123
  // The host requires a strictly increasing revision per update; the
106
124
  // registration itself is revision 1, so every later state follows it.
107
125
  const previous = removing(runtime).get(this.id);
108
- this.#pump = new Latest(async (state) => {
126
+ this.#pump = new Latest(async ({ state, when }) => {
109
127
  const revision = ++this.#revision;
110
128
  if (revision === 1) {
111
129
  await previous;
112
- await runtime.host.commands.register(this.id, 1, declaration, state);
130
+ await runtime.host.commands.register(this.id, 1, declaration, state, when);
113
131
  this.#registered = true;
114
132
  }
115
133
  else
116
- await runtime.host.commands.update(this.id, revision, state);
134
+ await runtime.host.commands.update(this.id, revision, state, when);
117
135
  });
118
136
  const initial = this.#state();
119
137
  this.#last = JSON.stringify(initial);
@@ -127,8 +145,22 @@ class CommandHandle {
127
145
  this.#binding = runtime.bind(() => this.#sync());
128
146
  scope.add(this);
129
147
  }
148
+ /**
149
+ * The state to send. A condition field is sent as a placeholder with the
150
+ * condition in `when`: the player evaluates it. Each function field is
151
+ * evaluated under its own name, so what it reads is reported as this
152
+ * command's (`Runtime.reader`).
153
+ */
130
154
  #state() {
131
- const reason = value(this.#spec.reason);
155
+ const runtime = this.#scope.runtime;
156
+ const spec = this.#spec;
157
+ const as = (name, read) => runtime.reader(`${this.#label} ${name}`, this.#where, read, name === "enabled" || name === "checked");
158
+ // enabled and checked first: what they read is reported as theirs
159
+ // (a condition can replace them), not as the reason's.
160
+ const when = {};
161
+ const enabled = as("enabled", () => field(spec.enabled, true, "enabled", when));
162
+ const checked = as("checked", () => field(spec.checked, false, "checked", when));
163
+ const reason = as("reason", () => value(spec.reason));
132
164
  if (reason !== null && typeof reason !== "string") {
133
165
  const problem = isTextValue(reason)
134
166
  ? textProblem(reason)
@@ -136,12 +168,13 @@ class CommandHandle {
136
168
  if (problem)
137
169
  throw failure("invalid", `command ${this.id}: ${problem}`);
138
170
  }
139
- return {
140
- enabled: value(this.#spec.enabled),
141
- checked: value(this.#spec.checked),
142
- busy: value(this.#spec.busy),
171
+ const state = {
172
+ enabled,
173
+ checked,
174
+ busy: as("busy", () => value(spec.busy)),
143
175
  reason,
144
176
  };
177
+ return Object.keys(when).length ? { state, when } : { state };
145
178
  }
146
179
  #sync() {
147
180
  if (this.#disposed)
@@ -1,5 +1,17 @@
1
1
  import type { Json, LocalServices, Transport, Wire } from "../wire.ts";
2
2
  import type { App, Disposable, Locale, PluginError, SystemInfo } from "./types.ts";
3
+ /**
4
+ * Why something listens, for the player's log line that says why a plugin
5
+ * keeps running (`events.subscribe { why }`, player 0.5.21): computed
6
+ * only when the player is told.
7
+ */
8
+ export type Why = () => string;
9
+ /**
10
+ * Events the player wakes a stopped plugin for (player 0.5.21): a listener
11
+ * of one does not keep the plugin running. They follow deliberate changes
12
+ * (an install, a key edit, the palette, a resolver), not playback.
13
+ */
14
+ export declare const WAKE_EVENTS: readonly string[];
3
15
  export interface PluginManifest {
4
16
  id: string;
5
17
  capabilities: string[];
@@ -58,6 +70,18 @@ export declare class Runtime {
58
70
  * on it, so the event counts as listened to while the binding reads it.
59
71
  */
60
72
  reading(event: string): void;
73
+ /**
74
+ * Evaluate `read` as `label`, added at `where`: the state it reads is
75
+ * reported as read by it. `field`: a command's `enabled` or `checked`,
76
+ * which a `plugin.when` condition can replace.
77
+ */
78
+ reader<T>(label: string, where: (() => string | undefined) | undefined, read: () => T, field?: boolean): T;
79
+ /**
80
+ * Where the plugin's code called into the SDK: the first stack frame
81
+ * outside the SDK, as a path relative to the plugin's directory and a
82
+ * line (`main.ts:353`). Captured now, formatted when asked.
83
+ */
84
+ callSite(): () => string | undefined;
61
85
  /** Coalesce: one evaluation pass per microtask turn, however many changes. */
62
86
  invalidate(): void;
63
87
  /**
@@ -74,7 +98,13 @@ export declare class Runtime {
74
98
  * state, a binding that reads it. Nothing when nothing subscribed to it
75
99
  * (no capability, or the service did not start).
76
100
  */
77
- hold(event: string): Disposable;
101
+ hold(event: string, why?: Why): Disposable;
102
+ /**
103
+ * Why the plugin listens to each event it really listens to (mirrors
104
+ * left out), as the player's log says it: for {@link TestHost} and
105
+ * `vestra-plugin test`.
106
+ */
107
+ listening(): Map<string, string[]>;
78
108
  /** Raw transport events the host pushes without a subscription. */
79
109
  raw(event: string, listener: Listener): Disposable;
80
110
  }
@@ -1,6 +1,36 @@
1
1
  /** Shared machinery behind every scope: invocation, update passes, events. */
2
2
  import { createWire, HostError } from "../wire.js";
3
3
  import { LOCALES } from "../i18n.js";
4
+ import { basename, isAbsolute, relative } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ /** The SDK's own files (`src/` or `dist/`): left out of a call site. */
7
+ const SDK_DIRECTORY = fileURLToPath(new URL("..", import.meta.url));
8
+ /**
9
+ * Events the player wakes a stopped plugin for (player 0.5.21): a listener
10
+ * of one does not keep the plugin running. They follow deliberate changes
11
+ * (an install, a key edit, the palette, a resolver), not playback.
12
+ */
13
+ export const WAKE_EVENTS = [
14
+ "packages.changed",
15
+ "bindings.changed",
16
+ "appearance.changed",
17
+ "resolvers.fileTypes",
18
+ ];
19
+ /**
20
+ * The state each event keeps current, and whether `plugin.when` can say
21
+ * the same without the plugin running.
22
+ */
23
+ const STATE = {
24
+ "source.state": { name: "plugin.player.state", when: true },
25
+ "window.state": { name: "plugin.window.state", when: true },
26
+ "appearance.changed": { name: "plugin.appearance", when: true },
27
+ "timeline.changed": { name: "plugin.timeline.marks", when: false },
28
+ "playback.backend": { name: "plugin.subtitles.state", when: false },
29
+ "resolvers.fileTypes": {
30
+ name: "plugin.resolvers.fileTypes",
31
+ when: false,
32
+ },
33
+ };
4
34
  export const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
5
35
  export function toError(error) {
6
36
  if (error instanceof Error) {
@@ -89,8 +119,10 @@ export class Runtime {
89
119
  #transport;
90
120
  /** Each binding with the events whose state it read last time (held). */
91
121
  #bindings = new Map();
92
- /** The events read by the binding being evaluated. */
122
+ /** The events read by the binding being evaluated, with who read them. */
93
123
  #reads;
124
+ /** Who reads now (a command's field and where it was added). */
125
+ #reader;
94
126
  #scheduled = false;
95
127
  #events = new Map();
96
128
  views = new Map();
@@ -171,26 +203,98 @@ export class Runtime {
171
203
  * on it, so the event counts as listened to while the binding reads it.
172
204
  */
173
205
  reading(event) {
174
- this.#reads?.add(event);
206
+ const reads = this.#reads;
207
+ if (!reads || reads.has(event))
208
+ return;
209
+ const reader = this.#reader;
210
+ const state = STATE[event];
211
+ reads.set(event, () => {
212
+ const read = state?.name ?? event;
213
+ const site = reader?.where?.();
214
+ const at = site ? ` (${site})` : "";
215
+ if (!reader)
216
+ return `a binding reads ${read}`;
217
+ if (!reader.field)
218
+ return `${reader.label} reads ${read}${at}`;
219
+ const hint = state?.when ? " — use plugin.when" : "";
220
+ return `${reader.label} is a function that reads ${read}${at}${hint}`;
221
+ });
222
+ }
223
+ /**
224
+ * Evaluate `read` as `label`, added at `where`: the state it reads is
225
+ * reported as read by it. `field`: a command's `enabled` or `checked`,
226
+ * which a `plugin.when` condition can replace.
227
+ */
228
+ reader(label, where, read, field = false) {
229
+ const outer = this.#reader;
230
+ this.#reader = { label, where, field };
231
+ try {
232
+ return read();
233
+ }
234
+ finally {
235
+ this.#reader = outer;
236
+ }
237
+ }
238
+ /**
239
+ * Where the plugin's code called into the SDK: the first stack frame
240
+ * outside the SDK, as a path relative to the plugin's directory and a
241
+ * line (`main.ts:353`). Captured now, formatted when asked.
242
+ */
243
+ callSite() {
244
+ const limit = Error.stackTraceLimit;
245
+ Error.stackTraceLimit = 40;
246
+ const error = new Error();
247
+ Error.stackTraceLimit = limit;
248
+ let site = null;
249
+ return () => {
250
+ if (site === null)
251
+ site = this.#site(error.stack ?? "");
252
+ return site;
253
+ };
254
+ }
255
+ #site(stack) {
256
+ for (const line of stack.split("\n").slice(1)) {
257
+ const match = /\(?((?:file:\/\/)?[^()\s]+?):(\d+):\d+\)?\s*$/.exec(line);
258
+ if (!match)
259
+ continue;
260
+ let file = match[1];
261
+ if (file.startsWith("file:"))
262
+ try {
263
+ file = fileURLToPath(file);
264
+ }
265
+ catch {
266
+ continue;
267
+ }
268
+ const sdk = relative(SDK_DIRECTORY, file);
269
+ if (!isAbsolute(file) || !(sdk.startsWith("..") || isAbsolute(sdk)))
270
+ continue;
271
+ const inside = relative(this.directory, file);
272
+ const shown = inside.startsWith("..") || isAbsolute(inside) ? basename(file) : inside;
273
+ return `${shown.replaceAll("\\", "/")}:${match[2]}`;
274
+ }
275
+ return undefined;
175
276
  }
176
277
  /** Evaluate one binding, holding the events whose state it read. */
177
278
  #evaluate(evaluate, held) {
178
- const reads = new Set();
279
+ const reads = new Map();
179
280
  const outer = this.#reads;
281
+ const reader = this.#reader;
180
282
  this.#reads = reads;
283
+ this.#reader = undefined;
181
284
  try {
182
285
  evaluate();
183
286
  }
184
287
  finally {
185
288
  this.#reads = outer;
289
+ this.#reader = reader;
186
290
  for (const [event, hold] of held)
187
291
  if (!reads.has(event)) {
188
292
  held.delete(event);
189
293
  void hold.dispose();
190
294
  }
191
- for (const event of reads)
295
+ for (const [event, why] of reads)
192
296
  if (!held.has(event))
193
- held.set(event, this.hold(event));
297
+ held.set(event, this.hold(event, why));
194
298
  }
195
299
  }
196
300
  /** Coalesce: one evaluation pass per microtask turn, however many changes. */
@@ -224,19 +328,24 @@ export class Runtime {
224
328
  let entry = this.#events.get(event);
225
329
  const created = !entry;
226
330
  if (!entry) {
227
- entry = { listeners: new Set(), active: 0 };
331
+ entry = { listeners: new Set(), active: 0, whys: new Map() };
228
332
  this.#events.set(event, entry);
229
333
  }
230
334
  const group = entry;
231
335
  group.listeners.add(listener);
232
- if (!mirror)
336
+ const token = {};
337
+ if (!mirror) {
233
338
  group.active++;
339
+ group.whys.set(token, listenerWhy(this.callSite()));
340
+ }
234
341
  if (created && (needs === null || this.can(needs))) {
235
342
  group.told = group.active > 0;
343
+ const whys = group.told ? allWhys(group) : undefined;
344
+ group.toldWhy = whys?.join("\n");
236
345
  group.off = this.host.events.subscribe(event, (data) => {
237
346
  for (const each of [...group.listeners])
238
347
  each(data);
239
- }, !group.told);
348
+ }, !group.told, whys);
240
349
  this.starting(group.off, (error) => console.error(`[${this.manifest.id}] subscribe ${event}:`, error));
241
350
  }
242
351
  else
@@ -250,6 +359,7 @@ export class Runtime {
250
359
  group.listeners.delete(listener);
251
360
  if (!mirror)
252
361
  group.active--;
362
+ group.whys.delete(token);
253
363
  if (this.#events.get(event) !== group)
254
364
  return;
255
365
  if (group.listeners.size)
@@ -265,11 +375,13 @@ export class Runtime {
265
375
  * state, a binding that reads it. Nothing when nothing subscribed to it
266
376
  * (no capability, or the service did not start).
267
377
  */
268
- hold(event) {
378
+ hold(event, why) {
269
379
  const group = this.#events.get(event);
270
380
  if (!group)
271
381
  return { dispose: async () => { } };
272
382
  group.active++;
383
+ const token = {};
384
+ group.whys.set(token, why ?? listenerWhy(this.callSite()));
273
385
  this.#tell(event, group);
274
386
  let held = true;
275
387
  return {
@@ -278,32 +390,62 @@ export class Runtime {
278
390
  return;
279
391
  held = false;
280
392
  group.active--;
393
+ group.whys.delete(token);
281
394
  if (this.#events.get(event) === group)
282
395
  this.#tell(event, group);
283
396
  },
284
397
  };
285
398
  }
286
- /** Tell the player when `group` changes between mirror and listener. */
399
+ /**
400
+ * Tell the player when `group` changes between mirror and listener, and
401
+ * why it listens when that changed.
402
+ */
287
403
  #tell(event, group) {
288
404
  const off = group.off;
405
+ if (!off)
406
+ return;
289
407
  const active = group.active > 0;
290
- if (!off || group.told === active)
408
+ const whys = active ? allWhys(group) : undefined;
409
+ const why = whys?.join("\n");
410
+ if (group.told === active && group.toldWhy === why)
291
411
  return;
292
412
  group.told = active;
413
+ group.toldWhy = why;
293
414
  // Part of activation while it runs: the player classifies the plugin
294
415
  // at its ready, which must see this mark. A failure is not one of
295
416
  // activation (an older player never refuses it).
296
417
  const told = off
297
- .then(() => this.host.events.listen(event, active))
418
+ .then(() => this.host.events.listen(event, active, whys))
298
419
  .catch(() => { });
299
420
  this.starting(told, () => { });
300
421
  }
422
+ /**
423
+ * Why the plugin listens to each event it really listens to (mirrors
424
+ * left out), as the player's log says it: for {@link TestHost} and
425
+ * `vestra-plugin test`.
426
+ */
427
+ listening() {
428
+ const result = new Map();
429
+ for (const [event, group] of this.#events)
430
+ if (group.active > 0)
431
+ result.set(event, allWhys(group));
432
+ return result;
433
+ }
301
434
  /** Raw transport events the host pushes without a subscription. */
302
435
  raw(event, listener) {
303
436
  const off = this.#transport.on(event, listener);
304
437
  return { dispose: async () => off() };
305
438
  }
306
439
  }
440
+ /** A plugin listener's reason: where it was added. */
441
+ const listenerWhy = (site) => () => {
442
+ const at = site();
443
+ return at ? `a listener added at ${at}` : "a listener";
444
+ };
445
+ /** Every active holder's reason in `group`, in the order they came, once each. */
446
+ function allWhys(group) {
447
+ return [...new Set([...group.whys.values()].map((why) => why()))];
448
+ }
307
449
  /** `plugin.app` from the start-up handshake (the runner's environment). */
308
450
  export function appInfo(raw, system = () => Promise.reject(failure("unavailable", "no player to ask"))) {
309
451
  const info = (raw ?? {});
@@ -8,6 +8,7 @@ export declare class ScopeImpl implements Plugin {
8
8
  get dataDirectory(): string;
9
9
  get disposed(): boolean;
10
10
  readonly commands: import("./types.ts").Commands;
11
+ readonly when: import("./types.ts").When;
11
12
  readonly windows: import("./types.ts").Windows;
12
13
  readonly panels: import("./types.ts").Panels;
13
14
  readonly dialogs: import("./types.ts").Dialogs;
@@ -1,6 +1,7 @@
1
1
  /** Scopes own handles. Disposal runs newest first and never throws. */
2
2
  import { failure, toError } from "./runtime.js";
3
3
  import { commands } from "./commands.js";
4
+ import { WHEN } from "./when.js";
4
5
  import { dialogs, panels, ui, windows } from "./ui.js";
5
6
  import { timeline } from "./timeline.js";
6
7
  import { drops } from "./drops.js";
@@ -42,6 +43,7 @@ export class ScopeImpl {
42
43
  return this.#disposed || (this.#parent?.disposed ?? false);
43
44
  }
44
45
  commands = commands(this);
46
+ when = WHEN;
45
47
  windows = windows(this);
46
48
  panels = panels(this);
47
49
  dialogs = dialogs(this);
@@ -271,7 +271,26 @@ export interface FakeCommand {
271
271
  */
272
272
  reason?: string | null;
273
273
  };
274
- /** The command state the player last received (after the update pass). */
274
+ /**
275
+ * The conditions the plugin sent for `enabled` and `checked`
276
+ * (`plugin.when`, wire form), `undefined` without. Like the player, the
277
+ * host evaluates them in {@link FakeCommand.state} against `source`,
278
+ * `mainWindow.fullscreen`, `appearance` and `language`.
279
+ *
280
+ * @since 1.5.40
281
+ */
282
+ when?: {
283
+ /** The condition of `enabled`. */
284
+ enabled?: Json;
285
+ /** The condition of `checked`. */
286
+ checked?: Json;
287
+ };
288
+ /**
289
+ * The command state as the player shows it: what the plugin last sent
290
+ * (after the update pass), with the fields given as conditions
291
+ * (`plugin.when`) evaluated now. With an `enabled` condition, `reason`
292
+ * is `null` while the command is enabled and not busy.
293
+ */
275
294
  state: {
276
295
  /** `false` when the plugin disabled the command. */
277
296
  enabled: boolean;
@@ -548,6 +567,16 @@ export declare class TestHost implements Transport {
548
567
  * @since 1.5.38
549
568
  */
550
569
  readonly listening: Set<string>;
570
+ /**
571
+ * Why the plugin listens to each event of {@link TestHost.listening}:
572
+ * every listener and every binding that reads its state, as the
573
+ * player's log says them (`a listener added at main.ts:12`,
574
+ * `command "Save" enabled is a function that reads plugin.player.state
575
+ * (main.ts:40) — use plugin.when`), in the order they came.
576
+ *
577
+ * @since 1.5.40
578
+ */
579
+ readonly listeningWhy: Map<string, string[]>;
551
580
  /**
552
581
  * The player's source facts (phase, position, duration, paused, rate,
553
582
  * audio tracks, loop, ...). Wire-level shape; change it with
@@ -2588,6 +2617,32 @@ export declare class TestHost implements Transport {
2588
2617
  * @since 1.0.0
2589
2618
  */
2590
2619
  close(): void;
2620
+ /**
2621
+ * Why the player would keep this plugin running while nothing uses it
2622
+ * (lazy activation, player 0.5.21), one line per reason, as the player's
2623
+ * log says it: an event it listens to that is not one the player wakes
2624
+ * it for (with what listens and where it was added), its mounted panel,
2625
+ * its open windows. Empty: the player may stop it while idle and start
2626
+ * it again on its next use. Filter chains, streams, jobs and the like
2627
+ * (which also keep a plugin running) are not listed.
2628
+ *
2629
+ * @capability None.
2630
+ * @since 1.5.40
2631
+ *
2632
+ * @example Check that a plugin can be stopped while idle
2633
+ * The test fails if the plugin keeps running for no reason after it
2634
+ * starts, and names the reason (a command whose `enabled` reads
2635
+ * `plugin.player.state`, say: give it a `plugin.when` condition). The
2636
+ * player stops such a plugin while nothing uses it and starts it again
2637
+ * on its next use, so it costs no process meanwhile.
2638
+ * ```ts
2639
+ * import assert from "node:assert/strict";
2640
+ * declare const host: TestHost;
2641
+ * // ---cut---
2642
+ * assert.deepEqual(host.keepsRunning, []);
2643
+ * ```
2644
+ */
2645
+ get keepsRunning(): string[];
2591
2646
  }
2592
2647
  /**
2593
2648
  * Activate a plugin against a test host, the way the player's runner does: