@tavora/sdk 0.0.0-stage → 0.2.2
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/LICENSE +21 -0
- package/README.md +68 -3
- package/dist/index.d.ts +173 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +267 -0
- package/dist/index.js.map +1 -0
- package/dist/protocol.d.ts +640 -0
- package/dist/protocol.d.ts.map +1 -0
- package/dist/protocol.js +123 -0
- package/dist/protocol.js.map +1 -0
- package/package.json +43 -3
- package/src/index.ts +491 -0
- package/src/protocol.ts +539 -0
package/dist/protocol.js
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// Wire protocol v1 between a module and the Tavora host, carried over a
|
|
2
|
+
// MessagePort. Specified in apps/docs/reference/protocol.md.
|
|
3
|
+
//
|
|
4
|
+
// Identity (which module, which game, which panel) is never part of a
|
|
5
|
+
// message. The host binds it to the port when it creates the channel.
|
|
6
|
+
/** Major protocol version. Every message carries it as `v`. */
|
|
7
|
+
export const PROTOCOL_VERSION = 1;
|
|
8
|
+
/** Posted by the SDK on its own window to ask the host for a bridge port. */
|
|
9
|
+
export const HELLO_MESSAGE = "tavora:hello";
|
|
10
|
+
/** Posted by the host preload on the module window, carrying the port. */
|
|
11
|
+
export const PORT_MESSAGE = "tavora:port";
|
|
12
|
+
/** Names of the semantic colour tokens in TavoraTheme.tokens. */
|
|
13
|
+
export const THEME_TOKENS = [
|
|
14
|
+
"background",
|
|
15
|
+
"foreground",
|
|
16
|
+
"card",
|
|
17
|
+
"card-foreground",
|
|
18
|
+
"popover",
|
|
19
|
+
"popover-foreground",
|
|
20
|
+
"primary",
|
|
21
|
+
"primary-hover",
|
|
22
|
+
"primary-foreground",
|
|
23
|
+
"secondary",
|
|
24
|
+
"secondary-foreground",
|
|
25
|
+
"muted",
|
|
26
|
+
"muted-foreground",
|
|
27
|
+
"accent",
|
|
28
|
+
"accent-foreground",
|
|
29
|
+
"destructive",
|
|
30
|
+
"border",
|
|
31
|
+
"input",
|
|
32
|
+
"ring",
|
|
33
|
+
"rail",
|
|
34
|
+
"success",
|
|
35
|
+
"warning",
|
|
36
|
+
"info",
|
|
37
|
+
];
|
|
38
|
+
export const DISCORD_COMMANDS = [
|
|
39
|
+
"GET_SELECTED_VOICE_CHANNEL",
|
|
40
|
+
"GET_GUILDS",
|
|
41
|
+
"GET_GUILD",
|
|
42
|
+
"GET_CHANNELS",
|
|
43
|
+
"GET_CHANNEL",
|
|
44
|
+
"SELECT_VOICE_CHANNEL",
|
|
45
|
+
"SET_VOICE_SETTINGS",
|
|
46
|
+
"SET_USER_VOICE_SETTINGS",
|
|
47
|
+
];
|
|
48
|
+
/**
|
|
49
|
+
* Discord RPC events a module may subscribe to. VOICE_CHANNEL_SELECT (the player joined, moved or left)
|
|
50
|
+
* takes no channel; the others are for one voice channel and need its id.
|
|
51
|
+
*/
|
|
52
|
+
export const DISCORD_EVENTS = ["VOICE_CHANNEL_SELECT", "VOICE_STATE_CREATE", "VOICE_STATE_UPDATE", "VOICE_STATE_DELETE", "SPEAKING_START", "SPEAKING_STOP"];
|
|
53
|
+
/** Methods reserved for later milestones, and the capability each needs. */
|
|
54
|
+
export const RESERVED_METHODS = {
|
|
55
|
+
"files.open": "files",
|
|
56
|
+
"files.save": "files",
|
|
57
|
+
"clipboard.writeText": "clipboard",
|
|
58
|
+
"clipboard.readText": "clipboard",
|
|
59
|
+
"sharedData.publish": "sharedData",
|
|
60
|
+
"sharedData.read": "sharedData",
|
|
61
|
+
"sharedData.subscribe": "sharedData",
|
|
62
|
+
};
|
|
63
|
+
export const LIMITS = {
|
|
64
|
+
/** Largest serialized request the host accepts. */
|
|
65
|
+
maxMessageBytes: 8 * 1024 * 1024,
|
|
66
|
+
/** Requests a module may have waiting at once. */
|
|
67
|
+
maxInFlight: 64,
|
|
68
|
+
maxKeyLength: 256,
|
|
69
|
+
/** Settings storage per module and game. */
|
|
70
|
+
maxStoreBytes: 5 * 1024 * 1024,
|
|
71
|
+
/** One document's data. */
|
|
72
|
+
maxDocumentBytes: 4 * 1024 * 1024,
|
|
73
|
+
/** All documents of one module in one game. */
|
|
74
|
+
maxDocumentsBytes: 50 * 1024 * 1024,
|
|
75
|
+
maxDocuments: 1000,
|
|
76
|
+
maxTitleLength: 200,
|
|
77
|
+
/** UI state for one panel. */
|
|
78
|
+
maxPanelStateBytes: 256 * 1024,
|
|
79
|
+
/** One stored file (see Blobs). */
|
|
80
|
+
maxBlobBytes: 50 * 1024 * 1024,
|
|
81
|
+
/** All stored files of one module in one game. */
|
|
82
|
+
maxBlobsBytes: 500 * 1024 * 1024,
|
|
83
|
+
/** A PNG a module puts on the clipboard. */
|
|
84
|
+
maxClipboardImageBytes: 20 * 1024 * 1024,
|
|
85
|
+
/** The body of one network.fetch answer, before any base64. A longer answer fails with QUOTA_EXCEEDED. */
|
|
86
|
+
maxNetworkResponseBytes: 5 * 1024 * 1024,
|
|
87
|
+
/** How long one network.fetch may take, redirects included. */
|
|
88
|
+
networkTimeoutMs: 15_000,
|
|
89
|
+
/** Redirects one network.fetch follows; each must go to a granted host. */
|
|
90
|
+
maxNetworkRedirects: 5,
|
|
91
|
+
/** network.fetch calls one panel may have running at once; more fail with BAD_REQUEST. */
|
|
92
|
+
maxNetworkInFlight: 6,
|
|
93
|
+
/** How often Tavora reads one AI account at most, for every module together; asking sooner gives the last reading. */
|
|
94
|
+
aiUsageMinReadMs: 60_000,
|
|
95
|
+
/** How long one aiUsage.read may take; the Codex command line can be slow to start. */
|
|
96
|
+
aiUsageTimeoutMs: 40_000,
|
|
97
|
+
/** How long one discord.command may take; a slower answer is `no-answer`. */
|
|
98
|
+
discordTimeoutMs: 15_000,
|
|
99
|
+
/** Largest Discord answer or event passed on, as JSON; a larger one is `no-answer` (or dropped, for an event). */
|
|
100
|
+
maxDiscordAnswerBytes: 512 * 1024,
|
|
101
|
+
/** Discord events one panel may subscribe to at once. */
|
|
102
|
+
maxDiscordSubscriptions: 32,
|
|
103
|
+
/** Entries in one menu, submenus included. */
|
|
104
|
+
maxMenuEntries: 100,
|
|
105
|
+
/** Characters of a menu label or heading. */
|
|
106
|
+
maxMenuLabel: 80,
|
|
107
|
+
/** Characters of the hint on the right of a menu item, such as a shortcut. */
|
|
108
|
+
maxMenuHint: 30,
|
|
109
|
+
};
|
|
110
|
+
/** The kinds of file a module can keep with blobs.put, found by looking at the file itself. */
|
|
111
|
+
export const BLOB_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"];
|
|
112
|
+
/**
|
|
113
|
+
* Files are sent to and shown from the module's own address, not over the
|
|
114
|
+
* bridge: POST /_blobs/ stores the request body and answers with a BlobInfo,
|
|
115
|
+
* GET /_blobs/<id> serves a file. Both only work from the module's own page.
|
|
116
|
+
*/
|
|
117
|
+
export const BLOB_PATH = "/_blobs/";
|
|
118
|
+
export const BLOB_ID = /^[a-f0-9]{32}$/;
|
|
119
|
+
/** POST a PNG here, from the module's own page, to put it on the clipboard (see clipboard.writeImage). */
|
|
120
|
+
export const CLIPBOARD_PATH = "/_clipboard/";
|
|
121
|
+
/** Document ids: host-generated UUIDs or module-chosen slugs. */
|
|
122
|
+
export const DOCUMENT_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;
|
|
123
|
+
//# sourceMappingURL=protocol.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"protocol.js","sourceRoot":"","sources":["../src/protocol.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,46 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tavora/sdk",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.2.2",
|
|
4
|
+
"description": "Typed client for Tavora modules: talks to the Tavora Desktop host over its bridge.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"sideEffects": false,
|
|
7
|
+
"keywords": [
|
|
8
|
+
"tavora",
|
|
9
|
+
"module",
|
|
10
|
+
"sdk",
|
|
11
|
+
"gaming"
|
|
12
|
+
],
|
|
13
|
+
"repository": {
|
|
14
|
+
"type": "git",
|
|
15
|
+
"url": "git+https://github.com/wellbrained/tavora.git",
|
|
16
|
+
"directory": "packages/sdk"
|
|
17
|
+
},
|
|
18
|
+
"homepage": "https://docs.mytavora.app/",
|
|
19
|
+
"bugs": "https://github.com/wellbrained/tavora/issues",
|
|
20
|
+
"exports": {
|
|
21
|
+
".": {
|
|
22
|
+
"types": "./dist/index.d.ts",
|
|
23
|
+
"default": "./dist/index.js"
|
|
24
|
+
},
|
|
25
|
+
"./protocol": {
|
|
26
|
+
"types": "./dist/protocol.d.ts",
|
|
27
|
+
"default": "./dist/protocol.js"
|
|
28
|
+
},
|
|
29
|
+
"./package.json": "./package.json"
|
|
30
|
+
},
|
|
31
|
+
"publishConfig": {
|
|
32
|
+
"access": "public"
|
|
33
|
+
},
|
|
34
|
+
"files": [
|
|
35
|
+
"dist",
|
|
36
|
+
"src",
|
|
37
|
+
"!src/**/*.test.ts",
|
|
38
|
+
"README.md",
|
|
39
|
+
"CHANGELOG.md"
|
|
40
|
+
],
|
|
41
|
+
"license": "MIT",
|
|
42
|
+
"scripts": {
|
|
43
|
+
"build": "tsc -p tsconfig.build.json",
|
|
44
|
+
"typecheck": "tsc --noEmit -p ."
|
|
45
|
+
}
|
|
6
46
|
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,491 @@
|
|
|
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
|
+
HELLO_MESSAGE,
|
|
9
|
+
LIMITS,
|
|
10
|
+
PORT_MESSAGE,
|
|
11
|
+
PROTOCOL_VERSION,
|
|
12
|
+
type AiUsageAccountInfo,
|
|
13
|
+
type AiUsageReading,
|
|
14
|
+
type BlobInfo,
|
|
15
|
+
type BlobUsage,
|
|
16
|
+
type Capability,
|
|
17
|
+
type DiscordCommand,
|
|
18
|
+
type DiscordCommands,
|
|
19
|
+
type DiscordEvent,
|
|
20
|
+
type DiscordEventName,
|
|
21
|
+
type DiscordResult,
|
|
22
|
+
type DiscordState,
|
|
23
|
+
type DocumentChange,
|
|
24
|
+
type DocumentSummary,
|
|
25
|
+
type ErrorCode,
|
|
26
|
+
type ErrorPayload,
|
|
27
|
+
type EventName,
|
|
28
|
+
type Events,
|
|
29
|
+
type HandshakeResult,
|
|
30
|
+
type HostMessage,
|
|
31
|
+
type JsonValue,
|
|
32
|
+
type MenuRequest,
|
|
33
|
+
type MethodName,
|
|
34
|
+
type Methods,
|
|
35
|
+
type NetworkRequest,
|
|
36
|
+
type NetworkResponse,
|
|
37
|
+
type PutDocumentParams,
|
|
38
|
+
type TavoraDocument,
|
|
39
|
+
type TavoraGame,
|
|
40
|
+
type TavoraTheme,
|
|
41
|
+
type TavoraViewport,
|
|
42
|
+
} from "./protocol.js";
|
|
43
|
+
|
|
44
|
+
export type {
|
|
45
|
+
AiUsageAccountInfo,
|
|
46
|
+
AiUsageReading,
|
|
47
|
+
BlobInfo,
|
|
48
|
+
BlobType,
|
|
49
|
+
BlobUsage,
|
|
50
|
+
Capability,
|
|
51
|
+
DiscordCommand,
|
|
52
|
+
DiscordCommands,
|
|
53
|
+
DiscordEvent,
|
|
54
|
+
DiscordEventName,
|
|
55
|
+
DiscordResult,
|
|
56
|
+
DiscordState,
|
|
57
|
+
DocumentChange,
|
|
58
|
+
DocumentSummary,
|
|
59
|
+
ErrorCode,
|
|
60
|
+
HandshakeResult,
|
|
61
|
+
JsonValue,
|
|
62
|
+
MenuEntry,
|
|
63
|
+
MenuRequest,
|
|
64
|
+
NetworkRequest,
|
|
65
|
+
NetworkResponse,
|
|
66
|
+
PutDocumentParams,
|
|
67
|
+
TavoraDocument,
|
|
68
|
+
TavoraGame,
|
|
69
|
+
TavoraTheme,
|
|
70
|
+
TavoraViewport,
|
|
71
|
+
ThemeToken,
|
|
72
|
+
} from "./protocol.js";
|
|
73
|
+
export { BLOB_ID, BLOB_TYPES, DISCORD_COMMANDS, DISCORD_EVENTS, LIMITS, PROTOCOL_VERSION, THEME_TOKENS } from "./protocol.js";
|
|
74
|
+
|
|
75
|
+
/** How long a menu may stay open; the host closes it itself when its panel goes. */
|
|
76
|
+
const MENU_TIMEOUT_MS = 60 * 60_000;
|
|
77
|
+
|
|
78
|
+
/** Version of this SDK package, sent to the host during the handshake. */
|
|
79
|
+
export const SDK_VERSION = "0.2.2";
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The oldest Tavora that has everything this SDK version offers. Put it in your manifest as
|
|
83
|
+
* `minTavora`, so an older Tavora says it needs an update instead of loading a module it cannot run
|
|
84
|
+
* (the starter, `npm create @tavora/module`, does). The changelog names it for every version.
|
|
85
|
+
*/
|
|
86
|
+
export const MIN_TAVORA = "0.17.12";
|
|
87
|
+
|
|
88
|
+
export class TavoraError extends Error {
|
|
89
|
+
readonly code: ErrorCode;
|
|
90
|
+
/** Set for PERMISSION_DENIED. */
|
|
91
|
+
readonly capability: Capability | undefined;
|
|
92
|
+
/** Set for CONFLICT: the document's current revision. */
|
|
93
|
+
readonly revision: number | undefined;
|
|
94
|
+
|
|
95
|
+
constructor(payload: ErrorPayload) {
|
|
96
|
+
super(payload.message);
|
|
97
|
+
this.name = "TavoraError";
|
|
98
|
+
this.code = payload.code;
|
|
99
|
+
this.capability = payload.capability;
|
|
100
|
+
this.revision = payload.revision;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
type Unsubscribe = () => void;
|
|
105
|
+
|
|
106
|
+
/** Small key/value settings: at most 5 MB of keys and values together, values plain JSON. */
|
|
107
|
+
export interface KeyValueStore {
|
|
108
|
+
/** T is not checked at runtime; the host only guarantees the value is JSON. */
|
|
109
|
+
get<T = JsonValue>(key: string): Promise<T | null>;
|
|
110
|
+
set(key: string, value: JsonValue): Promise<void>;
|
|
111
|
+
delete(key: string): Promise<void>;
|
|
112
|
+
keys(): Promise<string[]>;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export interface TavoraSDK {
|
|
116
|
+
/** What the host reported when this module connected. */
|
|
117
|
+
readonly host: HandshakeResult;
|
|
118
|
+
game: {
|
|
119
|
+
getCurrent(): Promise<TavoraGame>;
|
|
120
|
+
};
|
|
121
|
+
/** Small key/value settings, private to this module in the current game. */
|
|
122
|
+
storage: KeyValueStore & {
|
|
123
|
+
/** The same, shared by every game: settings that should not change from game to game. */
|
|
124
|
+
app: KeyValueStore;
|
|
125
|
+
};
|
|
126
|
+
/**
|
|
127
|
+
* Named documents (builds, plans, checklists), private to this module in
|
|
128
|
+
* the current game and shared by all of its panels.
|
|
129
|
+
*/
|
|
130
|
+
documents: {
|
|
131
|
+
list(): Promise<DocumentSummary[]>;
|
|
132
|
+
get<T = JsonValue>(id: string): Promise<(TavoraDocument & { data: T }) | null>;
|
|
133
|
+
/** Creates or replaces a document. Pass ifRevision to avoid overwriting another panel's change. */
|
|
134
|
+
put(document: PutDocumentParams): Promise<DocumentSummary>;
|
|
135
|
+
delete(id: string, options?: { ifRevision?: number }): Promise<void>;
|
|
136
|
+
/** Fires when another panel of this module changes a document. */
|
|
137
|
+
onChange(fn: (change: DocumentChange) => void): Unsubscribe;
|
|
138
|
+
};
|
|
139
|
+
/** UI state for this one panel (selection, scroll, open tab). Cleared when the panel is closed. */
|
|
140
|
+
panel: {
|
|
141
|
+
/** Which of the manifest's `panels` this panel is, by id; null for a module without panel types (or an older Tavora). */
|
|
142
|
+
readonly type: string | null;
|
|
143
|
+
getState<T = JsonValue>(): Promise<T | null>;
|
|
144
|
+
setState(value: JsonValue | null): Promise<void>;
|
|
145
|
+
};
|
|
146
|
+
workspace: {
|
|
147
|
+
getViewport(): Promise<TavoraViewport>;
|
|
148
|
+
onResize(fn: (viewport: TavoraViewport) => void): Unsubscribe;
|
|
149
|
+
};
|
|
150
|
+
ui: {
|
|
151
|
+
getTheme(): Promise<TavoraTheme>;
|
|
152
|
+
onThemeChange(fn: (theme: TavoraTheme) => void): Unsubscribe;
|
|
153
|
+
/** 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. */
|
|
154
|
+
getLanguage(): Promise<string>;
|
|
155
|
+
/** Fires when the player changes the language. */
|
|
156
|
+
onLanguageChange(fn: (language: string) => void): Unsubscribe;
|
|
157
|
+
/**
|
|
158
|
+
* Shows a menu that Tavora draws above every panel, so it is never cut off at the panel's edge. Call it from
|
|
159
|
+
* the click, right-click or key press that asked for it: the host refuses it otherwise (PERMISSION_DENIED).
|
|
160
|
+
* Resolves with the chosen item's id, or null when the player closed the menu without choosing.
|
|
161
|
+
*/
|
|
162
|
+
showMenu(request: MenuRequest): Promise<string | null>;
|
|
163
|
+
};
|
|
164
|
+
/**
|
|
165
|
+
* Files (pictures) this module keeps in the current game, shared by all of its panels. They live
|
|
166
|
+
* in a folder Tavora manages, not in a document, so they can be large: up to 50 MB each and
|
|
167
|
+
* 500 MB together. Only PNG, JPEG, GIF and WebP are accepted, judged by the file itself.
|
|
168
|
+
*/
|
|
169
|
+
blobs: {
|
|
170
|
+
/** Stores a file and returns its id. Rejects with QUOTA_EXCEEDED, or INVALID_PARAMS for another kind of file. */
|
|
171
|
+
put(file: Blob): Promise<BlobInfo>;
|
|
172
|
+
/** The address to show a stored file from, for example as an image source. */
|
|
173
|
+
url(id: string): string;
|
|
174
|
+
list(): Promise<BlobInfo[]>;
|
|
175
|
+
usage(): Promise<BlobUsage>;
|
|
176
|
+
/** Deletes a file; nothing happens when it does not exist. */
|
|
177
|
+
delete(id: string): Promise<void>;
|
|
178
|
+
};
|
|
179
|
+
clipboard: {
|
|
180
|
+
/**
|
|
181
|
+
* Puts a picture on the system clipboard, so the player can paste it anywhere. Call it from the click or key
|
|
182
|
+
* press that asked for it: the host refuses it otherwise (PERMISSION_DENIED), and one press copies one
|
|
183
|
+
* picture. PNG only, a complete one, at most 20 MB and 16384 pixels on a side (INVALID_PARAMS, QUOTA_EXCEEDED).
|
|
184
|
+
*/
|
|
185
|
+
writeImage(png: Blob): Promise<void>;
|
|
186
|
+
};
|
|
187
|
+
links: {
|
|
188
|
+
/**
|
|
189
|
+
* Opens a web address (http or https) in a Browser panel of this game, as a new tab or in a new
|
|
190
|
+
* panel. Call it from the click or key press that asked for it: the host refuses it otherwise
|
|
191
|
+
* (PERMISSION_DENIED). The module learns nothing about the page.
|
|
192
|
+
*/
|
|
193
|
+
open(url: string): Promise<void>;
|
|
194
|
+
};
|
|
195
|
+
network: {
|
|
196
|
+
/**
|
|
197
|
+
* Makes a web request through Tavora, to a host listed in `permissions.network` that the player
|
|
198
|
+
* granted (PERMISSION_DENIED otherwise). HTTPS only, no cookies, at most 5 MB back and 15 seconds.
|
|
199
|
+
* Error statuses such as 404 are answers, not errors; NETWORK_ERROR means no answer came.
|
|
200
|
+
*/
|
|
201
|
+
fetch(url: string, init?: Omit<NetworkRequest, "url">): Promise<NetworkResponse>;
|
|
202
|
+
};
|
|
203
|
+
/** 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). */
|
|
204
|
+
connectors: {
|
|
205
|
+
/** `ai-usage`: the usage of the Claude and Codex accounts the player set up in Settings > Integrations > AI usage. */
|
|
206
|
+
aiUsage: {
|
|
207
|
+
accounts(): Promise<AiUsageAccountInfo[]>;
|
|
208
|
+
/** Reads one account. Tavora reads each account at most once a minute; asking sooner gives the last reading. */
|
|
209
|
+
read(accountId: string): Promise<AiUsageReading>;
|
|
210
|
+
/** Fires when the player adds, renames or removes an account. */
|
|
211
|
+
onAccountsChange(fn: () => void): Unsubscribe;
|
|
212
|
+
};
|
|
213
|
+
/**
|
|
214
|
+
* `discord`: the Discord app on this PC, through Tavora's Discord sign-in. The connection runs while a
|
|
215
|
+
* panel has asked for state(); Tavora keeps the sign-in and sends only the commands in DISCORD_COMMANDS.
|
|
216
|
+
*/
|
|
217
|
+
discord: {
|
|
218
|
+
/** The connection now. The first call starts it for this panel. */
|
|
219
|
+
state(): Promise<DiscordState>;
|
|
220
|
+
onStateChange(fn: (state: DiscordState) => void): Unsubscribe;
|
|
221
|
+
/** Signs in, asking for approval in Discord the first time. Call it from a click or key press handler. */
|
|
222
|
+
connect(): Promise<void>;
|
|
223
|
+
/** Asks Discord to approve muting and deafening, for a sign-in that can't yet (canControl false). From a click or key press. */
|
|
224
|
+
allowControl(): Promise<void>;
|
|
225
|
+
/** 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. */
|
|
226
|
+
command<C extends DiscordCommand>(command: C, args: DiscordCommands[C]): Promise<DiscordResult>;
|
|
227
|
+
/** Starts sending an event to this panel (VOICE_CHANNEL_SELECT without a channel, the others for one voice channel). Kept across reconnects. */
|
|
228
|
+
subscribe(event: DiscordEventName, channelId?: string): Promise<void>;
|
|
229
|
+
unsubscribe(event: DiscordEventName, channelId?: string): Promise<void>;
|
|
230
|
+
onEvent(fn: (event: DiscordEvent) => void): Unsubscribe;
|
|
231
|
+
/** A picture from cdn.discordapp.com (an avatar or server icon address) as a small PNG data address, or null. */
|
|
232
|
+
image(url: string): Promise<string | null>;
|
|
233
|
+
};
|
|
234
|
+
};
|
|
235
|
+
lifecycle: {
|
|
236
|
+
/** The panel is hidden; pause timers and animations. Called immediately if already suspended. */
|
|
237
|
+
onSuspend(fn: () => void): Unsubscribe;
|
|
238
|
+
onResume(fn: () => void): Unsubscribe;
|
|
239
|
+
/** Best effort only: autosave must not depend on this firing. */
|
|
240
|
+
onDispose(fn: () => void): Unsubscribe;
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export interface ConnectOptions {
|
|
245
|
+
/** How long to wait for the host to hand over a port. Default 5000 ms. */
|
|
246
|
+
connectTimeoutMs?: number;
|
|
247
|
+
/** How long to wait for each request. Default 10000 ms. */
|
|
248
|
+
requestTimeoutMs?: number;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
let connection: Promise<TavoraSDK> | undefined;
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Connects to the Tavora host. Safe to call more than once: every call
|
|
255
|
+
* returns the same connection for this window. Rejects with a TavoraError
|
|
256
|
+
* (HOST_UNAVAILABLE, INCOMPATIBLE) if the module cannot run here.
|
|
257
|
+
*/
|
|
258
|
+
export function connect(options: ConnectOptions = {}): Promise<TavoraSDK> {
|
|
259
|
+
connection ??= openPort(options.connectTimeoutMs ?? 5000)
|
|
260
|
+
.then((port) => createClient(port, options.requestTimeoutMs ?? 10_000))
|
|
261
|
+
.catch((error: unknown) => {
|
|
262
|
+
connection = undefined;
|
|
263
|
+
throw error;
|
|
264
|
+
});
|
|
265
|
+
return connection;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function openPort(timeoutMs: number): Promise<MessagePort> {
|
|
269
|
+
return new Promise((resolve, reject) => {
|
|
270
|
+
const timer = setTimeout(() => {
|
|
271
|
+
window.removeEventListener("message", onMessage);
|
|
272
|
+
reject(
|
|
273
|
+
new TavoraError({
|
|
274
|
+
code: "HOST_UNAVAILABLE",
|
|
275
|
+
message: "Tavora host did not respond. Is this module running inside Tavora?",
|
|
276
|
+
}),
|
|
277
|
+
);
|
|
278
|
+
}, timeoutMs);
|
|
279
|
+
|
|
280
|
+
function onMessage(event: MessageEvent) {
|
|
281
|
+
if (event.source !== window || event.data?.type !== PORT_MESSAGE || !event.ports[0]) return;
|
|
282
|
+
clearTimeout(timer);
|
|
283
|
+
window.removeEventListener("message", onMessage);
|
|
284
|
+
resolve(event.ports[0]);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
window.addEventListener("message", onMessage);
|
|
288
|
+
window.postMessage({ type: HELLO_MESSAGE, v: PROTOCOL_VERSION }, window.location.origin);
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
async function createClient(port: MessagePort, requestTimeoutMs: number): Promise<TavoraSDK> {
|
|
293
|
+
let nextId = 1;
|
|
294
|
+
const pending = new Map<
|
|
295
|
+
number,
|
|
296
|
+
{ resolve(value: unknown): void; reject(error: TavoraError): void; timer: ReturnType<typeof setTimeout> }
|
|
297
|
+
>();
|
|
298
|
+
const listeners = new Map<EventName, Set<(data: unknown) => void>>();
|
|
299
|
+
let suspended = false;
|
|
300
|
+
|
|
301
|
+
port.onmessage = (event: MessageEvent<HostMessage>) => {
|
|
302
|
+
const message = event.data;
|
|
303
|
+
if (typeof message !== "object" || message === null || message.v !== PROTOCOL_VERSION) return;
|
|
304
|
+
|
|
305
|
+
if ("event" in message) {
|
|
306
|
+
if (message.event === "lifecycle.suspend") suspended = true;
|
|
307
|
+
if (message.event === "lifecycle.resume") suspended = false;
|
|
308
|
+
for (const fn of listeners.get(message.event) ?? []) {
|
|
309
|
+
try {
|
|
310
|
+
fn(message.data);
|
|
311
|
+
} catch (error) {
|
|
312
|
+
console.error(`[tavora] ${message.event} listener failed`, error);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const entry = pending.get(message.id);
|
|
319
|
+
if (!entry) return;
|
|
320
|
+
pending.delete(message.id);
|
|
321
|
+
clearTimeout(entry.timer);
|
|
322
|
+
if (message.ok) entry.resolve(message.result);
|
|
323
|
+
else entry.reject(new TavoraError(message.error));
|
|
324
|
+
};
|
|
325
|
+
port.start();
|
|
326
|
+
|
|
327
|
+
function call<M extends MethodName>(method: M, params?: Methods[M]["params"], timeoutMs = requestTimeoutMs): Promise<Methods[M]["result"]> {
|
|
328
|
+
const id = nextId++;
|
|
329
|
+
return new Promise((resolve, reject) => {
|
|
330
|
+
const timer = setTimeout(() => {
|
|
331
|
+
pending.delete(id);
|
|
332
|
+
reject(new TavoraError({ code: "TIMEOUT", message: `${method} timed out` }));
|
|
333
|
+
}, Math.max(timeoutMs, requestTimeoutMs));
|
|
334
|
+
pending.set(id, { resolve: resolve as (value: unknown) => void, reject, timer });
|
|
335
|
+
port.postMessage({ v: PROTOCOL_VERSION, id, method, params });
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function on<E extends EventName>(event: E, fn: (data: Events[E]) => void): Unsubscribe {
|
|
340
|
+
let set = listeners.get(event);
|
|
341
|
+
if (!set) listeners.set(event, (set = new Set()));
|
|
342
|
+
const listener = fn as (data: unknown) => void;
|
|
343
|
+
set.add(listener);
|
|
344
|
+
// Initial suspension can arrive before connect() callers register listeners.
|
|
345
|
+
if (event === "lifecycle.suspend" && suspended) {
|
|
346
|
+
try {
|
|
347
|
+
listener(null);
|
|
348
|
+
} catch (error) {
|
|
349
|
+
console.error(`[tavora] ${event} listener failed`, error);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
return () => set.delete(listener);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const host = await call("bridge.handshake", { protocol: PROTOCOL_VERSION, sdkVersion: SDK_VERSION });
|
|
356
|
+
|
|
357
|
+
return {
|
|
358
|
+
host,
|
|
359
|
+
game: {
|
|
360
|
+
getCurrent: () => call("game.getCurrent"),
|
|
361
|
+
},
|
|
362
|
+
storage: {
|
|
363
|
+
get: <T = JsonValue>(key: string) => call("storage.get", { key }) as Promise<T | null>,
|
|
364
|
+
set: async (key, value) => {
|
|
365
|
+
await call("storage.set", { key, value });
|
|
366
|
+
},
|
|
367
|
+
delete: async (key) => {
|
|
368
|
+
await call("storage.delete", { key });
|
|
369
|
+
},
|
|
370
|
+
keys: () => call("storage.keys"),
|
|
371
|
+
app: {
|
|
372
|
+
get: <T = JsonValue>(key: string) => call("storage.app.get", { key }) as Promise<T | null>,
|
|
373
|
+
set: async (key, value) => {
|
|
374
|
+
await call("storage.app.set", { key, value });
|
|
375
|
+
},
|
|
376
|
+
delete: async (key) => {
|
|
377
|
+
await call("storage.app.delete", { key });
|
|
378
|
+
},
|
|
379
|
+
keys: () => call("storage.app.keys"),
|
|
380
|
+
},
|
|
381
|
+
},
|
|
382
|
+
documents: {
|
|
383
|
+
list: () => call("documents.list"),
|
|
384
|
+
get: <T = JsonValue>(id: string) =>
|
|
385
|
+
call("documents.get", { id }) as Promise<(TavoraDocument & { data: T }) | null>,
|
|
386
|
+
put: (document) => call("documents.put", document),
|
|
387
|
+
delete: async (id, options = {}) => {
|
|
388
|
+
await call("documents.delete", { id, ...options });
|
|
389
|
+
},
|
|
390
|
+
onChange: (fn) => on("documents.changed", fn),
|
|
391
|
+
},
|
|
392
|
+
panel: {
|
|
393
|
+
type: host.panel?.type ?? null,
|
|
394
|
+
getState: <T = JsonValue>() => call("panel.getState") as Promise<T | null>,
|
|
395
|
+
setState: async (value) => {
|
|
396
|
+
await call("panel.setState", { value });
|
|
397
|
+
},
|
|
398
|
+
},
|
|
399
|
+
workspace: {
|
|
400
|
+
getViewport: () => call("workspace.getViewport"),
|
|
401
|
+
onResize: (fn) => on("workspace.resize", fn),
|
|
402
|
+
},
|
|
403
|
+
ui: {
|
|
404
|
+
getTheme: () => call("ui.getTheme"),
|
|
405
|
+
onThemeChange: (fn) => on("ui.theme", fn),
|
|
406
|
+
getLanguage: () => call("ui.getLanguage"),
|
|
407
|
+
onLanguageChange: (fn) => on("ui.language", fn),
|
|
408
|
+
// The player may take their time choosing.
|
|
409
|
+
showMenu: (request) => call("ui.showMenu", request, MENU_TIMEOUT_MS),
|
|
410
|
+
},
|
|
411
|
+
blobs: {
|
|
412
|
+
put: async (file) => {
|
|
413
|
+
let response: Response;
|
|
414
|
+
try {
|
|
415
|
+
response = await fetch(new URL(BLOB_PATH, window.location.href), {
|
|
416
|
+
method: "POST",
|
|
417
|
+
body: file,
|
|
418
|
+
headers: { "Content-Type": file.type || "application/octet-stream" },
|
|
419
|
+
});
|
|
420
|
+
} catch {
|
|
421
|
+
throw new TavoraError({ code: "INTERNAL", message: "could not store the file" });
|
|
422
|
+
}
|
|
423
|
+
const body = (await response.json().catch(() => null)) as (BlobInfo & { error?: ErrorPayload }) | null;
|
|
424
|
+
if (!response.ok || !body || body.error) {
|
|
425
|
+
throw new TavoraError(body?.error ?? { code: "INTERNAL", message: `could not store the file (${response.status})` });
|
|
426
|
+
}
|
|
427
|
+
return body;
|
|
428
|
+
},
|
|
429
|
+
url: (id) => new URL(`${BLOB_PATH}${encodeURIComponent(id)}`, window.location.href).href,
|
|
430
|
+
list: () => call("blobs.list"),
|
|
431
|
+
usage: () => call("blobs.usage"),
|
|
432
|
+
delete: async (id) => {
|
|
433
|
+
await call("blobs.delete", { id });
|
|
434
|
+
},
|
|
435
|
+
},
|
|
436
|
+
clipboard: {
|
|
437
|
+
writeImage: async (png) => {
|
|
438
|
+
if (png.type !== "image/png") throw new TavoraError({ code: "INVALID_PARAMS", message: "only a PNG picture can be copied" });
|
|
439
|
+
let response: Response;
|
|
440
|
+
try {
|
|
441
|
+
response = await fetch(new URL(CLIPBOARD_PATH, window.location.href), { method: "POST", body: png, headers: { "Content-Type": "image/png" } });
|
|
442
|
+
} catch {
|
|
443
|
+
throw new TavoraError({ code: "INTERNAL", message: "could not copy the picture" });
|
|
444
|
+
}
|
|
445
|
+
if (response.ok) return;
|
|
446
|
+
const body = (await response.json().catch(() => null)) as { error?: ErrorPayload } | null;
|
|
447
|
+
throw new TavoraError(body?.error ?? { code: "INTERNAL", message: `could not copy the picture (${response.status})` });
|
|
448
|
+
},
|
|
449
|
+
},
|
|
450
|
+
links: {
|
|
451
|
+
open: async (url) => {
|
|
452
|
+
await call("links.open", { url });
|
|
453
|
+
},
|
|
454
|
+
},
|
|
455
|
+
network: {
|
|
456
|
+
// The host gives up after its own limit; this waits a little longer so its answer arrives first.
|
|
457
|
+
fetch: (url, init = {}) => call("network.fetch", { ...init, url }, LIMITS.networkTimeoutMs + 5000),
|
|
458
|
+
},
|
|
459
|
+
connectors: {
|
|
460
|
+
aiUsage: {
|
|
461
|
+
accounts: () => call("aiUsage.accounts"),
|
|
462
|
+
read: (accountId) => call("aiUsage.read", { accountId }, LIMITS.aiUsageTimeoutMs + 5000),
|
|
463
|
+
onAccountsChange: (fn) => on("aiUsage.accountsChanged", () => fn()),
|
|
464
|
+
},
|
|
465
|
+
discord: {
|
|
466
|
+
state: () => call("discord.state"),
|
|
467
|
+
onStateChange: (fn) => on("discord.state", fn),
|
|
468
|
+
connect: async () => {
|
|
469
|
+
await call("discord.connect");
|
|
470
|
+
},
|
|
471
|
+
allowControl: async () => {
|
|
472
|
+
await call("discord.allowControl");
|
|
473
|
+
},
|
|
474
|
+
command: (command, args) => call("discord.command", { command, args }, LIMITS.discordTimeoutMs + 5000),
|
|
475
|
+
subscribe: async (event, channelId) => {
|
|
476
|
+
await call("discord.subscribe", channelId === undefined ? { event } : { event, channelId });
|
|
477
|
+
},
|
|
478
|
+
unsubscribe: async (event, channelId) => {
|
|
479
|
+
await call("discord.unsubscribe", channelId === undefined ? { event } : { event, channelId });
|
|
480
|
+
},
|
|
481
|
+
onEvent: (fn) => on("discord.event", fn),
|
|
482
|
+
image: (url) => call("discord.image", { url }),
|
|
483
|
+
},
|
|
484
|
+
},
|
|
485
|
+
lifecycle: {
|
|
486
|
+
onSuspend: (fn) => on("lifecycle.suspend", fn),
|
|
487
|
+
onResume: (fn) => on("lifecycle.resume", fn),
|
|
488
|
+
onDispose: (fn) => on("lifecycle.dispose", fn),
|
|
489
|
+
},
|
|
490
|
+
};
|
|
491
|
+
}
|