@blastlauncher/web 0.0.0-stage → 3.0.0-alpha.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.
Files changed (46) hide show
  1. package/LICENSE +7 -0
  2. package/README.md +85 -2
  3. package/assets/almostnode.mjs +735 -0
  4. package/assets/blast-sources.json +1 -0
  5. package/assets/blast-web.js +35 -0
  6. package/assets/esbuild.mjs +2695 -0
  7. package/assets/esbuild.wasm +0 -0
  8. package/dist/browser-bundle.d.ts +54 -0
  9. package/dist/browser-bundle.js +441 -0
  10. package/dist/browser-install.d.ts +66 -0
  11. package/dist/browser-install.js +326 -0
  12. package/dist/bundle-config.d.ts +10 -0
  13. package/dist/bundle-config.js +31 -0
  14. package/dist/bundles.d.ts +29 -0
  15. package/dist/bundles.js +126 -0
  16. package/dist/capabilities.d.ts +66 -0
  17. package/dist/capabilities.js +288 -0
  18. package/dist/custom-element.d.ts +6 -0
  19. package/dist/custom-element.js +36 -0
  20. package/dist/default-renderer.d.ts +27 -0
  21. package/dist/default-renderer.js +145 -0
  22. package/dist/default-styles.d.ts +2 -0
  23. package/dist/default-styles.js +28 -0
  24. package/dist/extension-files.d.ts +21 -0
  25. package/dist/extension-files.js +86 -0
  26. package/dist/handlers.d.ts +18 -0
  27. package/dist/handlers.js +22 -0
  28. package/dist/index.d.ts +14 -0
  29. package/dist/index.js +17 -0
  30. package/dist/launch.d.ts +11 -0
  31. package/dist/launch.js +29 -0
  32. package/dist/prebuilt.d.ts +24 -0
  33. package/dist/prebuilt.js +112 -0
  34. package/dist/render.d.ts +62 -0
  35. package/dist/render.js +288 -0
  36. package/dist/runtime-port.d.ts +5 -0
  37. package/dist/runtime-port.js +8 -0
  38. package/dist/sandbox.d.ts +25 -0
  39. package/dist/sandbox.js +71 -0
  40. package/dist/session.d.ts +77 -0
  41. package/dist/session.js +520 -0
  42. package/dist/source.d.ts +16 -0
  43. package/dist/source.js +42 -0
  44. package/package.json +33 -4
  45. package/sandbox/almostnode-full.mjs +19713 -0
  46. package/sandbox/index.html +1 -0
