@vestra-player/sdk 1.6.17 → 1.6.18
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 +13 -0
- package/dist/docs-tree.json.gz +0 -0
- package/dist/plugin/runtime.d.ts +26 -0
- package/dist/plugin/runtime.js +16 -0
- 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/llms.txt +1 -1
- package/package.json +5 -3
|
@@ -0,0 +1,466 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Player services: secrets, session storage, clipboard, system, tools,
|
|
3
|
+
* power, dialogs, drops, settings pages, the main window and packages.
|
|
4
|
+
*
|
|
5
|
+
* Part of `TestHost` (`./index.ts`): functions over the host and its private
|
|
6
|
+
* state (`Internals`, `./internals.ts`) that the class delegates to.
|
|
7
|
+
* Internal to the SDK; not exported from `@vestra-player/sdk/testing`.
|
|
8
|
+
*/
|
|
9
|
+
import { statSync } from "node:fs";
|
|
10
|
+
import { HostError } from "../../wire.js";
|
|
11
|
+
import { SESSION_STORAGE_LIMITS } from "../services.js";
|
|
12
|
+
import { validStep } from "../player.js";
|
|
13
|
+
import { runCore, windowFacts } from "./core-actions.js";
|
|
14
|
+
import { shownText, declaredText, shownValues, hostText } from "./i18n.js";
|
|
15
|
+
import { resetWrites, writeProperty, initialValues, seeImages, kebab, } from "./layout.js";
|
|
16
|
+
/** A `plugin.storage.session` key, checked as the player does. */
|
|
17
|
+
export function sessionKey(key) {
|
|
18
|
+
if (typeof key !== "string" ||
|
|
19
|
+
!key ||
|
|
20
|
+
key.includes("\0") ||
|
|
21
|
+
Buffer.byteLength(key) > SESSION_STORAGE_LIMITS.keyBytes)
|
|
22
|
+
throw new HostError("invalid", "session storage keys are 1–1024 bytes of text without NUL");
|
|
23
|
+
return key;
|
|
24
|
+
}
|
|
25
|
+
/** Code point (UTF-8 byte) order, as the player sorts session keys. */
|
|
26
|
+
export const byCodePoint = (a, b) => Buffer.compare(Buffer.from(a), Buffer.from(b));
|
|
27
|
+
/**
|
|
28
|
+
* A JSON value as the player hands it back: the player does not keep the
|
|
29
|
+
* order of object properties, it sorts them.
|
|
30
|
+
*/
|
|
31
|
+
export function sortedJson(value) {
|
|
32
|
+
if (Array.isArray(value))
|
|
33
|
+
return value.map(sortedJson);
|
|
34
|
+
if (value && typeof value === "object")
|
|
35
|
+
return Object.fromEntries(Object.keys(value)
|
|
36
|
+
.sort(byCodePoint)
|
|
37
|
+
.map((key) => [key, sortedJson(value[key])]));
|
|
38
|
+
return value;
|
|
39
|
+
}
|
|
40
|
+
/** What one session entry counts towards the player's quota. */
|
|
41
|
+
export const sessionCost = (key, value) => Buffer.byteLength(key) +
|
|
42
|
+
Buffer.byteLength(JSON.stringify(value)) +
|
|
43
|
+
SESSION_STORAGE_LIMITS.entryBytes;
|
|
44
|
+
export function validatePackage(host, _inner, path) {
|
|
45
|
+
const manifest = host.candidates.get(path);
|
|
46
|
+
if (!manifest)
|
|
47
|
+
throw new HostError("missing", `no test package at ${path}`);
|
|
48
|
+
return {
|
|
49
|
+
token: path,
|
|
50
|
+
digest: "0".repeat(64),
|
|
51
|
+
apiVersion: "vestra/1",
|
|
52
|
+
manifest: structuredClone(manifest),
|
|
53
|
+
checks: ["manifest", "digest", "productionJavaScript", "slintCompile"],
|
|
54
|
+
trustedExecution: true,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
/** Implements `TestHost.drop`. */
|
|
58
|
+
export function drop(host, _inner, paths, options = {}) {
|
|
59
|
+
const modifiers = {
|
|
60
|
+
alt: false,
|
|
61
|
+
shift: false,
|
|
62
|
+
cmd: false,
|
|
63
|
+
...options.modifiers,
|
|
64
|
+
};
|
|
65
|
+
if (options.target === "panel" && host.panelDrops) {
|
|
66
|
+
host.emit("drops.panel", {
|
|
67
|
+
paths,
|
|
68
|
+
at: options.at ?? { x: 0, y: 0 },
|
|
69
|
+
modifiers,
|
|
70
|
+
});
|
|
71
|
+
return host.settle();
|
|
72
|
+
}
|
|
73
|
+
const groups = new Map();
|
|
74
|
+
for (const path of paths) {
|
|
75
|
+
const kind = statSync(path, { throwIfNoEntry: false })?.isDirectory()
|
|
76
|
+
? "folder"
|
|
77
|
+
: (/\.([^./]+)$/.exec(path)?.[1]?.toLowerCase() ?? "");
|
|
78
|
+
const provider = host.dropProviders.find((p) => p.extensions.includes(kind));
|
|
79
|
+
if (provider)
|
|
80
|
+
groups.set(provider.id, [...(groups.get(provider.id) ?? []), path]);
|
|
81
|
+
}
|
|
82
|
+
for (const [id, group] of groups)
|
|
83
|
+
host.emit("drops.drop", { id, paths: group, modifiers });
|
|
84
|
+
return host.settle();
|
|
85
|
+
}
|
|
86
|
+
/** Implements `TestHost.setMainWindow`. */
|
|
87
|
+
export function setMainWindow(host, inner, patch) {
|
|
88
|
+
const entering = patch.fullscreen !== undefined &&
|
|
89
|
+
patch.fullscreen !== host.mainWindow.fullscreen;
|
|
90
|
+
Object.assign(host.mainWindow, patch);
|
|
91
|
+
// Like the player (reference §6.3): in fullscreen the attached columns
|
|
92
|
+
// (the sidebar, the adjust column) start collapsed at the right edge;
|
|
93
|
+
// leaving fullscreen docks them again.
|
|
94
|
+
for (const column of [host.layout.sidebar, host.layout.adjust]) {
|
|
95
|
+
if (entering && column && column.preferred && !column.detached) {
|
|
96
|
+
column.effective = !host.mainWindow.fullscreen;
|
|
97
|
+
column.reason = host.mainWindow.fullscreen ? "fullscreen" : null;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
host.emit("window.state", windowFacts(host, inner));
|
|
101
|
+
}
|
|
102
|
+
/** The default handlers of this area's wire methods, by method name. */
|
|
103
|
+
export function servicesHandlers(host, inner) {
|
|
104
|
+
return {
|
|
105
|
+
// The player validates again; these mirror what reaches it.
|
|
106
|
+
"system.reveal": (p) => void host.system.revealed.push(...p.paths),
|
|
107
|
+
"system.openPath": (p) => void host.system.opened.push(String(p.path)),
|
|
108
|
+
"system.openUrl": (p) => void host.system.urls.push(String(p.url)),
|
|
109
|
+
"secrets.get": (p) => host.secrets.get(String(p.name)) ?? null,
|
|
110
|
+
"secrets.set": (p) => {
|
|
111
|
+
const name = String(p.name);
|
|
112
|
+
if (!host.secrets.has(name) && host.secrets.size >= 256)
|
|
113
|
+
throw new HostError("limit", "a plugin stores at most 256 secret names");
|
|
114
|
+
host.secrets.set(name, String(p.value));
|
|
115
|
+
},
|
|
116
|
+
"secrets.delete": (p) => void host.secrets.delete(String(p.name)),
|
|
117
|
+
// As the player (src/host/src/session_storage.rs).
|
|
118
|
+
"storage.session.get": (p) => {
|
|
119
|
+
const key = sessionKey(p.key);
|
|
120
|
+
return host.sessionStorage.has(key)
|
|
121
|
+
? { value: structuredClone(host.sessionStorage.get(key)) }
|
|
122
|
+
: {};
|
|
123
|
+
},
|
|
124
|
+
"storage.session.set": (p) => {
|
|
125
|
+
const key = sessionKey(p.key);
|
|
126
|
+
if (!("value" in p))
|
|
127
|
+
throw new HostError("invalid", "set needs a JSON value");
|
|
128
|
+
let total = sessionCost(key, p.value);
|
|
129
|
+
for (const [other, value] of host.sessionStorage)
|
|
130
|
+
if (other !== key)
|
|
131
|
+
total += sessionCost(other, value);
|
|
132
|
+
if (total > SESSION_STORAGE_LIMITS.bytes)
|
|
133
|
+
throw new HostError("limit", `session storage holds at most ${SESSION_STORAGE_LIMITS.bytes} bytes per plugin (keys, JSON values and ${SESSION_STORAGE_LIMITS.entryBytes} per key); this value would make ${total}`);
|
|
134
|
+
host.sessionStorage.set(key, sortedJson(p.value));
|
|
135
|
+
},
|
|
136
|
+
"storage.session.delete": (p) => void host.sessionStorage.delete(sessionKey(p.key)),
|
|
137
|
+
"storage.session.keys": () => [...host.sessionStorage.keys()].sort(byCodePoint),
|
|
138
|
+
"app.system": () => ({
|
|
139
|
+
...structuredClone(host.systemInfo),
|
|
140
|
+
rendering: host.videoRender.gpu ? "gpu" : "software",
|
|
141
|
+
renderingReason: { ...host.rendering.reason },
|
|
142
|
+
}),
|
|
143
|
+
"clipboard.readText": () => host.clipboard.text,
|
|
144
|
+
"clipboard.writeText": (p) => {
|
|
145
|
+
if (typeof p.text !== "string")
|
|
146
|
+
throw new HostError("invalid", "text requires a string");
|
|
147
|
+
if (Buffer.byteLength(p.text) > 1024 * 1024)
|
|
148
|
+
throw new HostError("invalid", "text must be at most 1 MiB");
|
|
149
|
+
host.clipboard.text = p.text;
|
|
150
|
+
},
|
|
151
|
+
"clipboard.writeImage": (p) => {
|
|
152
|
+
const path = String(p.path);
|
|
153
|
+
if (!path.startsWith("/"))
|
|
154
|
+
throw new HostError("invalid", "image path must be absolute");
|
|
155
|
+
if (!/\.(png|jpe?g)$/i.test(path))
|
|
156
|
+
throw new HostError("invalid", "image must be PNG or JPEG");
|
|
157
|
+
host.clipboard.images.push(path);
|
|
158
|
+
},
|
|
159
|
+
"host.run": (p) => {
|
|
160
|
+
const actions = [
|
|
161
|
+
"play-pause",
|
|
162
|
+
"stop",
|
|
163
|
+
"previous",
|
|
164
|
+
"next",
|
|
165
|
+
"frame-back",
|
|
166
|
+
"frame-forward",
|
|
167
|
+
"seek-backward",
|
|
168
|
+
"seek-forward",
|
|
169
|
+
"rate-down",
|
|
170
|
+
"rate-up",
|
|
171
|
+
"rate-normal",
|
|
172
|
+
"volume-up",
|
|
173
|
+
"volume-down",
|
|
174
|
+
"mute",
|
|
175
|
+
"fullscreen",
|
|
176
|
+
];
|
|
177
|
+
if (!actions.includes(String(p.action)))
|
|
178
|
+
throw new HostError("invalid", `unknown host action ${p.action}`);
|
|
179
|
+
// SDK 1.5.19: one step seek of another step.
|
|
180
|
+
let seconds;
|
|
181
|
+
if (p.seconds !== undefined && p.seconds !== null) {
|
|
182
|
+
if (p.action !== "seek-backward" && p.action !== "seek-forward")
|
|
183
|
+
throw new HostError("invalid", `seconds applies only to seek-backward and seek-forward, not ${p.action}`);
|
|
184
|
+
if (!validStep(p.seconds))
|
|
185
|
+
throw new HostError("invalid", "seconds must be 0.1..3600 in tenths");
|
|
186
|
+
seconds = p.seconds;
|
|
187
|
+
}
|
|
188
|
+
// SDK 1.5.21: the main window's Toggle Fullscreen, like Escape: in when
|
|
189
|
+
// windowed, out when fullscreen, then `window.state`.
|
|
190
|
+
if (p.action === "fullscreen") {
|
|
191
|
+
host.coreRuns.push("fullscreen");
|
|
192
|
+
host.setMainWindow({ fullscreen: !host.mainWindow.fullscreen });
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
runCore(host, inner, String(p.action), seconds);
|
|
196
|
+
},
|
|
197
|
+
"power.preventSleep": (p) => {
|
|
198
|
+
if (p.mode !== "off" && p.mode !== "playing")
|
|
199
|
+
throw new HostError("invalid", 'mode must be "off" or "playing"');
|
|
200
|
+
host.power = p.mode;
|
|
201
|
+
},
|
|
202
|
+
"drops.provide": (p) => {
|
|
203
|
+
const id = String(p.id);
|
|
204
|
+
const index = host.dropProviders.findIndex((d) => d.id === id);
|
|
205
|
+
if (index >= 0)
|
|
206
|
+
host.dropProviders.splice(index, 1);
|
|
207
|
+
host.dropProviders.push({ id, extensions: p.extensions });
|
|
208
|
+
},
|
|
209
|
+
"drops.withdraw": (p) => {
|
|
210
|
+
const index = host.dropProviders.findIndex((d) => d.id === String(p.id));
|
|
211
|
+
if (index >= 0)
|
|
212
|
+
host.dropProviders.splice(index, 1);
|
|
213
|
+
},
|
|
214
|
+
"drops.listen": (p) => void (host.panelDrops = p.listen === true),
|
|
215
|
+
"settings.register": (p) => {
|
|
216
|
+
const id = String(p.id);
|
|
217
|
+
if (host.settings.pages.has(id))
|
|
218
|
+
throw new HostError("invalid", `page ${id} already exists`);
|
|
219
|
+
if (host.settings.pages.size >= 8)
|
|
220
|
+
throw new HostError("invalid", "a plugin has at most 8 settings pages");
|
|
221
|
+
const properties = initialValues(host, inner, shownValues(host, inner, p.properties));
|
|
222
|
+
seeImages(host, inner, properties);
|
|
223
|
+
resetWrites(host, inner, `$settings:${id}`);
|
|
224
|
+
host.settings.pages.set(id, {
|
|
225
|
+
category: String(p.category),
|
|
226
|
+
title: host.translate(String(p.title)),
|
|
227
|
+
order: Number(p.order ?? 0),
|
|
228
|
+
file: String(p.file),
|
|
229
|
+
component: String(p.component),
|
|
230
|
+
global: String(p.global),
|
|
231
|
+
props: { ...properties },
|
|
232
|
+
emit(callback, ...args) {
|
|
233
|
+
host.emit("ui.callback", {
|
|
234
|
+
view: `$settings:${id}`,
|
|
235
|
+
callback: kebab(callback),
|
|
236
|
+
args,
|
|
237
|
+
});
|
|
238
|
+
},
|
|
239
|
+
});
|
|
240
|
+
},
|
|
241
|
+
"settings.set": (p) => {
|
|
242
|
+
const page = host.settings.pages.get(String(p.id));
|
|
243
|
+
if (!page)
|
|
244
|
+
throw new HostError("missing", "page not registered");
|
|
245
|
+
const property = String(p.property);
|
|
246
|
+
const next = writeProperty(host, inner, `$settings:${String(p.id)}`, property, p, page.props[property]);
|
|
247
|
+
// A part of a value sent in parts is held until the last (player 0.6.0).
|
|
248
|
+
if (next !== undefined)
|
|
249
|
+
page.props[property] = next;
|
|
250
|
+
},
|
|
251
|
+
"settings.unregister": (p) => {
|
|
252
|
+
host.settings.pages.delete(String(p.id));
|
|
253
|
+
if (host.settings.current === String(p.id))
|
|
254
|
+
host.settings.current = [...host.settings.pages.keys()][0] ?? null;
|
|
255
|
+
},
|
|
256
|
+
"settings.show": (p) => {
|
|
257
|
+
const id = p.id === undefined ? null : String(p.id);
|
|
258
|
+
if (id !== null && !host.settings.pages.has(id))
|
|
259
|
+
throw new HostError("invalid", "not a settings page of this plugin");
|
|
260
|
+
host.settings.shows.push(id);
|
|
261
|
+
host.settings.open = true;
|
|
262
|
+
host.settings.current =
|
|
263
|
+
id ??
|
|
264
|
+
(host.settings.current && host.settings.pages.has(host.settings.current)
|
|
265
|
+
? host.settings.current
|
|
266
|
+
: ([...host.settings.pages.keys()][0] ?? null));
|
|
267
|
+
},
|
|
268
|
+
"dialogs.confirm": (p) => {
|
|
269
|
+
host.confirms.push(p);
|
|
270
|
+
const shown = (field, formatted = false) => p[field] === undefined
|
|
271
|
+
? undefined
|
|
272
|
+
: shownText(host, inner, p[field], formatted && typeof p[field] === "string" ? p.args : undefined, field);
|
|
273
|
+
host.confirmTexts.push({
|
|
274
|
+
message: shown("message", true),
|
|
275
|
+
...(p.detail === undefined ? {} : { detail: shown("detail", true) }),
|
|
276
|
+
confirm: shown("confirm"),
|
|
277
|
+
cancel: shown("cancel") ?? hostText(host, inner, "Cancel"),
|
|
278
|
+
...(p.checkbox === undefined ? {} : { checkbox: shown("checkbox") }),
|
|
279
|
+
});
|
|
280
|
+
const answer = host.dialogAnswers.shift();
|
|
281
|
+
if (answer === undefined)
|
|
282
|
+
throw new Error("test host: no dialog answer queued");
|
|
283
|
+
if (answer === null || Array.isArray(answer))
|
|
284
|
+
throw new Error("test host: a file dialog answer was queued");
|
|
285
|
+
return { confirmed: answer.confirmed, checked: answer.checked };
|
|
286
|
+
},
|
|
287
|
+
"window.state": () => windowFacts(host, inner),
|
|
288
|
+
"window.setVideoArea": (p) => {
|
|
289
|
+
if (host.mainWindow.fullscreen)
|
|
290
|
+
throw new HostError("unavailable", hostText(host, inner, "Not available in fullscreen"));
|
|
291
|
+
const width = Math.max(318, Number(p.width));
|
|
292
|
+
const height = Math.max(120, Number(p.height));
|
|
293
|
+
const clamped = width !== p.width || height !== p.height;
|
|
294
|
+
host.setMainWindow({ videoArea: { width, height } });
|
|
295
|
+
return { width, height, clamped };
|
|
296
|
+
},
|
|
297
|
+
"window.setAlwaysOnTop": (p) => void (host.mainWindow.onTop = p.onTop === true),
|
|
298
|
+
"window.setCursorAutoHide": (p) => {
|
|
299
|
+
if (p.mode !== "fullscreen" && p.mode !== "playing")
|
|
300
|
+
throw new HostError("invalid", "mode is fullscreen or playing");
|
|
301
|
+
host.mainWindow.cursorAutoHide = p.mode;
|
|
302
|
+
},
|
|
303
|
+
"window.setPictureInPicture": (p) => {
|
|
304
|
+
if (typeof p.on !== "boolean")
|
|
305
|
+
throw new HostError("invalid", "on requires a boolean");
|
|
306
|
+
const pip = host.mainWindow.pictureInPicture;
|
|
307
|
+
if (p.on === pip.active)
|
|
308
|
+
return;
|
|
309
|
+
if (p.on && !pip.supported)
|
|
310
|
+
throw new HostError("unsupported", hostText(host, inner, "Picture-in-picture is not supported on this system"));
|
|
311
|
+
const video = host.source.phase !== "empty" && host.source.hasVideo !== false;
|
|
312
|
+
if (p.on && !video)
|
|
313
|
+
throw new HostError("unavailable", hostText(host, inner, "No video"));
|
|
314
|
+
host.setMainWindow({
|
|
315
|
+
// The player leaves fullscreen before the window opens.
|
|
316
|
+
fullscreen: p.on ? false : host.mainWindow.fullscreen,
|
|
317
|
+
pictureInPicture: { ...pip, active: p.on },
|
|
318
|
+
});
|
|
319
|
+
},
|
|
320
|
+
"dialogs.open": (p) => {
|
|
321
|
+
const title = p.title === undefined
|
|
322
|
+
? undefined
|
|
323
|
+
: declaredText(host, inner, p.title, "title");
|
|
324
|
+
// A string file name is data; a text is shown translated, a path
|
|
325
|
+
// separator in the translation replaced.
|
|
326
|
+
const fileName = p.fileName === undefined
|
|
327
|
+
? undefined
|
|
328
|
+
: typeof p.fileName === "string"
|
|
329
|
+
? p.fileName
|
|
330
|
+
: declaredText(host, inner, p.fileName, "fileName").replace(/[/\\]/g, "-");
|
|
331
|
+
host.fileDialogs.push({
|
|
332
|
+
kind: String(p.kind),
|
|
333
|
+
...(title === undefined ? {} : { title }),
|
|
334
|
+
...(fileName === undefined ? {} : { fileName }),
|
|
335
|
+
});
|
|
336
|
+
const answer = host.dialogAnswers.shift();
|
|
337
|
+
if (answer === undefined)
|
|
338
|
+
throw new Error("test host: no dialog answer queued");
|
|
339
|
+
if (answer !== null && !Array.isArray(answer))
|
|
340
|
+
throw new Error("test host: a confirm answer was queued");
|
|
341
|
+
return answer
|
|
342
|
+
? { cancelled: false, paths: answer }
|
|
343
|
+
: { cancelled: true, paths: [] };
|
|
344
|
+
},
|
|
345
|
+
"packages.list": () => structuredClone(host.packages),
|
|
346
|
+
"packages.setEnabled": (p) => {
|
|
347
|
+
const id = String(p.id);
|
|
348
|
+
if (id === host.manifestId && p.enabled === false)
|
|
349
|
+
throw new HostError("invalid", "a plugin cannot disable itself");
|
|
350
|
+
const entry = host.packages.find((each) => each.id === id);
|
|
351
|
+
if (!entry)
|
|
352
|
+
throw new HostError("missing", `no plugin ${id}`);
|
|
353
|
+
// As the player (with SDK 1.5.18 on): users manage third-party plugins only.
|
|
354
|
+
if (entry.builtin && p.enabled === false)
|
|
355
|
+
throw new HostError("unsupported", `${id} is a built-in plugin: built-in plugins cannot be disabled`);
|
|
356
|
+
host.packageCalls.push({
|
|
357
|
+
method: "setEnabled",
|
|
358
|
+
id,
|
|
359
|
+
enabled: p.enabled === true,
|
|
360
|
+
});
|
|
361
|
+
entry.state = p.enabled === true ? "running" : "disabled";
|
|
362
|
+
host.setPackages(host.packages);
|
|
363
|
+
},
|
|
364
|
+
// As the player (SDK 1.5.19): stop and start again, built-ins included;
|
|
365
|
+
// `packages.changed` shows `starting`, then `running`.
|
|
366
|
+
"packages.reload": (p) => {
|
|
367
|
+
const id = String(p.id);
|
|
368
|
+
if (id === host.manifestId)
|
|
369
|
+
throw new HostError("invalid", "a plugin cannot reload itself");
|
|
370
|
+
const entry = host.packages.find((each) => each.id === id);
|
|
371
|
+
if (!entry)
|
|
372
|
+
throw new HostError("missing", `no plugin ${id}`);
|
|
373
|
+
if (entry.state === "starting")
|
|
374
|
+
throw new HostError("busy", "the package is being installed or reloaded");
|
|
375
|
+
if (entry.state === "disabled")
|
|
376
|
+
throw new HostError("invalid", `${id} is disabled: enable it instead of reloading it`);
|
|
377
|
+
host.packageCalls.push({ method: "reload", id });
|
|
378
|
+
entry.state = "starting";
|
|
379
|
+
entry.error = null;
|
|
380
|
+
host.setPackages(host.packages);
|
|
381
|
+
setTimeout(() => {
|
|
382
|
+
entry.state = "running";
|
|
383
|
+
host.setPackages(host.packages);
|
|
384
|
+
}, 0);
|
|
385
|
+
},
|
|
386
|
+
"packages.validate": (p) => validatePackage(host, inner, String(p.directory)),
|
|
387
|
+
"packages.validateArchive": (p) => validatePackage(host, inner, String(p.file)),
|
|
388
|
+
"packages.install": (p) => {
|
|
389
|
+
const manifest = host.candidates.get(String(p.token));
|
|
390
|
+
if (!manifest)
|
|
391
|
+
throw new HostError("stale", "unknown validation token");
|
|
392
|
+
const source = p.source ?? {
|
|
393
|
+
kind: "file",
|
|
394
|
+
};
|
|
395
|
+
if (source.kind === "bundled")
|
|
396
|
+
throw new HostError("invalid", "source bundled is reserved");
|
|
397
|
+
const replace = p.replace === true;
|
|
398
|
+
// As player 0.6.4: `enabled` is a boolean, or absent (`null` too).
|
|
399
|
+
const requested = p.enabled ?? undefined;
|
|
400
|
+
if (requested !== undefined && typeof requested !== "boolean")
|
|
401
|
+
throw new HostError("invalid", "enabled must be a boolean");
|
|
402
|
+
const entry = host.packages.find((each) => each.id === manifest.id);
|
|
403
|
+
if (entry?.builtin)
|
|
404
|
+
throw new HostError("unsupported", "built-in plugins are updated with the player");
|
|
405
|
+
const identity = (s) => !s || s.kind === "link" || s.kind === "file"
|
|
406
|
+
? "manual"
|
|
407
|
+
: s.kind === "repository"
|
|
408
|
+
? `repository ${s.url}`
|
|
409
|
+
: s.kind;
|
|
410
|
+
if (entry && identity(entry.source) !== identity(source) && !replace)
|
|
411
|
+
throw new HostError("conflict", `${manifest.id} is installed from another source`);
|
|
412
|
+
host.packageCalls.push({
|
|
413
|
+
method: "install",
|
|
414
|
+
id: manifest.id,
|
|
415
|
+
source,
|
|
416
|
+
replace,
|
|
417
|
+
...(requested === undefined ? {} : { enabled: requested }),
|
|
418
|
+
});
|
|
419
|
+
// As player 0.6.4: a first install runs; an update or reinstall (any
|
|
420
|
+
// source, `replace` included) keeps a disabled plugin disabled unless
|
|
421
|
+
// `enabled` is given. Players up to 0.6.3 run every install.
|
|
422
|
+
const enabled = !host.installKeepsDisabled
|
|
423
|
+
? true
|
|
424
|
+
: (requested ?? entry?.state !== "disabled");
|
|
425
|
+
const next = {
|
|
426
|
+
id: manifest.id,
|
|
427
|
+
version: manifest.version,
|
|
428
|
+
origin: "installed",
|
|
429
|
+
state: enabled ? "running" : "disabled",
|
|
430
|
+
error: null,
|
|
431
|
+
capabilities: [...manifest.capabilities],
|
|
432
|
+
directory: `/test-host/packages/${manifest.id}`,
|
|
433
|
+
builtin: false,
|
|
434
|
+
source,
|
|
435
|
+
dataDirectory: entry?.dataDirectory ?? null,
|
|
436
|
+
};
|
|
437
|
+
host.setPackages([
|
|
438
|
+
...host.packages.filter((each) => each.id !== manifest.id),
|
|
439
|
+
next,
|
|
440
|
+
]);
|
|
441
|
+
return { operationId: String(p.requestId), status: "committed" };
|
|
442
|
+
},
|
|
443
|
+
"packages.uninstall": (p) => {
|
|
444
|
+
const id = String(p.id);
|
|
445
|
+
const entry = host.packages.find((each) => each.id === id);
|
|
446
|
+
if (!entry)
|
|
447
|
+
throw new HostError("missing", `no plugin ${id}`);
|
|
448
|
+
if (entry.builtin)
|
|
449
|
+
throw new HostError("unsupported", "built-in plugins cannot be uninstalled");
|
|
450
|
+
if (entry.origin !== "installed")
|
|
451
|
+
throw new HostError("unsupported", "only installed plugins can be uninstalled");
|
|
452
|
+
host.packageCalls.push({
|
|
453
|
+
method: "uninstall",
|
|
454
|
+
id,
|
|
455
|
+
removeData: p.removeData === true,
|
|
456
|
+
});
|
|
457
|
+
host.setPackages(host.packages.filter((each) => each.id !== id));
|
|
458
|
+
return { operationId: String(p.requestId), status: "committed" };
|
|
459
|
+
},
|
|
460
|
+
"packages.installed": (p) => ({
|
|
461
|
+
revision: host.packages.some((each) => each.id === p.id) ? 1 : 0,
|
|
462
|
+
current: null,
|
|
463
|
+
previous: null,
|
|
464
|
+
}),
|
|
465
|
+
};
|
|
466
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { HostError } from "../../wire.ts";
|
|
2
|
+
import type { TestHost } from "./index.ts";
|
|
3
|
+
import type { Handler, Internals } from "./internals.ts";
|
|
4
|
+
export declare const tick: () => Promise<void>;
|
|
5
|
+
/**
|
|
6
|
+
* Refusals for a position or resolver another plugin holds: not activation
|
|
7
|
+
* failures (`startPlugin`).
|
|
8
|
+
*/
|
|
9
|
+
export declare const takenErrors: WeakSet<Error>;
|
|
10
|
+
export declare const taken: (message: string) => HostError;
|
|
11
|
+
/** Lines `vestra-plugin test` printed already (one per plugin and reasons). */
|
|
12
|
+
export declare const told: Set<string>;
|
|
13
|
+
/**
|
|
14
|
+
* Under `vestra-plugin test` (`VESTRA_PLUGIN_RESIDENCY=1`): say once per
|
|
15
|
+
* test file why the plugin would keep running after it starts.
|
|
16
|
+
*/
|
|
17
|
+
export declare function tellResidency(host: TestHost): void;
|
|
18
|
+
/** Implements `TestHost.on`. */
|
|
19
|
+
export declare function on(_host: TestHost, inner: Internals, event: string, listener: (data: unknown) => void): () => void;
|
|
20
|
+
/** Implements `TestHost.emit`. */
|
|
21
|
+
export declare function emit(host: TestHost, inner: Internals, event: string, data: unknown): void;
|
|
22
|
+
/** Implements `TestHost.settle`. */
|
|
23
|
+
export declare function settle(_host: TestHost, inner: Internals, ms?: number): Promise<void>;
|
|
24
|
+
/** Implements `TestHost.close`. */
|
|
25
|
+
export declare function close(host: TestHost, inner: Internals): void;
|
|
26
|
+
/** Implements `TestHost.keepsRunning`. */
|
|
27
|
+
export declare function keepsRunning(host: TestHost, _inner: Internals): string[];
|
|
28
|
+
/** The default handlers of this area's wire methods, by method name. */
|
|
29
|
+
export declare function transportHandlers(host: TestHost, _inner: Internals): Record<string, Handler>;
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The transport side of the fake player: the event bus (`on`, `emit`),
|
|
3
|
+
* `settle`, `close`, event subscriptions and the residency report.
|
|
4
|
+
*
|
|
5
|
+
* Part of `TestHost` (`./index.ts`): functions over the host and its private
|
|
6
|
+
* state (`Internals`, `./internals.ts`) that the class delegates to.
|
|
7
|
+
* Internal to the SDK; not exported from `@vestra-player/sdk/testing`.
|
|
8
|
+
*/
|
|
9
|
+
import { rmSync } from "node:fs";
|
|
10
|
+
import { HostError } from "../../wire.js";
|
|
11
|
+
import { WAKE_EVENTS } from "../runtime.js";
|
|
12
|
+
export const tick = () => new Promise((resolve) => setImmediate(resolve));
|
|
13
|
+
/**
|
|
14
|
+
* Refusals for a position or resolver another plugin holds: not activation
|
|
15
|
+
* failures (`startPlugin`).
|
|
16
|
+
*/
|
|
17
|
+
export const takenErrors = new WeakSet();
|
|
18
|
+
export const taken = (message) => {
|
|
19
|
+
const error = new HostError("conflict", message);
|
|
20
|
+
takenErrors.add(error);
|
|
21
|
+
return error;
|
|
22
|
+
};
|
|
23
|
+
/** Lines `vestra-plugin test` printed already (one per plugin and reasons). */
|
|
24
|
+
export const told = new Set();
|
|
25
|
+
/**
|
|
26
|
+
* Under `vestra-plugin test` (`VESTRA_PLUGIN_RESIDENCY=1`): say once per
|
|
27
|
+
* test file why the plugin would keep running after it starts.
|
|
28
|
+
*/
|
|
29
|
+
export function tellResidency(host) {
|
|
30
|
+
if (process.env.VESTRA_PLUGIN_RESIDENCY !== "1")
|
|
31
|
+
return;
|
|
32
|
+
const reasons = host.keepsRunning;
|
|
33
|
+
if (!reasons.length)
|
|
34
|
+
return;
|
|
35
|
+
const line = `${host.manifestId} keeps running while idle (lazy activation): ${reasons.join("; ")}`;
|
|
36
|
+
if (told.has(line))
|
|
37
|
+
return;
|
|
38
|
+
told.add(line);
|
|
39
|
+
console.log(`# ${line}`);
|
|
40
|
+
}
|
|
41
|
+
/** Implements `TestHost.on`. */
|
|
42
|
+
export function on(_host, inner, event, listener) {
|
|
43
|
+
const group = inner.listeners.get(event) ?? new Set();
|
|
44
|
+
group.add(listener);
|
|
45
|
+
inner.listeners.set(event, group);
|
|
46
|
+
return () => {
|
|
47
|
+
group.delete(listener);
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
/** Implements `TestHost.emit`. */
|
|
51
|
+
export function emit(host, inner, event, data) {
|
|
52
|
+
if ([
|
|
53
|
+
"source.state",
|
|
54
|
+
"playback.backend",
|
|
55
|
+
"window.state",
|
|
56
|
+
"timeline.changed",
|
|
57
|
+
"appearance.changed",
|
|
58
|
+
].includes(event) &&
|
|
59
|
+
!host.subscriptions.has(event))
|
|
60
|
+
return;
|
|
61
|
+
for (const listener of [...(inner.listeners.get(event) ?? [])])
|
|
62
|
+
listener(data);
|
|
63
|
+
}
|
|
64
|
+
/** Implements `TestHost.settle`. */
|
|
65
|
+
export async function settle(_host, inner, ms = 0) {
|
|
66
|
+
const deadline = Date.now() + ms;
|
|
67
|
+
for (let idle = 0; idle < 3;) {
|
|
68
|
+
await tick();
|
|
69
|
+
await new Promise((resolve) => setTimeout(resolve, 1));
|
|
70
|
+
idle = inner.inFlight === 0 && Date.now() >= deadline ? idle + 1 : 0;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/** Implements `TestHost.close`. */
|
|
74
|
+
export function close(host, inner) {
|
|
75
|
+
for (const producer of host.producers.values())
|
|
76
|
+
producer.server.close();
|
|
77
|
+
// Cleanup never fails a test: on Windows a file a closing handle still
|
|
78
|
+
// holds makes the removal fail now and then (`ENOTEMPTY`, `EBUSY`,
|
|
79
|
+
// `EPERM`); retry, then leave the directory to the system's cleanup.
|
|
80
|
+
try {
|
|
81
|
+
rmSync(inner.scratch, {
|
|
82
|
+
recursive: true,
|
|
83
|
+
force: true,
|
|
84
|
+
maxRetries: 5,
|
|
85
|
+
retryDelay: 100,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
// Left in the temporary directory.
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
/** Implements `TestHost.keepsRunning`. */
|
|
93
|
+
export function keepsRunning(host, _inner) {
|
|
94
|
+
const reasons = [];
|
|
95
|
+
for (const event of host.listening)
|
|
96
|
+
if (!WAKE_EVENTS.includes(event))
|
|
97
|
+
for (const why of host.listeningWhy.get(event) ?? ["a listener"])
|
|
98
|
+
reasons.push(`listens to ${event}: ${why}`);
|
|
99
|
+
// A sidebar panel is never live (player 0.7.3): the current tab keeps
|
|
100
|
+
// the plugin running as busy work, a background tab not at all. The
|
|
101
|
+
// adjust column's current tab only while the column is open (0.7.6).
|
|
102
|
+
for (const panel of host.panels.values()) {
|
|
103
|
+
if (panel.slot === "sidebar") {
|
|
104
|
+
if (host.layout.sidebar?.owner === 1)
|
|
105
|
+
reasons.push("its sidebar tab is the current tab");
|
|
106
|
+
}
|
|
107
|
+
else if (panel.slot === "adjust") {
|
|
108
|
+
if (host.layout.adjust?.owner === 1 && host.layout.adjust.preferred)
|
|
109
|
+
reasons.push("its adjust tab is the current tab");
|
|
110
|
+
}
|
|
111
|
+
else
|
|
112
|
+
reasons.push("a mounted panel");
|
|
113
|
+
}
|
|
114
|
+
for (const [id, window] of host.windows)
|
|
115
|
+
if (window.visible)
|
|
116
|
+
reasons.push(`an open window (${id})`);
|
|
117
|
+
return reasons;
|
|
118
|
+
}
|
|
119
|
+
/** The default handlers of this area's wire methods, by method name. */
|
|
120
|
+
export function transportHandlers(host, _inner) {
|
|
121
|
+
return {
|
|
122
|
+
"events.subscribe": (p) => {
|
|
123
|
+
const event = String(p.event);
|
|
124
|
+
host.subscriptions.add(event);
|
|
125
|
+
if (p.mirror === true)
|
|
126
|
+
host.listening.delete(event);
|
|
127
|
+
else
|
|
128
|
+
host.listening.add(event);
|
|
129
|
+
if (p.mirror !== true && Array.isArray(p.why))
|
|
130
|
+
host.listeningWhy.set(event, p.why.map(String));
|
|
131
|
+
else
|
|
132
|
+
host.listeningWhy.delete(event);
|
|
133
|
+
},
|
|
134
|
+
"events.unsubscribe": (p) => {
|
|
135
|
+
host.subscriptions.delete(String(p.event));
|
|
136
|
+
host.listening.delete(String(p.event));
|
|
137
|
+
host.listeningWhy.delete(String(p.event));
|
|
138
|
+
},
|
|
139
|
+
};
|
|
140
|
+
}
|