@vestra-player/sdk 1.5.1 → 1.5.3
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 +30 -0
- package/dist/plugin/packages.js +5 -0
- package/dist/plugin/resolvers.js +16 -4
- package/dist/plugin/stream.d.ts +11 -1
- package/dist/plugin/stream.js +142 -68
- package/dist/plugin/testing.d.ts +88 -2
- package/dist/plugin/testing.js +360 -22
- package/dist/plugin/types.d.ts +369 -22
- package/dist/plugin/ui.js +99 -3
- package/dist/source-resolvers.d.ts +33 -6
- package/dist/source-resolvers.js +42 -14
- package/dist/wire.d.ts +20 -1
- package/package.json +1 -1
package/dist/plugin/testing.js
CHANGED
|
@@ -61,7 +61,7 @@ import { mkdirSync, mkdtempSync, rmSync, statSync } from "node:fs";
|
|
|
61
61
|
import { createServer } from "node:net";
|
|
62
62
|
import { randomBytes } from "node:crypto";
|
|
63
63
|
import { tmpdir } from "node:os";
|
|
64
|
-
import { join } from "node:path";
|
|
64
|
+
import { extname, isAbsolute, join } from "node:path";
|
|
65
65
|
import { fileURLToPath } from "node:url";
|
|
66
66
|
import { HostError } from "../wire.js";
|
|
67
67
|
import { COMMAND_LIMITS, MENU_IDS } from "../commands.js";
|
|
@@ -110,6 +110,23 @@ const readyCapabilities = {
|
|
|
110
110
|
close: true,
|
|
111
111
|
snapshotSource: true,
|
|
112
112
|
};
|
|
113
|
+
/**
|
|
114
|
+
* Refusals for a position or resolver another plugin holds: not activation
|
|
115
|
+
* failures (`startPlugin`).
|
|
116
|
+
*/
|
|
117
|
+
const takenErrors = new WeakSet();
|
|
118
|
+
const taken = (message) => {
|
|
119
|
+
const error = new HostError("conflict", message);
|
|
120
|
+
takenErrors.add(error);
|
|
121
|
+
return error;
|
|
122
|
+
};
|
|
123
|
+
const SLOT_LABELS = {
|
|
124
|
+
bottom: "底栏",
|
|
125
|
+
sidebar: "侧栏",
|
|
126
|
+
backdrop: "视频背景",
|
|
127
|
+
};
|
|
128
|
+
/** The player's limit of sidebar tabs. */
|
|
129
|
+
const MAX_SIDEBAR_TABS = 8;
|
|
113
130
|
/**
|
|
114
131
|
* An in-memory fake Vestra Player for plugin tests.
|
|
115
132
|
*
|
|
@@ -531,6 +548,109 @@ export class TestHost {
|
|
|
531
548
|
this.backdrop.effective = effective;
|
|
532
549
|
this.emit("window.state", this.#windowFacts());
|
|
533
550
|
}
|
|
551
|
+
#nextOwner = 2;
|
|
552
|
+
/** Owners of other plugins' panels, by plugin id ({@link TestHost.mountOther}). */
|
|
553
|
+
#others = new Map();
|
|
554
|
+
/**
|
|
555
|
+
* Simulate another plugin mounting a panel, before or after the plugin
|
|
556
|
+
* under test, as the player handles it: in the sidebar it is another tab
|
|
557
|
+
* (after the existing ones; the first panel's tab is current); the bottom
|
|
558
|
+
* bar and the backdrop take one panel, so the plugin's own mount there
|
|
559
|
+
* then rejects with `conflict` (not an activation failure, as in the
|
|
560
|
+
* player). Pushed as a `window.state` change.
|
|
561
|
+
*
|
|
562
|
+
* @param slot - The position.
|
|
563
|
+
* @param plugin - The other plugin's manifest id.
|
|
564
|
+
* @param options - `title`: its tab label (default: `plugin`).
|
|
565
|
+
*
|
|
566
|
+
* @example The playlist next to this plugin's sidebar panel
|
|
567
|
+
* ```ts
|
|
568
|
+
* const host = new TestHost();
|
|
569
|
+
* host.mountOther("sidebar", "vestra.playlist", { title: "播放列表" });
|
|
570
|
+
* const { plugin } = await startPlugin(activate, { host, directory });
|
|
571
|
+
* assert.equal(host.layout.sidebar?.panels?.length, 2);
|
|
572
|
+
* ```
|
|
573
|
+
*
|
|
574
|
+
* @since 1.5.2
|
|
575
|
+
*/
|
|
576
|
+
mountOther(slot, plugin, options = {}) {
|
|
577
|
+
if (this.#others.has(plugin))
|
|
578
|
+
throw new Error(`${plugin} already has a panel`);
|
|
579
|
+
const owner = this.#nextOwner++;
|
|
580
|
+
if (slot === "backdrop") {
|
|
581
|
+
if (this.backdrop)
|
|
582
|
+
throw new Error("the backdrop is taken");
|
|
583
|
+
this.backdrop = { owner: plugin, effective: true };
|
|
584
|
+
}
|
|
585
|
+
else {
|
|
586
|
+
if (slot === "bottom" && this.layout.bottom)
|
|
587
|
+
throw new Error("the bottom bar is taken");
|
|
588
|
+
this.#addPanel(slot, { owner, plugin, title: options.title ?? plugin });
|
|
589
|
+
}
|
|
590
|
+
this.#others.set(plugin, { slot, owner });
|
|
591
|
+
this.emit("window.state", this.#windowFacts());
|
|
592
|
+
}
|
|
593
|
+
/**
|
|
594
|
+
* Simulate another plugin's panel going away ({@link TestHost.mountOther}):
|
|
595
|
+
* when its tab was current, the next tab (else the previous one) is.
|
|
596
|
+
*
|
|
597
|
+
* @since 1.5.2
|
|
598
|
+
*/
|
|
599
|
+
unmountOther(plugin) {
|
|
600
|
+
const other = this.#others.get(plugin);
|
|
601
|
+
if (!other)
|
|
602
|
+
throw new Error(`${plugin} has no panel`);
|
|
603
|
+
this.#others.delete(plugin);
|
|
604
|
+
if (other.slot === "backdrop")
|
|
605
|
+
this.backdrop = null;
|
|
606
|
+
else
|
|
607
|
+
this.#removePanel(other.slot, other.owner);
|
|
608
|
+
this.emit("window.state", this.#windowFacts());
|
|
609
|
+
}
|
|
610
|
+
/**
|
|
611
|
+
* Simulate the user clicking a sidebar tab: `plugin`'s panel (the plugin
|
|
612
|
+
* under test's `manifestId`, or one of {@link TestHost.mountOther})
|
|
613
|
+
* becomes the current tab. Pushed as a `window.state` change.
|
|
614
|
+
*
|
|
615
|
+
* @since 1.5.2
|
|
616
|
+
*/
|
|
617
|
+
selectTab(plugin) {
|
|
618
|
+
const slot = this.layout.sidebar;
|
|
619
|
+
const panel = slot?.panels?.find((entry) => entry.plugin === plugin);
|
|
620
|
+
if (!slot || !panel)
|
|
621
|
+
throw new Error(`${plugin} has no sidebar tab`);
|
|
622
|
+
slot.owner = panel.owner;
|
|
623
|
+
this.emit("window.state", this.#windowFacts());
|
|
624
|
+
}
|
|
625
|
+
#addPanel(slot, panel) {
|
|
626
|
+
const state = this.layout[slot];
|
|
627
|
+
if (state)
|
|
628
|
+
state.panels = [...(state.panels ?? []), panel];
|
|
629
|
+
else
|
|
630
|
+
this.layout[slot] = {
|
|
631
|
+
owner: panel.owner,
|
|
632
|
+
preferred: true,
|
|
633
|
+
effective: true,
|
|
634
|
+
reason: null,
|
|
635
|
+
detached: false,
|
|
636
|
+
panels: [panel],
|
|
637
|
+
};
|
|
638
|
+
}
|
|
639
|
+
#removePanel(slot, owner) {
|
|
640
|
+
const state = this.layout[slot];
|
|
641
|
+
if (!state)
|
|
642
|
+
return;
|
|
643
|
+
const panels = state.panels ?? [];
|
|
644
|
+
const index = panels.findIndex((panel) => panel.owner === owner);
|
|
645
|
+
const rest = panels.filter((panel) => panel.owner !== owner);
|
|
646
|
+
if (rest.length === 0) {
|
|
647
|
+
this.layout[slot] = null;
|
|
648
|
+
return;
|
|
649
|
+
}
|
|
650
|
+
state.panels = rest;
|
|
651
|
+
if (state.owner === owner)
|
|
652
|
+
state.owner = (rest[index] ?? rest[rest.length - 1]).owner;
|
|
653
|
+
}
|
|
534
654
|
/**
|
|
535
655
|
* The display layer (`plugin.video.view()`): who owns it (this plugin is
|
|
536
656
|
* `manifestId`) and the values the player holds.
|
|
@@ -640,6 +760,29 @@ export class TestHost {
|
|
|
640
760
|
producers = new Map();
|
|
641
761
|
/** Registered source resolvers (`resolvers.add`) by id: their declarations. */
|
|
642
762
|
resolvers = new Map();
|
|
763
|
+
/**
|
|
764
|
+
* Resolvers other plugins hold, as `resolvers.add` declares them plus the
|
|
765
|
+
* holder's id: a registration of this plugin that claims one of their
|
|
766
|
+
* extensions (or scheme + host pairs) is refused with `conflict`, which
|
|
767
|
+
* does not fail activation, like the player.
|
|
768
|
+
*
|
|
769
|
+
* @example
|
|
770
|
+
* ```ts
|
|
771
|
+
* const host = new TestHost();
|
|
772
|
+
* host.otherResolvers.push({ owner: "vestra.nes", extensions: ["nes"] });
|
|
773
|
+
* ```
|
|
774
|
+
*/
|
|
775
|
+
otherResolvers = [];
|
|
776
|
+
/**
|
|
777
|
+
* File opens waiting for this plugin's file resolver (`extensions`), by
|
|
778
|
+
* request id: the path and how it was opened. Opening a matching path
|
|
779
|
+
* (`player.open`, {@link TestHost.openElsewhere}) sends
|
|
780
|
+
* `source.resolveRequest`; the answer selects the stream, fails the open
|
|
781
|
+
* or, once the source changed, is `stale`. Any later source change
|
|
782
|
+
* cancels it (`source.resolveCancel`). Web resolvers are not simulated:
|
|
783
|
+
* emit `source.resolveRequest` yourself.
|
|
784
|
+
*/
|
|
785
|
+
resolutions = new Map();
|
|
643
786
|
/**
|
|
644
787
|
* Answers for `dialogs.open` (paths, `null` = cancelled) and
|
|
645
788
|
* `dialogs.confirm` (`{ confirmed, checked }`), consumed in order. A
|
|
@@ -733,8 +876,12 @@ export class TestHost {
|
|
|
733
876
|
#overrides = new Map();
|
|
734
877
|
#listeners = new Map();
|
|
735
878
|
#inFlight = 0;
|
|
736
|
-
#next = { snapshot: 0, job: 0, producer: 0, source: 0 };
|
|
879
|
+
#next = { snapshot: 0, job: 0, producer: 0, source: 0, resolution: 0 };
|
|
737
880
|
#sourceBook = new Map();
|
|
881
|
+
/** The pending resolutions' stamps and operations (beside `resolutions`). */
|
|
882
|
+
#resolving = new Map();
|
|
883
|
+
/** The producer the source is (`openProvider`, a resolved stream). */
|
|
884
|
+
#selected = null;
|
|
738
885
|
#scratch = mkdtempSync(join(tmpdir(), "vestra-test-host-"));
|
|
739
886
|
/**
|
|
740
887
|
* `plugin.dataDirectory`: a fresh empty temporary directory, removed with
|
|
@@ -1234,7 +1381,9 @@ export class TestHost {
|
|
|
1234
1381
|
*/
|
|
1235
1382
|
openElsewhere(source, phase = "ready") {
|
|
1236
1383
|
this.#intent({ kind: "open", source, startPaused: false });
|
|
1237
|
-
this
|
|
1384
|
+
// A file this plugin's file resolver claims waits for its answer.
|
|
1385
|
+
if (this.#resolve(source, false) === null)
|
|
1386
|
+
this.setSource({ phase });
|
|
1238
1387
|
}
|
|
1239
1388
|
/**
|
|
1240
1389
|
* Press a binding like the host's table would: panel-focus bindings first
|
|
@@ -1565,14 +1714,166 @@ export class TestHost {
|
|
|
1565
1714
|
if (control.kind === "rate")
|
|
1566
1715
|
next.rate = Number(control.rate);
|
|
1567
1716
|
}
|
|
1717
|
+
if (intent.kind === "openProvider")
|
|
1718
|
+
this.#selected = Number(intent.producerId);
|
|
1719
|
+
if (intent.kind === "open" || intent.kind === "close")
|
|
1720
|
+
this.#selected = null;
|
|
1568
1721
|
this.source = next;
|
|
1569
1722
|
this.subtitles.source = {
|
|
1570
1723
|
generation: next.stamp.generation,
|
|
1571
1724
|
phase: next.phase,
|
|
1572
1725
|
};
|
|
1573
1726
|
this.emit("source.state", this.source);
|
|
1727
|
+
this.#cancelResolutions();
|
|
1574
1728
|
return { ticket: { stamp: next.stamp, cycle: next.cycle, owner: 1 } };
|
|
1575
1729
|
}
|
|
1730
|
+
/** The file resolver of this plugin that claims `source`, if any. */
|
|
1731
|
+
#fileResolver(source) {
|
|
1732
|
+
if (source.includes("://") || !isAbsolute(source))
|
|
1733
|
+
return null;
|
|
1734
|
+
const extension = extname(source).slice(1).toLowerCase();
|
|
1735
|
+
if (!extension)
|
|
1736
|
+
return null;
|
|
1737
|
+
for (const [id, declaration] of this.resolvers) {
|
|
1738
|
+
const extensions = declaration.extensions;
|
|
1739
|
+
if (extensions?.includes(extension))
|
|
1740
|
+
return id;
|
|
1741
|
+
}
|
|
1742
|
+
return null;
|
|
1743
|
+
}
|
|
1744
|
+
/** Whether producer `id` is the player's source. */
|
|
1745
|
+
#current(id) {
|
|
1746
|
+
return (this.source.kind === "producer" &&
|
|
1747
|
+
(this.#selected === id || this.source.source === `producer://${id}`));
|
|
1748
|
+
}
|
|
1749
|
+
/** Start resolving `source` for the open just made; its request id, or null. */
|
|
1750
|
+
#resolve(source, paused, operation) {
|
|
1751
|
+
const resolverId = this.#fileResolver(source);
|
|
1752
|
+
if (resolverId === null)
|
|
1753
|
+
return null;
|
|
1754
|
+
const requestId = ++this.#next.resolution;
|
|
1755
|
+
this.resolutions.set(requestId, { resolverId, source, paused });
|
|
1756
|
+
this.#resolving.set(requestId, {
|
|
1757
|
+
stamp: { ...this.source.stamp },
|
|
1758
|
+
...(operation === undefined ? {} : { operation }),
|
|
1759
|
+
});
|
|
1760
|
+
this.emit("source.resolveRequest", { requestId, resolverId, url: source });
|
|
1761
|
+
return requestId;
|
|
1762
|
+
}
|
|
1763
|
+
/** Settle a resolution's open; `error` fails it. */
|
|
1764
|
+
#settle(requestId, error) {
|
|
1765
|
+
const operation = this.#resolving.get(requestId)?.operation;
|
|
1766
|
+
this.resolutions.delete(requestId);
|
|
1767
|
+
this.#resolving.delete(requestId);
|
|
1768
|
+
if (operation === undefined)
|
|
1769
|
+
return;
|
|
1770
|
+
const entry = this.#sourceBook.get(operation);
|
|
1771
|
+
this.#sourceBook.set(operation, error === undefined
|
|
1772
|
+
? { status: "completed", ticket: entry?.ticket ?? null }
|
|
1773
|
+
: { status: "failed", error: { backend: error } });
|
|
1774
|
+
}
|
|
1775
|
+
/** Resolutions whose open is no longer the source (or whose resolver left). */
|
|
1776
|
+
#cancelResolutions() {
|
|
1777
|
+
const stamp = this.source.stamp;
|
|
1778
|
+
for (const [requestId, resolution] of [...this.resolutions]) {
|
|
1779
|
+
const pending = this.#resolving.get(requestId);
|
|
1780
|
+
if (pending.stamp.generation === stamp.generation &&
|
|
1781
|
+
pending.stamp.intentVersion === stamp.intentVersion &&
|
|
1782
|
+
this.resolvers.has(resolution.resolverId))
|
|
1783
|
+
continue;
|
|
1784
|
+
this.#settle(requestId, "source resolution cancelled, expired or superseded");
|
|
1785
|
+
this.emit("source.resolveCancel", {
|
|
1786
|
+
requestId,
|
|
1787
|
+
resolverId: resolution.resolverId,
|
|
1788
|
+
});
|
|
1789
|
+
}
|
|
1790
|
+
}
|
|
1791
|
+
/** `sourceResolvers.register`: the player's rules for both forms. */
|
|
1792
|
+
#register(p) {
|
|
1793
|
+
const id = String(p.id);
|
|
1794
|
+
const extensions = p.extensions;
|
|
1795
|
+
if (this.resolvers.has(id))
|
|
1796
|
+
throw new HostError("conflict", "resolver id already registered");
|
|
1797
|
+
if (extensions !== undefined) {
|
|
1798
|
+
if (p.schemes !== undefined ||
|
|
1799
|
+
p.hosts !== undefined ||
|
|
1800
|
+
!Array.isArray(extensions) ||
|
|
1801
|
+
extensions.length < 1 ||
|
|
1802
|
+
extensions.length > 16 ||
|
|
1803
|
+
extensions.some((e) => typeof e !== "string" ||
|
|
1804
|
+
!/^[a-z0-9]{1,16}$/.test(e) ||
|
|
1805
|
+
e === "folder"))
|
|
1806
|
+
throw new HostError("invalid", "file resolvers take 1–16 file extensions (lower case, no dot, not folder)");
|
|
1807
|
+
}
|
|
1808
|
+
const claims = (other) => extensions?.find((e) => other.extensions?.includes(e)) ??
|
|
1809
|
+
(other.hosts?.some((h) => p.hosts?.includes(h)) &&
|
|
1810
|
+
other.schemes?.some((x) => p.schemes?.includes(x))
|
|
1811
|
+
? "host"
|
|
1812
|
+
: undefined);
|
|
1813
|
+
for (const own of this.resolvers.values())
|
|
1814
|
+
if (claims(own))
|
|
1815
|
+
throw new HostError("conflict", "already registered by this plugin");
|
|
1816
|
+
for (const other of this.otherResolvers) {
|
|
1817
|
+
const what = claims(other);
|
|
1818
|
+
if (what) {
|
|
1819
|
+
const target = what === "host" ? "这个网站" : `文件类型 .${what}`;
|
|
1820
|
+
throw taken(`${target} 已由 ${other.owner} 解析`);
|
|
1821
|
+
}
|
|
1822
|
+
}
|
|
1823
|
+
this.resolvers.set(id, p);
|
|
1824
|
+
}
|
|
1825
|
+
/** `sourceResolvers.respond` for a simulated file resolution. */
|
|
1826
|
+
#respond(p) {
|
|
1827
|
+
const requestId = Number(p.requestId);
|
|
1828
|
+
const resolution = this.resolutions.get(requestId);
|
|
1829
|
+
if (!resolution) {
|
|
1830
|
+
// Web resolutions are not simulated: their answers are only recorded.
|
|
1831
|
+
if (requestId > this.#next.resolution)
|
|
1832
|
+
return null;
|
|
1833
|
+
throw new HostError("stale", "resolution no longer pending");
|
|
1834
|
+
}
|
|
1835
|
+
const fail = (message) => {
|
|
1836
|
+
this.#settle(requestId, message);
|
|
1837
|
+
this.setSource({
|
|
1838
|
+
phase: "error",
|
|
1839
|
+
terminalReason: { failed: message },
|
|
1840
|
+
});
|
|
1841
|
+
throw new HostError("resolution", message);
|
|
1842
|
+
};
|
|
1843
|
+
if (typeof p.error === "string")
|
|
1844
|
+
fail(p.error.trim() ? p.error.slice(0, 2048) : "resolver failed");
|
|
1845
|
+
const result = (p.result ?? {});
|
|
1846
|
+
const producer = this.producers.get(Number(result.producerId));
|
|
1847
|
+
if (result.kind !== "stream")
|
|
1848
|
+
fail("invalid resolved stream descriptor");
|
|
1849
|
+
if (!producer || producer.released)
|
|
1850
|
+
fail("unavailable: producer absent");
|
|
1851
|
+
// Like openProvider, but the source keeps the path and `startPaused`.
|
|
1852
|
+
this.#selected = producer.id;
|
|
1853
|
+
producer.active = true;
|
|
1854
|
+
producer.acknowledged = false;
|
|
1855
|
+
producer.pending = {
|
|
1856
|
+
producerId: producer.id,
|
|
1857
|
+
epoch: producer.epoch + 1,
|
|
1858
|
+
baseSample: producer.receivedFrames,
|
|
1859
|
+
paused: resolution.paused,
|
|
1860
|
+
};
|
|
1861
|
+
this.#settle(requestId);
|
|
1862
|
+
this.setSource({
|
|
1863
|
+
kind: "producer",
|
|
1864
|
+
phase: "ready",
|
|
1865
|
+
paused: resolution.paused,
|
|
1866
|
+
capabilities: {
|
|
1867
|
+
pause: true,
|
|
1868
|
+
seek: false,
|
|
1869
|
+
rate: false,
|
|
1870
|
+
restart: false,
|
|
1871
|
+
close: true,
|
|
1872
|
+
snapshotSource: false,
|
|
1873
|
+
},
|
|
1874
|
+
});
|
|
1875
|
+
return { stamp: { ...this.source.stamp } };
|
|
1876
|
+
}
|
|
1576
1877
|
#processingState() {
|
|
1577
1878
|
const list = (stage) => [...this.processing.resources]
|
|
1578
1879
|
.filter(([, value]) => value.stage === stage)
|
|
@@ -1710,8 +2011,18 @@ export class TestHost {
|
|
|
1710
2011
|
"ui.mount": (p) => {
|
|
1711
2012
|
if (this.panel)
|
|
1712
2013
|
throw new HostError("conflict", "owner already mounted");
|
|
1713
|
-
|
|
1714
|
-
|
|
2014
|
+
// Like the player: one panel in the bottom bar and the backdrop, tabs
|
|
2015
|
+
// in the sidebar (up to 8).
|
|
2016
|
+
const holder = p.slot === "backdrop"
|
|
2017
|
+
? this.backdrop?.owner
|
|
2018
|
+
: p.slot === "bottom"
|
|
2019
|
+
? this.layout.bottom?.panels?.[0]?.plugin
|
|
2020
|
+
: undefined;
|
|
2021
|
+
if (holder !== undefined || (p.slot === "bottom" && this.layout.bottom))
|
|
2022
|
+
throw taken(`${SLOT_LABELS[String(p.slot)]}已被 ${holder} 使用`);
|
|
2023
|
+
if (p.slot === "sidebar" &&
|
|
2024
|
+
(this.layout.sidebar?.panels?.length ?? 0) >= MAX_SIDEBAR_TABS)
|
|
2025
|
+
throw taken(`侧栏最多 ${MAX_SIDEBAR_TABS} 个页签`);
|
|
1715
2026
|
this.#seeImages(p.properties);
|
|
1716
2027
|
const host = this;
|
|
1717
2028
|
this.panel = {
|
|
@@ -1734,25 +2045,25 @@ export class TestHost {
|
|
|
1734
2045
|
return { status: "ready", prepareMs: 0, shellGeneration: 1 };
|
|
1735
2046
|
}
|
|
1736
2047
|
const slot = String(p.slot);
|
|
1737
|
-
this
|
|
2048
|
+
this.#addPanel(slot, {
|
|
1738
2049
|
owner: 1,
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
detached: false,
|
|
1743
|
-
};
|
|
2050
|
+
plugin: this.manifestId,
|
|
2051
|
+
title: typeof p.title === "string" && p.title ? p.title : this.manifestId,
|
|
2052
|
+
});
|
|
1744
2053
|
return { status: "ready", prepareMs: 0, shellGeneration: 1 };
|
|
1745
2054
|
},
|
|
1746
2055
|
"ui.unmount": () => {
|
|
1747
2056
|
if (this.panel?.slot === "backdrop")
|
|
1748
2057
|
this.backdrop = null;
|
|
1749
2058
|
else if (this.panel)
|
|
1750
|
-
this
|
|
2059
|
+
this.#removePanel(this.panel.slot, 1);
|
|
1751
2060
|
this.panel = null;
|
|
1752
2061
|
},
|
|
1753
2062
|
"ui.menu": (p) => {
|
|
1754
2063
|
if (!this.panel)
|
|
1755
2064
|
throw new HostError("unavailable", "panel not shown");
|
|
2065
|
+
if (this.panel.slot === "sidebar" && this.layout.sidebar?.owner !== 1)
|
|
2066
|
+
throw new HostError("unavailable", "the panel's tab is not the current one");
|
|
1756
2067
|
const items = p.items;
|
|
1757
2068
|
for (const id of items)
|
|
1758
2069
|
if (id !== null && !this.commands.has(id))
|
|
@@ -1773,6 +2084,12 @@ export class TestHost {
|
|
|
1773
2084
|
throw new HostError("missing", "no panel at this position");
|
|
1774
2085
|
if (p.detached !== undefined && slot === "bottom")
|
|
1775
2086
|
throw new HostError("invalid", "the bottom bar cannot be detached");
|
|
2087
|
+
if (p.active !== undefined && p.active !== null) {
|
|
2088
|
+
const panel = state.panels?.find((entry) => entry.plugin === p.active);
|
|
2089
|
+
if (!panel)
|
|
2090
|
+
throw new HostError("missing", `${String(p.active)} has no panel at this position`);
|
|
2091
|
+
state.owner = panel.owner;
|
|
2092
|
+
}
|
|
1776
2093
|
if (typeof p.visible === "boolean") {
|
|
1777
2094
|
state.preferred = p.visible;
|
|
1778
2095
|
state.effective = p.visible;
|
|
@@ -2144,8 +2461,17 @@ export class TestHost {
|
|
|
2144
2461
|
"source.state": () => this.source,
|
|
2145
2462
|
"source.submit": (p) => {
|
|
2146
2463
|
const intent = p.intent;
|
|
2147
|
-
const
|
|
2148
|
-
|
|
2464
|
+
const ticket = this.#intent(intent);
|
|
2465
|
+
const operation = Number(p.operationId);
|
|
2466
|
+
// A file this plugin's file resolver claims: pending until it answers.
|
|
2467
|
+
if (intent.kind === "open" &&
|
|
2468
|
+
this.#resolve(String(intent.source), intent.startPaused === true, operation) !== null) {
|
|
2469
|
+
const pending = { status: "pending", ...ticket };
|
|
2470
|
+
this.#sourceBook.set(operation, pending);
|
|
2471
|
+
return pending;
|
|
2472
|
+
}
|
|
2473
|
+
const result = { status: "completed", ...ticket };
|
|
2474
|
+
this.#sourceBook.set(operation, result);
|
|
2149
2475
|
// Selecting a provider asks it to acknowledge a start control.
|
|
2150
2476
|
const producer = this.producers.get(Number(intent.producerId));
|
|
2151
2477
|
if (intent.kind === "openProvider" && producer) {
|
|
@@ -2573,9 +2899,12 @@ export class TestHost {
|
|
|
2573
2899
|
return { jobId: job.jobId, cancel: "requested" };
|
|
2574
2900
|
},
|
|
2575
2901
|
"jobs.operation": () => ({ status: "unknown" }),
|
|
2576
|
-
"sourceResolvers.register": (p) => void this
|
|
2577
|
-
"sourceResolvers.unregister": (p) =>
|
|
2578
|
-
|
|
2902
|
+
"sourceResolvers.register": (p) => void this.#register(p),
|
|
2903
|
+
"sourceResolvers.unregister": (p) => {
|
|
2904
|
+
this.resolvers.delete(String(p.id));
|
|
2905
|
+
this.#cancelResolutions();
|
|
2906
|
+
},
|
|
2907
|
+
"sourceResolvers.respond": (p) => this.#respond(p),
|
|
2579
2908
|
"producer.prepare": async () => {
|
|
2580
2909
|
const id = ++this.#next.producer;
|
|
2581
2910
|
const tcp = this.producerTransport === "tcp";
|
|
@@ -2621,8 +2950,7 @@ export class TestHost {
|
|
|
2621
2950
|
});
|
|
2622
2951
|
socket.on("error", () => { });
|
|
2623
2952
|
socket.on("close", () => {
|
|
2624
|
-
if (this.
|
|
2625
|
-
this.source.source === `producer://${producer.id}`)
|
|
2953
|
+
if (this.#current(producer.id))
|
|
2626
2954
|
producer.droppedWhileSelected = true;
|
|
2627
2955
|
});
|
|
2628
2956
|
});
|
|
@@ -2653,8 +2981,7 @@ export class TestHost {
|
|
|
2653
2981
|
const producer = this.producers.get(Number(p.producerId));
|
|
2654
2982
|
if (!producer)
|
|
2655
2983
|
throw new HostError("missing", "producer");
|
|
2656
|
-
const current = this.
|
|
2657
|
-
this.source.source === `producer://${producer.id}`;
|
|
2984
|
+
const current = this.#current(producer.id);
|
|
2658
2985
|
return {
|
|
2659
2986
|
input: null,
|
|
2660
2987
|
pendingControl: producer.pending,
|
|
@@ -2756,7 +3083,18 @@ export async function startPlugin(activate, options = {}) {
|
|
|
2756
3083
|
mkdirSync(data, { recursive: true });
|
|
2757
3084
|
const instance = await createPlugin(host, manifest, directory, data, host.app);
|
|
2758
3085
|
await activate(instance.plugin);
|
|
2759
|
-
|
|
3086
|
+
// Like the player: a position or resolver another plugin holds is not an
|
|
3087
|
+
// activation failure.
|
|
3088
|
+
for (;;) {
|
|
3089
|
+
try {
|
|
3090
|
+
await instance.activated();
|
|
3091
|
+
break;
|
|
3092
|
+
}
|
|
3093
|
+
catch (error) {
|
|
3094
|
+
if (!(error instanceof Error && takenErrors.has(error)))
|
|
3095
|
+
throw error;
|
|
3096
|
+
}
|
|
3097
|
+
}
|
|
2760
3098
|
await host.settle();
|
|
2761
3099
|
return {
|
|
2762
3100
|
/** The test host the plugin talks to. */
|