@vestra-player/sdk 1.6.17 → 1.6.19

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.
Files changed (88) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/dist/docs-tree.json.gz +0 -0
  3. package/dist/plugin/appearance.js +1 -1
  4. package/dist/plugin/bindings.js +1 -1
  5. package/dist/plugin/commands.js +3 -2
  6. package/dist/plugin/connection.d.ts +119 -0
  7. package/dist/plugin/connection.js +402 -0
  8. package/dist/plugin/drops.d.ts +3 -3
  9. package/dist/plugin/drops.js +1 -1
  10. package/dist/plugin/errors.d.ts +9 -0
  11. package/dist/plugin/errors.js +35 -0
  12. package/dist/plugin/filters.js +1 -1
  13. package/dist/plugin/index.d.ts +1 -1
  14. package/dist/plugin/index.js +3 -1
  15. package/dist/plugin/language.js +1 -1
  16. package/dist/plugin/latest.d.ts +12 -0
  17. package/dist/plugin/latest.js +52 -0
  18. package/dist/plugin/local.js +1 -2
  19. package/dist/plugin/markdown.d.ts +2 -2
  20. package/dist/plugin/markdown.js +1 -1
  21. package/dist/plugin/media.js +1 -1
  22. package/dist/plugin/osd.js +2 -2
  23. package/dist/plugin/packages.js +1 -1
  24. package/dist/plugin/passes.d.ts +111 -0
  25. package/dist/plugin/passes.js +341 -0
  26. package/dist/plugin/player.d.ts +2 -3
  27. package/dist/plugin/player.js +2 -7
  28. package/dist/plugin/resolvers.js +1 -1
  29. package/dist/plugin/runtime.d.ts +26 -185
  30. package/dist/plugin/runtime.js +35 -755
  31. package/dist/plugin/scope.d.ts +2 -1
  32. package/dist/plugin/scope.js +5 -2
  33. package/dist/plugin/services/dialogs.d.ts +3 -0
  34. package/dist/plugin/services/dialogs.js +62 -0
  35. package/dist/plugin/services.js +1 -1
  36. package/dist/plugin/settings.js +2 -2
  37. package/dist/plugin/shaders.js +2 -1
  38. package/dist/plugin/stream.js +1 -2
  39. package/dist/plugin/subtitles.js +2 -2
  40. package/dist/plugin/testing/commands.d.ts +65 -0
  41. package/dist/plugin/testing/commands.js +412 -0
  42. package/dist/plugin/testing/core-actions.d.ts +39 -0
  43. package/dist/plugin/testing/core-actions.js +371 -0
  44. package/dist/plugin/testing/declarations.d.ts +37 -0
  45. package/dist/plugin/testing/declarations.js +80 -0
  46. package/dist/plugin/testing/i18n.d.ts +28 -0
  47. package/dist/plugin/testing/i18n.js +107 -0
  48. package/dist/plugin/testing/index.d.ts +3140 -0
  49. package/dist/plugin/testing/index.js +2796 -0
  50. package/dist/plugin/testing/internals.d.ts +76 -0
  51. package/dist/plugin/testing/internals.js +60 -0
  52. package/dist/plugin/testing/jobs.d.ts +9 -0
  53. package/dist/plugin/testing/jobs.js +304 -0
  54. package/dist/plugin/testing/layout.d.ts +85 -0
  55. package/dist/plugin/testing/layout.js +622 -0
  56. package/dist/plugin/testing/media.d.ts +55 -0
  57. package/dist/plugin/testing/media.js +615 -0
  58. package/dist/plugin/testing/osd.d.ts +4 -0
  59. package/dist/plugin/testing/osd.js +57 -0
  60. package/dist/plugin/testing/processing.d.ts +56 -0
  61. package/dist/plugin/testing/processing.js +491 -0
  62. package/dist/plugin/testing/services.d.ts +50 -0
  63. package/dist/plugin/testing/services.js +466 -0
  64. package/dist/plugin/testing/transport.d.ts +29 -0
  65. package/dist/plugin/testing/transport.js +140 -0
  66. package/dist/plugin/testing.d.ts +1 -3140
  67. package/dist/plugin/testing.js +1 -6203
  68. package/dist/plugin/timeline.js +2 -1
  69. package/dist/plugin/view/base.d.ts +29 -0
  70. package/dist/plugin/view/base.js +88 -0
  71. package/dist/plugin/view/binder.d.ts +22 -0
  72. package/dist/plugin/view/binder.js +57 -0
  73. package/dist/plugin/view/image.d.ts +21 -0
  74. package/dist/plugin/view/image.js +60 -0
  75. package/dist/plugin/view/layout.d.ts +47 -0
  76. package/dist/plugin/view/layout.js +171 -0
  77. package/dist/plugin/view/panel.d.ts +3 -0
  78. package/dist/plugin/view/panel.js +178 -0
  79. package/dist/plugin/view/window.d.ts +3 -0
  80. package/dist/plugin/view/window.js +99 -0
  81. package/dist/plugin/view/writer.d.ts +58 -0
  82. package/dist/plugin/view/writer.js +634 -0
  83. package/dist/plugin/view.js +2 -1
  84. package/dist/plugin/window.js +1 -1
  85. package/llms.txt +1 -1
  86. package/package.json +6 -4
  87. package/dist/plugin/ui.d.ts +0 -74
  88. package/dist/plugin/ui.js +0 -1234