@@ -0,0 +1,22 @@
1
+ /** localStorage, the Clipboard API (the browser asks the user) and a new tab for URLs. */
2
+ export function createDefaultHandlers() {
3
+ let storage;
4
+ try {
5
+ storage = globalThis.localStorage;
6
+ }
7
+ catch {
8
+ // Blocked storage (private windows, sandboxed frames): capabilities fall back to memory.
9
+ }
10
+ return {
11
+ ...(storage ? { storage } : {}),
12
+ clipboard: {
13
+ readText: () => navigator.clipboard.readText(),
14
+ writeText: (text) => navigator.clipboard.writeText(text),
15
+ },
16
+ openUrl: (url) => {
17
+ // Only web URLs: an extension must not open javascript: or file: links.
18
+ if (/^https?:\/\//i.test(url))
19
+ window.open(url, "_blank", "noopener,noreferrer");
20
+ },
21
+ };
22
+ }
@@ -0,0 +1,14 @@
1
+ import { type WebMount, type WebMountOptions } from "./session.js";
2
+ export * from "./browser-install.js";
3
+ export * from "./browser-bundle.js";
4
+ export { bundleKey, bundleMatches, parseBundleIndex, sha256Hex } from "./bundles.js";
5
+ export { createDefaultHandlers } from "./handlers.js";
6
+ export { defineBlastExtension } from "./custom-element.js";
7
+ export { prewarm } from "./sandbox.js";
8
+ export type { WebExit, WebFrame, WebHandlers, WebMount, WebMountOptions, WebPreferenceRequest, WebStatus, WebView, } from "./session.js";
9
+ /**
10
+ * Runs a Raycast extension inside `element` (or hands its views to
11
+ * `options.render`). The extension executes in the cross-origin sandbox at
12
+ * `options.sandboxUrl`; it never touches the embedding page.
13
+ */
14
+ export declare function mount(element: HTMLElement, options: WebMountOptions): WebMount;
package/dist/index.js ADDED
@@ -0,0 +1,17 @@
1
+ import { startSession } from "./session.js";
2
+ export * from "./browser-install.js";
3
+ export * from "./browser-bundle.js";
4
+ export { bundleKey, bundleMatches, parseBundleIndex, sha256Hex } from "./bundles.js";
5
+ export { createDefaultHandlers } from "./handlers.js";
6
+ export { defineBlastExtension } from "./custom-element.js";
7
+ export { prewarm } from "./sandbox.js";
8
+ /**
9
+ * Runs a Raycast extension inside `element` (or hands its views to
10
+ * `options.render`). The extension executes in the cross-origin sandbox at
11
+ * `options.sandboxUrl`; it never touches the embedding page.
12
+ */
13
+ export function mount(element, options) {
14
+ if (!element)
15
+ throw new TypeError("mount requires an element");
16
+ return startSession(element, options);
17
+ }
@@ -0,0 +1,11 @@
1
+ export declare class BrowserLaunchTimeoutError extends Error {
2
+ readonly code = "BROWSER_LAUNCH_TIMEOUT";
3
+ readonly stage: string;
4
+ constructor(timeoutMs: number, stage: string);
5
+ }
6
+ export declare function withLaunchTimeout<T>(promise: Promise<T>, timeoutMs: number, stage?: string): Promise<T>;
7
+ export declare function createLaunchGeneration(): {
8
+ next: () => number;
9
+ current: () => number;
10
+ isCurrent: (token: number) => boolean;
11
+ };
package/dist/launch.js ADDED
@@ -0,0 +1,29 @@
1
+ export class BrowserLaunchTimeoutError extends Error {
2
+ code = "BROWSER_LAUNCH_TIMEOUT";
3
+ stage;
4
+ constructor(timeoutMs, stage) {
5
+ super(`Command did not start (${stage}) after ${timeoutMs} ms`);
6
+ this.name = "BrowserLaunchTimeoutError";
7
+ this.stage = stage;
8
+ }
9
+ }
10
+ export function withLaunchTimeout(promise, timeoutMs, stage = "waiting for runtime port") {
11
+ return new Promise((resolve, reject) => {
12
+ const timer = setTimeout(() => reject(new BrowserLaunchTimeoutError(timeoutMs, stage)), timeoutMs);
13
+ promise.then((value) => {
14
+ clearTimeout(timer);
15
+ resolve(value);
16
+ }, (error) => {
17
+ clearTimeout(timer);
18
+ reject(error);
19
+ });
20
+ });
21
+ }
22
+ export function createLaunchGeneration() {
23
+ let generation = 0;
24
+ return {
25
+ next: () => ++generation,
26
+ current: () => generation,
27
+ isCurrent: (token) => token === generation,
28
+ };
29
+ }
@@ -0,0 +1,24 @@
1
+ import type { BrowserBundleFile } from "./browser-bundle.js";
2
+ import { type BundleIndex, type CatalogManifest } from "./bundles.js";
3
+ /** Where a deployment serves its prebuilt bundles (see `WebMountOptions.bundlesUrl`). */
4
+ export type BundleLocation = {
5
+ bundlesUrl: string;
6
+ buildUrl?: string | undefined;
7
+ };
8
+ export type PrebuiltCommand = {
9
+ code: string;
10
+ files: Map<string, BrowserBundleFile>;
11
+ manifest: CatalogManifest;
12
+ };
13
+ /**
14
+ * The validated index, fetched once per location. A `buildUrl` whose `sha`
15
+ * differs from the index's `buildSha` means the index came from another
16
+ * deploy (a stale cache), so it is ignored.
17
+ */
18
+ export declare function loadBundleIndex(location: BundleLocation): Promise<BundleIndex | undefined>;
19
+ /** The shared runtime the prebuilt bundles import from, when the index has one. */
20
+ export declare function loadPrebuiltRuntime(location: BundleLocation): Promise<string | undefined>;
21
+ /** Bundles are keyed by Store name, so only `raycast/extensions` sources have one. */
22
+ export declare function storeExtensionName(source: string): string | undefined;
23
+ /** A verified prebuilt command, or undefined to fall back to fetching and bundling sources. */
24
+ export declare function loadPrebuiltCommand(location: BundleLocation, source: string, command: string): Promise<PrebuiltCommand | undefined>;
@@ -0,0 +1,112 @@
1
+ import { bundleKey, bundleMatches, parseBundleIndex, sha256Hex, } from "./bundles.js";
2
+ import { resolveExtensionSource } from "./extension-files.js";
3
+ import { isTextExtension, parseExtensionSource } from "./source.js";
4
+ /** Relative URLs resolve against the page; tests pass absolute ones. */
5
+ const page = () => globalThis.location?.href;
6
+ const indexes = new Map();
7
+ const runtimes = new Map();
8
+ const locationKey = (location) => `${location.bundlesUrl}\n${location.buildUrl ?? ""}`;
9
+ const urlFor = (path, location) => new URL(path, new URL(location.bundlesUrl, page()));
10
+ /** Fetches a file the index lists; undefined unless its size and digest match. */
11
+ async function verified(entry, location) {
12
+ const response = await fetch(urlFor(entry.path, location));
13
+ if (!response.ok)
14
+ return undefined;
15
+ const bytes = await response.arrayBuffer();
16
+ if (bytes.byteLength !== entry.bytes || (await sha256Hex(bytes)) !== entry.sha256)
17
+ return undefined;
18
+ return bytes;
19
+ }
20
+ /**
21
+ * The validated index, fetched once per location. A `buildUrl` whose `sha`
22
+ * differs from the index's `buildSha` means the index came from another
23
+ * deploy (a stale cache), so it is ignored.
24
+ */
25
+ export function loadBundleIndex(location) {
26
+ const key = locationKey(location);
27
+ let pending = indexes.get(key);
28
+ if (!pending) {
29
+ pending = (async () => {
30
+ const [indexResponse, buildResponse] = await Promise.all([
31
+ fetch(new URL(location.bundlesUrl, page())),
32
+ location.buildUrl ? fetch(new URL(location.buildUrl, page())) : null,
33
+ ]);
34
+ if (!indexResponse.ok || (buildResponse && !buildResponse.ok))
35
+ return undefined;
36
+ const index = parseBundleIndex(await indexResponse.json());
37
+ if (buildResponse) {
38
+ const build = (await buildResponse.json());
39
+ if (build.sha !== index.buildSha)
40
+ return undefined;
41
+ }
42
+ return index;
43
+ })().catch(() => undefined);
44
+ // Only a usable index is kept; a failed fetch is retried by the next launch.
45
+ void pending.then((index) => index ?? indexes.delete(key));
46
+ indexes.set(key, pending);
47
+ }
48
+ return pending;
49
+ }
50
+ /** The shared runtime the prebuilt bundles import from, when the index has one. */
51
+ export function loadPrebuiltRuntime(location) {
52
+ const key = locationKey(location);
53
+ let pending = runtimes.get(key);
54
+ if (!pending) {
55
+ pending = (async () => {
56
+ const index = await loadBundleIndex(location);
57
+ const bytes = index?.runtime ? await verified(index.runtime, location) : undefined;
58
+ return bytes ? new TextDecoder().decode(bytes) : undefined;
59
+ })().catch(() => undefined);
60
+ void pending.then((code) => code ?? runtimes.delete(key));
61
+ runtimes.set(key, pending);
62
+ }
63
+ return pending;
64
+ }
65
+ /** Bundles are keyed by Store name, so only `raycast/extensions` sources have one. */
66
+ export function storeExtensionName(source) {
67
+ try {
68
+ const parsed = parseExtensionSource(resolveExtensionSource(source));
69
+ const [folder, name, ...rest] = parsed.path.split("/");
70
+ return parsed.owner === "raycast" &&
71
+ parsed.repo === "extensions" &&
72
+ folder === "extensions" &&
73
+ name &&
74
+ rest.length === 0
75
+ ? name
76
+ : undefined;
77
+ }
78
+ catch {
79
+ return undefined;
80
+ }
81
+ }
82
+ /** A verified prebuilt command, or undefined to fall back to fetching and bundling sources. */
83
+ export async function loadPrebuiltCommand(location, source, command) {
84
+ try {
85
+ const extension = storeExtensionName(source);
86
+ const index = extension ? await loadBundleIndex(location) : undefined;
87
+ if (!extension || !index)
88
+ return undefined;
89
+ const key = bundleKey(extension, command);
90
+ const entry = index.bundles[key];
91
+ // An entry without manifest data predates 0133; the source path has the full package.json.
92
+ if (!entry?.manifest)
93
+ return undefined;
94
+ const response = await fetch(urlFor(entry.path, location));
95
+ if (!response.ok)
96
+ return undefined;
97
+ const bytes = await response.arrayBuffer();
98
+ if (!(await bundleMatches(index, index.buildSha, key, bytes)))
99
+ return undefined;
100
+ const files = new Map();
101
+ for (const [name, file] of Object.entries(entry.files ?? {})) {
102
+ const fileBytes = await verified(file, location);
103
+ if (!fileBytes)
104
+ return undefined;
105
+ files.set(`/${name}`, isTextExtension(name) ? new TextDecoder().decode(fileBytes) : new Uint8Array(fileBytes));
106
+ }
107
+ return { code: new TextDecoder().decode(bytes), files, manifest: entry.manifest };
108
+ }
109
+ catch {
110
+ return undefined;
111
+ }
112
+ }
@@ -0,0 +1,62 @@
1
+ /** @param {unknown} value */
2
+ export declare function escapeHtml(value: unknown): string;
3
+ /**
4
+ * A shortcut keycap. A combo the browser keeps for itself is drawn muted with
5
+ * the hint as its tooltip, so it never looks live when it is not.
6
+ * @param {"small" | "kbd"} tag @param {string} label @param {(label: string) => string} [hint]
7
+ */
8
+ export declare const keycap: (tag: "small" | "kbd", label: string, hint?: (label: string) => string) => string;
9
+ /** Render the deliberately small, safe markdown subset used by Detail. @param {unknown} markdown */
10
+ export declare function renderMarkdown(markdown: unknown): string;
11
+ export type FooterStatus = {
12
+ text: string;
13
+ error?: boolean;
14
+ progress?: boolean;
15
+ stage?: string;
16
+ };
17
+ export type ExtensionContext = {
18
+ extensionTitle: string;
19
+ commandTitle: string;
20
+ icon?: string;
21
+ iconUrl?: string;
22
+ };
23
+ /**
24
+ * Toast, HUD and alert: the feedback a command shows over whatever it draws.
25
+ * @param {import("@blastlauncher/client-core").ControllerView} view
26
+ * @param {{reservedHint?: (label: string) => string}} [options]
27
+ */
28
+ export declare function feedbackMarkup(view: import("@blastlauncher/client-core").ControllerView, options?: {
29
+ reservedHint?: (label: string) => string;
30
+ }): string;
31
+ /**
32
+ * `actionsKey` labels the Actions footer (`⌘K`, or `Ctrl+K` off macOS);
33
+ * `reservedHint` returns a tooltip for a shortcut label the browser reserves.
34
+ * @param {import("@blastlauncher/client-core").ControllerView} view
35
+ * @param {{status?: FooterStatus, context?: ExtensionContext, actionsKey?: string, reservedHint?: (label: string) => string}} [options]
36
+ */
37
+ export declare function render(view: import("@blastlauncher/client-core").ControllerView, options?: {
38
+ status?: FooterStatus;
39
+ context?: ExtensionContext;
40
+ actionsKey?: string;
41
+ reservedHint?: (label: string) => string;
42
+ }): string;
43
+ /** @param {{title: string, subtitle: string, icon: string, iconUrl?: string, accessory: string}[]} rows @param {number} selected @param {string} query @param {{open?: boolean, query?: string, actions?: {title: string, shortcut?: string}[], selected?: number, toast?: string, status?: FooterStatus}} [panel] */
44
+ export declare function renderRoot(rows: {
45
+ title: string;
46
+ subtitle: string;
47
+ icon: string;
48
+ iconUrl?: string;
49
+ accessory: string;
50
+ }[], selected: number, query: string, panel?: {
51
+ open?: boolean;
52
+ query?: string;
53
+ actions?: {
54
+ title: string;
55
+ shortcut?: string;
56
+ }[];
57
+ selected?: number;
58
+ toast?: string;
59
+ status?: FooterStatus;
60
+ }): string;
61
+ /** @param {import("@blastlauncher/protocol/preference-fields").Preference[]} fields @param {Record<string, unknown>} values @param {string} title @param {string[]} unavailable */
62
+ export declare function renderPreferences(fields: import("@blastlauncher/protocol/preference-fields").Preference[], values: Record<string, unknown>, title: string, unavailable?: string[]): string;
package/dist/render.js ADDED
@@ -0,0 +1,288 @@
1
+ // @ts-check
2
+ /** @param {unknown} value */
3
+ export function escapeHtml(value) {
4
+ return String(value ?? "")
5
+ .replaceAll("&", "&amp;")
6
+ .replaceAll("<", "&lt;")
7
+ .replaceAll(">", "&gt;")
8
+ .replaceAll('"', "&quot;")
9
+ .replaceAll("'", "&#39;");
10
+ }
11
+ /**
12
+ * A footer hint is a real button: clicking it does what its key does. The
13
+ * label is visible text and the key renders as a keycap.
14
+ * @param {string} label
15
+ * @param {string} key
16
+ * @param {"primary" | "actions" | "back"} action
17
+ */
18
+ const footerButton = (label, key, action) => `<button type="button" class="footer-key" data-footer="${action}"><span>${escapeHtml(label)}</span><kbd>${escapeHtml(key)}</kbd></button>`;
19
+ /**
20
+ * A shortcut keycap. A combo the browser keeps for itself is drawn muted with
21
+ * the hint as its tooltip, so it never looks live when it is not.
22
+ * @param {"small" | "kbd"} tag @param {string} label @param {(label: string) => string} [hint]
23
+ */
24
+ export const keycap = (tag, label, hint) => {
25
+ if (!label)
26
+ return "";
27
+ const reserved = hint?.(label);
28
+ return reserved
29
+ ? `<${tag} class="reserved" title="${escapeHtml(reserved)}" aria-label="${escapeHtml(`${label}, ${reserved}`)}">${escapeHtml(label)}</${tag}>`
30
+ : `<${tag}>${escapeHtml(label)}</${tag}>`;
31
+ };
32
+ /** @param {string} label @param {string} action @param {boolean} [selected] */
33
+ const button = (label, action, selected = false) => `<button data-input="${escapeHtml(action)}"${selected ? ' class="selected"' : ""}>${escapeHtml(label)}</button>`;
34
+ /**
35
+ * Links and images only carry https URLs; images may also inline `data:image`.
36
+ * Anything else (javascript:, http:, relative) keeps just its visible text.
37
+ * @param {string} raw @param {boolean} image
38
+ */
39
+ const safeUrl = (raw, image) => {
40
+ if (image && /^data:image\/[a-z0-9.+-]+[;,]\S*$/iu.test(raw))
41
+ return raw;
42
+ try {
43
+ return new URL(raw).protocol === "https:" ? raw : undefined;
44
+ }
45
+ catch {
46
+ return undefined;
47
+ }
48
+ };
49
+ /** Emphasis only ever sees escaped text segments, never generated tags or URLs. @param {string} text */
50
+ const emphasis = (text) => escapeHtml(text)
51
+ .replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
52
+ .replace(/(^|[^\w])__([^_]+)__(?!\w)/g, "$1<strong>$2</strong>")
53
+ .replace(/\*([^*]+)\*/g, "<em>$1</em>")
54
+ .replace(/(^|[^\w])_([^_]+)_(?!\w)/g, "$1<em>$2</em>");
55
+ /** Tokenize code, links and images first so emphasis cannot rewrite inside them. @param {string} value */
56
+ const renderMarkdownInline = (value) => {
57
+ let out = "";
58
+ let last = 0;
59
+ for (const match of value.matchAll(/`([^`]+)`|(!?)\[([^\]]*)\]\(([^\s)]+)\)/gu)) {
60
+ out += emphasis(value.slice(last, match.index));
61
+ last = match.index + match[0].length;
62
+ const [, code, bang, label = "", url = ""] = match;
63
+ if (code !== undefined)
64
+ out += `<code>${escapeHtml(code)}</code>`;
65
+ else if (bang) {
66
+ const src = safeUrl(url, true);
67
+ out += src ? `<img src="${escapeHtml(src)}" alt="${escapeHtml(label)}">` : escapeHtml(label);
68
+ }
69
+ else {
70
+ const href = safeUrl(url, false);
71
+ out += href
72
+ ? `<a href="${escapeHtml(href)}" target="_blank" rel="noreferrer noopener">${emphasis(label)}</a>`
73
+ : emphasis(label);
74
+ }
75
+ }
76
+ return out + emphasis(value.slice(last));
77
+ };
78
+ /** Render the deliberately small, safe markdown subset used by Detail. @param {unknown} markdown */
79
+ export function renderMarkdown(markdown) {
80
+ const lines = String(markdown ?? "").split(/\r?\n/);
81
+ const result = [];
82
+ let list = false;
83
+ for (const line of lines) {
84
+ const item = line.match(/^\s*[-*]\s+(.+)$/);
85
+ if (item) {
86
+ if (!list) {
87
+ result.push("<ul>");
88
+ list = true;
89
+ }
90
+ result.push(`<li>${renderMarkdownInline(item[1] ?? "")}</li>`);
91
+ continue;
92
+ }
93
+ if (list) {
94
+ result.push("</ul>");
95
+ list = false;
96
+ }
97
+ const heading = line.match(/^\s*(#{1,3})\s+(.+)$/);
98
+ if (heading) {
99
+ const level = heading[1]?.length ?? 1;
100
+ result.push(`<h${level}>${renderMarkdownInline(heading[2] ?? "")}</h${level}>`);
101
+ continue;
102
+ }
103
+ if (!line.trim())
104
+ continue;
105
+ result.push(`<p>${renderMarkdownInline(line)}</p>`);
106
+ }
107
+ if (list)
108
+ result.push("</ul>");
109
+ return result.join("");
110
+ }
111
+ /**
112
+ * @typedef {{text: string, error?: boolean, progress?: boolean, stage?: string}} FooterStatus
113
+ * @typedef {{extensionTitle: string, commandTitle: string, icon?: string, iconUrl?: string}} ExtensionContext
114
+ */
115
+ /** @param {FooterStatus} status */
116
+ function statusMarkup(status) {
117
+ return `<span id="status" role="status" data-error="${status.error ? "true" : "false"}"${status.stage ? ` data-stage="${escapeHtml(status.stage)}"` : ""}>${status.progress ? '<span class="status-spinner" aria-hidden="true"></span>' : ""}${escapeHtml(status.text)}</span>`;
118
+ }
119
+ /** @param {ExtensionContext | undefined} context */
120
+ function breadcrumbMarkup(context) {
121
+ if (!context)
122
+ return "";
123
+ const icon = context.iconUrl
124
+ ? `<img src="${escapeHtml(context.iconUrl)}" alt="" width="20" height="20" onerror="this.hidden=true">`
125
+ : escapeHtml(context.icon ?? "⌘");
126
+ return `<div class="breadcrumb"><span class="breadcrumb-icon" aria-hidden="true">${icon}</span><span>${escapeHtml(context.extensionTitle)}</span><span class="breadcrumb-separator">›</span><span>${escapeHtml(context.commandTitle)}</span></div>`;
127
+ }
128
+ /**
129
+ * The footer names where the user is, as Raycast does: the extension's icon and
130
+ * the view's navigation title. It repeats the h1 and breadcrumb, so it is hidden
131
+ * from assistive tech.
132
+ * @param {ExtensionContext | undefined} context @param {string} title
133
+ */
134
+ function footerContextMarkup(context, title) {
135
+ if (!context)
136
+ return "";
137
+ const icon = context.iconUrl
138
+ ? `<img src="${escapeHtml(context.iconUrl)}" alt="" width="20" height="20" onerror="this.hidden=true">`
139
+ : escapeHtml(context.icon ?? "⌘");
140
+ return `<span class="footer-context" aria-hidden="true"><span class="footer-icon">${icon}</span><span class="footer-title">${escapeHtml(title || context.commandTitle)}</span></span>`;
141
+ }
142
+ /**
143
+ * Toast, HUD and alert: the feedback a command shows over whatever it draws.
144
+ * @param {import("@blastlauncher/client-core").ControllerView} view
145
+ * @param {{reservedHint?: (label: string) => string}} [options]
146
+ */
147
+ export function feedbackMarkup(view, options = {}) {
148
+ const toast = view.feedback.toast
149
+ ? `<aside class="toast" data-style="${escapeHtml(view.feedback.toast.style)}"><strong>${escapeHtml(view.feedback.toast.title)}</strong> ${escapeHtml(view.feedback.toast.message)}${view.feedback.toast.actions.map((action, index) => `<button type="button" data-toast-action="${index}">${escapeHtml(action.title)} ${keycap("small", action.shortcut, options.reservedHint)}</button>`).join("")}</aside>`
150
+ : "";
151
+ const hud = view.feedback.hud
152
+ ? `<aside class="hud" role="status">${escapeHtml(view.feedback.hud.title)}</aside>`
153
+ : "";
154
+ const alert = view.feedback.alert
155
+ ? `<section class="alert-backdrop"><dialog open class="alert" role="alertdialog" aria-modal="true" aria-labelledby="alert-title"><h2 id="alert-title">${escapeHtml(view.feedback.alert.title)}</h2><p>${escapeHtml(view.feedback.alert.message)}</p>${view.feedback.alert.rememberUserChoice ? `<label><input id="alert-remember" type="checkbox"${view.feedback.alert.remember ? " checked" : ""}> Remember my choice</label>` : ""}<footer>${view.feedback.alert.dismiss ? `<button type="button" data-alert="dismiss">${escapeHtml(view.feedback.alert.dismiss.title)}</button>` : ""}${view.feedback.alert.primary ? `<button type="button" data-alert="confirm">${escapeHtml(view.feedback.alert.primary.title)}</button>` : ""}</footer></dialog></section>`
156
+ : "";
157
+ return `${toast}${hud}${alert}`;
158
+ }
159
+ /**
160
+ * `actionsKey` labels the Actions footer (`⌘K`, or `Ctrl+K` off macOS);
161
+ * `reservedHint` returns a tooltip for a shortcut label the browser reserves.
162
+ * @param {import("@blastlauncher/client-core").ControllerView} view
163
+ * @param {{status?: FooterStatus, context?: ExtensionContext, actionsKey?: string, reservedHint?: (label: string) => string}} [options]
164
+ */
165
+ export function render(view, options = {}) {
166
+ const status = options.status ?? { text: "Ready" };
167
+ const search = `<input id="search" value="${escapeHtml(view.search.text)}" placeholder="${escapeHtml(view.search.placeholder)}" aria-label="Search">${view.dropdown ? `<button type="button" class="dropdown-trigger" data-dropdown-open aria-label="${escapeHtml(view.dropdown.tooltip || view.dropdown.label)}">${escapeHtml(view.dropdown.label || "Filter")}⌄</button>` : ""}`;
168
+ const rows = view.rows
169
+ .map((row) => row.kind === "section"
170
+ ? `<li class="section-header" data-id="${escapeHtml(row.id)}"><strong>${escapeHtml(row.title)}</strong>${row.count === null ? "" : `<small>${row.count}</small>`}</li>`
171
+ : `<li${row.selected ? ' class="selected"' : ""} data-id="${escapeHtml(row.id)}"><span class="root-icon"${row.iconTint ? ` style="color:${escapeHtml(row.iconTint)}"` : ""}>${escapeHtml(row.iconLabel)}</span><span class="root-copy"><strong>${escapeHtml(row.title)}</strong>${row.subtitle ? `<small>${escapeHtml(row.subtitle)}</small>` : ""}</span>${row.accessories ? `<span class="root-accessory">${escapeHtml(row.accessories)}</span>` : ""}</li>`)
172
+ .join("");
173
+ const grid = (view.grid ?? [])
174
+ .map((row) => `${row.header ? `<li class="grid-header">${escapeHtml(row.header)}</li>` : ""}<li class="grid-row">${row.cells.map((cell) => `<button type="button" class="grid-cell${cell.selected ? " selected" : ""}" data-id="${escapeHtml(cell.id)}"><span class="grid-content">${escapeHtml(cell.content)}</span><strong>${escapeHtml(cell.title)}</strong><small>${escapeHtml(cell.subtitle)}</small></button>`).join("")}</li>`)
175
+ .join("");
176
+ const dropdown = view.dropdown?.open
177
+ ? `<section class="dropdown-panel" role="dialog" aria-label="${escapeHtml(view.dropdown.label)}"><input id="dropdown-search" data-dropdown-search value="${escapeHtml(view.dropdown.query)}" aria-label="Filter options"><ul>${view.dropdown.options.map((option) => `<li><button type="button" data-dropdown-value="${escapeHtml(option.value)}"${option.selected ? ' class="selected"' : ""}>${escapeHtml(option.title)}${option.current ? " ✓" : ""}</button></li>`).join("")}</ul></section>`
178
+ : "";
179
+ const actions = view.actions.open
180
+ ? `<section class="actions"><strong>${escapeHtml(view.actions.title)}</strong><div class="breadcrumb">${view.actions.breadcrumb.map(escapeHtml).join(" / ")}</div><input id="action-search" value="${escapeHtml(view.actions.query)}" placeholder="Search actions…" aria-label="Action search"><ul>${view.actions.rows.map((row, index) => `<li${row.selected ? ' class="selected"' : ""} data-action-index="${index}">${escapeHtml(row.title)} ${keycap("small", row.shortcut, options.reservedHint)}</li>`).join("")}</ul></section>`
181
+ : "";
182
+ const formView = view.form;
183
+ const form = formView
184
+ ? `<form class="extension-form">${formView.items
185
+ .map((item) => {
186
+ if (item.kind === "separator")
187
+ return `<hr class="form-separator">`;
188
+ if (item.kind === "description")
189
+ return `<p class="form-description">${escapeHtml(item.title)}${item.text ? `: ${escapeHtml(item.text)}` : ""}</p>`;
190
+ const field = formView.fields.find((candidate) => candidate.id === item.id);
191
+ if (!field)
192
+ return "";
193
+ const label = escapeHtml(field.title || field.label || field.id);
194
+ const common = `id="field-${escapeHtml(field.id)}" data-field="${escapeHtml(field.id)}"`;
195
+ const error = field.error
196
+ ? `<small class="form-error" role="alert">${escapeHtml(field.error)}</small>`
197
+ : "";
198
+ if (field.type === "Form.Checkbox")
199
+ return `<label class="form-field checkbox"><span>${label}</span><input ${common} type="checkbox"${field.checked ? " checked" : ""}></label>`;
200
+ if (field.type === "Form.Dropdown" || field.type === "Form.TagPicker")
201
+ return `<label class="form-field"><span>${label}</span><select ${common} ${field.type === "Form.TagPicker" ? "multiple" : ""}>${field.options.map((option) => `<option value="${escapeHtml(option.value)}"${field.selected.some((selected) => selected.value === option.value) ? " selected" : ""}>${escapeHtml(option.title)}</option>`).join("")}</select>${error}</label>`;
202
+ if (field.type === "Form.DatePicker")
203
+ return `<label class="form-field"><span>${label}</span><input ${common} type="date" value="${escapeHtml(field.text.slice(0, 10))}">${error}</label>`;
204
+ if (field.type === "Form.FilePicker")
205
+ return `<p class="form-unavailable">${label}: not available in the browser</p>`;
206
+ const area = field.type === "Form.TextArea";
207
+ return `<label class="form-field"><span>${label}</span>${area ? `<textarea ${common} placeholder="${escapeHtml(field.placeholder)}">${escapeHtml(field.text)}</textarea>` : `<input ${common} type="${field.type === "Form.PasswordField" ? "password" : "text"}"${field.hasValue ? " data-has-value" : ""} value="${escapeHtml(field.text)}" placeholder="${escapeHtml(field.placeholder)}">`}${error}</label>`;
208
+ })
209
+ .join("")}${button("Submit", "primary")}</form>`
210
+ : "";
211
+ const detail = view.kind === "detail" || (view.kind === "list" && view.detail)
212
+ ? `<article class="detail">${renderMarkdown(view.detail)}</article>`
213
+ : "";
214
+ const noRows = view.kind === "grid"
215
+ ? (view.grid ?? []).every((row) => row.cells.length === 0)
216
+ : view.rows.length === 0;
217
+ const empty = view.empty
218
+ ? `<p class="empty"><strong>${escapeHtml(view.empty.title)}</strong> ${escapeHtml(view.empty.description)}</p>`
219
+ : noRows && (view.kind === "list" || view.kind === "grid")
220
+ ? `<p class="empty">${escapeHtml(view.emptyText || "No results")}</p>`
221
+ : "";
222
+ const menu = view.menuBar
223
+ ? `<nav class="menu-bar" aria-label="${escapeHtml(view.menuBar.tooltip || view.menuBar.title || "Menu")}"><h2>${escapeHtml(view.menuBar.title || "Menu")}</h2><ul>${view.menuBar.rows
224
+ .map((row) => row.kind === "section"
225
+ ? `<li class="menu-section"><strong>${escapeHtml(row.title)}</strong></li>`
226
+ : `<li><button type="button" data-menu-id="${escapeHtml(row.id)}"${row.selected ? ' class="selected"' : ""}${row.disabled ? " disabled" : ""}><span class="menu-icon"${row.iconTint ? ` style="color:${escapeHtml(row.iconTint)}"` : ""}>${escapeHtml(row.iconLabel || "")}</span><span>${escapeHtml(row.title)}</span>${row.subtitle ? `<small>${escapeHtml(row.subtitle)}</small>` : ""}${keycap("kbd", row.shortcut, options.reservedHint)}${row.kind === "submenu" ? " ›" : ""}</button></li>`)
227
+ .join("")}</ul></nav>`
228
+ : "";
229
+ const body = view.kind === "menu-bar"
230
+ ? menu
231
+ : view.kind === "form"
232
+ ? form
233
+ : view.kind === "detail"
234
+ ? detail
235
+ : view.kind === "grid"
236
+ ? `<ul class="grid">${grid}</ul>${empty}`
237
+ : detail
238
+ ? // `isShowingDetail`: the selected item's detail sits beside the rows, as in Raycast.
239
+ `<div class="list-split"><div class="list-pane"><ul class="rows">${rows}</ul>${empty}</div>${detail}</div>`
240
+ : `<ul class="rows">${rows}</ul>${empty}`;
241
+ const footer = `<footer>${footerContextMarkup(options.context, view.title)}${statusMarkup(status)}<span class="footer-hints">${view.primary.enabled ? footerButton(view.primary.title, view.primary.shortcut || "↵", "primary") : ""}${footerButton("Actions", options.actionsKey ?? "⌘K", "actions")}</span></footer>`;
242
+ // Back sits left of the search field, where Raycast puts it in pushed views.
243
+ const back = `<button type="button" class="back-key" data-footer="back" title="Back (Esc)"><span aria-hidden="true">←</span><span class="visually-hidden">Back</span></button>`;
244
+ return `<main data-kind="${escapeHtml(view.kind)}" data-loading="${view.loading ? "true" : "false"}" aria-busy="${view.loading ? "true" : "false"}"><header><h1>${escapeHtml(view.title)}</h1><div class="search-line">${back}${breadcrumbMarkup(options.context)}${search}</div></header>${body}${actions}${dropdown}${footer}${feedbackMarkup(view, options)}</main>`;
245
+ }
246
+ /** @param {{title: string, subtitle: string, icon: string, iconUrl?: string, accessory: string}[]} rows @param {number} selected @param {string} query @param {{open?: boolean, query?: string, actions?: {title: string, shortcut?: string}[], selected?: number, toast?: string, status?: FooterStatus}} [panel] */
247
+ export function renderRoot(rows, selected, query, panel = {}) {
248
+ const options = rows
249
+ .map((row, index) => `<li id="root-option-${index}" role="option" aria-selected="${index === selected}" data-root-index="${index}"${index === selected ? ' class="selected"' : ""}><span class="root-icon" aria-hidden="true">${row.iconUrl ? `<img src="${escapeHtml(row.iconUrl)}" alt="" width="24" height="24" onerror="this.hidden=true;this.nextElementSibling.hidden=false">` : ""}<span${row.iconUrl ? " hidden" : ""}>${escapeHtml(row.icon)}</span></span><span class="root-copy"><strong>${escapeHtml(row.title)}</strong><small>${escapeHtml(row.subtitle)}</small></span><span class="root-accessory">${escapeHtml(row.accessory)}</span></li>`)
250
+ .join("");
251
+ const actions = panel.open
252
+ ? (panel.actions ?? [])
253
+ .map((action, index) => `<li id="root-action-${index}" role="option" aria-selected="${index === panel.selected}" data-root-action-index="${index}"${index === panel.selected ? ' class="selected"' : ""}>${escapeHtml(action.title)}${action.shortcut ? ` <small>${escapeHtml(action.shortcut)}</small>` : ""}</li>`)
254
+ .join("")
255
+ : "";
256
+ const actionPanel = panel.open
257
+ ? `<section class="actions" role="dialog" aria-label="Actions"><strong>Actions</strong><input id="root-action-search" value="${escapeHtml(panel.query ?? "")}" placeholder="Search actions…" aria-label="Search actions" role="combobox" aria-controls="root-actions" aria-activedescendant="${panel.selected !== undefined && panel.selected >= 0 ? `root-action-${panel.selected}` : ""}" autocomplete="off" autofocus><ul id="root-actions" role="listbox" aria-label="Command actions">${actions}</ul></section>`
258
+ : "";
259
+ const toast = panel.toast
260
+ ? `<span role="status" class="root-toast">${escapeHtml(panel.toast)}</span>`
261
+ : "";
262
+ const status = panel.status ?? { text: "Ready" };
263
+ return `<main class="root" data-kind="root" data-loading="false"><header><h1>Blast</h1><input id="root-search" value="${escapeHtml(query)}" placeholder="Search extension commands…" aria-label="Search extension commands" role="combobox" aria-controls="root-options" aria-activedescendant="${selected >= 0 ? `root-option-${selected}` : ""}" autocomplete="off" autofocus></header><ul id="root-options" class="rows root-options" role="listbox" aria-label="Extension commands">${options}</ul>${rows.length === 0 ? '<p class="empty">No matching commands</p>' : ""}<footer>${statusMarkup(status)}<span class="footer-hints">${footerButton("Run Command", "↵", "primary")}${footerButton("Actions", "⌘K", "actions")}${status.error ? footerButton("Back", "Esc", "back") : ""}</span>${toast}</footer>${actionPanel}</main>`;
264
+ }
265
+ /** @param {import("@blastlauncher/protocol/preference-fields").Preference[]} fields @param {Record<string, unknown>} values @param {string} title @param {string[]} unavailable */
266
+ export function renderPreferences(fields, values, title, unavailable = []) {
267
+ const rows = fields
268
+ .map((field) => {
269
+ const value = values[field.name] ?? field.default ?? (field.type === "checkbox" ? false : "");
270
+ const required = field.required ? " *" : "";
271
+ const description = field.description
272
+ ? `<small class="preference-description">${escapeHtml(field.description)}</small>`
273
+ : "";
274
+ if (field.type === "checkbox")
275
+ return `<label class="preference-row"><span>${escapeHtml(field.title)}${required}</span><input name="${escapeHtml(field.name)}" type="checkbox" data-preference="${escapeHtml(field.name)}"${value ? " checked" : ""}>${description}</label>`;
276
+ if (field.type === "dropdown")
277
+ return `<label class="preference-row"><span>${escapeHtml(field.title)}${required}</span><select name="${escapeHtml(field.name)}" data-preference="${escapeHtml(field.name)}">${field.data.map((option) => `<option value="${escapeHtml(option.value)}"${String(value) === option.value ? " selected" : ""}>${escapeHtml(option.title)}</option>`).join("")}</select>${description}</label>`;
278
+ if (field.type === "file" || field.type === "directory" || field.type === "appPicker")
279
+ return `<div class="preference-row unavailable"><strong>${escapeHtml(field.title)}${required}</strong><span>Needs a file on your computer; this is not available in the browser.</span>${description}</div>`;
280
+ return `<label class="preference-row"><span>${escapeHtml(field.title)}${required}</span><input name="${escapeHtml(field.name)}" type="${field.type === "password" ? "password" : "text"}" data-preference="${escapeHtml(field.name)}" value="${escapeHtml(String(value))}" placeholder="${escapeHtml(field.placeholder ?? "")}">${description}</label>`;
281
+ })
282
+ .join("");
283
+ const disabled = unavailable.length > 0 ||
284
+ fields.some((field) => field.required &&
285
+ field.default === undefined &&
286
+ (values[field.name] === undefined || values[field.name] === ""));
287
+ return `<main class="preferences" data-kind="preferences"><header><h1>${escapeHtml(title)}</h1><p class="preference-note">These values stay in this browser only.</p></header><form id="preferences-form"><div class="preference-fields">${rows}</div><footer><span id="preference-error" role="alert"></span><span class="footer-hints"><button type="submit" class="footer-key"${disabled ? " disabled" : ""}><span>Run</span><kbd>↵</kbd></button><button type="button" class="footer-key" data-preference-back><span>Back</span><kbd>Esc</kbd></button></span></footer></form></main>`;
288
+ }
@@ -0,0 +1,5 @@
1
+ export type RuntimePortRequest = {
2
+ type: "blast-port-request";
3
+ };
4
+ export declare function isRuntimePortRequest(value: unknown): value is RuntimePortRequest;
5
+ export declare function acceptsRuntimePortRequest(source: unknown, expectedSource: unknown, transferred: boolean, data: unknown): boolean;
@@ -0,0 +1,8 @@
1
+ export function isRuntimePortRequest(value) {
2
+ return (typeof value === "object" &&
3
+ value !== null &&
4
+ value.type === "blast-port-request");
5
+ }
6
+ export function acceptsRuntimePortRequest(source, expectedSource, transferred, data) {
7
+ return !transferred && source === expectedSource && isRuntimePortRequest(data);
8
+ }
@@ -0,0 +1,25 @@
1
+ export type SandboxRuntime = {
2
+ execute(code: string, filename?: string): Promise<unknown>;
3
+ getVFS(): {
4
+ mkdirSync(path: string, options?: {
5
+ recursive?: boolean;
6
+ }): void;
7
+ writeFileSync(path: string, data: string | Uint8Array): void;
8
+ };
9
+ iframe?: HTMLIFrameElement;
10
+ terminate?: () => void;
11
+ };
12
+ /** What decides which sandbox a session can adopt: the same page, assets and bundles. */
13
+ export type SandboxOptions = {
14
+ sandboxUrl: string;
15
+ assetsUrl?: string | undefined;
16
+ bundlesUrl?: string | undefined;
17
+ buildUrl?: string | undefined;
18
+ };
19
+ /** `path` under `assetsUrl`, which defaults to the package's own `assets/` directory. */
20
+ export declare const assetUrl: (options: Pick<SandboxOptions, "assetsUrl">, path: string) => string;
21
+ export declare function disposeSandbox(runtime: SandboxRuntime | undefined): void;
22
+ /** Starts a sandbox now so the next `mount()` with the same options skips that wait. */
23
+ export declare function prewarm(options: SandboxOptions): void;
24
+ /** The warm sandbox for these options if there is one, otherwise a new one. */
25
+ export declare function acquireSandbox(options: SandboxOptions): Promise<SandboxRuntime>;