@tavora/sdk 0.0.0-stage → 0.2.5

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/src/index.ts ADDED
@@ -0,0 +1,586 @@
1
+ // @tavora/sdk: thin typed client for modules running inside Tavora Desktop.
2
+ // The host enforces every check; this package only marshals typed calls.
3
+ // Protocol reference: apps/docs/reference/protocol.md.
4
+
5
+ import {
6
+ BLOB_PATH,
7
+ CLIPBOARD_PATH,
8
+ PICTURES_PATH,
9
+ HELLO_MESSAGE,
10
+ LIMITS,
11
+ PORT_MESSAGE,
12
+ PROTOCOL_VERSION,
13
+ type AiUsageAccountInfo,
14
+ type AiUsageReading,
15
+ type BlobInfo,
16
+ type BlobUsage,
17
+ type Capability,
18
+ type DiscordCommand,
19
+ type DiscordCommands,
20
+ type DiscordEvent,
21
+ type DiscordEventName,
22
+ type DiscordResult,
23
+ type DiscordState,
24
+ type DocumentChange,
25
+ type DocumentSummary,
26
+ type ErrorCode,
27
+ type ErrorPayload,
28
+ type EventName,
29
+ type Events,
30
+ type HandshakeResult,
31
+ type HostMessage,
32
+ type JsonValue,
33
+ type MenuRequest,
34
+ type MethodName,
35
+ type Methods,
36
+ type NetworkRequest,
37
+ type NetworkResponse,
38
+ type PanelContent,
39
+ type PanelLaunch,
40
+ type PanelOpenResult,
41
+ type PutDocumentParams,
42
+ type TavoraDocument,
43
+ type TavoraGame,
44
+ type TavoraTheme,
45
+ type TavoraViewport,
46
+ } from "./protocol.js";
47
+
48
+ export type {
49
+ AiUsageAccountInfo,
50
+ AiUsageReading,
51
+ BlobInfo,
52
+ BlobType,
53
+ BlobUsage,
54
+ Capability,
55
+ DiscordCommand,
56
+ DiscordCommands,
57
+ DiscordEvent,
58
+ DiscordEventName,
59
+ DiscordResult,
60
+ DiscordState,
61
+ DocumentChange,
62
+ DocumentSummary,
63
+ ErrorCode,
64
+ HandshakeResult,
65
+ JsonValue,
66
+ MenuEntry,
67
+ MenuRequest,
68
+ NetworkRequest,
69
+ NetworkResponse,
70
+ PanelContent,
71
+ PanelLaunch,
72
+ PanelOpenResult,
73
+ PutDocumentParams,
74
+ TavoraDocument,
75
+ TavoraGame,
76
+ TavoraTheme,
77
+ TavoraViewport,
78
+ ThemeToken,
79
+ } from "./protocol.js";
80
+ export { BLOB_ID, BLOB_TYPES, DISCORD_COMMANDS, DISCORD_EVENTS, LIMITS, PROTOCOL_VERSION, THEME_TOKENS } from "./protocol.js";
81
+
82
+ /** How long a menu may stay open; the host closes it itself when its panel goes. */
83
+ const MENU_TIMEOUT_MS = 60 * 60_000;
84
+
85
+ /** Version of this SDK package, sent to the host during the handshake. */
86
+ export const SDK_VERSION = "0.2.5";
87
+
88
+ /**
89
+ * The oldest Tavora that has everything this SDK version offers. Put it in your manifest as
90
+ * `minTavora`, so an older Tavora says it needs an update instead of loading a module it cannot run
91
+ * (the starter, `npm create @tavora/module`, does). The changelog names it for every version.
92
+ */
93
+ export const MIN_TAVORA = "0.17.19";
94
+
95
+ export class TavoraError extends Error {
96
+ readonly code: ErrorCode;
97
+ /** Set for PERMISSION_DENIED. */
98
+ readonly capability: Capability | undefined;
99
+ /** Set for CONFLICT: the document's current revision. */
100
+ readonly revision: number | undefined;
101
+
102
+ constructor(payload: ErrorPayload) {
103
+ super(payload.message);
104
+ this.name = "TavoraError";
105
+ this.code = payload.code;
106
+ this.capability = payload.capability;
107
+ this.revision = payload.revision;
108
+ }
109
+ }
110
+
111
+ type Unsubscribe = () => void;
112
+
113
+ /** Small key/value settings: at most 5 MB of keys and values together, values plain JSON. */
114
+ export interface KeyValueStore {
115
+ /** T is not checked at runtime; the host only guarantees the value is JSON. */
116
+ get<T = JsonValue>(key: string): Promise<T | null>;
117
+ set(key: string, value: JsonValue): Promise<void>;
118
+ delete(key: string): Promise<void>;
119
+ keys(): Promise<string[]>;
120
+ }
121
+
122
+ export interface TavoraSDK {
123
+ /** What the host reported when this module connected. */
124
+ readonly host: HandshakeResult;
125
+ game: {
126
+ getCurrent(): Promise<TavoraGame>;
127
+ };
128
+ /** Small key/value settings, private to this module in the current game. */
129
+ storage: KeyValueStore & {
130
+ /** The same, shared by every game: settings that should not change from game to game. */
131
+ app: KeyValueStore;
132
+ };
133
+ /**
134
+ * Named documents (builds, plans, checklists), private to this module in
135
+ * the current game and shared by all of its panels.
136
+ */
137
+ documents: {
138
+ list(): Promise<DocumentSummary[]>;
139
+ get<T = JsonValue>(id: string): Promise<(TavoraDocument & { data: T }) | null>;
140
+ /** Creates or replaces a document. Pass ifRevision to avoid overwriting another panel's change. */
141
+ put(document: PutDocumentParams): Promise<DocumentSummary>;
142
+ delete(id: string, options?: { ifRevision?: number }): Promise<void>;
143
+ /** Fires when another panel of this module changes a document. */
144
+ onChange(fn: (change: DocumentChange) => void): Unsubscribe;
145
+ };
146
+ /** UI state for this one panel (selection, scroll, open tab). Cleared when the panel is closed. */
147
+ panel: {
148
+ /** Which of the manifest's `panels` this panel is, by id; null for a module without panel types (or an older Tavora). */
149
+ readonly type: string | null;
150
+ getState<T = JsonValue>(): Promise<T | null>;
151
+ setState(value: JsonValue | null): Promise<void>;
152
+ /**
153
+ * Why this page is loading: `{ reason: "add" }` for a panel the player just added (pick its content
154
+ * yourself, such as the last one used, or show a picker), or `{ reason: "restore", content }` for a panel
155
+ * that was open before, with what it last showed. Needs Tavora 0.17.17 or newer.
156
+ */
157
+ getLaunch(): Promise<PanelLaunch>;
158
+ /**
159
+ * Tells Tavora what this panel shows now, such as the build picked in your toolbar's selector, or null for
160
+ * nothing. Tavora keeps it with the panel, shows the title when the player hovers the tab, and gives it
161
+ * back through getLaunch after a restart. Needs Tavora 0.17.17 or newer.
162
+ */
163
+ setContent(content: PanelContent | null): Promise<void>;
164
+ /**
165
+ * Lets another panel of this module switch this one to other content (`panels.open`). The handler saves any
166
+ * pending edits and switches, then resolves; it throws (or rejects) when it cannot, and the panel keeps what it
167
+ * showed. It has 5 seconds. One handler per page: a new one replaces the last. Without one, Tavora opens a new
168
+ * panel instead of switching this one. Needs Tavora 0.17.18 or newer.
169
+ */
170
+ onNavigate(fn: (content: PanelContent) => void | Promise<void>): Unsubscribe;
171
+ };
172
+ /** The module's other panels. */
173
+ panels: {
174
+ /**
175
+ * Opens one of this module's own panel types in the current game, on `content` when given. Tavora brings
176
+ * forward a panel of that type already showing the content, else switches the one used last
177
+ * (`panel.onNavigate`), else opens a new one. Call it from the click or key press that asked for it: the host
178
+ * refuses it otherwise (PERMISSION_DENIED). Needs Tavora 0.17.18 or newer.
179
+ */
180
+ open(type: string, options?: { content?: PanelContent }): Promise<PanelOpenResult>;
181
+ };
182
+ workspace: {
183
+ getViewport(): Promise<TavoraViewport>;
184
+ onResize(fn: (viewport: TavoraViewport) => void): Unsubscribe;
185
+ };
186
+ ui: {
187
+ getTheme(): Promise<TavoraTheme>;
188
+ onThemeChange(fn: (theme: TavoraTheme) => void): Unsubscribe;
189
+ /** The language Tavora shows its own texts in, as a code such as "en" or "de". Show yours in it, or in English when you have no translation. */
190
+ getLanguage(): Promise<string>;
191
+ /** Fires when the player changes the language. */
192
+ onLanguageChange(fn: (language: string) => void): Unsubscribe;
193
+ /**
194
+ * Shows a menu that Tavora draws above every panel, so it is never cut off at the panel's edge. Call it from
195
+ * the click, right-click or key press that asked for it: the host refuses it otherwise (PERMISSION_DENIED).
196
+ * Resolves with the chosen item's id, or null when the player closed the menu without choosing.
197
+ */
198
+ showMenu(request: MenuRequest): Promise<string | null>;
199
+ };
200
+ /**
201
+ * Files (pictures) this module keeps in the current game, shared by all of its panels. They live
202
+ * in a folder Tavora manages, not in a document, so they can be large: up to 50 MB each and
203
+ * 500 MB together. Only PNG, JPEG, GIF and WebP are accepted, judged by the file itself.
204
+ */
205
+ blobs: {
206
+ /** Stores a file and returns its id. Rejects with QUOTA_EXCEEDED, or INVALID_PARAMS for another kind of file. */
207
+ put(file: Blob): Promise<BlobInfo>;
208
+ /** The address to show a stored file from, for example as an image source. */
209
+ url(id: string): string;
210
+ list(): Promise<BlobInfo[]>;
211
+ usage(): Promise<BlobUsage>;
212
+ /** Deletes a file; nothing happens when it does not exist. */
213
+ delete(id: string): Promise<void>;
214
+ };
215
+ clipboard: {
216
+ /**
217
+ * Puts a picture on the system clipboard, so the player can paste it anywhere. Call it from the click or key
218
+ * press that asked for it: the host refuses it otherwise (PERMISSION_DENIED), and one press copies one
219
+ * picture. PNG only, a complete one, at most 20 MB and 16384 pixels on a side (INVALID_PARAMS, QUOTA_EXCEEDED).
220
+ */
221
+ writeImage(png: Blob): Promise<void>;
222
+ };
223
+ pictures: {
224
+ /**
225
+ * Saves a picture as a .png file in Tavora's folder in the player's Pictures (Pictures\Tavora), right away,
226
+ * without a dialog. `name` suggests the file name: Tavora makes it safe and adds " (2)" and so on rather than
227
+ * replace a file. Resolves with the name it was saved under; the module never learns where the folder is.
228
+ * Call it from the click or key press that asked for it: the host refuses it otherwise (PERMISSION_DENIED), and
229
+ * one press saves one picture. The same PNG limits as clipboard.writeImage apply.
230
+ */
231
+ save(png: Blob, name?: string): Promise<{ fileName: string }>;
232
+ };
233
+ links: {
234
+ /**
235
+ * Opens a web address (http or https) in a Browser panel of this game, as a new tab or in a new
236
+ * panel. Call it from the click or key press that asked for it: the host refuses it otherwise
237
+ * (PERMISSION_DENIED). The module learns nothing about the page.
238
+ */
239
+ open(url: string): Promise<void>;
240
+ };
241
+ network: {
242
+ /**
243
+ * Makes a web request through Tavora, to a host listed in `permissions.network` that the player
244
+ * granted (PERMISSION_DENIED otherwise). HTTPS only, no cookies, at most 5 MB back and 15 seconds.
245
+ * Error statuses such as 404 are answers, not errors; NETWORK_ERROR means no answer came.
246
+ */
247
+ fetch(url: string, init?: Omit<NetworkRequest, "url">): Promise<NetworkResponse>;
248
+ };
249
+ /** Parts of Tavora that hold a sign-in, used without seeing it. Each needs its connector in `permissions.connectors` and the player's grant (PERMISSION_DENIED otherwise). */
250
+ connectors: {
251
+ /** `ai-usage`: the usage of the Claude and Codex accounts the player set up in Settings > Integrations > AI usage. */
252
+ aiUsage: {
253
+ accounts(): Promise<AiUsageAccountInfo[]>;
254
+ /** Reads one account. Tavora reads each account at most once a minute; asking sooner gives the last reading. */
255
+ read(accountId: string): Promise<AiUsageReading>;
256
+ /** Fires when the player adds, renames or removes an account. */
257
+ onAccountsChange(fn: () => void): Unsubscribe;
258
+ };
259
+ /**
260
+ * `discord`: the Discord app on this PC, through Tavora's Discord sign-in. The connection runs while a
261
+ * panel has asked for state(); Tavora keeps the sign-in and sends only the commands in DISCORD_COMMANDS.
262
+ */
263
+ discord: {
264
+ /** The connection now. The first call starts it for this panel. */
265
+ state(): Promise<DiscordState>;
266
+ onStateChange(fn: (state: DiscordState) => void): Unsubscribe;
267
+ /** Signs in, asking for approval in Discord the first time. Call it from a click or key press handler. */
268
+ connect(): Promise<void>;
269
+ /** Asks Discord to approve muting and deafening, for a sign-in that can't yet (canControl false). From a click or key press. */
270
+ allowControl(): Promise<void>;
271
+ /** Sends one command; resolves with Discord's answer as it came, or why there is none. SELECT_VOICE_CHANNEL and SET_VOICE_SETTINGS only from a click or key press. */
272
+ command<C extends DiscordCommand>(command: C, args: DiscordCommands[C]): Promise<DiscordResult>;
273
+ /** Starts sending an event to this panel (VOICE_CHANNEL_SELECT without a channel, the others for one voice channel). Kept across reconnects. */
274
+ subscribe(event: DiscordEventName, channelId?: string): Promise<void>;
275
+ unsubscribe(event: DiscordEventName, channelId?: string): Promise<void>;
276
+ onEvent(fn: (event: DiscordEvent) => void): Unsubscribe;
277
+ /** A picture from cdn.discordapp.com (an avatar or server icon address) as a small PNG data address, or null. */
278
+ image(url: string): Promise<string | null>;
279
+ };
280
+ };
281
+ lifecycle: {
282
+ /** The panel is hidden; pause timers and animations. Called immediately if already suspended. */
283
+ onSuspend(fn: () => void): Unsubscribe;
284
+ onResume(fn: () => void): Unsubscribe;
285
+ /** Best effort only: autosave must not depend on this firing. */
286
+ onDispose(fn: () => void): Unsubscribe;
287
+ };
288
+ }
289
+
290
+ export interface ConnectOptions {
291
+ /** How long to wait for the host to hand over a port. Default 5000 ms. */
292
+ connectTimeoutMs?: number;
293
+ /** How long to wait for each request. Default 10000 ms. */
294
+ requestTimeoutMs?: number;
295
+ }
296
+
297
+ let connection: Promise<TavoraSDK> | undefined;
298
+
299
+ /**
300
+ * Connects to the Tavora host. Safe to call more than once: every call
301
+ * returns the same connection for this window. Rejects with a TavoraError
302
+ * (HOST_UNAVAILABLE, INCOMPATIBLE) if the module cannot run here.
303
+ */
304
+ export function connect(options: ConnectOptions = {}): Promise<TavoraSDK> {
305
+ connection ??= openPort(options.connectTimeoutMs ?? 5000)
306
+ .then((port) => createClient(port, options.requestTimeoutMs ?? 10_000))
307
+ .catch((error: unknown) => {
308
+ connection = undefined;
309
+ throw error;
310
+ });
311
+ return connection;
312
+ }
313
+
314
+ function openPort(timeoutMs: number): Promise<MessagePort> {
315
+ return new Promise((resolve, reject) => {
316
+ const timer = setTimeout(() => {
317
+ window.removeEventListener("message", onMessage);
318
+ reject(
319
+ new TavoraError({
320
+ code: "HOST_UNAVAILABLE",
321
+ message: "Tavora host did not respond. Is this module running inside Tavora?",
322
+ }),
323
+ );
324
+ }, timeoutMs);
325
+
326
+ function onMessage(event: MessageEvent) {
327
+ if (event.source !== window || event.data?.type !== PORT_MESSAGE || !event.ports[0]) return;
328
+ clearTimeout(timer);
329
+ window.removeEventListener("message", onMessage);
330
+ resolve(event.ports[0]);
331
+ }
332
+
333
+ window.addEventListener("message", onMessage);
334
+ window.postMessage({ type: HELLO_MESSAGE, v: PROTOCOL_VERSION }, window.location.origin);
335
+ });
336
+ }
337
+
338
+ async function createClient(port: MessagePort, requestTimeoutMs: number): Promise<TavoraSDK> {
339
+ let nextId = 1;
340
+ const pending = new Map<
341
+ number,
342
+ { resolve(value: unknown): void; reject(error: TavoraError): void; timer: ReturnType<typeof setTimeout> }
343
+ >();
344
+ const listeners = new Map<EventName, Set<(data: unknown) => void>>();
345
+ let suspended = false;
346
+
347
+ port.onmessage = (event: MessageEvent<HostMessage>) => {
348
+ const message = event.data;
349
+ if (typeof message !== "object" || message === null || message.v !== PROTOCOL_VERSION) return;
350
+
351
+ if ("event" in message) {
352
+ if (message.event === "lifecycle.suspend") suspended = true;
353
+ if (message.event === "lifecycle.resume") suspended = false;
354
+ for (const fn of listeners.get(message.event) ?? []) {
355
+ try {
356
+ fn(message.data);
357
+ } catch (error) {
358
+ console.error(`[tavora] ${message.event} listener failed`, error);
359
+ }
360
+ }
361
+ return;
362
+ }
363
+
364
+ const entry = pending.get(message.id);
365
+ if (!entry) return;
366
+ pending.delete(message.id);
367
+ clearTimeout(entry.timer);
368
+ if (message.ok) entry.resolve(message.result);
369
+ else entry.reject(new TavoraError(message.error));
370
+ };
371
+ port.start();
372
+
373
+ function call<M extends MethodName>(method: M, params?: Methods[M]["params"], timeoutMs = requestTimeoutMs): Promise<Methods[M]["result"]> {
374
+ const id = nextId++;
375
+ return new Promise((resolve, reject) => {
376
+ const timer = setTimeout(() => {
377
+ pending.delete(id);
378
+ reject(new TavoraError({ code: "TIMEOUT", message: `${method} timed out` }));
379
+ }, Math.max(timeoutMs, requestTimeoutMs));
380
+ pending.set(id, { resolve: resolve as (value: unknown) => void, reject, timer });
381
+ port.postMessage({ v: PROTOCOL_VERSION, id, method, params });
382
+ });
383
+ }
384
+
385
+ function on<E extends EventName>(event: E, fn: (data: Events[E]) => void): Unsubscribe {
386
+ let set = listeners.get(event);
387
+ if (!set) listeners.set(event, (set = new Set()));
388
+ const listener = fn as (data: unknown) => void;
389
+ set.add(listener);
390
+ // Initial suspension can arrive before connect() callers register listeners.
391
+ if (event === "lifecycle.suspend" && suspended) {
392
+ try {
393
+ listener(null);
394
+ } catch (error) {
395
+ console.error(`[tavora] ${event} listener failed`, error);
396
+ }
397
+ }
398
+ return () => set.delete(listener);
399
+ }
400
+
401
+ const host = await call("bridge.handshake", { protocol: PROTOCOL_VERSION, sdkVersion: SDK_VERSION });
402
+
403
+ // panel.onNavigate: Tavora asks, the handler saves and switches, and the answer says how it went.
404
+ let navigate: ((content: PanelContent) => void | Promise<void>) | null = null;
405
+ on("panel.navigate", ({ requestId, content }) => {
406
+ const handler = navigate;
407
+ void (async () => {
408
+ let ok = false;
409
+ if (handler) {
410
+ try {
411
+ await handler(content);
412
+ ok = true;
413
+ } catch (error) {
414
+ console.error("[tavora] panel.onNavigate handler failed", error);
415
+ }
416
+ }
417
+ await call("panel.navigated", { requestId, handled: handler !== null, ok }).catch(() => {});
418
+ })();
419
+ });
420
+
421
+ return {
422
+ host,
423
+ game: {
424
+ getCurrent: () => call("game.getCurrent"),
425
+ },
426
+ storage: {
427
+ get: <T = JsonValue>(key: string) => call("storage.get", { key }) as Promise<T | null>,
428
+ set: async (key, value) => {
429
+ await call("storage.set", { key, value });
430
+ },
431
+ delete: async (key) => {
432
+ await call("storage.delete", { key });
433
+ },
434
+ keys: () => call("storage.keys"),
435
+ app: {
436
+ get: <T = JsonValue>(key: string) => call("storage.app.get", { key }) as Promise<T | null>,
437
+ set: async (key, value) => {
438
+ await call("storage.app.set", { key, value });
439
+ },
440
+ delete: async (key) => {
441
+ await call("storage.app.delete", { key });
442
+ },
443
+ keys: () => call("storage.app.keys"),
444
+ },
445
+ },
446
+ documents: {
447
+ list: () => call("documents.list"),
448
+ get: <T = JsonValue>(id: string) =>
449
+ call("documents.get", { id }) as Promise<(TavoraDocument & { data: T }) | null>,
450
+ put: (document) => call("documents.put", document),
451
+ delete: async (id, options = {}) => {
452
+ await call("documents.delete", { id, ...options });
453
+ },
454
+ onChange: (fn) => on("documents.changed", fn),
455
+ },
456
+ panel: {
457
+ type: host.panel?.type ?? null,
458
+ getState: <T = JsonValue>() => call("panel.getState") as Promise<T | null>,
459
+ setState: async (value) => {
460
+ await call("panel.setState", { value });
461
+ },
462
+ getLaunch: () => call("panel.getLaunch"),
463
+ setContent: async (content) => {
464
+ await call("panel.setContent", { content });
465
+ },
466
+ onNavigate: (fn) => {
467
+ navigate = fn;
468
+ return () => {
469
+ if (navigate === fn) navigate = null;
470
+ };
471
+ },
472
+ },
473
+ panels: {
474
+ // Tavora may wait for another panel to save first.
475
+ open: (type, options = {}) =>
476
+ call("panels.open", { type, ...(options.content ? { content: options.content } : {}) }, LIMITS.navigateTimeoutMs + 5_000),
477
+ },
478
+ workspace: {
479
+ getViewport: () => call("workspace.getViewport"),
480
+ onResize: (fn) => on("workspace.resize", fn),
481
+ },
482
+ ui: {
483
+ getTheme: () => call("ui.getTheme"),
484
+ onThemeChange: (fn) => on("ui.theme", fn),
485
+ getLanguage: () => call("ui.getLanguage"),
486
+ onLanguageChange: (fn) => on("ui.language", fn),
487
+ // The player may take their time choosing.
488
+ showMenu: (request) => call("ui.showMenu", request, MENU_TIMEOUT_MS),
489
+ },
490
+ blobs: {
491
+ put: async (file) => {
492
+ let response: Response;
493
+ try {
494
+ response = await fetch(new URL(BLOB_PATH, window.location.href), {
495
+ method: "POST",
496
+ body: file,
497
+ headers: { "Content-Type": file.type || "application/octet-stream" },
498
+ });
499
+ } catch {
500
+ throw new TavoraError({ code: "INTERNAL", message: "could not store the file" });
501
+ }
502
+ const body = (await response.json().catch(() => null)) as (BlobInfo & { error?: ErrorPayload }) | null;
503
+ if (!response.ok || !body || body.error) {
504
+ throw new TavoraError(body?.error ?? { code: "INTERNAL", message: `could not store the file (${response.status})` });
505
+ }
506
+ return body;
507
+ },
508
+ url: (id) => new URL(`${BLOB_PATH}${encodeURIComponent(id)}`, window.location.href).href,
509
+ list: () => call("blobs.list"),
510
+ usage: () => call("blobs.usage"),
511
+ delete: async (id) => {
512
+ await call("blobs.delete", { id });
513
+ },
514
+ },
515
+ clipboard: {
516
+ writeImage: async (png) => {
517
+ if (png.type !== "image/png") throw new TavoraError({ code: "INVALID_PARAMS", message: "only a PNG picture can be copied" });
518
+ let response: Response;
519
+ try {
520
+ response = await fetch(new URL(CLIPBOARD_PATH, window.location.href), { method: "POST", body: png, headers: { "Content-Type": "image/png" } });
521
+ } catch {
522
+ throw new TavoraError({ code: "INTERNAL", message: "could not copy the picture" });
523
+ }
524
+ if (response.ok) return;
525
+ const body = (await response.json().catch(() => null)) as { error?: ErrorPayload } | null;
526
+ throw new TavoraError(body?.error ?? { code: "INTERNAL", message: `could not copy the picture (${response.status})` });
527
+ },
528
+ },
529
+ pictures: {
530
+ save: async (png, name = "") => {
531
+ if (png.type !== "image/png") throw new TavoraError({ code: "INVALID_PARAMS", message: "only a PNG picture can be saved" });
532
+ const address = new URL(PICTURES_PATH, window.location.href);
533
+ if (name) address.searchParams.set("name", name);
534
+ let response: Response;
535
+ try {
536
+ response = await fetch(address, { method: "POST", body: png, headers: { "Content-Type": "image/png" } });
537
+ } catch {
538
+ throw new TavoraError({ code: "INTERNAL", message: "could not save the picture" });
539
+ }
540
+ const body = (await response.json().catch(() => null)) as { fileName?: string; error?: ErrorPayload } | null;
541
+ if (response.ok && typeof body?.fileName === "string") return { fileName: body.fileName };
542
+ throw new TavoraError(body?.error ?? { code: "INTERNAL", message: `could not save the picture (${response.status})` });
543
+ },
544
+ },
545
+ links: {
546
+ open: async (url) => {
547
+ await call("links.open", { url });
548
+ },
549
+ },
550
+ network: {
551
+ // The host gives up after its own limit; this waits a little longer so its answer arrives first.
552
+ fetch: (url, init = {}) => call("network.fetch", { ...init, url }, LIMITS.networkTimeoutMs + 5000),
553
+ },
554
+ connectors: {
555
+ aiUsage: {
556
+ accounts: () => call("aiUsage.accounts"),
557
+ read: (accountId) => call("aiUsage.read", { accountId }, LIMITS.aiUsageTimeoutMs + 5000),
558
+ onAccountsChange: (fn) => on("aiUsage.accountsChanged", () => fn()),
559
+ },
560
+ discord: {
561
+ state: () => call("discord.state"),
562
+ onStateChange: (fn) => on("discord.state", fn),
563
+ connect: async () => {
564
+ await call("discord.connect");
565
+ },
566
+ allowControl: async () => {
567
+ await call("discord.allowControl");
568
+ },
569
+ command: (command, args) => call("discord.command", { command, args }, LIMITS.discordTimeoutMs + 5000),
570
+ subscribe: async (event, channelId) => {
571
+ await call("discord.subscribe", channelId === undefined ? { event } : { event, channelId });
572
+ },
573
+ unsubscribe: async (event, channelId) => {
574
+ await call("discord.unsubscribe", channelId === undefined ? { event } : { event, channelId });
575
+ },
576
+ onEvent: (fn) => on("discord.event", fn),
577
+ image: (url) => call("discord.image", { url }),
578
+ },
579
+ },
580
+ lifecycle: {
581
+ onSuspend: (fn) => on("lifecycle.suspend", fn),
582
+ onResume: (fn) => on("lifecycle.resume", fn),
583
+ onDispose: (fn) => on("lifecycle.dispose", fn),
584
+ },
585
+ };
586
+ }