package/CHANGELOG.md CHANGED
@@ -1,5 +1,26 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.6.19
4
+
5
+ **Who must change: nobody.** Step 1 of the SDK refactor, batches B and C (`doc/specs/sdk-refactor.md` §4 in the player repository): `src/plugin/runtime.ts` and `src/plugin/ui.ts` are split by layer; no API, behaviour or wire change, `apiVersion` stays `vestra/1`.
6
+
7
+ - **`runtime.ts` is split into four modules.** `connection.ts` (L1) owns the transport and its typed client, the event subscriptions with their `why`/`mirror` marks (`on`, `hold`, `raw`), the call site of a listener, the activation state (`starting`, `declaring`, `activated`) and the handshake facts (`app`, `locale`, `features`); `passes.ts` (L2) holds the update passes (`bind`, `reading`, `depends`, `part`, `observe`, `needsFull`, `reader`, `invalidate`, `changed`, the safety pass) and the pass counters; `errors.ts` holds `failure`, `quiet`, `uncertain`, `toError`, `sleep` and `abortError` (from `player.ts`); `latest.ts` holds the `Latest` single-flight queue. `Runtime` stays, as the connection plus the pass methods delegated to its `Passes`, so `runtime.host`, `runtime.on`, `runtime.bind` and the rest are unchanged for every caller, `TestHost` and the player's runner.
8
+ - **No module imports the pass scheduler for a helper any more**: the SDK's modules take the helpers from `errors.ts` and `latest.ts`, and `player.ts` and `stream.ts` no longer import each other.
9
+ - **A layer check in `npm run check`** (`scripts/check-layers.mjs`): the wire and its clients (L0) and the primitives (L1: `connection`, `errors`, `latest`, `values`, `stream`, `services`, `local`, `osd`, `bindings`, `language`, `packages`, `markdown`, `drops`) must not import `passes.ts` or `runtime.ts`, nor load `passes.ts` through other modules, value imports under `src/` must not form a cycle (beyond one known cycle among the test host's modules), and a relative import the check cannot resolve fails. Three L1 → L2 edges that wait for the `player.ts` split are printed as a note on every run.
10
+ - **`ui.ts` is split into `src/plugin/view/` and a dialogs service** (batch C). `view/writer.ts` (L1) is the write path of one view: values, encoding and comparison with what was sent, row patches, parts of large writes (`more`), the value limits, `stale` fallback and the back-off after a failed write; it knows nothing of passes. `view/binder.ts` (L2) holds `bind`, `snapshot` and `evaluate` with the read tracking of the passes. `view/base.ts` keeps `ViewBase` for windows, panels and settings pages: callbacks plus a writer and a binder over it, and, as before, every view joins the update passes when made. `view/window.ts` (tool windows), `view/panel.ts` (panels), `view/layout.ts` (the panel positions, `LayoutService`) and `view/image.ts` (`plugin.ui` and image values) hold the rest; `plugin.dialogs` moved to `services/dialogs.ts`. Commands take `ImageHandle` from `view/image.ts`, so the command module no longer imports the view module. Every write, its order and its back-off are the code as it was, moved.
11
+ - **The layer check covers the views**: `view/writer.ts`, `view/image.ts` and `services/dialogs.ts` are L1 and must not import the framework, which now includes `view/binder.ts`.
12
+ - Golden wire traces, host scenarios, pass counts and the API surface snapshot are identical; the public declarations (`dist/index.d.ts`, `dist/plugin/types.d.ts`, `dist/plugin/testing/index.d.ts`, `dist/player-runtime.d.ts`) are unchanged.
13
+
14
+ ## 1.6.18
15
+
16
+ **Who must change: nobody.** Characterisation tests for the SDK refactor (step 0 of the plan, `doc/specs/sdk-refactor.md` §4.0 in the player repository); no API change, no wire change, `apiVersion` stays `vestra/1`.
17
+
18
+ - **Golden wire traces** (`test/golden-traces.test.ts`): the ten examples, and the six built-ins named by the plan when `VESTRA_PLAYER` points at a player checkout, run one fixed script against `TestHost` (start, open a source, three state changes, one command, dispose); the `(method, params)` sequence, every `events.subscribe` line with its `mirror`/`why` marks (line numbers stripped) and the update-pass counts per phase are compared with `test/fixtures/traces/*.json`. Eight host scenarios (`host-*.json`) pin the test host's own edges: `disconnect()` with requests in flight, the `commands.keys` push after `commands.register` against `settle()`, `press`, `handle()`/`reset()`, a stream producer's temporary files, `startPlugin` with a `taken` conflict, the `VESTRA_PLUGIN_RESIDENCY=1` line and a host never started. `UPDATE_TRACES=1` rewrites them.
19
+ - **API surface snapshot** (`scripts/api-surface.mjs`, `npm run api-surface -- --check|--update`, `test/api-surface.test.ts`): every exported declaration and member of the `.` and `./testing` entries (the declaration files package.json `exports` names), sorted, in `test/fixtures/api-surface.txt`.
20
+ - **Pass counters**: `Runtime.counts` (internal, read-only: passes, full passes, safety passes, binding evaluations, view bind functions evaluated and skipped) for the SDK's own tests and benchmark; no behaviour change, nothing new on `Plugin` or `TestHost`.
21
+ - **Benchmark** (`npm run bench`, `scripts/bench.mjs`): a 20,000-row keyed list with 1 % and 10 % row changes through the diff engine, 1,000 passes over 50 bound views and 100 commands, and the state-change-to-send latency; medians of five runs, baseline in `docs/maintaining.md`.
22
+ - **`@vestra-player/sdk/testing` is split into modules** (step 1 of the refactor, batch A). **Who must change: nobody**: no API, behaviour or wire change. The single 7,600-line `src/plugin/testing.ts` is now the directory `src/plugin/testing/`: `index.ts` keeps the module page, every export and the one `TestHost` class with all its public members and doc comments; the class keeps its private state in one internal object (`internals.ts`) and delegates its wire handlers and longer methods to area modules (`transport`, `commands`, `declarations`, `core-actions`, `i18n`, `layout`, `osd`, `jobs`, `processing`, `media`, `services`). Error codes, messages and handler semantics are unchanged: every golden trace and host scenario, the API surface snapshot and the pass counts are identical, and the declarations (`dist/plugin/testing/index.d.ts`) equal the former `dist/plugin/testing.d.ts` apart from relative import paths. The `./testing` export now points at `dist/plugin/testing/index.js`; `dist/plugin/testing.js` stays as a re-export. The documentation tree (`dist/docs-tree.json.gz`) is unchanged.
23
+
3
24
  ## 1.6.17
4
25
 
5
26
  **Who must change: nobody.** Tables follow the slim context menu (B5): a data refresh of `BUILTIN_COMMANDS`, `BUILTIN_SUBMENUS` and the test host's core commands for the player's right-click menu (player 0.7.6); no API change, `apiVersion` stays `vestra/1`. A plugin whose tests compare a path or a key against the tables sees the player's current ones.
@@ -25,11 +46,13 @@
25
46
  - **Wire** (additive): `ui.setMount`, `ui.interaction`, `ui.menu` and `ui.unmount` take an optional `slot` naming the panel (without it: the plugin's only panel; `ui.unmount` without it removes every panel); `ui.callback` and `ui.preparing` carry the panel's `slot`, and `ui.callback` keeps `view: "$mount"`. Older players ignore `slot`.
26
47
  - **`BUILTIN_COMMANDS`**: the player's seek, frame and speed actions (`host/seek-backward`, `seek-forward`, `frame-back`, `frame-forward`, `rate-down`, `rate-up`, `rate-normal`) leave Playback ▸ Seek and Speed for the command palette (player 0.7.6, with the playback plugin's panel); their keys and categories stay.
27
48
  - **`TestHost`**: `panels` (by slot, in mount order) and `mounted(slot?)`; without a slot `mounted()` returns the only panel and throws when there are several. `panel` stays, as the last panel mounted. The handlers take `slot` as the player does; `ui.interaction` stores the locks without it.
49
+
28
50
  ## 1.6.14
29
51
 
30
52
  **Who must change: nobody.** A data refresh: `BUILTIN_COMMANDS` lists the built-in capture plugin's commands as the player registers them since its capture tab; no API change, `apiVersion` stays `vestra/1`.
31
53
 
32
54
  - **`BUILTIN_COMMANDS`**: `vestra.capture` has **Video ▸ Snapshot ▸ Capture…** (Alt+S), which opens its sidebar tab; Save/Copy Rendered Frame, Save Current Frame As…, Export Tasks…, the capture formats, Snapshot Settings…, Snapshot Folder… and Open Snapshot Folder left the menu for the command palette, and new palette commands came: Export Clip Range (Ctrl+Shift+E), Save and Copy Video Frame Only, Show Last Capture in Folder, Copy Last Snapshot, Set Clip Start, Set Clip End and Export Last 30 Seconds (Ctrl+Shift+R). A plugin that checks a key or menu path against the table (`vestra-plugin check`, `TestHost`) sees the player's current ones.
55
+
33
56
  ## 1.6.13
34
57
 
35
58
  **Who must change: plugin tests that toggle a column in fullscreen.** In fullscreen `toggle()` now only switches the column on or off (user ruling 2026-11-08); a test that asserted `effective: true` after toggling a column on in fullscreen now sees `effective: false`, `reason: "fullscreen"` (on, revealed by the right edge). Plugin code needs no change; `apiVersion` stays `vestra/1`, and the wire gains one optional field.
@@ -45,6 +68,7 @@
45
68
  - **`Panel.toggle` and `PanelSlot.toggle` slide a collapsed column out.** In fullscreen a sidebar or adjust column the user wants shown is collapsed at the right edge (`reason: "fullscreen"`). Toggling it then turned it off; it now shows it, which player 0.7.6 answers by sliding the column out, as its own Adjust button and Alt+1…4 do. Toggling a column that is on screen still turns it off. With players before 0.7.6 a show of a collapsed column changes nothing on screen, as before.
46
69
  - **[adjust-panel](docs/examples/adjust-panel.md)** declares `events`: without it `plugin.player.state` changes only after the plugin's own calls, so a panel activated before a file was opened stayed disabled at 1×. A new test opens the file after activation. Its presets are four (0.5×, 1×, 1.5×, 2×), which fit the adjust column at its least width. No other example reads `player.state` without `events`.
47
70
  - **A development warning for state followed without `events`.** A plugin that registers an `onChange` or a `bind` whose function reads changing state (`player.state`, a panel position, and the like) without the `events` capability gets one line in its log, once per plugin: changes made elsewhere never reach it. Only a warning; behaviour is unchanged. `TestHost` already behaved like the player here (it delivers `source.state` and the other state events only to a plugin that subscribed, which needs `events`): a new test pins that down. The example's old tests passed because they opened the file before starting the plugin or did not check `enabled`. No SDK example or test relied on the opposite, so nobody must change.
71
+
48
72
  ## 1.6.11
49
73
 
50
74
  **Who must change: nobody.** Additions only: a mount slot, a panel position, a button icon and `TestHost` support; `apiVersion` stays `vestra/1`, no new capability. A plugin that mounts `"adjust"` sets `minPlayerVersion` to `0.7.6`: older players refuse the slot (`invalid`), which fails the activation like any refused mount.
Binary file
@@ -1,5 +1,5 @@
1
1
  /** The player's palette (`plugin.appearance`; `ui.theme` to change it). */
2
- import { failure } from "./runtime.js";
2
+ import { failure } from "./errors.js";
3
3
  import { windowOf } from "./window.js";
4
4
  import { LOCALES } from "../i18n.js";
5
5
  const preference = (value) => value === "system" || LOCALES.includes(value)
@@ -6,7 +6,7 @@
6
6
  * binding = { modifier "+" } input modifiers in order Ctrl, Alt, Shift, Cmd
7
7
  * input = key | "Mouse:" mouse
8
8
  */
9
- import { failure } from "./runtime.js";
9
+ import { failure } from "./errors.js";
10
10
  /**
11
11
  * The names a binding (`CommandOptions.bindings`, `Bindings.set`) is made
12
12
  * of, as the player's binding parser accepts them. A binding is
@@ -1,8 +1,9 @@
1
1
  /** Menu commands: declaration, state sync (revisions hidden) and dispatch. */
2
- import { failure, Latest, quiet, uncertain } from "./runtime.js";
2
+ import { Latest } from "./latest.js";
3
+ import { failure, quiet, uncertain } from "./errors.js";
3
4
  import { checkBindings } from "./bindings.js";
4
5
  import { BAR_BUTTON_ICONS, BAR_BUTTON_LIMITS, MENU_IDS } from "../commands.js";
5
- import { ImageHandle } from "./ui.js";
6
+ import { ImageHandle } from "./view/image.js";
6
7
  import { isTextValue, textProblem } from "../i18n.js";
7
8
  import { conditionJson, isCondition } from "./when.js";
8
9
  const value = (item) => typeof item === "function" ? item() : item;
@@ -0,0 +1,119 @@
1
+ import type { Json, LocalServices, Transport, Wire } from "../wire.ts";
2
+ import type { App, Disposable, Locale, StopReason, 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[];
15
+ export interface PluginManifest {
16
+ id: string;
17
+ capabilities: string[];
18
+ }
19
+ /** How the plugin is run: the player's runner, or `TestHost`. */
20
+ export interface RuntimeOptions {
21
+ /**
22
+ * A declaration the player refuses during activation fails activation
23
+ * (`startPlugin`, so a plugin's tests show it) instead of being logged,
24
+ * reported to `onError` and skipped (the player's runner).
25
+ */
26
+ strictDeclarations?: boolean;
27
+ }
28
+ /** A stop reason the runner passed on, if this SDK knows it. */
29
+ export declare function stopReason(value: unknown): StopReason | undefined;
30
+ type Listener = (data: unknown) => void;
31
+ /**
32
+ * Per-plugin connection to the player. Scopes reach it through `Runtime`;
33
+ * the flat RPC clients stay private here so protocol fields never reach
34
+ * plugin code.
35
+ */
36
+ export declare class Connection {
37
+ #private;
38
+ readonly host: Wire;
39
+ readonly manifest: PluginManifest;
40
+ readonly directory: string;
41
+ /** Writable per-plugin data directory from the host, if it provided one. */
42
+ readonly dataDirectory: string | undefined;
43
+ /** Test doubles for local processes; the real system when absent. */
44
+ readonly local: LocalServices | undefined;
45
+ /** Registrations that activation must wait for before reporting ready. */
46
+ readonly startup: Set<Promise<unknown>>;
47
+ readonly views: Map<string, (callback: string, args: Json[]) => void>;
48
+ readonly commands: Map<string, () => void>;
49
+ readonly services: Map<string, unknown>;
50
+ /**
51
+ * Set when the player stops the plugin (`PluginInstance.dispose`), before
52
+ * the root scope is disposed: `DisposeInfo` and `plugin.stopReason`.
53
+ */
54
+ stopping: {
55
+ reason: StopReason | undefined;
56
+ } | undefined;
57
+ /** `plugin.app`, from the start-up handshake; `system()` asks the player. */
58
+ readonly app: App;
59
+ /** `plugin.i18n.locale`, from the start-up handshake; English when unknown. */
60
+ readonly locale: Locale;
61
+ /**
62
+ * Protocol additions the player advertises at start-up (`features` in the
63
+ * handshake's app data, player 0.6.0): `ui.patch` (row patches of
64
+ * property writes). Not part of the plugin API; empty for older players.
65
+ */
66
+ readonly features: ReadonlySet<string>;
67
+ constructor(transport: Transport, manifest: PluginManifest, directory: string, dataDirectory?: string, app?: unknown, options?: RuntimeOptions);
68
+ can(capability: string): boolean;
69
+ /** Lazily created singletons shared by every scope of this plugin. */
70
+ service<T>(name: string, create: () => T): T;
71
+ /**
72
+ * Registrations started while `activate` runs must succeed before the
73
+ * plugin reports ready; later ones report failures to their scope.
74
+ */
75
+ starting<T>(work: Promise<T>, report: (error: unknown) => void): Promise<T>;
76
+ /**
77
+ * A declaration the player checks on its own (`what`: "command x"),
78
+ * started like `starting`. In the player's runner a refusal of it during
79
+ * activation (`invalid`, `conflict`, `limit`) does not fail the plugin:
80
+ * it is logged, skipped and reported to the scope once activation
81
+ * completes, so an `onError` added anywhere in `activate` hears it (SDK
82
+ * 1.5.49; VS Code logs a bad contribution and activates the extension).
83
+ * Under `strictDeclarations` (TestHost) it fails activation as before.
84
+ */
85
+ declaring<T>(work: Promise<T>, what: string, report: (error: unknown) => void): Promise<T>;
86
+ activated(): Promise<void>;
87
+ /**
88
+ * Where the plugin's code called into the SDK: the first stack frame
89
+ * outside the SDK, as a path relative to the plugin's directory and a
90
+ * line (`main.ts:353`). Captured now, formatted when asked.
91
+ */
92
+ callSite(): () => string | undefined;
93
+ /**
94
+ * Host events with one `events.subscribe` per name, however many listeners.
95
+ * Without the `events` capability the listener simply never fires.
96
+ * `mirror`: the SDK's own copy of the state (`plugin.player.state` and
97
+ * the like), not a listener of the plugin's (see {@link EventGroup}).
98
+ */
99
+ on(event: string, listener: Listener,
100
+ /** The capability the event needs; `null`: none. */
101
+ needs?: string | null, mirror?: boolean): Disposable;
102
+ /**
103
+ * Count `event` as listened to while held: a watcher of a service's
104
+ * state, a binding that reads it. Nothing when nothing subscribed to it
105
+ * (no capability, or the service did not start).
106
+ */
107
+ hold(event: string, why?: Why): Disposable;
108
+ /**
109
+ * Why the plugin listens to each event it really listens to (mirrors
110
+ * left out), as the player's log says it: for {@link TestHost} and
111
+ * `vestra-plugin test`.
112
+ */
113
+ listening(): Map<string, string[]>;
114
+ /** Raw transport events the host pushes without a subscription. */
115
+ raw(event: string, listener: Listener): Disposable;
116
+ }
117
+ /** `plugin.app` from the start-up handshake (the runner's environment). */
118
+ export declare function appInfo(raw: unknown, system?: () => Promise<SystemInfo>): App;
119
+ export {};
@@ -0,0 +1,402 @@
1
+ /**
2
+ * The plugin's connection to the player (L1, refactor plan §2.2): the
3
+ * transport and its typed client (`host`), event subscriptions counted per
4
+ * name with their `why`/`mirror` marks (`on`, `hold`, `raw`), the
5
+ * activation state machine (`starting`, `declaring`, `activated`) and what
6
+ * the start-up handshake said (`app`, `locale`, `features`). It knows
7
+ * nothing of update passes (`passes.ts`); `Runtime` (`runtime.ts`) adds them.
8
+ */
9
+ import { createWire, HostError } from "../wire.js";
10
+ import { LOCALES } from "../i18n.js";
11
+ import { failure } from "./errors.js";
12
+ import { basename, isAbsolute, relative } from "node:path";
13
+ import { fileURLToPath } from "node:url";
14
+ /** The SDK's own files (`src/` or `dist/`): left out of a call site. */
15
+ const SDK_DIRECTORY = fileURLToPath(new URL("..", import.meta.url));
16
+ /**
17
+ * Events the player wakes a stopped plugin for (player 0.5.21): a listener
18
+ * of one does not keep the plugin running. They follow deliberate changes
19
+ * (an install, a key edit, the palette, a resolver), not playback.
20
+ */
21
+ export const WAKE_EVENTS = [
22
+ "packages.changed",
23
+ "bindings.changed",
24
+ // Player 0.7.6: sent to a plugin whose own keys changed and, from SDK
25
+ // 1.6.16, to every listener when a player command (`host/<id>`) is
26
+ // rebound, which wakes each parked listener.
27
+ "commands.keys",
28
+ "appearance.changed",
29
+ "resolvers.fileTypes",
30
+ ];
31
+ /**
32
+ * The player's answers to a declaration that refuse that one declaration
33
+ * (a command's path, texts or count): during activation they skip it
34
+ * instead of failing the plugin (`Connection.declaring`, SDK 1.5.49).
35
+ */
36
+ const DECLARATION_REFUSALS = new Set([
37
+ "invalid",
38
+ "conflict",
39
+ "limit",
40
+ ]);
41
+ /** The reasons `StopReason` names; anything else reads as `undefined`. */
42
+ const STOP_REASONS = new Set([
43
+ "quit",
44
+ "disable",
45
+ "uninstall",
46
+ "reload",
47
+ "upgrade",
48
+ "idle",
49
+ "failed",
50
+ "host-lost",
51
+ ]);
52
+ /** A stop reason the runner passed on, if this SDK knows it. */
53
+ export function stopReason(value) {
54
+ return typeof value === "string" && STOP_REASONS.has(value)
55
+ ? value
56
+ : undefined;
57
+ }
58
+ /**
59
+ * Per-plugin connection to the player. Scopes reach it through `Runtime`;
60
+ * the flat RPC clients stay private here so protocol fields never reach
61
+ * plugin code.
62
+ */
63
+ export class Connection {
64
+ host;
65
+ manifest;
66
+ directory;
67
+ /** Writable per-plugin data directory from the host, if it provided one. */
68
+ dataDirectory;
69
+ /** Test doubles for local processes; the real system when absent. */
70
+ local;
71
+ /** Registrations that activation must wait for before reporting ready. */
72
+ startup = new Set();
73
+ #activating = true;
74
+ /** `RuntimeOptions.strictDeclarations`. */
75
+ #strict;
76
+ /** Declarations skipped during activation: reported once it completes. */
77
+ #refusals = [];
78
+ #transport;
79
+ #events = new Map();
80
+ /** The missing `events` warning was given (once per plugin). */
81
+ #warnedEvents = false;
82
+ views = new Map();
83
+ commands = new Map();
84
+ services = new Map();
85
+ /**
86
+ * Set when the player stops the plugin (`PluginInstance.dispose`), before
87
+ * the root scope is disposed: `DisposeInfo` and `plugin.stopReason`.
88
+ */
89
+ stopping;
90
+ /** `plugin.app`, from the start-up handshake; `system()` asks the player. */
91
+ app;
92
+ /** `plugin.i18n.locale`, from the start-up handshake; English when unknown. */
93
+ locale;
94
+ /**
95
+ * Protocol additions the player advertises at start-up (`features` in the
96
+ * handshake's app data, player 0.6.0): `ui.patch` (row patches of
97
+ * property writes). Not part of the plugin API; empty for older players.
98
+ */
99
+ features;
100
+ constructor(transport, manifest, directory, dataDirectory, app, options = {}) {
101
+ this.#transport = transport;
102
+ this.#strict = options.strictDeclarations ?? false;
103
+ this.dataDirectory = dataDirectory;
104
+ this.local = transport.local;
105
+ this.host = createWire(transport);
106
+ const host = this.host;
107
+ this.app = appInfo(app, () => host.app.system());
108
+ const features = app
109
+ ?.features;
110
+ this.features = new Set(Array.isArray(features)
111
+ ? features.filter((name) => typeof name === "string")
112
+ : []);
113
+ this.locale = LOCALES.includes(transport.locale ?? "")
114
+ ? transport.locale
115
+ : "en";
116
+ this.manifest = manifest;
117
+ this.directory = directory;
118
+ transport.on("ui.callback", (data) => {
119
+ const event = data;
120
+ // A panel's callback names its slot (player 0.7.6: one panel per
121
+ // slot); older players name only `"$mount"`, and the plugin has one.
122
+ const view = event.view === "$mount"
123
+ ? event.slot
124
+ ? `panel:${event.slot}`
125
+ : [...this.views.keys()].find((key) => key.startsWith("panel:"))
126
+ : event.view;
127
+ if (view)
128
+ this.views.get(view)?.(event.callback, event.args);
129
+ });
130
+ transport.on("commands.execute", (data) => {
131
+ this.commands.get(data.id)?.();
132
+ });
133
+ }
134
+ can(capability) {
135
+ return this.manifest.capabilities.includes(capability);
136
+ }
137
+ /** Lazily created singletons shared by every scope of this plugin. */
138
+ service(name, create) {
139
+ if (!this.services.has(name))
140
+ this.services.set(name, create());
141
+ return this.services.get(name);
142
+ }
143
+ /**
144
+ * Registrations started while `activate` runs must succeed before the
145
+ * plugin reports ready; later ones report failures to their scope.
146
+ */
147
+ starting(work, report) {
148
+ if (this.#activating)
149
+ this.startup.add(work);
150
+ else
151
+ work.catch(report);
152
+ work.catch(() => { });
153
+ return work;
154
+ }
155
+ /**
156
+ * A declaration the player checks on its own (`what`: "command x"),
157
+ * started like `starting`. In the player's runner a refusal of it during
158
+ * activation (`invalid`, `conflict`, `limit`) does not fail the plugin:
159
+ * it is logged, skipped and reported to the scope once activation
160
+ * completes, so an `onError` added anywhere in `activate` hears it (SDK
161
+ * 1.5.49; VS Code logs a bad contribution and activates the extension).
162
+ * Under `strictDeclarations` (TestHost) it fails activation as before.
163
+ */
164
+ declaring(work, what, report) {
165
+ if (!this.#activating || this.#strict)
166
+ return this.starting(work, report);
167
+ const settled = work.then(() => { }, (error) => {
168
+ if (!(error instanceof HostError) ||
169
+ !DECLARATION_REFUSALS.has(error.code))
170
+ throw error;
171
+ console.error(`[${this.manifest.id}] ${what} skipped, the player refused it (${error.code}): ${error.message}`);
172
+ this.#refusals.push(() => report(error));
173
+ });
174
+ // Observed now: `activated()` may come after `activate` awaits, and
175
+ // its `allSettled` still sees the rejection.
176
+ settled.catch(() => { });
177
+ this.startup.add(settled);
178
+ work.catch(() => { });
179
+ return work;
180
+ }
181
+ async activated() {
182
+ while (this.startup.size) {
183
+ const pending = [...this.startup];
184
+ this.startup.clear();
185
+ for (const result of await Promise.allSettled(pending))
186
+ if (result.status === "rejected")
187
+ throw result.reason;
188
+ }
189
+ this.#activating = false;
190
+ for (const report of this.#refusals.splice(0))
191
+ report();
192
+ }
193
+ /**
194
+ * Where the plugin's code called into the SDK: the first stack frame
195
+ * outside the SDK, as a path relative to the plugin's directory and a
196
+ * line (`main.ts:353`). Captured now, formatted when asked.
197
+ */
198
+ callSite() {
199
+ const limit = Error.stackTraceLimit;
200
+ Error.stackTraceLimit = 40;
201
+ const error = new Error();
202
+ Error.stackTraceLimit = limit;
203
+ let site = null;
204
+ return () => {
205
+ if (site === null)
206
+ site = this.#site(error.stack ?? "");
207
+ return site;
208
+ };
209
+ }
210
+ #site(stack) {
211
+ for (const line of stack.split("\n").slice(1)) {
212
+ const match = /\(?((?:file:\/\/)?[^()\s]+?):(\d+):\d+\)?\s*$/.exec(line);
213
+ if (!match)
214
+ continue;
215
+ let file = match[1];
216
+ if (file.startsWith("file:"))
217
+ try {
218
+ file = fileURLToPath(file);
219
+ }
220
+ catch {
221
+ continue;
222
+ }
223
+ const sdk = relative(SDK_DIRECTORY, file);
224
+ if (!isAbsolute(file) || !(sdk.startsWith("..") || isAbsolute(sdk)))
225
+ continue;
226
+ const inside = relative(this.directory, file);
227
+ const shown = inside.startsWith("..") || isAbsolute(inside) ? basename(file) : inside;
228
+ return `${shown.replaceAll("\\", "/")}:${match[2]}`;
229
+ }
230
+ return undefined;
231
+ }
232
+ /**
233
+ * Host events with one `events.subscribe` per name, however many listeners.
234
+ * Without the `events` capability the listener simply never fires.
235
+ * `mirror`: the SDK's own copy of the state (`plugin.player.state` and
236
+ * the like), not a listener of the plugin's (see {@link EventGroup}).
237
+ */
238
+ on(event, listener,
239
+ /** The capability the event needs; `null`: none. */
240
+ needs = "events", mirror = false) {
241
+ let entry = this.#events.get(event);
242
+ const created = !entry;
243
+ if (!entry) {
244
+ entry = { listeners: new Set(), active: 0, whys: new Map() };
245
+ this.#events.set(event, entry);
246
+ }
247
+ const group = entry;
248
+ group.listeners.add(listener);
249
+ const token = {};
250
+ if (!mirror) {
251
+ group.active++;
252
+ group.whys.set(token, listenerWhy(this.callSite()));
253
+ }
254
+ if (created && needs === "events" && !this.can(needs))
255
+ group.unheard = true;
256
+ if (!mirror && group.unheard)
257
+ this.#warnUnheard(event);
258
+ if (created && (needs === null || this.can(needs))) {
259
+ group.told = group.active > 0;
260
+ const whys = group.told ? allWhys(group) : undefined;
261
+ group.toldWhy = whys?.join("\n");
262
+ group.off = this.host.events.subscribe(event, (data) => {
263
+ for (const each of [...group.listeners])
264
+ each(data);
265
+ }, !group.told, whys);
266
+ this.starting(group.off, (error) => console.error(`[${this.manifest.id}] subscribe ${event}:`, error));
267
+ }
268
+ else
269
+ this.#tell(event, group);
270
+ let listening = true;
271
+ return {
272
+ dispose: async () => {
273
+ if (!listening)
274
+ return;
275
+ listening = false;
276
+ group.listeners.delete(listener);
277
+ if (!mirror)
278
+ group.active--;
279
+ group.whys.delete(token);
280
+ if (this.#events.get(event) !== group)
281
+ return;
282
+ if (group.listeners.size)
283
+ return this.#tell(event, group);
284
+ this.#events.delete(event);
285
+ const off = await group.off?.catch(() => undefined);
286
+ await off?.().catch(() => { });
287
+ },
288
+ };
289
+ }
290
+ /**
291
+ * Count `event` as listened to while held: a watcher of a service's
292
+ * state, a binding that reads it. Nothing when nothing subscribed to it
293
+ * (no capability, or the service did not start).
294
+ */
295
+ hold(event, why) {
296
+ const group = this.#events.get(event);
297
+ if (!group)
298
+ return { dispose: async () => { } };
299
+ if (group.unheard)
300
+ this.#warnUnheard(event);
301
+ group.active++;
302
+ const token = {};
303
+ group.whys.set(token, why ?? listenerWhy(this.callSite()));
304
+ this.#tell(event, group);
305
+ let held = true;
306
+ return {
307
+ dispose: async () => {
308
+ if (!held)
309
+ return;
310
+ held = false;
311
+ group.active--;
312
+ group.whys.delete(token);
313
+ if (this.#events.get(event) === group)
314
+ this.#tell(event, group);
315
+ },
316
+ };
317
+ }
318
+ /**
319
+ * A development warning, once per plugin, no behaviour change (SDK
320
+ * 1.6.12): the plugin follows `event` (an `onChange`, a `bind` that reads
321
+ * the state) without the `events` capability, so changes made elsewhere
322
+ * (the user, the player, another plugin) never reach it; the state moves
323
+ * only after its own calls.
324
+ */
325
+ #warnUnheard(event) {
326
+ if (this.#warnedEvents)
327
+ return;
328
+ this.#warnedEvents = true;
329
+ console.warn(`[${this.manifest.id}] follows ${event} (onChange, or a bind() that reads the state) without the "events" capability: changes made elsewhere will not reach it, only its own calls refresh the state. Declare "events" in manifest.json.`);
330
+ }
331
+ /**
332
+ * Tell the player when `group` changes between mirror and listener, and
333
+ * why it listens when that changed.
334
+ */
335
+ #tell(event, group) {
336
+ const off = group.off;
337
+ if (!off)
338
+ return;
339
+ const active = group.active > 0;
340
+ const whys = active ? allWhys(group) : undefined;
341
+ const why = whys?.join("\n");
342
+ if (group.told === active && group.toldWhy === why)
343
+ return;
344
+ group.told = active;
345
+ group.toldWhy = why;
346
+ // Part of activation while it runs: the player classifies the plugin
347
+ // at its ready, which must see this mark. A failure is not one of
348
+ // activation (an older player never refuses it).
349
+ const told = off
350
+ .then(() => this.host.events.listen(event, active, whys))
351
+ .catch(() => { });
352
+ this.starting(told, () => { });
353
+ }
354
+ /**
355
+ * Why the plugin listens to each event it really listens to (mirrors
356
+ * left out), as the player's log says it: for {@link TestHost} and
357
+ * `vestra-plugin test`.
358
+ */
359
+ listening() {
360
+ const result = new Map();
361
+ for (const [event, group] of this.#events)
362
+ if (group.active > 0)
363
+ result.set(event, allWhys(group));
364
+ return result;
365
+ }
366
+ /** Raw transport events the host pushes without a subscription. */
367
+ raw(event, listener) {
368
+ const off = this.#transport.on(event, listener);
369
+ return { dispose: async () => off() };
370
+ }
371
+ }
372
+ /** A plugin listener's reason: where it was added. */
373
+ const listenerWhy = (site) => () => {
374
+ const at = site();
375
+ return at ? `a listener added at ${at}` : "a listener";
376
+ };
377
+ /** Every active holder's reason in `group`, in the order they came, once each. */
378
+ function allWhys(group) {
379
+ return [...new Set([...group.whys.values()].map((why) => why()))];
380
+ }
381
+ /** `plugin.app` from the start-up handshake (the runner's environment). */
382
+ export function appInfo(raw, system = () => Promise.reject(failure("unavailable", "no player to ask"))) {
383
+ const info = (raw ?? {});
384
+ const parts = (info.components ?? {});
385
+ const text = (value) => (typeof value === "string" ? value : null);
386
+ const libav = {};
387
+ for (const [name, version] of Object.entries(parts.libav ?? {}))
388
+ if (typeof version === "string")
389
+ libav[name] = version;
390
+ const app = {
391
+ version: text(info.version) ?? "0.0.0",
392
+ build: text(info.build),
393
+ components: Object.freeze({
394
+ mpv: text(parts.mpv),
395
+ libav: Object.freeze(libav),
396
+ slint: text(parts.slint) ?? "",
397
+ }),
398
+ };
399
+ // A method, not data: kept out of comparisons and JSON of the facts.
400
+ Object.defineProperty(app, "system", { value: system, enumerable: false });
401
+ return Object.freeze(app);
402
+ }
@@ -1,4 +1,4 @@
1
- import type { Runtime } from "./runtime.ts";
1
+ import type { Connection } from "./connection.ts";
2
2
  import type { ScopeImpl } from "./scope.ts";
3
3
  import type { Disposable, DropModifiers, Drops } from "./types.ts";
4
4
  type PanelDrop = {
@@ -16,11 +16,11 @@ type PanelDrop = {
16
16
  declare class DropService {
17
17
  #private;
18
18
  readonly handlers: Map<string, (paths: string[], modifiers: DropModifiers) => void>;
19
- constructor(runtime: Runtime);
19
+ constructor(runtime: Connection);
20
20
  id(): string;
21
21
  /** The first listener asks the player for panel drops; the last stops them. */
22
22
  listen(scope: ScopeImpl, listener: (drop: PanelDrop) => void): Disposable;
23
23
  }
24
- export declare const dropsOf: (runtime: Runtime) => DropService;
24
+ export declare const dropsOf: (runtime: Connection) => DropService;
25
25
  export declare const drops: (scope: ScopeImpl) => Drops;
26
26
  export {};
@@ -1,5 +1,5 @@
1
1
  /** File drops (`plugin.drops`, `Panel.onDrop`; capability `ui.drop`). */
2
- import { failure } from "./runtime.js";
2
+ import { failure } from "./errors.js";
3
3
  const modifiers = (raw) => Object.freeze({
4
4
  alt: raw?.alt === true,
5
5
  shift: raw?.shift === true,
@@ -0,0 +1,9 @@
1
+ import type { PluginError } from "./types.ts";
2
+ export declare const sleep: (ms: number) => Promise<void>;
3
+ export declare function toError(error: unknown): PluginError;
4
+ export declare const failure: (code: string, message?: string) => PluginError;
5
+ /** Host replies are definite; anything else (lost reply, client timeout) is not. */
6
+ export declare const uncertain: (error: unknown) => boolean;
7
+ /** Mark a promise as observed so an unused `ready` never crashes the runner. */
8
+ export declare function quiet<T>(promise: Promise<T>): Promise<T>;
9
+ export declare function abortError(): PluginError;