@vestra-player/sdk 1.5.24 → 1.5.26

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.
@@ -7,7 +7,7 @@ import type { BuiltinCommand, BuiltinSubmenu } from "../commands.ts";
7
7
  import { CAPABILITY_INFO, MESSAGE_LIMIT } from "../capabilities.ts";
8
8
  import type { CapabilityInfo } from "../capabilities.ts";
9
9
  import { OSD_LIMITS } from "./osd.ts";
10
- import { SECRET_LIMITS } from "./services.ts";
10
+ import { SECRET_LIMITS, SESSION_STORAGE_LIMITS } from "./services.ts";
11
11
  import { BINDING_INPUTS } from "./bindings.ts";
12
12
  import { THEME_PALETTES } from "../palettes.ts";
13
13
  import { IMAGE_LIMITS, UI_VALUE_LIMITS } from "./values.ts";
@@ -34,6 +34,8 @@ export { MESSAGE_LIMIT };
34
34
  export { OSD_LIMITS };
35
35
  /** The player's limits on stored secrets. */
36
36
  export { SECRET_LIMITS };
37
+ /** The player's limits on `plugin.storage.session`. */
38
+ export { SESSION_STORAGE_LIMITS };
37
39
  /** The names bindings are made of. */
38
40
  export { BINDING_INPUTS };
39
41
  /** The colours of the player's palettes, by `Theme` token. */
@@ -1072,6 +1074,18 @@ export declare class TestHost implements Transport {
1072
1074
  * new name fails `limit` once 256 names are stored.
1073
1075
  */
1074
1076
  readonly secrets: Map<string, string>;
1077
+ /**
1078
+ * `plugin.storage.session`: key → value, as the player keeps them. Preset
1079
+ * entries to simulate values an earlier run of the plugin stored in the
1080
+ * same player session, or pass `startPlugin(..., { sessionStorage })`.
1081
+ * Like the player, a key is 1–1024 bytes, a `set` that would make the
1082
+ * entries larger than 1 MiB (keys, values as compact JSON and
1083
+ * `SESSION_STORAGE_LIMITS.entryBytes` per key) fails `limit`, and an
1084
+ * object's properties are stored sorted, as the player hands them back.
1085
+ *
1086
+ * @since 1.5.25
1087
+ */
1088
+ readonly sessionStorage: Map<string, Json>;
1075
1089
  /** What `plugin.app.system()` reports. */
1076
1090
  systemInfo: WireSystemInfo;
1077
1091
  /**
@@ -1594,6 +1608,15 @@ export declare function startPlugin(activate: (plugin: Plugin) => unknown, optio
1594
1608
  host?: TestHost;
1595
1609
  /** Use an existing directory as `plugin.dataDirectory` (to test a restart). */
1596
1610
  dataDirectory?: string;
1611
+ /**
1612
+ * Start with these `plugin.storage.session` values (to test a restart
1613
+ * within one player session): pass the previous run's
1614
+ * `host.sessionStorage`. They are copied into the new host's
1615
+ * {@link TestHost.sessionStorage}.
1616
+ *
1617
+ * @since 1.5.25
1618
+ */
1619
+ sessionStorage?: ReadonlyMap<string, Json>;
1597
1620
  /**
1598
1621
  * The interface language to play ({@link TestHost.locale}): the plugin's
1599
1622
  * `locales/<locale>.po` (or `.json`) in `directory` is loaded and its
@@ -69,7 +69,7 @@ import { localSocketPath } from "./local.js";
69
69
  import { BUILTIN_COMMANDS, BUILTIN_SUBMENUS, COMMAND_LIMITS, MENU_IDS, } from "../commands.js";
70
70
  import { CAPABILITY_INFO, MESSAGE_LIMIT } from "../capabilities.js";
71
71
  import { OSD_LIMITS } from "./osd.js";
72
- import { SECRET_LIMITS } from "./services.js";
72
+ import { SECRET_LIMITS, SESSION_STORAGE_LIMITS } from "./services.js";
73
73
  import { BINDING_INPUTS } from "./bindings.js";
74
74
  import { THEME_PALETTES } from "../palettes.js";
75
75
  import { IMAGE_LIMITS, UI_VALUE_LIMITS, valueLimitProblem } from "./values.js";
@@ -93,6 +93,8 @@ export { MESSAGE_LIMIT };
93
93
  export { OSD_LIMITS };
94
94
  /** The player's limits on stored secrets. */
95
95
  export { SECRET_LIMITS };
96
+ /** The player's limits on `plugin.storage.session`. */
97
+ export { SESSION_STORAGE_LIMITS };
96
98
  /** The names bindings are made of. */
97
99
  export { BINDING_INPUTS };
98
100
  /** The colours of the player's palettes, by `Theme` token. */
@@ -131,6 +133,34 @@ export { LIST_SEPARATORS };
131
133
  * ```
132
134
  */
133
135
  export { LOCALES };
136
+ /** A `plugin.storage.session` key, checked as the player does. */
137
+ function sessionKey(key) {
138
+ if (typeof key !== "string" ||
139
+ !key ||
140
+ key.includes("\0") ||
141
+ Buffer.byteLength(key) > SESSION_STORAGE_LIMITS.keyBytes)
142
+ throw new HostError("invalid", "session storage keys are 1–1024 bytes of text without NUL");
143
+ return key;
144
+ }
145
+ /** Code point (UTF-8 byte) order, as the player sorts session keys. */
146
+ const byCodePoint = (a, b) => Buffer.compare(Buffer.from(a), Buffer.from(b));
147
+ /**
148
+ * A JSON value as the player hands it back: the player does not keep the
149
+ * order of object properties, it sorts them.
150
+ */
151
+ function sortedJson(value) {
152
+ if (Array.isArray(value))
153
+ return value.map(sortedJson);
154
+ if (value && typeof value === "object")
155
+ return Object.fromEntries(Object.keys(value)
156
+ .sort(byCodePoint)
157
+ .map((key) => [key, sortedJson(value[key])]));
158
+ return value;
159
+ }
160
+ /** What one session entry counts towards the player's quota. */
161
+ const sessionCost = (key, value) => Buffer.byteLength(key) +
162
+ Buffer.byteLength(JSON.stringify(value)) +
163
+ SESSION_STORAGE_LIMITS.entryBytes;
134
164
  const kebab = (name) => name.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`);
