@vestra-player/sdk 1.5.6 → 1.5.8
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 +18 -0
- package/bin/vestra-plugin.mjs +24 -10
- package/dist/commands.d.ts +1 -2
- package/dist/commands.js +1 -2
- package/dist/plugin/packages.js +1 -65
- package/dist/plugin/resolvers.js +64 -0
- package/dist/plugin/stream.js +62 -4
- package/dist/plugin/testing.d.ts +45 -1
- package/dist/plugin/testing.js +87 -4
- package/dist/plugin/types.d.ts +72 -30
- package/dist/plugin/window.d.ts +2 -0
- package/dist/plugin/window.js +4 -0
- package/dist/wire.d.ts +3 -12
- package/dist/wire.js +0 -10
- package/llms-full.txt +70 -34
- package/llms.txt +40 -40
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.5.8
|
|
4
|
+
|
|
5
|
+
**Trial loading is removed; `resolvers.fileTypes`; the CLI under `--permission`.** `apiVersion` stays `vestra/1`. The removal breaks code that called `PackageCandidate.trial()`: only Vestra Player AI did, and it moves to installing (an install can be rolled back).
|
|
6
|
+
|
|
7
|
+
- **Removed: `PackageCandidate.trial()`, the `PackageTrial` type, `PluginInfo.origin` `"trial"` and the error code `cleanup`** (only `PackageTrial.stop` used it). The player (0.4.2) no longer answers `packages.trial`, `packages.trialState`, `packages.trialStateByRequest` or `packages.stopTrial`, and never lists a plugin with origin `"trial"`. Trial loads only supported plugins using `commands`, `ui.window` and `events`, listed the plugin under another id (`<id>.trial.<n>`), and `trial()` returned before activation ended, so the feature was removed instead of extended. Check a package with `validate` and install it; `rollback` restores the previous version.
|
|
8
|
+
- **`plugin.resolvers.fileTypes`** (player 0.4.2): the extensions some plugin's file resolver handles, lower case, sorted, each once, without who registered them; `[]` from older players. Reading needs no capability. **`plugin.resolvers.onFileTypesChange(listener)`** follows it when a file resolver comes or goes (needs `events`). For a playlist that accepts ROMs while an emulator plugin runs. `TestHost`: `host.fileTypes()` (this plugin's file resolvers plus `otherResolvers`) and `host.updateFileTypes()` after changing `otherResolvers`.
|
|
9
|
+
- **`vestra-plugin` runs under Node's permission model** (`--permission`, with `--allow-child-process` for the type checker): looking for `node_modules/@types`, `tsconfig.json` and `.git` in parent folders it may not read threw `ERR_ACCESS_DENIED`; such a folder now counts as absent.
|
|
10
|
+
- `Packages.validate`: the player (0.4.2) keeps the 32 most recent candidates and drops the oldest, whose `install` then fails with `stale`; before, the 33rd validation of a run failed with `busy` until the player restarted.
|
|
11
|
+
|
|
12
|
+
## 1.5.7
|
|
13
|
+
|
|
14
|
+
**Replaced or closed streams stop with `null`.** A fix; no API change, `apiVersion` stays `vestra/1`, any player.
|
|
15
|
+
|
|
16
|
+
- **`Stream.onStop(null)` when another source replaced the stream or it was closed**, as documented. The player retires a stream's producer at once in both cases, and the SDK then passed the player's answer (`unavailable`, "producer absent") to `onStop` as an error. The SDK now reads the player's state to tell the two apart: replaced or closed is `null`; a failure is a `stream` error with the player's text (also when the player retired the producer right after failing it, which used to give "producer absent" too).
|
|
17
|
+
- The same in the start-up paths: `openStream` replaced or closed before it ran rejects with `replaced` (was `unavailable`); a file resolver's stream replaced after the player selected it is a cancellation and no longer reaches `onError`.
|
|
18
|
+
- **A file resolver's stream that cannot start after `resolve` returned** fails the open with Chinese text instead of the SDK's English (`"audio device is not ready: deviceLost"`): `device` "音频输出设备不可用", `timeout` "启动超时", `busy` "播放器正忙,请稍后重试", other start-up failures "无法启动播放"; the English detail goes to the plugin's log. The plugin never saw these errors, so the user read the SDK's words. An error thrown by `frame()` keeps its message; text for a file the plugin can judge (a bad header) belongs in `resolve`.
|
|
19
|
+
- **`TestHost` retires producers like the player**: on `open`, `close`, another `openProvider` or resolved stream, a retired producer's `producer.state` answers `unavailable` and `producer.ack` `stale`. New `FakeProducer.retired`, and **`host.failStream(producerId, message)`** to fail the playing stream the way the player does (`phase: "error"` with `message`, producer retired). Tests that replaced a stream used to pass where the player failed.
|
|
20
|
+
|
|
3
21
|
## 1.5.6
|
|
4
22
|
|
|
5
23
|
**A reference for AI assistants.** Documentation and packaging only; no API change, `apiVersion` stays `vestra/1`. (1.5.5 was not published to npm; this release includes its changes.)
|
package/bin/vestra-plugin.mjs
CHANGED
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
*/
|
|
35
35
|
import { spawnSync } from "node:child_process";
|
|
36
36
|
import { createHash } from "node:crypto";
|
|
37
|
-
import { existsSync } from "node:fs";
|
|
37
|
+
import { existsSync as exists } from "node:fs";
|
|
38
38
|
import {
|
|
39
39
|
cp,
|
|
40
40
|
mkdir,
|
|
@@ -110,6 +110,20 @@ async function json(path, fallback) {
|
|
|
110
110
|
}
|
|
111
111
|
|
|
112
112
|
/** A module from the plugin's project first, else the SDK's own dependencies. */
|
|
113
|
+
/**
|
|
114
|
+
* Whether a path exists. Under Node's permission model (`--permission`)
|
|
115
|
+
* `existsSync` throws for a path outside the allowed ones; such a path
|
|
116
|
+
* counts as absent, as for a user who cannot read it.
|
|
117
|
+
*/
|
|
118
|
+
function present(path) {
|
|
119
|
+
try {
|
|
120
|
+
return exists(path);
|
|
121
|
+
} catch (error) {
|
|
122
|
+
if (error?.code === "ERR_ACCESS_DENIED") return false;
|
|
123
|
+
throw error;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
113
127
|
function resolveFrom(directory, specifier) {
|
|
114
128
|
for (const base of [directory, sdkRoot]) {
|
|
115
129
|
try {
|
|
@@ -139,7 +153,7 @@ async function typescript() {
|
|
|
139
153
|
async function plugin(path) {
|
|
140
154
|
const directory = resolve(path);
|
|
141
155
|
const name = relative(process.cwd(), directory) || basename(directory);
|
|
142
|
-
if (!
|
|
156
|
+
if (!present(join(directory, "manifest.json")))
|
|
143
157
|
fail(`${name}: no manifest.json in ${directory}`);
|
|
144
158
|
const manifest = await json(join(directory, "manifest.json"));
|
|
145
159
|
const { problems, hints } = manifestProblems(manifest, directory);
|
|
@@ -165,7 +179,7 @@ function displayFiles(target) {
|
|
|
165
179
|
const field = name === "README.md" ? "readme" : "changelog";
|
|
166
180
|
if (
|
|
167
181
|
target.manifest[field] === undefined &&
|
|
168
|
-
|
|
182
|
+
present(join(target.directory, name))
|
|
169
183
|
)
|
|
170
184
|
files.add(name);
|
|
171
185
|
}
|
|
@@ -200,7 +214,7 @@ function dependencies(target) {
|
|
|
200
214
|
};
|
|
201
215
|
if (
|
|
202
216
|
!Object.keys(deps).length ||
|
|
203
|
-
|
|
217
|
+
present(join(target.directory, "node_modules"))
|
|
204
218
|
)
|
|
205
219
|
return;
|
|
206
220
|
console.log(`${target.name}: installing dependencies (npm ci)`);
|
|
@@ -221,7 +235,7 @@ function* upwards(directory) {
|
|
|
221
235
|
for (let current = directory; ;) {
|
|
222
236
|
yield current;
|
|
223
237
|
const parent = dirname(current);
|
|
224
|
-
if (parent === current ||
|
|
238
|
+
if (parent === current || present(join(current, ".git"))) return;
|
|
225
239
|
current = parent;
|
|
226
240
|
}
|
|
227
241
|
}
|
|
@@ -235,13 +249,13 @@ function checkConfig(target, sources) {
|
|
|
235
249
|
const roots = [];
|
|
236
250
|
for (const directory of upwards(target.directory)) {
|
|
237
251
|
const types = join(directory, "node_modules/@types");
|
|
238
|
-
if (
|
|
252
|
+
if (present(types)) roots.push(types);
|
|
239
253
|
}
|
|
240
254
|
const node = resolveFrom(target.directory, "@types/node/package.json");
|
|
241
255
|
if (node) roots.push(dirname(dirname(node)));
|
|
242
256
|
const base = [...upwards(target.directory)]
|
|
243
257
|
.map((directory) => join(directory, "tsconfig.json"))
|
|
244
|
-
.find((file) =>
|
|
258
|
+
.find((file) => present(file));
|
|
245
259
|
return {
|
|
246
260
|
...(base ? { extends: base } : {}),
|
|
247
261
|
compilerOptions: {
|
|
@@ -262,7 +276,7 @@ async function check(target) {
|
|
|
262
276
|
const own = (await files(target.directory)).filter(
|
|
263
277
|
(file) => isTypeScript(file) || file.endsWith(".mts"),
|
|
264
278
|
);
|
|
265
|
-
const tests =
|
|
279
|
+
const tests = present(join(target.directory, "tests"))
|
|
266
280
|
? (await readdir(join(target.directory, "tests")))
|
|
267
281
|
.filter(isTypeScript)
|
|
268
282
|
.map((file) => join("tests", file))
|
|
@@ -308,7 +322,7 @@ function entries(target) {
|
|
|
308
322
|
const entry = target.manifest.entry.replace(/^dist\//, "");
|
|
309
323
|
const candidates = [entry.replace(/\.js$/, ".ts"), entry];
|
|
310
324
|
const found = candidates.find((file) =>
|
|
311
|
-
|
|
325
|
+
present(join(target.directory, file)),
|
|
312
326
|
);
|
|
313
327
|
if (!found)
|
|
314
328
|
fail(`${target.name}: entry ${target.manifest.entry} has no source file`);
|
|
@@ -454,7 +468,7 @@ async function build(target, flag) {
|
|
|
454
468
|
|
|
455
469
|
async function test(target) {
|
|
456
470
|
const directory = join(target.directory, "tests");
|
|
457
|
-
const tests =
|
|
471
|
+
const tests = present(directory)
|
|
458
472
|
? (await readdir(directory))
|
|
459
473
|
.filter((file) => /\.test\.(ts|mjs)$/.test(file))
|
|
460
474
|
.sort()
|
package/dist/commands.d.ts
CHANGED
|
@@ -20,8 +20,7 @@ import type { Transport } from "./wire.ts";
|
|
|
20
20
|
*/
|
|
21
21
|
export declare const COMMAND_LIMITS: {
|
|
22
22
|
/**
|
|
23
|
-
* Commands one plugin instance may have at the same time (160).
|
|
24
|
-
* package is its own instance.
|
|
23
|
+
* Commands one plugin instance may have at the same time (160).
|
|
25
24
|
*/
|
|
26
25
|
readonly perPlugin: 160;
|
|
27
26
|
/** Commands of every plugin plus the player's own, together (1024). */
|
package/dist/commands.js
CHANGED
|
@@ -18,8 +18,7 @@
|
|
|
18
18
|
*/
|
|
19
19
|
export const COMMAND_LIMITS = {
|
|
20
20
|
/**
|
|
21
|
-
* Commands one plugin instance may have at the same time (160).
|
|
22
|
-
* package is its own instance.
|
|
21
|
+
* Commands one plugin instance may have at the same time (160).
|
|
23
22
|
*/
|
|
24
23
|
perPlugin: 160,
|
|
25
24
|
/** Commands of every plugin plus the player's own, together (1024). */
|
package/dist/plugin/packages.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/** Package validation
|
|
1
|
+
/** Package validation and install/rollback (`plugins.manage`). */
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
3
|
import { resolve } from "node:path";
|
|
4
4
|
import { failure, sleep, uncertain } from "./runtime.js";
|
|
@@ -9,51 +9,6 @@ const TERMINAL = [
|
|
|
9
9
|
"failedDegraded",
|
|
10
10
|
"tooLate",
|
|
11
11
|
];
|
|
12
|
-
const STARTING = ["preparing", "activating", "starting"];
|
|
13
|
-
class TrialHandle {
|
|
14
|
-
#scope;
|
|
15
|
-
#record;
|
|
16
|
-
#stopped;
|
|
17
|
-
constructor(scope, record) {
|
|
18
|
-
this.#scope = scope;
|
|
19
|
-
this.#record = record;
|
|
20
|
-
scope.add(this);
|
|
21
|
-
}
|
|
22
|
-
get status() {
|
|
23
|
-
return this.#record.status;
|
|
24
|
-
}
|
|
25
|
-
/**
|
|
26
|
-
* Concurrent calls share one stop; a confirmed stop is final. A failed one
|
|
27
|
-
* (code `cleanup`: the host did not confirm cleanup) is forgotten, so the
|
|
28
|
-
* next call sends a new stop to the host.
|
|
29
|
-
*/
|
|
30
|
-
stop() {
|
|
31
|
-
const host = this.#scope.runtime.host.packages;
|
|
32
|
-
if (this.#stopped)
|
|
33
|
-
return this.#stopped;
|
|
34
|
-
const stopping = (async () => {
|
|
35
|
-
this.#record = await host.stopTrial(this.#record.trialId);
|
|
36
|
-
while (!this.#record.cleanupConfirmed &&
|
|
37
|
-
this.#record.status !== "stopped" &&
|
|
38
|
-
this.#record.status !== "failed") {
|
|
39
|
-
await sleep(250);
|
|
40
|
-
this.#record = await host.trialState(this.#record.trialId);
|
|
41
|
-
}
|
|
42
|
-
if (!this.#record.cleanupConfirmed)
|
|
43
|
-
throw failure("cleanup", `trial cleanup not confirmed: ${this.#record.status}`);
|
|
44
|
-
})();
|
|
45
|
-
this.#stopped = stopping;
|
|
46
|
-
stopping.catch(() => {
|
|
47
|
-
if (this.#stopped === stopping)
|
|
48
|
-
this.#stopped = undefined;
|
|
49
|
-
});
|
|
50
|
-
return stopping;
|
|
51
|
-
}
|
|
52
|
-
async dispose() {
|
|
53
|
-
this.#scope.forget(this);
|
|
54
|
-
await this.stop().catch(() => { });
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
12
|
class ChangeHandle {
|
|
58
13
|
#scope;
|
|
59
14
|
#id;
|
|
@@ -123,24 +78,6 @@ async function change(scope, submit, id) {
|
|
|
123
78
|
}
|
|
124
79
|
return new ChangeHandle(scope, operation);
|
|
125
80
|
}
|
|
126
|
-
async function trial(scope, runtime, token) {
|
|
127
|
-
const host = runtime.host.packages;
|
|
128
|
-
const requestId = randomUUID();
|
|
129
|
-
let record;
|
|
130
|
-
try {
|
|
131
|
-
record = await host.trial(token, requestId);
|
|
132
|
-
}
|
|
133
|
-
catch (error) {
|
|
134
|
-
if (!uncertain(error))
|
|
135
|
-
throw error;
|
|
136
|
-
record = await host.trialStateByRequest(requestId);
|
|
137
|
-
}
|
|
138
|
-
while (STARTING.includes(record.status)) {
|
|
139
|
-
await sleep(250);
|
|
140
|
-
record = await host.trialState(record.trialId);
|
|
141
|
-
}
|
|
142
|
-
return new TrialHandle(scope, record);
|
|
143
|
-
}
|
|
144
81
|
function installOptions(options) {
|
|
145
82
|
if (options === undefined)
|
|
146
83
|
return {};
|
|
@@ -171,7 +108,6 @@ function candidate(scope, directory, checked) {
|
|
|
171
108
|
digest: checked.digest,
|
|
172
109
|
manifest: checked.manifest,
|
|
173
110
|
checks: checked.checks,
|
|
174
|
-
trial: () => trial(scope, runtime, checked.token),
|
|
175
111
|
install: async (options) => {
|
|
176
112
|
const wire = installOptions(options);
|
|
177
113
|
return change(scope, (requestId, revision) => runtime.host.packages.install(checked.token, revision, requestId, wire), checked.manifest.id);
|
package/dist/plugin/resolvers.js
CHANGED
|
@@ -5,8 +5,72 @@
|
|
|
5
5
|
import { failure, quiet } from "./runtime.js";
|
|
6
6
|
import { playerOf } from "./player.js";
|
|
7
7
|
import { resolvedStream } from "./stream.js";
|
|
8
|
+
import { windowOf } from "./window.js";
|
|
8
9
|
const isFiles = (options) => "extensions" in options;
|
|
10
|
+
const sameTypes = (a, b) => a.length === b.length && a.every((type, i) => type === b[i]);
|
|
11
|
+
/**
|
|
12
|
+
* One per plugin: the list comes with the `window.state` read before
|
|
13
|
+
* activation (`[]` from older players) and is read again when the plugin
|
|
14
|
+
* first uses it; with `events`, `resolvers.fileTypes` keeps it current.
|
|
15
|
+
*/
|
|
16
|
+
class FileTypesService {
|
|
17
|
+
#runtime;
|
|
18
|
+
types;
|
|
19
|
+
#listeners = new Set();
|
|
20
|
+
#started = false;
|
|
21
|
+
constructor(runtime) {
|
|
22
|
+
this.#runtime = runtime;
|
|
23
|
+
this.types = Object.freeze([...(windowOf(runtime).fileTypes ?? [])]);
|
|
24
|
+
}
|
|
25
|
+
start() {
|
|
26
|
+
if (this.#started)
|
|
27
|
+
return;
|
|
28
|
+
this.#started = true;
|
|
29
|
+
this.#runtime.on("resolvers.fileTypes", (data) => {
|
|
30
|
+
const types = data?.fileTypes;
|
|
31
|
+
if (Array.isArray(types) && types.every((t) => typeof t === "string"))
|
|
32
|
+
this.accept(types);
|
|
33
|
+
});
|
|
34
|
+
// What changed between the start-up read and the subscription.
|
|
35
|
+
const window = windowOf(this.#runtime);
|
|
36
|
+
void window.read().then(() => {
|
|
37
|
+
if (window.fileTypes)
|
|
38
|
+
this.accept(window.fileTypes, this.#runtime.can("events"));
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
/** `notify`: false without `events`, whose listeners never fire. */
|
|
42
|
+
accept(types, notify = true) {
|
|
43
|
+
if (sameTypes(types, this.types))
|
|
44
|
+
return;
|
|
45
|
+
this.types = Object.freeze([...types]);
|
|
46
|
+
if (notify)
|
|
47
|
+
for (const listener of [...this.#listeners])
|
|
48
|
+
listener([...types]);
|
|
49
|
+
this.#runtime.invalidate();
|
|
50
|
+
}
|
|
51
|
+
watch(listener) {
|
|
52
|
+
this.#listeners.add(listener);
|
|
53
|
+
return { dispose: async () => void this.#listeners.delete(listener) };
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
const fileTypesOf = (runtime) => runtime.service("fileTypes", () => new FileTypesService(runtime));
|
|
9
57
|
export const resolvers = (scope) => ({
|
|
58
|
+
get fileTypes() {
|
|
59
|
+
const service = fileTypesOf(scope.runtime);
|
|
60
|
+
service.start();
|
|
61
|
+
return service.types;
|
|
62
|
+
},
|
|
63
|
+
onFileTypesChange(listener) {
|
|
64
|
+
const service = fileTypesOf(scope.runtime);
|
|
65
|
+
service.start();
|
|
66
|
+
const watch = service.watch((types) => scope.run(listener, types));
|
|
67
|
+
return scope.add({
|
|
68
|
+
dispose: async () => {
|
|
69
|
+
scope.forget(watch);
|
|
70
|
+
await watch.dispose();
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
},
|
|
10
74
|
add(options) {
|
|
11
75
|
const runtime = scope.runtime;
|
|
12
76
|
const registration = isFiles(options)
|
package/dist/plugin/stream.js
CHANGED
|
@@ -79,7 +79,8 @@ class StreamHandle {
|
|
|
79
79
|
}, 5);
|
|
80
80
|
}
|
|
81
81
|
catch (error) {
|
|
82
|
-
|
|
82
|
+
const retired = await retirement(this.#service, this.#generation, error);
|
|
83
|
+
void this.#end(retired === undefined ? toError(error) : retired);
|
|
83
84
|
}
|
|
84
85
|
}
|
|
85
86
|
async #render() {
|
|
@@ -131,6 +132,28 @@ class StreamHandle {
|
|
|
131
132
|
await this.stop().catch(() => { });
|
|
132
133
|
}
|
|
133
134
|
}
|
|
135
|
+
/**
|
|
136
|
+
* The player retires a producer the moment another source replaces it or
|
|
137
|
+
* the source closes, and after it failed, so its `producer.state` then
|
|
138
|
+
* answers `unavailable` (and `producer.ack` `stale`). The player's state
|
|
139
|
+
* says which: `null` (replaced or closed), the failure with the player's
|
|
140
|
+
* words, or `undefined` when the error is not a retirement.
|
|
141
|
+
*/
|
|
142
|
+
async function retirement(service, generation, error) {
|
|
143
|
+
const code = error?.code;
|
|
144
|
+
if (code !== "unavailable" && code !== "stale")
|
|
145
|
+
return undefined;
|
|
146
|
+
const readable = service.runtime.can("playback.read");
|
|
147
|
+
if (readable)
|
|
148
|
+
await service.refresh().catch(() => { });
|
|
149
|
+
const raw = service.raw;
|
|
150
|
+
if (raw.stamp.generation !== generation || raw.phase === "empty")
|
|
151
|
+
return null;
|
|
152
|
+
if (raw.phase === "error")
|
|
153
|
+
return failure("stream", service.state.error ?? "无法打开此媒体");
|
|
154
|
+
// Without `playback.read` the state may be behind: no failure was seen.
|
|
155
|
+
return readable ? undefined : null;
|
|
156
|
+
}
|
|
134
157
|
/** Start-up timeouts say which step ran out of time. */
|
|
135
158
|
function timeout(stage, message) {
|
|
136
159
|
return Object.assign(failure("timeout", message), { stage });
|
|
@@ -303,10 +326,40 @@ export async function openStream(scope, service, options) {
|
|
|
303
326
|
// An abort can arrive before the host answered the selecting request: wait
|
|
304
327
|
// for that answer so a source selected afterwards is still closed.
|
|
305
328
|
await opened?.catch(() => { });
|
|
329
|
+
// Read before closing: replaced during activation is not a failure.
|
|
330
|
+
const retired = generation === undefined
|
|
331
|
+
? undefined
|
|
332
|
+
: await retirement(service, generation, error);
|
|
306
333
|
await abandon(service, p, generation);
|
|
307
|
-
|
|
334
|
+
if (retired === null)
|
|
335
|
+
throw failure("replaced", "另一个来源已替换此媒体");
|
|
336
|
+
throw retired ?? error;
|
|
308
337
|
}
|
|
309
338
|
}
|
|
339
|
+
/**
|
|
340
|
+
* What the user reads when a file resolver's stream cannot start. The
|
|
341
|
+
* player shows the answer as the open's failure, and the plugin never sees
|
|
342
|
+
* these errors: they happen after `resolve` returned. The SDK's and the
|
|
343
|
+
* player's own codes get the player's words; the detail goes to the log.
|
|
344
|
+
* An error the plugin threw from `frame()` keeps its own words.
|
|
345
|
+
*/
|
|
346
|
+
const START_TEXT = {
|
|
347
|
+
device: "音频输出设备不可用",
|
|
348
|
+
timeout: "启动超时",
|
|
349
|
+
busy: "播放器正忙,请稍后重试",
|
|
350
|
+
stream: "无法启动播放",
|
|
351
|
+
producer: "无法启动播放",
|
|
352
|
+
unavailable: "无法启动播放",
|
|
353
|
+
invalid: "无法启动播放",
|
|
354
|
+
};
|
|
355
|
+
function startFailure(scope, error) {
|
|
356
|
+
const code = error?.code;
|
|
357
|
+
const text = code === undefined ? undefined : START_TEXT[code];
|
|
358
|
+
if (text === undefined)
|
|
359
|
+
return error;
|
|
360
|
+
console.error(`[${scope.id}] file resolver stream did not start:`, error.message);
|
|
361
|
+
return Object.assign(failure(code, text), { cause: error });
|
|
362
|
+
}
|
|
310
363
|
/**
|
|
311
364
|
* A file resolver's stream (`ResolvedStream`): prepared and prerolled while
|
|
312
365
|
* the open waits, then handed to the host as the answer. Once the host
|
|
@@ -320,6 +373,8 @@ export async function resolvedStream(scope, service, stream, signal) {
|
|
|
320
373
|
const p = await prepare(host, stream, () => {
|
|
321
374
|
if (signal.aborted || scope.disposed)
|
|
322
375
|
throw abortError();
|
|
376
|
+
}).catch((error) => {
|
|
377
|
+
throw startFailure(scope, error);
|
|
323
378
|
});
|
|
324
379
|
return {
|
|
325
380
|
kind: "stream",
|
|
@@ -336,9 +391,12 @@ export async function resolvedStream(scope, service, stream, signal) {
|
|
|
336
391
|
throw abortError();
|
|
337
392
|
}
|
|
338
393
|
catch (error) {
|
|
394
|
+
// Replaced or closed before it ran is a cancellation, not a failure.
|
|
395
|
+
const retired = await retirement(service, stamp.generation, error);
|
|
339
396
|
await abandon(service, p, stamp.generation);
|
|
340
|
-
|
|
341
|
-
|
|
397
|
+
const reported = retired === undefined ? error : retired;
|
|
398
|
+
if (reported && reported.code !== "aborted")
|
|
399
|
+
scope.report(reported);
|
|
342
400
|
return;
|
|
343
401
|
}
|
|
344
402
|
const handle = handleOf(scope, service, p, stamp.generation);
|
package/dist/plugin/testing.d.ts
CHANGED
|
@@ -117,6 +117,13 @@ export interface FakeProducer {
|
|
|
117
117
|
videoFrames: number;
|
|
118
118
|
/** `true` once the plugin released the producer (stream stopped or disposed). */
|
|
119
119
|
released: boolean;
|
|
120
|
+
/**
|
|
121
|
+
* `true` once the player retired the producer, as the real player does at
|
|
122
|
+
* once: another source replaced it (`open`, another stream, a resolved
|
|
123
|
+
* file), the source was closed, or {@link TestHost.failStream} failed it.
|
|
124
|
+
* A retired producer's `producer.state` answers `unavailable`.
|
|
125
|
+
*/
|
|
126
|
+
retired: boolean;
|
|
120
127
|
/**
|
|
121
128
|
* `true` if the frame connection closed while this stream was still the
|
|
122
129
|
* player's source: the player reports that as a playback failure
|
|
@@ -766,6 +773,27 @@ export declare class TestHost implements Transport {
|
|
|
766
773
|
/** Exact host names it resolves (a web resolver), with `schemes`. */
|
|
767
774
|
hosts?: string[];
|
|
768
775
|
}[];
|
|
776
|
+
/**
|
|
777
|
+
* What `plugin.resolvers.fileTypes` reports: the extensions of this
|
|
778
|
+
* plugin's file resolvers and of {@link TestHost.otherResolvers}, sorted,
|
|
779
|
+
* each once.
|
|
780
|
+
*/
|
|
781
|
+
fileTypes(): string[];
|
|
782
|
+
/**
|
|
783
|
+
* Push `resolvers.fileTypes` when {@link TestHost.fileTypes} changed:
|
|
784
|
+
* called after this plugin's resolvers come and go; call it yourself
|
|
785
|
+
* after changing {@link TestHost.otherResolvers} (another plugin started
|
|
786
|
+
* or stopped).
|
|
787
|
+
*
|
|
788
|
+
* @example
|
|
789
|
+
* ```ts
|
|
790
|
+
* host.otherResolvers.push({ owner: "vestra.nes", extensions: ["nes"] });
|
|
791
|
+
* host.updateFileTypes();
|
|
792
|
+
* await host.settle();
|
|
793
|
+
* assert.deepEqual(plugin.resolvers.fileTypes, ["nes"]);
|
|
794
|
+
* ```
|
|
795
|
+
*/
|
|
796
|
+
updateFileTypes(): void;
|
|
769
797
|
/**
|
|
770
798
|
* File opens waiting for this plugin's file resolver (`extensions`), by
|
|
771
799
|
* request id: the path and how it was opened. Opening a matching path
|
|
@@ -1025,6 +1053,22 @@ export declare class TestHost implements Transport {
|
|
|
1025
1053
|
* ```
|
|
1026
1054
|
*/
|
|
1027
1055
|
openElsewhere(source: string, phase?: SourceState["phase"]): void;
|
|
1056
|
+
/**
|
|
1057
|
+
* Simulate a generated stream failing in the player (its audio device
|
|
1058
|
+
* lost, its frame connection broken), like the player: the source fails
|
|
1059
|
+
* with `message` (`phase: "error"`) and the producer is retired at once.
|
|
1060
|
+
* The stream's `onStop` listeners get a `stream` error with `message`.
|
|
1061
|
+
*
|
|
1062
|
+
* @param producerId - The producer, from {@link TestHost.producers}.
|
|
1063
|
+
* @param message - The player's failure text.
|
|
1064
|
+
*
|
|
1065
|
+
* @example
|
|
1066
|
+
* ```ts
|
|
1067
|
+
* const [producer] = host.producers.values();
|
|
1068
|
+
* host.failStream(producer.id, "producer transport closed");
|
|
1069
|
+
* ```
|
|
1070
|
+
*/
|
|
1071
|
+
failStream(producerId: number, message: string): void;
|
|
1028
1072
|
/**
|
|
1029
1073
|
* Press a binding like the host's table would: panel-focus bindings first
|
|
1030
1074
|
* when `panel` is true, then global ones; the earliest registration wins a
|
|
@@ -1072,7 +1116,7 @@ export declare class TestHost implements Transport {
|
|
|
1072
1116
|
/** Its version, `null` when unknown. */
|
|
1073
1117
|
version: string | null;
|
|
1074
1118
|
/** Where it was loaded from. */
|
|
1075
|
-
origin: "installed" | "development"
|
|
1119
|
+
origin: "installed" | "development";
|
|
1076
1120
|
/** Its run state. */
|
|
1077
1121
|
state: "starting" | "running" | "disabled" | "failed" | "stopped";
|
|
1078
1122
|
/** Why it failed (`state: "failed"`). */
|
package/dist/plugin/testing.js
CHANGED
|
@@ -773,6 +773,43 @@ export class TestHost {
|
|
|
773
773
|
* ```
|
|
774
774
|
*/
|
|
775
775
|
otherResolvers = [];
|
|
776
|
+
/**
|
|
777
|
+
* What `plugin.resolvers.fileTypes` reports: the extensions of this
|
|
778
|
+
* plugin's file resolvers and of {@link TestHost.otherResolvers}, sorted,
|
|
779
|
+
* each once.
|
|
780
|
+
*/
|
|
781
|
+
fileTypes() {
|
|
782
|
+
const types = new Set();
|
|
783
|
+
for (const declaration of [
|
|
784
|
+
...this.resolvers.values(),
|
|
785
|
+
...this.otherResolvers,
|
|
786
|
+
])
|
|
787
|
+
for (const type of declaration.extensions ?? [])
|
|
788
|
+
types.add(type);
|
|
789
|
+
return [...types].sort();
|
|
790
|
+
}
|
|
791
|
+
#sentTypes = "[]";
|
|
792
|
+
/**
|
|
793
|
+
* Push `resolvers.fileTypes` when {@link TestHost.fileTypes} changed:
|
|
794
|
+
* called after this plugin's resolvers come and go; call it yourself
|
|
795
|
+
* after changing {@link TestHost.otherResolvers} (another plugin started
|
|
796
|
+
* or stopped).
|
|
797
|
+
*
|
|
798
|
+
* @example
|
|
799
|
+
* ```ts
|
|
800
|
+
* host.otherResolvers.push({ owner: "vestra.nes", extensions: ["nes"] });
|
|
801
|
+
* host.updateFileTypes();
|
|
802
|
+
* await host.settle();
|
|
803
|
+
* assert.deepEqual(plugin.resolvers.fileTypes, ["nes"]);
|
|
804
|
+
* ```
|
|
805
|
+
*/
|
|
806
|
+
updateFileTypes() {
|
|
807
|
+
const types = this.fileTypes();
|
|
808
|
+
if (JSON.stringify(types) === this.#sentTypes)
|
|
809
|
+
return;
|
|
810
|
+
this.#sentTypes = JSON.stringify(types);
|
|
811
|
+
this.emit("resolvers.fileTypes", { fileTypes: types });
|
|
812
|
+
}
|
|
776
813
|
/**
|
|
777
814
|
* File opens waiting for this plugin's file resolver (`extensions`), by
|
|
778
815
|
* request id: the path and how it was opened. Opening a matching path
|
|
@@ -1361,6 +1398,7 @@ export class TestHost {
|
|
|
1361
1398
|
videoArea: { ...this.mainWindow.videoArea },
|
|
1362
1399
|
pictureInPicture: { ...this.mainWindow.pictureInPicture },
|
|
1363
1400
|
appearance: this.appearance,
|
|
1401
|
+
fileTypes: this.fileTypes(),
|
|
1364
1402
|
};
|
|
1365
1403
|
}
|
|
1366
1404
|
/**
|
|
@@ -1385,6 +1423,31 @@ export class TestHost {
|
|
|
1385
1423
|
if (this.#resolve(source, false) === null)
|
|
1386
1424
|
this.setSource({ phase });
|
|
1387
1425
|
}
|
|
1426
|
+
/**
|
|
1427
|
+
* Simulate a generated stream failing in the player (its audio device
|
|
1428
|
+
* lost, its frame connection broken), like the player: the source fails
|
|
1429
|
+
* with `message` (`phase: "error"`) and the producer is retired at once.
|
|
1430
|
+
* The stream's `onStop` listeners get a `stream` error with `message`.
|
|
1431
|
+
*
|
|
1432
|
+
* @param producerId - The producer, from {@link TestHost.producers}.
|
|
1433
|
+
* @param message - The player's failure text.
|
|
1434
|
+
*
|
|
1435
|
+
* @example
|
|
1436
|
+
* ```ts
|
|
1437
|
+
* const [producer] = host.producers.values();
|
|
1438
|
+
* host.failStream(producer.id, "producer transport closed");
|
|
1439
|
+
* ```
|
|
1440
|
+
*/
|
|
1441
|
+
failStream(producerId, message) {
|
|
1442
|
+
const producer = this.producers.get(producerId);
|
|
1443
|
+
if (!producer || !this.#current(producerId))
|
|
1444
|
+
throw new Error(`producer ${producerId} is not the player's source`);
|
|
1445
|
+
this.setSource({
|
|
1446
|
+
phase: "error",
|
|
1447
|
+
terminalReason: { failed: message },
|
|
1448
|
+
});
|
|
1449
|
+
producer.retired = true;
|
|
1450
|
+
}
|
|
1388
1451
|
/**
|
|
1389
1452
|
* Press a binding like the host's table would: panel-focus bindings first
|
|
1390
1453
|
* when `panel` is true, then global ones; the earliest registration wins a
|
|
@@ -1721,6 +1784,10 @@ export class TestHost {
|
|
|
1721
1784
|
if (control.kind === "rate")
|
|
1722
1785
|
next.rate = Number(control.rate);
|
|
1723
1786
|
}
|
|
1787
|
+
// Like the player: the selected producer is retired as soon as another
|
|
1788
|
+
// source replaces it or the source closes.
|
|
1789
|
+
if (["open", "openProvider", "close"].includes(String(intent.kind)))
|
|
1790
|
+
this.#retireSelected(intent.kind === "openProvider" ? Number(intent.producerId) : null);
|
|
1724
1791
|
if (intent.kind === "openProvider")
|
|
1725
1792
|
this.#selected = Number(intent.producerId);
|
|
1726
1793
|
if (intent.kind === "open" || intent.kind === "close")
|
|
@@ -1748,6 +1815,14 @@ export class TestHost {
|
|
|
1748
1815
|
}
|
|
1749
1816
|
return null;
|
|
1750
1817
|
}
|
|
1818
|
+
/** Retire the producer that is the player's source, unless it is `keep`. */
|
|
1819
|
+
#retireSelected(keep) {
|
|
1820
|
+
for (const producer of this.producers.values())
|
|
1821
|
+
if (producer.id !== keep &&
|
|
1822
|
+
!producer.retired &&
|
|
1823
|
+
this.#current(producer.id))
|
|
1824
|
+
producer.retired = true;
|
|
1825
|
+
}
|
|
1751
1826
|
/** Whether producer `id` is the player's source. */
|
|
1752
1827
|
#current(id) {
|
|
1753
1828
|
return (this.source.kind === "producer" &&
|
|
@@ -1853,9 +1928,10 @@ export class TestHost {
|
|
|
1853
1928
|
const producer = this.producers.get(Number(result.producerId));
|
|
1854
1929
|
if (result.kind !== "stream")
|
|
1855
1930
|
fail("invalid resolved stream descriptor");
|
|
1856
|
-
if (!producer || producer.released)
|
|
1931
|
+
if (!producer || producer.released || producer.retired)
|
|
1857
1932
|
fail("unavailable: producer absent");
|
|
1858
1933
|
// Like openProvider, but the source keeps the path and `startPaused`.
|
|
1934
|
+
this.#retireSelected(producer.id);
|
|
1859
1935
|
this.#selected = producer.id;
|
|
1860
1936
|
producer.active = true;
|
|
1861
1937
|
producer.acknowledged = false;
|
|
@@ -2645,8 +2721,6 @@ export class TestHost {
|
|
|
2645
2721
|
const entry = this.packages.find((each) => each.id === id);
|
|
2646
2722
|
if (!entry)
|
|
2647
2723
|
throw new HostError("missing", `no plugin ${id}`);
|
|
2648
|
-
if (entry.origin === "trial")
|
|
2649
|
-
throw new HostError("unsupported", "a trial is stopped with its handle");
|
|
2650
2724
|
this.packageCalls.push({
|
|
2651
2725
|
method: "setEnabled",
|
|
2652
2726
|
id,
|
|
@@ -2906,10 +2980,14 @@ export class TestHost {
|
|
|
2906
2980
|
return { jobId: job.jobId, cancel: "requested" };
|
|
2907
2981
|
},
|
|
2908
2982
|
"jobs.operation": () => ({ status: "unknown" }),
|
|
2909
|
-
"sourceResolvers.register": (p) =>
|
|
2983
|
+
"sourceResolvers.register": (p) => {
|
|
2984
|
+
this.#register(p);
|
|
2985
|
+
this.updateFileTypes();
|
|
2986
|
+
},
|
|
2910
2987
|
"sourceResolvers.unregister": (p) => {
|
|
2911
2988
|
this.resolvers.delete(String(p.id));
|
|
2912
2989
|
this.#cancelResolutions();
|
|
2990
|
+
this.updateFileTypes();
|
|
2913
2991
|
},
|
|
2914
2992
|
"sourceResolvers.respond": (p) => this.#respond(p),
|
|
2915
2993
|
"producer.prepare": async () => {
|
|
@@ -2922,6 +3000,7 @@ export class TestHost {
|
|
|
2922
3000
|
receivedFrames: 0,
|
|
2923
3001
|
videoFrames: 0,
|
|
2924
3002
|
released: false,
|
|
3003
|
+
retired: false,
|
|
2925
3004
|
droppedWhileSelected: false,
|
|
2926
3005
|
path,
|
|
2927
3006
|
admitted: !tcp,
|
|
@@ -2988,6 +3067,8 @@ export class TestHost {
|
|
|
2988
3067
|
const producer = this.producers.get(Number(p.producerId));
|
|
2989
3068
|
if (!producer)
|
|
2990
3069
|
throw new HostError("missing", "producer");
|
|
3070
|
+
if (producer.retired || producer.released)
|
|
3071
|
+
throw new HostError("unavailable", "producer absent");
|
|
2991
3072
|
const current = this.#current(producer.id);
|
|
2992
3073
|
return {
|
|
2993
3074
|
input: null,
|
|
@@ -3017,6 +3098,8 @@ export class TestHost {
|
|
|
3017
3098
|
const producer = this.producers.get(Number(control.producerId));
|
|
3018
3099
|
if (!producer)
|
|
3019
3100
|
throw new HostError("missing", "producer");
|
|
3101
|
+
if (producer.retired || producer.released)
|
|
3102
|
+
throw new HostError("stale", "producer control changed");
|
|
3020
3103
|
producer.pending = null;
|
|
3021
3104
|
producer.acknowledged = true;
|
|
3022
3105
|
producer.epoch = Number(control.epoch);
|