@qping/plugin-bus 0.2.0 → 0.4.0

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/actions.ts ADDED
@@ -0,0 +1,164 @@
1
+ /**
2
+ * Action registry types.
3
+ *
4
+ * A plugin registers its full action set once at startup via `plugin.actions([...])`. Search
5
+ * items and the detail page only reference actions by id; the parameters an action needs are
6
+ * produced inside its own `execute`, never carried on the item.
7
+ */
8
+
9
+ import type { PluginHostEnv } from "./hostEnv.ts";
10
+
11
+ /** An i18n message key plus the English fallback the host uses when the key is missing. */
12
+ export type LocalizedText = {
13
+ key: string;
14
+ defaultValue: string;
15
+ };
16
+
17
+ /** Keys the host deliberately permits for action shortcuts. */
18
+ export const Key = {
19
+ Enter: "Enter", Tab: "Tab", Space: "Space", Delete: "Delete", Backspace: "Backspace",
20
+ Escape: "Escape", Left: "Left", Right: "Right", Up: "Up", Down: "Down",
21
+ A: "A", B: "B", C: "C", D: "D", E: "E", F: "F", G: "G", H: "H", I: "I",
22
+ J: "J", K: "K", L: "L", M: "M", N: "N", O: "O", P: "P", Q: "Q", R: "R",
23
+ S: "S", T: "T", U: "U", V: "V", W: "W", X: "X", Y: "Y", Z: "Z",
24
+ D0: "D0", D1: "D1", D2: "D2", D3: "D3", D4: "D4",
25
+ D5: "D5", D6: "D6", D7: "D7", D8: "D8", D9: "D9",
26
+ F1: "F1", F2: "F2", F3: "F3", F4: "F4", F5: "F5", F6: "F6",
27
+ F7: "F7", F8: "F8", F9: "F9", F10: "F10", F11: "F11", F12: "F12",
28
+ } as const;
29
+
30
+ export type HotkeyKey = (typeof Key)[keyof typeof Key];
31
+
32
+ /** Permitted modifier combinations, e.g. `Modifiers.ControlShift`. */
33
+ export const Modifiers = {
34
+ None: 0,
35
+ Control: 1,
36
+ Alt: 2,
37
+ ControlAlt: 3,
38
+ Shift: 4,
39
+ ControlShift: 5,
40
+ AltShift: 6,
41
+ ControlAltShift: 7,
42
+ } as const;
43
+
44
+ export type HotkeyModifiers = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7;
45
+
46
+ export type Hotkey = {
47
+ key: HotkeyKey;
48
+ modifiers?: HotkeyModifiers;
49
+ };
50
+
51
+ /** Actions the host itself can carry out. Anything else belongs in `execute`. */
52
+ export const HostAction = {
53
+ Copy: "copy",
54
+ CopyAndPaste: "copyAndPaste",
55
+ AddClipboardHistory: "addClipboardHistory",
56
+ Execute: "execute",
57
+ OpenInExplorer: "openInExplorer",
58
+ OpenInBrowser: "openInBrowser",
59
+ OpenPlugin: "openPlugin",
60
+ Run: "run",
61
+ Kill: "kill",
62
+ } as const;
63
+
64
+ export type HostActionKind = (typeof HostAction)[keyof typeof HostAction];
65
+
66
+ /** Command spec for {@link HostAction.Run}. */
67
+ export type RunSpec = {
68
+ name?: string;
69
+ command: string;
70
+ args?: string;
71
+ workingDirectory?: string;
72
+ runAsAdmin?: boolean;
73
+ isBashScript?: boolean;
74
+ scripts?: string | string[];
75
+ };
76
+
77
+ /**
78
+ * What the host should do, with the parameters that kind actually needs. The discriminated union
79
+ * is the point: `{ kind: HostAction.Copy, path }` does not compile.
80
+ */
81
+ export type HostActionRequest =
82
+ | { kind: typeof HostAction.Copy; text: string }
83
+ | { kind: typeof HostAction.CopyAndPaste; text: string }
84
+ | { kind: typeof HostAction.AddClipboardHistory; texts: string[] }
85
+ | { kind: typeof HostAction.Execute; path: string; args?: string; runAsAdmin?: boolean }
86
+ | { kind: typeof HostAction.OpenInExplorer; path: string }
87
+ | { kind: typeof HostAction.OpenInBrowser; url: string | string[] }
88
+ | { kind: typeof HostAction.OpenPlugin; pluginId: string }
89
+ | { kind: typeof HostAction.Run; command: RunSpec }
90
+ | { kind: typeof HostAction.Kill; pid: number };
91
+
92
+ /** Opens a web detail page. `page` defaults to the entry declared in plugin.json. */
93
+ export type DetailRequest = {
94
+ page?: string;
95
+ title?: string;
96
+ initialState?: unknown;
97
+ };
98
+
99
+ export type ActionTarget =
100
+ /** Run one privileged host-side action (clipboard, process launch, browser, ...). */
101
+ | { kind: "host"; action: HostActionRequest }
102
+ /** Hand off to the currently active detail page as host.event.detailAction. */
103
+ | { kind: "web"; payload?: unknown }
104
+ /** Open a new web detail page. */
105
+ | ({ kind: "detail" } & DetailRequest);
106
+
107
+ export type ActionAfter = "keep" | "close" | "refresh";
108
+
109
+ type ActionOutcomeBase = {
110
+ /** Status bar text. */
111
+ message?: LocalizedText;
112
+ };
113
+
114
+ /**
115
+ * The result of running an action. An action selects at most one execution target. Host targets
116
+ * may choose a follow-up lifecycle action; web and detail targets keep their current surface alive.
117
+ */
118
+ export type ActionOutcome = ActionOutcomeBase & (
119
+ | { target?: undefined; after?: ActionAfter }
120
+ | { target: Extract<ActionTarget, { kind: "host" }>; after?: ActionAfter }
121
+ | { target: Exclude<ActionTarget, { kind: "host" }>; after?: "keep" }
122
+ );
123
+
124
+ /**
125
+ * What an action sees when it runs. `item` is the original object returned by `search()`,
126
+ * including fields the host never saw — the SDK keeps it so actions do not have to re-derive
127
+ * their data from the item id.
128
+ */
129
+ export type ActionContext<TItem = unknown> = PluginHostEnv & {
130
+ actionId: string;
131
+ itemId: string;
132
+ query: string;
133
+ item?: TItem;
134
+ };
135
+
136
+ /**
137
+ * One registered action. `hotkey` is optional: without it the first registered action gets Enter
138
+ * and the rest are click-only, matching search result items.
139
+ */
140
+ export type ActionDefinition<TItem = any> = {
141
+ id: string;
142
+ title: LocalizedText;
143
+ description?: LocalizedText;
144
+ hotkey?: Hotkey;
145
+ execute: (context: ActionContext<TItem>) => ActionOutcome | void | Promise<ActionOutcome | void>;
146
+ };
147
+
148
+ /** The registry shape sent to the host in the initialize response (no `execute`). */
149
+ export type ActionManifestEntry = {
150
+ id: string;
151
+ title: LocalizedText;
152
+ description?: LocalizedText;
153
+ hotkey?: Hotkey;
154
+ };
155
+
156
+ export function toActionManifest(definition: ActionDefinition): ActionManifestEntry {
157
+ const entry: ActionManifestEntry = {
158
+ id: definition.id,
159
+ title: definition.title,
160
+ };
161
+ if (definition.description) entry.description = definition.description;
162
+ if (definition.hotkey) entry.hotkey = definition.hotkey;
163
+ return entry;
164
+ }
package/src/bootstrap.ts CHANGED
@@ -1,159 +1,159 @@
1
- /**
2
- * v3 Node SDK bootstrap entry. Reads the bootstrap line from stdin (pipePath\ttoken), connects to
3
- * the named pipe, completes bus.handshake (presenting the token and receiving bound identity),
4
- * and starts a HandlerRouter stamped with that identity.
5
- *
6
- * This mirrors the C# NodeProcessController's spawn contract: the host writes one line to the
7
- * Node process's stdin — "<pipePath>\t<token>" — then waits for the Node side to connect the pipe
8
- * and complete handshake before promoting the session to Ready.
9
- */
10
-
11
- import readline from "node:readline/promises";
12
- import { randomBytes } from "node:crypto";
13
- import { NodeTransport } from "./transport.ts";
14
- import { HandlerRouter } from "./router.ts";
15
- import {
16
- type Envelope,
17
- EndpointIds,
18
- MessageKind,
19
- ProtocolVersion,
20
- Routes,
21
- } from "./protocol.ts";
22
-
23
- export interface PluginHandlers {
24
- [route: string]: (payload: any) => Promise<any> | any;
25
- }
26
-
27
- export interface PluginRuntime {
28
- transport: NodeTransport;
29
- router: HandlerRouter;
30
- close(): Promise<void>;
31
- }
32
-
33
- const SUPPORTED_VERSIONS = [ProtocolVersion];
34
-
35
- /**
36
- * Connects to the host pipe (reading the bootstrap line from stdin), completes handshake, and
37
- * returns a runtime whose router dispatches inbound plugin.call.* requests to the given handlers.
38
- */
39
- export async function runPlugin(handlers: PluginHandlers): Promise<PluginRuntime> {
40
- const { pipePath, token } = await readBootstrapLine();
41
-
42
- const transport = new NodeTransport();
43
- await transport.connect(pipePath);
44
-
45
- const identity = await completeHandshake(transport, token);
46
- const router = new HandlerRouter({ send: (env: Envelope) => transport.send(env) });
47
- router.setIdentity(identity);
48
-
49
- // Passive host-liveness watchdog: exit if Host stops sending bus.ping (orphan process).
50
- // Threshold is well above the host ping interval (~2s) to tolerate brief host stalls.
51
- const HOST_LOST_MS = 15_000;
52
- let lastPingAt = Date.now();
53
- const watchdog = setInterval(() => {
54
- if (Date.now() - lastPingAt > HOST_LOST_MS) {
55
- clearInterval(watchdog);
56
- process.exit(1);
57
- }
58
- }, 1_000);
59
- watchdog.unref?.();
60
-
61
- transport.onDisconnect(() => {
62
- clearInterval(watchdog);
63
- process.exit(1);
64
- });
65
-
66
- transport.onMessage((env) => {
67
- if (env.route === Routes.Bus.Ping) lastPingAt = Date.now();
68
- router.dispatch(env);
69
- });
70
-
71
- for (const [route, handler] of Object.entries(handlers)) {
72
- router.handle(route, handler);
73
- }
74
-
75
- return {
76
- transport,
77
- router,
78
- close: async () => {
79
- clearInterval(watchdog);
80
- await transport.close();
81
- },
82
- };
83
- }
84
-
85
- /** Reads line 1 of stdin: "<pipePath>\t<token>". */
86
- async function readBootstrapLine(): Promise<{ pipePath: string; token: string }> {
87
- const rl = readline.createInterface({ input: process.stdin, crlfDelay: Infinity });
88
- try {
89
- const line = await new Promise<string>((resolve, reject) => {
90
- rl.once("line", (l) => resolve(l));
91
- rl.once("close", () => reject(new Error("stdin closed before bootstrap line")));
92
- });
93
- const [pipePath, token] = line.split("\t");
94
- if (!pipePath || !token) {
95
- throw new Error(`malformed bootstrap line: ${JSON.stringify(line)}`);
96
- }
97
- return { pipePath, token };
98
- } finally {
99
- rl.close();
100
- }
101
- }
102
-
103
- /**
104
- * Sends bus.handshake with the bootstrap token and waits for the host response that binds
105
- * plugin/entry/session/endpoint identity. Rejects on HandshakeFailed / ProtocolMismatch / timeout.
106
- */
107
- export async function completeHandshake(
108
- transport: NodeTransport,
109
- token: string,
110
- timeoutMs = 10000,
111
- ): Promise<{ pluginId: string; entryId: string; sessionId: string; endpointId: string }> {
112
- const id = randomBytes(16).toString("hex");
113
- const req: Envelope = {
114
- version: ProtocolVersion,
115
- id,
116
- traceId: id,
117
- sessionId: "",
118
- pluginId: "",
119
- entryId: "",
120
- endpointId: EndpointIds.NodeMain,
121
- kind: MessageKind.Request,
122
- route: Routes.Bus.Handshake,
123
- timeoutMs,
124
- payload: {
125
- version: ProtocolVersion,
126
- supportedVersions: SUPPORTED_VERSIONS,
127
- token,
128
- },
129
- };
130
-
131
- return new Promise((resolve, reject) => {
132
- const timer = setTimeout(() => {
133
- unsubscribe();
134
- reject(new Error(`bus.handshake timed out after ${timeoutMs}ms`));
135
- }, timeoutMs);
136
-
137
- const unsubscribe = transport.onMessage((env: Envelope) => {
138
- if (env.kind !== MessageKind.Response || env.correlationId !== id) return;
139
- clearTimeout(timer);
140
- unsubscribe();
141
- if (env.error) {
142
- reject(new Error(`${env.error.code}: ${env.error.message}`));
143
- return;
144
- }
145
- const p = (env.payload ?? {}) as Record<string, unknown>;
146
- const pluginId = String(p.pluginId ?? "");
147
- const entryId = String(p.entryId ?? "");
148
- const sessionId = String(p.sessionId ?? "");
149
- const endpointId = String(p.endpointId ?? EndpointIds.NodeMain);
150
- if (!pluginId || !entryId || !sessionId) {
151
- reject(new Error("bus.handshake success response missing bound identity"));
152
- return;
153
- }
154
- resolve({ pluginId, entryId, sessionId, endpointId });
155
- });
156
-
157
- transport.send(req);
158
- });
159
- }
1
+ /**
2
+ * v3 Node SDK bootstrap entry. Reads the bootstrap line from stdin (pipePath\ttoken), connects to
3
+ * the named pipe, completes bus.handshake (presenting the token and receiving bound identity),
4
+ * and starts a HandlerRouter stamped with that identity.
5
+ *
6
+ * This mirrors the C# NodeProcessController's spawn contract: the host writes one line to the
7
+ * Node process's stdin — "<pipePath>\t<token>" — then waits for the Node side to connect the pipe
8
+ * and complete handshake before promoting the session to Ready.
9
+ */
10
+
11
+ import readline from "node:readline/promises";
12
+ import { randomBytes } from "node:crypto";
13
+ import { NodeTransport } from "./transport.ts";
14
+ import { HandlerRouter } from "./router.ts";
15
+ import {
16
+ type Envelope,
17
+ EndpointIds,
18
+ MessageKind,
19
+ ProtocolVersion,
20
+ Routes,
21
+ } from "./protocol.ts";
22
+
23
+ export interface PluginHandlers {
24
+ [route: string]: (payload: any, context: { sessionId: string }) => Promise<any> | any;
25
+ }
26
+
27
+ export interface PluginRuntime {
28
+ transport: NodeTransport;
29
+ router: HandlerRouter;
30
+ close(): Promise<void>;
31
+ }
32
+
33
+ const SUPPORTED_VERSIONS = [ProtocolVersion];
34
+
35
+ /**
36
+ * Connects to the host pipe (reading the bootstrap line from stdin), completes handshake, and
37
+ * returns a runtime whose router dispatches inbound plugin.call.* requests to the given handlers.
38
+ */
39
+ export async function runPlugin(handlers: PluginHandlers): Promise<PluginRuntime> {
40
+ const { pipePath, token } = await readBootstrapLine();
41
+
42
+ const transport = new NodeTransport();
43
+ await transport.connect(pipePath);
44
+
45
+ const identity = await completeHandshake(transport, token);
46
+ const router = new HandlerRouter({ send: (env: Envelope) => transport.send(env) });
47
+ router.setIdentity(identity);
48
+
49
+ // Passive host-liveness watchdog: exit if Host stops sending bus.ping (orphan process).
50
+ // Threshold is well above the host ping interval (~2s) to tolerate brief host stalls.
51
+ const HOST_LOST_MS = 15_000;
52
+ let lastPingAt = Date.now();
53
+ const watchdog = setInterval(() => {
54
+ if (Date.now() - lastPingAt > HOST_LOST_MS) {
55
+ clearInterval(watchdog);
56
+ process.exit(1);
57
+ }
58
+ }, 1_000);
59
+ watchdog.unref?.();
60
+
61
+ transport.onDisconnect(() => {
62
+ clearInterval(watchdog);
63
+ process.exit(1);
64
+ });
65
+
66
+ transport.onMessage((env) => {
67
+ if (env.route === Routes.Bus.Ping) lastPingAt = Date.now();
68
+ router.dispatch(env);
69
+ });
70
+
71
+ for (const [route, handler] of Object.entries(handlers)) {
72
+ router.handle(route, handler);
73
+ }
74
+
75
+ return {
76
+ transport,
77
+ router,
78
+ close: async () => {
79
+ clearInterval(watchdog);
80
+ await transport.close();
81
+ },
82
+ };
83
+ }
84
+
85
+ /** Reads line 1 of stdin: "<pipePath>\t<token>". */
86
+ async function readBootstrapLine(): Promise<{ pipePath: string; token: string }> {
87
+ const rl = readline.createInterface({ input: process.stdin, crlfDelay: Infinity });
88
+ try {
89
+ const line = await new Promise<string>((resolve, reject) => {
90
+ rl.once("line", (l) => resolve(l));
91
+ rl.once("close", () => reject(new Error("stdin closed before bootstrap line")));
92
+ });
93
+ const [pipePath, token] = line.split("\t");
94
+ if (!pipePath || !token) {
95
+ throw new Error(`malformed bootstrap line: ${JSON.stringify(line)}`);
96
+ }
97
+ return { pipePath, token };
98
+ } finally {
99
+ rl.close();
100
+ }
101
+ }
102
+
103
+ /**
104
+ * Sends bus.handshake with the bootstrap token and waits for the host response that binds
105
+ * plugin/entry/session/endpoint identity. Rejects on HandshakeFailed / ProtocolMismatch / timeout.
106
+ */
107
+ export async function completeHandshake(
108
+ transport: NodeTransport,
109
+ token: string,
110
+ timeoutMs = 10000,
111
+ ): Promise<{ pluginId: string; entryId: string; sessionId: string; endpointId: string }> {
112
+ const id = randomBytes(16).toString("hex");
113
+ const req: Envelope = {
114
+ version: ProtocolVersion,
115
+ id,
116
+ traceId: id,
117
+ sessionId: "",
118
+ pluginId: "",
119
+ entryId: "",
120
+ endpointId: EndpointIds.NodeMain,
121
+ kind: MessageKind.Request,
122
+ route: Routes.Bus.Handshake,
123
+ timeoutMs,
124
+ payload: {
125
+ version: ProtocolVersion,
126
+ supportedVersions: SUPPORTED_VERSIONS,
127
+ token,
128
+ },
129
+ };
130
+
131
+ return new Promise((resolve, reject) => {
132
+ const timer = setTimeout(() => {
133
+ unsubscribe();
134
+ reject(new Error(`bus.handshake timed out after ${timeoutMs}ms`));
135
+ }, timeoutMs);
136
+
137
+ const unsubscribe = transport.onMessage((env: Envelope) => {
138
+ if (env.kind !== MessageKind.Response || env.correlationId !== id) return;
139
+ clearTimeout(timer);
140
+ unsubscribe();
141
+ if (env.error) {
142
+ reject(new Error(`${env.error.code}: ${env.error.message}`));
143
+ return;
144
+ }
145
+ const p = (env.payload ?? {}) as Record<string, unknown>;
146
+ const pluginId = String(p.pluginId ?? "");
147
+ const entryId = String(p.entryId ?? "");
148
+ const sessionId = String(p.sessionId ?? "");
149
+ const endpointId = String(p.endpointId ?? EndpointIds.NodeMain);
150
+ if (!pluginId || !entryId || !sessionId) {
151
+ reject(new Error("bus.handshake success response missing bound identity"));
152
+ return;
153
+ }
154
+ resolve({ pluginId, entryId, sessionId, endpointId });
155
+ });
156
+
157
+ transport.send(req);
158
+ });
159
+ }
package/src/dev.ts ADDED
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Development-time helpers for MyTools plugins.
3
+ *
4
+ * This entry point is intended for build and watch scripts, not plugin runtime code.
5
+ */
6
+ import { requestDevelopmentPluginRefreshWithOptions } from "./developmentRefresh.ts";
7
+
8
+ /**
9
+ * Notifies a running MyTools instance that a development plugin was rebuilt.
10
+ * The request is retried once after a short delay when the pipe is not yet available.
11
+ */
12
+ export function requestDevelopmentPluginRefresh(pluginId: string): Promise<void> {
13
+ return requestDevelopmentPluginRefreshWithOptions(pluginId);
14
+ }
@@ -0,0 +1,95 @@
1
+ import { createConnection } from "node:net";
2
+
3
+ export const DEVELOPMENT_REFRESH_PIPE_PATH = "\\\\.\\pipe\\MyTools.DevelopmentPlugins.Refresh";
4
+
5
+ const DEFAULT_RETRY_DELAY_MS = 250;
6
+ const DEFAULT_MAX_ATTEMPTS = 2;
7
+ const DEFAULT_REQUEST_TIMEOUT_MS = 2_000;
8
+ const VALID_PLUGIN_ID = /^[a-z0-9](?:[a-z0-9.-]{0,62}[a-z0-9])?$/;
9
+
10
+ export type DevelopmentRefreshRequestOptions = {
11
+ pipePath?: string;
12
+ retryDelayMs?: number;
13
+ maxAttempts?: number;
14
+ requestTimeoutMs?: number;
15
+ };
16
+
17
+ /** Internal implementation with injectable timing and endpoint for protocol tests. */
18
+ export async function requestDevelopmentPluginRefreshWithOptions(
19
+ pluginId: string,
20
+ options: DevelopmentRefreshRequestOptions = {},
21
+ ): Promise<void> {
22
+ if (!VALID_PLUGIN_ID.test(pluginId)) {
23
+ throw new TypeError(`Invalid MyTools plugin ID: ${pluginId}`);
24
+ }
25
+
26
+ const pipePath = options.pipePath ?? DEVELOPMENT_REFRESH_PIPE_PATH;
27
+ const retryDelayMs = options.retryDelayMs ?? DEFAULT_RETRY_DELAY_MS;
28
+ const maxAttempts = options.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
29
+ const requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
30
+ if (!Number.isInteger(maxAttempts) || maxAttempts < 1) {
31
+ throw new RangeError("maxAttempts must be a positive integer");
32
+ }
33
+ if (!Number.isFinite(retryDelayMs) || retryDelayMs < 0) {
34
+ throw new RangeError("retryDelayMs must be a non-negative number");
35
+ }
36
+ if (!Number.isFinite(requestTimeoutMs) || requestTimeoutMs <= 0) {
37
+ throw new RangeError("requestTimeoutMs must be a positive number");
38
+ }
39
+
40
+ let lastError: unknown;
41
+ for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
42
+ try {
43
+ await sendRefreshRequest(pipePath, pluginId, requestTimeoutMs);
44
+ return;
45
+ } catch (error) {
46
+ lastError = error;
47
+ if (attempt < maxAttempts) {
48
+ await delay(retryDelayMs);
49
+ }
50
+ }
51
+ }
52
+
53
+ throw new Error(
54
+ `Failed to request MyTools refresh for ${pluginId} after ${maxAttempts} attempts`,
55
+ { cause: lastError },
56
+ );
57
+ }
58
+
59
+ function sendRefreshRequest(
60
+ pipePath: string,
61
+ pluginId: string,
62
+ requestTimeoutMs: number,
63
+ ): Promise<void> {
64
+ return new Promise((resolve, reject) => {
65
+ const socket = createConnection(pipePath);
66
+ let settled = false;
67
+ let timeout: ReturnType<typeof setTimeout> | undefined;
68
+
69
+ const settle = (error?: Error) => {
70
+ if (settled) return;
71
+ settled = true;
72
+ if (timeout) clearTimeout(timeout);
73
+ if (error) reject(error);
74
+ else resolve();
75
+ };
76
+
77
+ socket.once("connect", () => {
78
+ socket.end(`${pluginId}\n`, () => {
79
+ settle();
80
+ });
81
+ });
82
+ socket.once("error", (error) => {
83
+ socket.destroy();
84
+ settle(error);
85
+ });
86
+ timeout = setTimeout(() => {
87
+ socket.destroy();
88
+ settle(new Error(`Development refresh request timed out after ${requestTimeoutMs}ms`));
89
+ }, requestTimeoutMs);
90
+ });
91
+ }
92
+
93
+ function delay(milliseconds: number): Promise<void> {
94
+ return new Promise((resolve) => setTimeout(resolve, milliseconds));
95
+ }
package/src/hostEnv.ts ADDED
@@ -0,0 +1,21 @@
1
+ /** Environment the host stamps onto every plugin.call payload. */
2
+
3
+ export type PluginTheme = "light" | "dark";
4
+
5
+ export type PluginHostEnv = {
6
+ locale: string;
7
+ fallbackLocale: string;
8
+ theme: PluginTheme;
9
+ };
10
+
11
+ export function asTheme(value: unknown): PluginTheme {
12
+ return value === "light" ? "light" : "dark";
13
+ }
14
+
15
+ export function asHostEnv(payload: any): PluginHostEnv {
16
+ return {
17
+ locale: typeof payload?.locale === "string" ? payload.locale : "en-US",
18
+ fallbackLocale: typeof payload?.fallbackLocale === "string" ? payload.fallbackLocale : "en-US",
19
+ theme: asTheme(payload?.theme),
20
+ };
21
+ }
@@ -0,0 +1,28 @@
1
+ /** Render a host-formatted shortcut as the same sequence of keycaps used by the desktop action bar. */
2
+ export function renderHotkeyKeycaps(element: HTMLElement, hotkey: string): void {
3
+ const normalized = hotkey.trim();
4
+ element.hidden = normalized.length === 0;
5
+ element.setAttribute("aria-label", normalized);
6
+ element.classList.add("hotkey-keycaps");
7
+
8
+ const keycaps = normalized.length === 0
9
+ ? []
10
+ : normalized.split("+").map((token) => createKeycap(token.trim()));
11
+ element.replaceChildren(...keycaps);
12
+ }
13
+
14
+ function createKeycap(token: string): HTMLSpanElement {
15
+ const keycap = document.createElement("span");
16
+ keycap.className = "hotkey-keycap";
17
+ keycap.setAttribute("aria-hidden", "true");
18
+
19
+ if (token.toLowerCase() === "enter" || token.toLowerCase() === "return") {
20
+ keycap.classList.add("hotkey-keycap-enter");
21
+ keycap.textContent = "↵";
22
+ keycap.title = "Enter";
23
+ } else {
24
+ keycap.textContent = token;
25
+ }
26
+
27
+ return keycap;
28
+ }