135
165
  const tick = () => new Promise((resolve) => setImmediate(resolve));
136
166
  function initialSource() {
@@ -1156,6 +1186,18 @@ export class TestHost {
1156
1186
  * new name fails `limit` once 256 names are stored.
1157
1187
  */
1158
1188
  secrets = new Map();
1189
+ /**
1190
+ * `plugin.storage.session`: key → value, as the player keeps them. Preset
1191
+ * entries to simulate values an earlier run of the plugin stored in the
1192
+ * same player session, or pass `startPlugin(..., { sessionStorage })`.
1193
+ * Like the player, a key is 1–1024 bytes, a `set` that would make the
1194
+ * entries larger than 1 MiB (keys, values as compact JSON and
1195
+ * `SESSION_STORAGE_LIMITS.entryBytes` per key) fails `limit`, and an
1196
+ * object's properties are stored sorted, as the player hands them back.
1197
+ *
1198
+ * @since 1.5.25
1199
+ */
1200
+ sessionStorage = new Map();
1159
1201
  /** What `plugin.app.system()` reports. */
1160
1202
  systemInfo = {
1161
1203
  os: "macos",
@@ -1622,6 +1664,11 @@ export class TestHost {
1622
1664
  */
1623
1665
  async request(method, params = {}) {
1624
1666
  const record = (params ?? {});
1667
+ // The plugin runner refuses a request over the message limit before
1668
+ // sending it (runtime/runner.ts): the player never sees it.
1669
+ const line = JSON.stringify({ id: this.calls.length + 1, method, params });
1670
+ if (Buffer.byteLength(line, "utf8") + 1 > MESSAGE_LIMIT)
1671
+ throw new HostError("limit", `${method}: request exceeds the player's 1 MiB message limit`);
1625
1672
  this.calls.push({ method, params: record });
1626
1673
  this.#inFlight++;
1627
1674
  try {
@@ -2709,6 +2756,27 @@ export class TestHost {
2709
2756
  this.secrets.set(name, String(p.value));
2710
2757
  },
2711
2758
  "secrets.delete": (p) => void this.secrets.delete(String(p.name)),
2759
+ // As the player (host/src/session_storage.rs).
2760
+ "storage.session.get": (p) => {
2761
+ const key = sessionKey(p.key);
2762
+ return this.sessionStorage.has(key)
2763
+ ? { value: structuredClone(this.sessionStorage.get(key)) }
2764
+ : {};
2765
+ },
2766
+ "storage.session.set": (p) => {
2767
+ const key = sessionKey(p.key);
2768
+ if (!("value" in p))
2769
+ throw new HostError("invalid", "set needs a JSON value");
2770
+ let total = sessionCost(key, p.value);
2771
+ for (const [other, value] of this.sessionStorage)
2772
+ if (other !== key)
2773
+ total += sessionCost(other, value);
2774
+ if (total > SESSION_STORAGE_LIMITS.bytes)
2775
+ throw new HostError("limit", `session storage holds at most ${SESSION_STORAGE_LIMITS.bytes} bytes per plugin (keys, JSON values and ${SESSION_STORAGE_LIMITS.entryBytes} per key); this value would make ${total}`);
2776
+ this.sessionStorage.set(key, sortedJson(p.value));
2777
+ },
2778
+ "storage.session.delete": (p) => void this.sessionStorage.delete(sessionKey(p.key)),
2779
+ "storage.session.keys": () => [...this.sessionStorage.keys()].sort(byCodePoint),
2712
2780
  "app.system": () => structuredClone(this.systemInfo),
2713
2781
  "clipboard.readText": () => this.clipboard.text,
2714
2782
  "clipboard.writeText": (p) => {
@@ -3741,6 +3809,8 @@ export async function startPlugin(activate, options = {}) {
3741
3809
  host.manifestId = manifest.id;
3742
3810
  host.loadCatalog(directory);
3743
3811
  const data = options.dataDirectory ?? host.dataDirectory;
3812
+ for (const [key, value] of options.sessionStorage ?? [])
3813
+ host.sessionStorage.set(key, sortedJson(value));
3744
3814
  mkdirSync(data, { recursive: true });
3745
3815
  const instance = await createPlugin(host, manifest, directory, data, host.app);
3746
3816
  await activate(instance.plugin);
@@ -4390,7 +4390,8 @@ export interface SubtitleConditional {
4390
4390
  }
4391
4391
  /**
4392
4392
  * `plugin.subtitles`: tracks, uploads and style. Reading needs
4393
- * `subtitles.read`, changes need `subtitles.control`.
4393
+ * `subtitles.read`; changes need `subtitles.control` and also
4394
+ * `subtitles.read`, since they read the current selection first.
4394
4395
  *
4395
4396
  * @example Load an SRT file as the secondary subtitle
4396
4397
  * ```ts
@@ -5232,6 +5233,83 @@ export interface Storage {
5232
5233
  validate?: (value: unknown) => value is T;
5233
5234
  debounceMs?: number;
5234
5235
  }): JsonStore<T>;
5236
+ /**
5237
+ * Values the player keeps in its memory for this plugin until the player
5238
+ * quits: they survive a restart of the plugin's process (reload, upgrade,
5239
+ * crash, failed start) but not the player. See {@link SessionStorage}.
5240
+ *
5241
+ * @since 1.5.25
5242
+ */
5243
+ readonly session: SessionStorage;
5244
+ }
5245
+ /**
5246
+ * `plugin.storage.session`: JSON values that live as long as the **player**,
5247
+ * not as long as the plugin's process (like `chrome.storage.session`). No
5248
+ * capability; needs player 0.5.5 (older players reject the calls with
5249
+ * `unsupported`).
5250
+ *
5251
+ * `activate` can run more than once in one player session: the player stops
5252
+ * and starts a plugin's process again when the plugin is reloaded (also a
5253
+ * development load with `--plugin`) or upgraded, and after a crash or a
5254
+ * failed start. A later player may also restart plugins that share a
5255
+ * process, or stop an idle plugin and start it on its next use. Variables do
5256
+ * not survive that; keep state that must survive a restart here (or in
5257
+ * {@link Storage.json | storage.json} when it must also survive the player
5258
+ * quitting).
5259
+ *
5260
+ * | | Values |
5261
+ * | --- | --- |
5262
+ * | The plugin's process restarts: reload, upgrade, crash | kept |
5263
+ * | The plugin failed to start or failed, and starts again | kept |
5264
+ * | The user disables the plugin, or uninstalls it | cleared |
5265
+ * | The player quits | cleared |
5266
+ *
5267
+ * - **Per plugin.** A plugin sees only its own keys. Like
5268
+ * {@link Plugin.dataDirectory | dataDirectory}, the values belong to the
5269
+ * installation: a development load (`--plugin`) has its own.
5270
+ * - **JSON.** `set` stores what `JSON.stringify(value)` writes, and `get`
5271
+ * returns that, parsed: `undefined` object properties are dropped, a `Date`
5272
+ * comes back as a string, `NaN` as `null`. **The order of an object's
5273
+ * properties is not kept**: they may come back in another order. A value
5274
+ * `JSON.stringify` cannot write (`undefined`, a function, a cycle, a
5275
+ * `BigInt`) or a string with a lone surrogate (`"\uD800"`) fails with
5276
+ * `invalid`. The value is not checked when it comes back: after an upgrade
5277
+ * an older version's value may be read, so check its shape when it matters.
5278
+ * - **Bounded.** Keys are 1–1024 bytes of text. A plugin holds at most 1 MiB,
5279
+ * counting every key, its value as compact JSON and 128 bytes per key
5280
+ * ({@link "@vestra-player/sdk/testing"!SESSION_STORAGE_LIMITS | SESSION_STORAGE_LIMITS}); a
5281
+ * `set` beyond it fails with `limit` and changes nothing. One `set` is
5282
+ * also one message to the player, at most 1 MiB with its key, so a single
5283
+ * value is a little smaller than that; a larger one fails with `limit`.
5284
+ * Split large state over several keys.
5285
+ * - **Each call is one request.** A `set` is kept once its promise
5286
+ * resolves, so a crash right after loses nothing; there is no debounce.
5287
+ * Calls answer in order.
5288
+ *
5289
+ * @example
5290
+ * ```ts
5291
+ * // A queue the plugin is working through: after a reload or a crash it
5292
+ * // goes on where it stopped, after the player quits it starts afresh.
5293
+ * const pending = (await plugin.storage.session.get<string[]>("queue")) ?? [];
5294
+ * pending.push("https://example.com/a.mkv");
5295
+ * await plugin.storage.session.set("queue", pending);
5296
+ * ```
5297
+ *
5298
+ * @since 1.5.25
5299
+ */
5300
+ export interface SessionStorage {
5301
+ /** The value stored under `key`, or `undefined` when there is none. */
5302
+ get<T = unknown>(key: string): Promise<T | undefined>;
5303
+ /**
5304
+ * Store or replace `value` under `key`. `invalid` for a bad key or a value
5305
+ * that is not JSON, `limit` when the plugin's values would be over 1 MiB
5306
+ * or the request over the 1 MiB message limit.
5307
+ */
5308
+ set(key: string, value: unknown): Promise<void>;
5309
+ /** Remove a key; removing a missing one is fine. */
5310
+ delete(key: string): Promise<void>;
5311
+ /** The keys stored, in code point order. */
5312
+ keys(): Promise<string[]>;
5235
5313
  }
5236
5314
  /**
5237
5315
  * `plugin.system`: files and links, executed by the player. No capability.
@@ -0,0 +1,23 @@
1
+ /**
2
+ * The TypeScript language-service plugin `@vestra-player/sdk/ts-plugin`: the
3
+ * capability checker of `vestra-plugin check` in the editor. A use of an API
4
+ * member whose capability `manifest.json` does not declare is an error at
5
+ * the member, with the quick fix "Add '<capability>' to manifest.json".
6
+ *
7
+ * A project enables it in its tsconfig.json:
8
+ *
9
+ * { "compilerOptions": { "plugins": [{ "name": "@vestra-player/sdk/ts-plugin" }] } }
10
+ *
11
+ * The editor must run the project's own TypeScript (VS Code: "TypeScript:
12
+ * Select TypeScript Version" → "Use Workspace Version"); `tsc` ignores
13
+ * language-service plugins, which is why `vestra-plugin check` runs the same
14
+ * checker. The manifest is the nearest `manifest.json` above the file; files
15
+ * in its `tests/`, `node_modules/`, `dist/` and `vendor/` are not checked.
16
+ *
17
+ * @internal
18
+ */
19
+ import type TS from "typescript";
20
+ /** @internal The plugin factory tsserver calls (`ts-plugin/index.js`). */
21
+ export default function init(modules: {
22
+ typescript: typeof TS;
23
+ }): TS.server.PluginModule;
@@ -0,0 +1,135 @@
1
+ import { addCapabilityEdit, CAPABILITY_DIAGNOSTIC, findUndeclaredCapabilities, isPluginSource, } from "./capability-check.js";
2
+ const FIX_NAME = "vestraAddCapability";
3
+ /** The nearest manifest.json at or above a file, with its capabilities. */
4
+ function manifestFor(ts, file) {
5
+ let directory = file.replaceAll("\\", "/");
6
+ for (;;) {
7
+ const parent = directory.slice(0, directory.lastIndexOf("/"));
8
+ if (!parent || parent === directory)
9
+ return undefined;
10
+ directory = parent;
11
+ const path = `${directory}/manifest.json`;
12
+ if (!ts.sys.fileExists(path))
13
+ continue;
14
+ try {
15
+ const manifest = JSON.parse(ts.sys.readFile(path) ?? "");
16
+ if (!Array.isArray(manifest.capabilities))
17
+ return undefined;
18
+ return {
19
+ path,
20
+ directory,
21
+ capabilities: manifest.capabilities.filter((each) => typeof each === "string"),
22
+ };
23
+ }
24
+ catch {
25
+ return undefined;
26
+ }
27
+ }
28
+ }
29
+ /**
30
+ * The manifest's text for the quick fix. The TypeScript server applies a
31
+ * code fix only to files it knows, and editors do not hand it JSON files
32
+ * (VS Code does not), so the manifest is made known first; its text is then
33
+ * the server's: an open buffer when the editor shares one, else the file on
34
+ * disk (VS Code: the saved manifest, not unsaved edits).
35
+ */
36
+ function manifestText(ts, info, path) {
37
+ const service = info.project?.projectService;
38
+ const script = service && ts.server?.toNormalizedPath
39
+ ? service.getOrCreateScriptInfoForNormalizedPath(ts.server.toNormalizedPath(path), false)
40
+ : undefined;
41
+ const snapshot = script?.getSnapshot();
42
+ return snapshot
43
+ ? snapshot.getText(0, snapshot.getLength())
44
+ : ts.sys.readFile(path);
45
+ }
46
+ /** @internal The plugin factory tsserver calls (`ts-plugin/index.js`). */
47
+ export default function init(modules) {
48
+ const ts = modules.typescript;
49
+ return {
50
+ create(info) {
51
+ const service = info.languageService;
52
+ const log = (message) => info.project?.projectService?.logger?.info(`[@vestra-player/sdk/ts-plugin] ${message}`);
53
+ const findings = (file) => {
54
+ const manifest = manifestFor(ts, file);
55
+ const program = service.getProgram();
56
+ if (!manifest || !program || !isPluginSource(manifest.directory, file))
57
+ return [];
58
+ try {
59
+ return findUndeclaredCapabilities(ts, program, {
60
+ files: [file],
61
+ capabilities: manifest.capabilities,
62
+ });
63
+ }
64
+ catch (error) {
65
+ log(`check failed: ${error.stack ?? String(error)}`);
66
+ return [];
67
+ }
68
+ };
69
+ const proxy = Object.create(null);
70
+ for (const key of Object.keys(service)) {
71
+ const member = service[key];
72
+ proxy[key] =
73
+ typeof member === "function" ? member.bind(service) : member;
74
+ }
75
+ proxy.getSemanticDiagnostics = (file) => {
76
+ const prior = service.getSemanticDiagnostics(file);
77
+ const source = service.getProgram()?.getSourceFile(file);
78
+ return [
79
+ ...prior,
80
+ ...findings(file).map((finding) => ({
81
+ file: source,
82
+ start: finding.start,
83
+ length: finding.length,
84
+ messageText: finding.message,
85
+ category: ts.DiagnosticCategory.Error,
86
+ code: CAPABILITY_DIAGNOSTIC,
87
+ source: "vestra",
88
+ })),
89
+ ];
90
+ };
91
+ proxy.getSupportedCodeFixes = (file) => [
92
+ ...service.getSupportedCodeFixes(file),
93
+ String(CAPABILITY_DIAGNOSTIC),
94
+ ];
95
+ proxy.getCodeFixesAtPosition = (file, start, end, errorCodes, format, preferences) => {
96
+ const prior = service.getCodeFixesAtPosition(file, start, end, errorCodes, format, preferences);
97
+ if (!errorCodes.includes(CAPABILITY_DIAGNOSTIC))
98
+ return prior;
99
+ const manifest = manifestFor(ts, file);
100
+ if (!manifest)
101
+ return prior;
102
+ const text = manifestText(ts, info, manifest.path);
103
+ if (text === undefined)
104
+ return prior;
105
+ const wanted = new Set(findings(file)
106
+ .filter((finding) => finding.start <= end && finding.start + finding.length >= start)
107
+ .map((finding) => finding.capability));
108
+ const fixes = [];
109
+ for (const capability of wanted) {
110
+ const edit = addCapabilityEdit(ts, text, capability);
111
+ if (!edit)
112
+ continue;
113
+ fixes.push({
114
+ fixName: FIX_NAME,
115
+ description: `Add '${capability}' to manifest.json`,
116
+ changes: [
117
+ {
118
+ fileName: manifest.path,
119
+ textChanges: [
120
+ {
121
+ span: { start: edit.start, length: edit.length },
122
+ newText: edit.newText,
123
+ },
124
+ ],
125
+ },
126
+ ],
127
+ });
128
+ }
129
+ return [...prior, ...fixes];
130
+ };
131
+ log("enabled");
132
+ return proxy;
133
+ },
134
+ };
135
+ }
package/dist/wire.d.ts CHANGED
@@ -488,6 +488,18 @@ export declare function createWire(transport: Transport): {
488
488
  set: (name: string, value: string) => Promise<void>;
489
489
  delete: (name: string) => Promise<void>;
490
490
  };
491
+ /**
492
+ * JSON values the player keeps per plugin until it quits (SDK 1.5.25,
493
+ * player 0.5.5). `get` answers `{}` for a missing key.
494
+ */
495
+ storageSession: {
496
+ get: (key: string) => Promise<{
497
+ value?: Json;
498
+ }>;
499
+ set: (key: string, value: Json) => Promise<void>;
500
+ delete: (key: string) => Promise<void>;
501
+ keys: () => Promise<string[]>;
502
+ };
491
503
  app: {
492
504
  system: () => Promise<WireSystemInfo>;
493
505
  };
package/dist/wire.js CHANGED
@@ -182,6 +182,16 @@ export function createWire(transport) {
182
182
  set: (name, value) => transport.request("secrets.set", { name, value }),
183
183
  delete: (name) => transport.request("secrets.delete", { name }),
184
184
  },
185
+ /**
186
+ * JSON values the player keeps per plugin until it quits (SDK 1.5.25,
187
+ * player 0.5.5). `get` answers `{}` for a missing key.
188
+ */
189
+ storageSession: {
190
+ get: (key) => transport.request("storage.session.get", { key }),
191
+ set: (key, value) => transport.request("storage.session.set", { key, value }),
192
+ delete: (key) => transport.request("storage.session.delete", { key }),
193
+ keys: () => transport.request("storage.session.keys"),
194
+ },
185
195
  app: {
186
196
  system: () => transport.request("app.system"),
187
197
  },