@vestra-player/sdk 1.5.39 → 1.5.41
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 +15 -0
- package/bin/vestra-plugin.mjs +5 -1
- package/dist/commands.d.ts +12 -2
- package/dist/commands.js +4 -2
- package/dist/plugin/commands.js +41 -8
- package/dist/plugin/runtime.d.ts +31 -1
- package/dist/plugin/runtime.js +154 -12
- package/dist/plugin/scope.d.ts +1 -0
- package/dist/plugin/scope.js +2 -0
- package/dist/plugin/testing.d.ts +57 -2
- package/dist/plugin/testing.js +121 -1
- package/dist/plugin/types.d.ts +244 -11
- package/dist/plugin/ui.js +6 -4
- package/dist/plugin/when.d.ts +46 -0
- package/dist/plugin/when.js +106 -0
- package/dist/wire.d.ts +4 -4
- package/dist/wire.js +13 -3
- package/llms-full.txt +241 -16
- package/llms.txt +19 -19
- package/package.json +1 -1
- package/slint/vestra/bar-buttons.slint +23 -6
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.5.41
|
|
4
|
+
|
|
5
|
+
**Who must change: nobody.** No API changes, `apiVersion` stays `vestra/1`, no player version needed. Only how the player draws the bottom bar's strip of command buttons changes (player 0.5.22); a plugin's declarations and a bar that places `BarButtons` stay as they are.
|
|
6
|
+
|
|
7
|
+
- **`@vestra/bar-buttons.slint`** (the read-only copy of the player's widget library): every enabled button of the strip draws its icon in the accent colour (`Theme.accent`, `Theme.accent-press` while pressed; `Theme.icon-disabled` when disabled), plugin images (`plugin.ui.image`) included. Before, icons were in the text colour and only a checked toggle in the accent. A checked command (`checked: true`, the sidebar toggle while the sidebar is on) now shows a 4px dot in the icon's colour centred under the icon, as the macOS Dock marks a running app; the accessibility checked state is unchanged.
|
|
8
|
+
- _Commands_ (reference), `CommandOptions.button` and `TestHost.barButtons[].checked` describe the new look.
|
|
9
|
+
|
|
10
|
+
## 1.5.40
|
|
11
|
+
|
|
12
|
+
**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).
|
|
13
|
+
|
|
14
|
+
- **`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.
|
|
15
|
+
- **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.
|
|
16
|
+
- **`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.
|
|
17
|
+
|
|
3
18
|
## 1.5.39
|
|
4
19
|
|
|
5
20
|
**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.
|
package/bin/vestra-plugin.mjs
CHANGED
|
@@ -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
|
-
|
|
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`);
|
package/dist/commands.d.ts
CHANGED
|
@@ -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
|
}
|
package/dist/plugin/commands.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
140
|
-
enabled
|
|
141
|
-
checked
|
|
142
|
-
busy: value(
|
|
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)
|
package/dist/plugin/runtime.d.ts
CHANGED
|
@@ -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
|
}
|
package/dist/plugin/runtime.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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 ?? {});
|
package/dist/plugin/scope.d.ts
CHANGED
|
@@ -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;
|
package/dist/plugin/scope.js
CHANGED
|
@@ -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);
|
package/dist/plugin/testing.d.ts
CHANGED
|
@@ -271,7 +271,26 @@ export interface FakeCommand {
|
|
|
271
271
|
*/
|
|
272
272
|
reason?: string | null;
|
|
273
273
|
};
|
|
274
|
-
/**
|
|
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;
|
|
@@ -351,7 +370,7 @@ export interface FakeBarButton {
|
|
|
351
370
|
readonly order: number;
|
|
352
371
|
/** Clickable: the command is enabled and not busy. */
|
|
353
372
|
readonly enabled: boolean;
|
|
354
|
-
/**
|
|
373
|
+
/** Shown with the on dot under its icon: the command is checked. */
|
|
355
374
|
readonly checked: boolean;
|
|
356
375
|
/**
|
|
357
376
|
* The tooltip without the key: the command's shown title (trailing "…"
|
|
@@ -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:
|