@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,288 @@
1
+ const emptyPreferences = () => ({ extension: {}, commands: {} });
2
+ export function createBrowserPreferenceStore(extension, storage) {
3
+ const key = `blast:${extension}:preferences`;
4
+ let memory = null;
5
+ const readRaw = () => {
6
+ try {
7
+ return storage?.getItem(key) ?? memory;
8
+ }
9
+ catch {
10
+ return memory;
11
+ }
12
+ };
13
+ const read = () => {
14
+ const raw = readRaw();
15
+ if (!raw)
16
+ return emptyPreferences();
17
+ try {
18
+ const parsed = JSON.parse(raw);
19
+ if (!parsed || typeof parsed !== "object" || !parsed.extension || !parsed.commands)
20
+ return emptyPreferences();
21
+ return { extension: parsed.extension, commands: parsed.commands };
22
+ }
23
+ catch {
24
+ return emptyPreferences();
25
+ }
26
+ };
27
+ const save = (next) => {
28
+ const value = JSON.stringify(next);
29
+ memory = value;
30
+ try {
31
+ storage?.setItem(key, value);
32
+ }
33
+ catch {
34
+ // The memory copy keeps the demo usable in private browsing.
35
+ }
36
+ };
37
+ return {
38
+ read,
39
+ save,
40
+ values(command) {
41
+ const current = read();
42
+ return { ...current.extension, ...current.commands[command] };
43
+ },
44
+ update(extensionValues, command, commandValues) {
45
+ const current = read();
46
+ save({
47
+ extension: { ...current.extension, ...extensionValues },
48
+ commands: {
49
+ ...current.commands,
50
+ [command]: { ...current.commands[command], ...commandValues },
51
+ },
52
+ });
53
+ },
54
+ };
55
+ }
56
+ const denial = (message) => ({
57
+ ok: false,
58
+ error: { code: "DENIED", message },
59
+ });
60
+ function paramsOf(value) {
61
+ return value && typeof value === "object" && !Array.isArray(value)
62
+ ? value
63
+ : {};
64
+ }
65
+ function extensionEntry(catalog, name) {
66
+ return catalog.extensions.find((extension) => extension.name === name);
67
+ }
68
+ const keyFor = (extension, key, internal) =>
69
+ // User keys get their own namespace so a LocalStorage key such as
70
+ // "internal:x" can never collide with Blast's internal or OAuth entries.
71
+ `blast:${extension}:${internal ? "internal" : "user"}:${key}`;
72
+ const decode = (value) => {
73
+ if (value === null)
74
+ return null;
75
+ try {
76
+ return JSON.parse(value);
77
+ }
78
+ catch {
79
+ return null;
80
+ }
81
+ };
82
+ const tokenKey = (extension, provider) => keyFor(extension, `oauth:${provider}`, true);
83
+ /** Browser-backed capability handlers. All browser effects are injected for unit tests. */
84
+ export function createBrowserCapabilities(options) {
85
+ const memory = new Map();
86
+ let copied = "";
87
+ const browserStorage = options.storage;
88
+ const read = (key) => {
89
+ try {
90
+ return browserStorage?.getItem(key) ?? memory.get(key) ?? null;
91
+ }
92
+ catch {
93
+ return memory.get(key) ?? null;
94
+ }
95
+ };
96
+ const write = (key, value) => {
97
+ memory.set(key, value);
98
+ try {
99
+ browserStorage?.setItem(key, value);
100
+ }
101
+ catch {
102
+ // Private browsing and disabled storage are expected; memory is the fallback.
103
+ }
104
+ };
105
+ const remove = (key) => {
106
+ memory.delete(key);
107
+ try {
108
+ browserStorage?.removeItem(key);
109
+ }
110
+ catch {
111
+ // The memory value has already been removed.
112
+ }
113
+ };
114
+ const all = (extension) => {
115
+ const prefix = `blast:${extension}:user:`;
116
+ const values = new Map();
117
+ try {
118
+ for (let index = 0; index < (browserStorage?.length ?? 0); index++) {
119
+ const storedKey = browserStorage?.key(index);
120
+ if (storedKey?.startsWith(prefix)) {
121
+ const name = storedKey.slice(prefix.length);
122
+ const value = browserStorage?.getItem(storedKey);
123
+ if (typeof value === "string")
124
+ values.set(name, value);
125
+ }
126
+ }
127
+ }
128
+ catch {
129
+ // Merge the memory fallback below.
130
+ }
131
+ for (const [storedKey, value] of memory) {
132
+ if (storedKey.startsWith(prefix))
133
+ values.set(storedKey.slice(prefix.length), value);
134
+ }
135
+ const result = {};
136
+ for (const [name, value] of values) {
137
+ try {
138
+ result[name] = JSON.parse(value);
139
+ }
140
+ catch {
141
+ // Ignore values written by another application under our prefix.
142
+ }
143
+ }
144
+ return result;
145
+ };
146
+ const clear = (extension) => {
147
+ const prefix = `blast:${extension}:user:`;
148
+ const keys = new Set();
149
+ for (const key of memory.keys())
150
+ if (key.startsWith(prefix))
151
+ keys.add(key);
152
+ try {
153
+ for (let index = 0; index < (browserStorage?.length ?? 0); index++) {
154
+ const key = browserStorage?.key(index);
155
+ if (key?.startsWith(prefix))
156
+ keys.add(key);
157
+ }
158
+ }
159
+ catch {
160
+ // Clear the known memory keys even when browser storage is inaccessible.
161
+ }
162
+ for (const key of keys)
163
+ remove(key);
164
+ };
165
+ return async function handle(request, context) {
166
+ const params = paramsOf(request.params);
167
+ if (request.name === "storage.get" ||
168
+ request.name === "storage.set" ||
169
+ request.name === "storage.remove") {
170
+ const key = typeof params.key === "string" ? params.key : "";
171
+ if (!key)
172
+ return denial(`${request.name} requires a non-empty key`);
173
+ const storedKey = keyFor(context.extensionName, key, params.scope === "blast:internal");
174
+ if (request.name === "storage.get")
175
+ return { ok: true, value: decode(read(storedKey)) };
176
+ if (request.name === "storage.remove") {
177
+ remove(storedKey);
178
+ return { ok: true, value: null };
179
+ }
180
+ write(storedKey, JSON.stringify(params.value));
181
+ return { ok: true, value: null };
182
+ }
183
+ if (request.name === "storage.all") {
184
+ return { ok: true, value: all(context.extensionName) };
185
+ }
186
+ if (request.name === "storage.clear") {
187
+ clear(context.extensionName);
188
+ return { ok: true, value: null };
189
+ }
190
+ if (request.name === "oauth.tokens.get") {
191
+ return {
192
+ ok: true,
193
+ value: decode(read(tokenKey(context.extensionName, String(params.providerId ?? "")))),
194
+ };
195
+ }
196
+ if (request.name === "oauth.tokens.set") {
197
+ write(tokenKey(context.extensionName, String(params.providerId ?? "")), JSON.stringify(params.tokens));
198
+ return { ok: true, value: null };
199
+ }
200
+ if (request.name === "oauth.tokens.remove") {
201
+ remove(tokenKey(context.extensionName, String(params.providerId ?? "")));
202
+ return { ok: true, value: null };
203
+ }
204
+ if (request.name === "clipboard.copy" || request.name === "clipboard.paste") {
205
+ const text = typeof params.text === "string" ? params.text : "";
206
+ copied = text;
207
+ try {
208
+ await options.clipboard?.writeText?.(text);
209
+ }
210
+ catch {
211
+ // The session fallback still makes read useful when permission is denied.
212
+ }
213
+ if (request.name === "clipboard.paste")
214
+ options.status?.("Copied — paste with ⌘V");
215
+ else
216
+ options.status?.("Copied");
217
+ return { ok: true, value: null };
218
+ }
219
+ if (request.name === "clipboard.read") {
220
+ try {
221
+ const text = await options.clipboard?.readText?.();
222
+ if (typeof text === "string")
223
+ return { ok: true, value: { text } };
224
+ }
225
+ catch {
226
+ // Fall back to the last value Blast copied in this session.
227
+ }
228
+ return { ok: true, value: { text: copied } };
229
+ }
230
+ if (request.name === "clipboard.clear") {
231
+ copied = "";
232
+ return { ok: true, value: null };
233
+ }
234
+ if (request.name === "open.url") {
235
+ const url = typeof params.url === "string" ? params.url : "";
236
+ options.openUrl?.(url);
237
+ return { ok: true, value: null };
238
+ }
239
+ if (request.name === "command.metadata") {
240
+ const command = extensionEntry(options.catalog, context.extensionName)?.commands.find((item) => item.name === context.commandName);
241
+ return command
242
+ ? { ok: true, value: null }
243
+ : denial("This command is not in the browser catalog");
244
+ }
245
+ if (request.name === "command.launch") {
246
+ const targetExtension = String(params.extensionName ?? context.extensionName);
247
+ if (targetExtension !== context.extensionName)
248
+ return denial("The browser demo can launch commands only from the current extension");
249
+ const command = typeof params.name === "string" ? params.name : "";
250
+ if (!extensionEntry(options.catalog, targetExtension)?.commands.some((item) => item.name === command))
251
+ return denial("That command is not in the browser catalog");
252
+ // A background launch is a host request, not a second foreground session.
253
+ // Running it through `launchCommand` would replace the port the caller is
254
+ // waiting on (one-thing/remove-one-thing then never finishes), and a
255
+ // browser tab has no background process or menu bar to update anyway.
256
+ if (params.type === "background") {
257
+ options.status?.(`Background command accepted: ${command}`);
258
+ return { ok: true, value: null };
259
+ }
260
+ if (!options.launchCommand)
261
+ return denial("Command launching is unavailable in this browser session");
262
+ await options.launchCommand(context.source, command);
263
+ return { ok: true, value: null };
264
+ }
265
+ if (request.name === "oauth.authorize")
266
+ return denial("OAuth authorization needs a host redirect and is unavailable in a browser tab");
267
+ if (request.name === "preferences.open") {
268
+ const scope = params.scope;
269
+ if (scope !== "extension" && scope !== "command")
270
+ return denial("preferences.open requires an extension or command scope");
271
+ options.openPreferences?.(scope, context);
272
+ return { ok: true, value: null };
273
+ }
274
+ if (request.name === "applications.list")
275
+ return { ok: true, value: [] };
276
+ if (request.name === "applications.default" || request.name === "applications.frontmost")
277
+ return denial("Applications are unavailable in a browser tab");
278
+ if (request.name.startsWith("applications."))
279
+ return denial("Applications are unavailable in a browser tab");
280
+ if (request.name.startsWith("selection."))
281
+ return denial("Selection is unavailable in a browser tab");
282
+ if (request.name.startsWith("window."))
283
+ return denial("Window management is unavailable in a browser tab");
284
+ if (request.name.startsWith("ai."))
285
+ return denial("AI access is unavailable in a browser tab");
286
+ return denial(`${request.name} is not supported by the browser demo`);
287
+ };
288
+ }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Defines `<blast-extension extension="raycast/extensions/name" sandbox-url="…">`
3
+ * for pages that prefer declarative embedding. Optional attributes: `source`,
4
+ * `command`, `assets-url`, `theme`, and the booleans `root` and `fixtures`.
5
+ */
6
+ export declare function defineBlastExtension(tag?: string): void;
@@ -0,0 +1,36 @@
1
+ import { startSession } from "./session.js";
2
+ /**
3
+ * Defines `<blast-extension extension="raycast/extensions/name" sandbox-url="…">`
4
+ * for pages that prefer declarative embedding. Optional attributes: `source`,
5
+ * `command`, `assets-url`, `theme`, and the booleans `root` and `fixtures`.
6
+ */
7
+ export function defineBlastExtension(tag = "blast-extension") {
8
+ if (customElements.get(tag))
9
+ return;
10
+ customElements.define(tag, class extends HTMLElement {
11
+ #session;
12
+ connectedCallback() {
13
+ const options = {
14
+ sandboxUrl: this.getAttribute("sandbox-url") ?? "/sandbox/",
15
+ root: this.hasAttribute("root"),
16
+ fixtures: this.hasAttribute("fixtures"),
17
+ };
18
+ for (const [attribute, key] of [
19
+ ["extension", "extension"],
20
+ ["source", "source"],
21
+ ["command", "command"],
22
+ ["assets-url", "assetsUrl"],
23
+ ["theme", "theme"],
24
+ ]) {
25
+ const value = this.getAttribute(attribute);
26
+ if (value)
27
+ options[key] = value;
28
+ }
29
+ this.#session = startSession(this, options);
30
+ }
31
+ disconnectedCallback() {
32
+ this.#session?.close();
33
+ this.#session = undefined;
34
+ }
35
+ });
36
+ }
@@ -0,0 +1,27 @@
1
+ import type { ControllerInput, ControllerView } from "@blastlauncher/client-core";
2
+ export type WebStatus = {
3
+ text: string;
4
+ error?: boolean;
5
+ progress?: boolean;
6
+ };
7
+ export type WebView = ControllerView | {
8
+ type: "loading";
9
+ extension?: string;
10
+ command?: string;
11
+ } | {
12
+ type: "error";
13
+ message: string;
14
+ };
15
+ export type ViewInput = (input: ControllerInput) => void;
16
+ export type Theme = string | Record<string, string>;
17
+ /**
18
+ * Draws views with the shared HTML renderer and turns clicks, typing and keys
19
+ * into controller input. Everything is scoped to `element`.
20
+ */
21
+ export declare function createDefaultRenderer(element: HTMLElement, send: ViewInput, options?: {
22
+ theme?: Theme;
23
+ actionsKey?: string;
24
+ }): {
25
+ draw(next: WebView, status: WebStatus): void;
26
+ destroy(): void;
27
+ };
@@ -0,0 +1,145 @@
1
+ import { defaultStyles } from "./default-styles.js";
2
+ import { render } from "./render.js";
3
+ const STYLE_ID = "blast-web-styles";
4
+ function installStyles(root) {
5
+ if (root.getElementById(STYLE_ID))
6
+ return;
7
+ const style = root.createElement("style");
8
+ style.id = STYLE_ID;
9
+ style.textContent = defaultStyles;
10
+ root.head.prepend(style);
11
+ }
12
+ const field = (target) => target instanceof HTMLElement ? target.closest("[data-field]") : null;
13
+ /**
14
+ * Draws views with the shared HTML renderer and turns clicks, typing and keys
15
+ * into controller input. Everything is scoped to `element`.
16
+ */
17
+ export function createDefaultRenderer(element, send, options = {}) {
18
+ installStyles(element.ownerDocument);
19
+ element.classList.add("blast-web");
20
+ if (typeof options.theme === "string")
21
+ element.dataset.theme = options.theme;
22
+ else if (options.theme)
23
+ for (const [key, value] of Object.entries(options.theme))
24
+ element.style.setProperty(key, value);
25
+ if (!element.hasAttribute("tabindex"))
26
+ element.tabIndex = 0;
27
+ let view;
28
+ const onKeydown = (event) => {
29
+ if (!view)
30
+ return;
31
+ const target = event.target;
32
+ const typing = target instanceof HTMLTextAreaElement;
33
+ if (event.key === "ArrowDown")
34
+ send({ type: "move", direction: "down" });
35
+ else if (event.key === "ArrowUp")
36
+ send({ type: "move", direction: "up" });
37
+ else if (event.key === "Enter" && (event.metaKey || event.ctrlKey))
38
+ send(view.kind === "form" ? { type: "submit" } : { type: "primary" });
39
+ else if (event.key === "Enter" && !typing && !(target instanceof HTMLButtonElement))
40
+ send(view.kind === "form" ? { type: "submit" } : { type: "primary" });
41
+ else if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k")
42
+ send({ type: view.actions.open ? "closeActions" : "openActions" });
43
+ else if (event.key === "Escape") {
44
+ if (!view.actions.open && !view.dropdown?.open && view.search.text)
45
+ send({ type: "search", text: "" });
46
+ else
47
+ send({ type: "back" });
48
+ }
49
+ else
50
+ return;
51
+ event.preventDefault();
52
+ };
53
+ const onInput = (event) => {
54
+ const target = event.target;
55
+ const formField = field(target);
56
+ if (target.id === "search")
57
+ send({ type: "search", text: target.value });
58
+ else if (target.id === "action-search")
59
+ send({ type: "actionsQuery", text: target.value });
60
+ else if (target.hasAttribute("data-dropdown-search"))
61
+ send({ type: "dropdownQuery", text: target.value });
62
+ else if (formField?.dataset.field)
63
+ send(target.type === "checkbox"
64
+ ? { type: "toggle", id: formField.dataset.field }
65
+ : { type: "setValue", id: formField.dataset.field, value: target.value });
66
+ };
67
+ const onClick = (event) => {
68
+ const target = event.target;
69
+ const footer = target.closest("[data-footer]")?.dataset.footer;
70
+ if (footer === "primary")
71
+ return send({ type: "primary" });
72
+ if (footer === "actions")
73
+ return send({ type: "openActions" });
74
+ if (footer === "back")
75
+ return send({ type: "back" });
76
+ if (target.closest("[data-dropdown-open]"))
77
+ return send({ type: "openDropdown" });
78
+ const action = target.closest("[data-action-index]");
79
+ if (action && view?.actions.open) {
80
+ const delta = Number(action.dataset.actionIndex) - view.actions.selectedIndex;
81
+ for (let step = 0; step < Math.abs(delta); step++)
82
+ send({ type: "move", direction: delta > 0 ? "down" : "up" });
83
+ return send({ type: "primary" });
84
+ }
85
+ const item = target.closest("[data-id], [data-menu-id]");
86
+ if (item)
87
+ send({ type: "select", id: item.dataset.id ?? item.dataset.menuId ?? "" });
88
+ };
89
+ const onSubmit = (event) => {
90
+ event.preventDefault();
91
+ send({ type: "submit" });
92
+ };
93
+ element.addEventListener("keydown", onKeydown);
94
+ element.addEventListener("input", onInput);
95
+ element.addEventListener("click", onClick);
96
+ element.addEventListener("submit", onSubmit);
97
+ /** Redrawing replaces the DOM, so put the caret back where the visitor left it. */
98
+ const withFocus = (draw) => {
99
+ const active = element.contains(document.activeElement)
100
+ ? document.activeElement
101
+ : undefined;
102
+ const key = active?.id || active?.dataset.field;
103
+ const selection = active ? [active.selectionStart, active.selectionEnd] : undefined;
104
+ draw();
105
+ const next = (key
106
+ ? element.querySelector(`#${CSS.escape(key)}, [data-field="${key}"]`)
107
+ : null) ?? (active ? null : element.querySelector("#search"));
108
+ if (!next)
109
+ return;
110
+ next.focus({ preventScroll: true });
111
+ if (selection?.[0] != null && selection[1] != null)
112
+ next.setSelectionRange?.(selection[0], selection[1]);
113
+ };
114
+ return {
115
+ draw(next, status) {
116
+ if ("type" in next && next.type === "loading") {
117
+ view = undefined;
118
+ element.innerHTML = `<p role="status" aria-busy="true"></p>`;
119
+ element.firstElementChild.textContent = status.text || "Loading extension…";
120
+ return;
121
+ }
122
+ if ("type" in next && next.type === "error") {
123
+ view = undefined;
124
+ element.innerHTML = `<p role="alert" data-blast-error></p>`;
125
+ element.firstElementChild.textContent = next.message;
126
+ return;
127
+ }
128
+ view = next;
129
+ withFocus(() => {
130
+ element.innerHTML = render(view, {
131
+ status,
132
+ ...(options.actionsKey ? { actionsKey: options.actionsKey } : {}),
133
+ });
134
+ });
135
+ },
136
+ destroy() {
137
+ element.removeEventListener("keydown", onKeydown);
138
+ element.removeEventListener("input", onInput);
139
+ element.removeEventListener("click", onClick);
140
+ element.removeEventListener("submit", onSubmit);
141
+ element.classList.remove("blast-web");
142
+ element.replaceChildren();
143
+ },
144
+ };
145
+ }
@@ -0,0 +1,2 @@
1
+ /** Default look of the built-in renderer; every colour is a `--blast-*` custom property. */
2
+ export declare const defaultStyles = "\n.blast-web{--blast-bg:#fcfcfc;--blast-fg:#171717;--blast-muted:#646464;--blast-line:#e2e2e2;--blast-selection:#ededed;--blast-accent:#ff6338;--blast-error:#d93036;--blast-radius:12px;font:14px/1.4 system-ui,sans-serif;background:var(--blast-bg);color:var(--blast-fg);border:1px solid var(--blast-line);border-radius:var(--blast-radius);overflow:hidden}\n.blast-web[data-theme=dark]{--blast-bg:#161616;--blast-fg:#f2f2f2;--blast-muted:#a0a0a0;--blast-line:#343434;--blast-selection:#282828}\n@media(prefers-color-scheme:dark){.blast-web:not([data-theme=light]){--blast-bg:#161616;--blast-fg:#f2f2f2;--blast-muted:#a0a0a0;--blast-line:#343434;--blast-selection:#282828}}\n.blast-web main{display:flex;flex-direction:column;min-height:16rem}\n.blast-web header{padding:.5rem .75rem;border-bottom:1px solid var(--blast-line)}\n.blast-web h1{margin:0 0 .25rem;font-size:12px;font-weight:600;color:var(--blast-muted)}\n.blast-web .search-line{display:flex;gap:.5rem;align-items:center}\n.blast-web input,.blast-web textarea,.blast-web select{font:inherit;color:inherit;background:transparent;border:0;outline:0}\n.blast-web #search,.blast-web .search-line input{flex:1;padding:.25rem 0}\n.blast-web ul{margin:0;padding:.25rem;list-style:none;flex:1;overflow:auto}\n.blast-web li{display:flex;gap:.5rem;align-items:center;padding:.4rem .5rem;border-radius:8px}\n.blast-web li.selected,.blast-web .selected{background:var(--blast-selection)}\n.blast-web .section-header{color:var(--blast-muted);font-size:12px}\n.blast-web small,.blast-web .root-accessory{color:var(--blast-muted)}\n.blast-web .root-copy{flex:1;display:flex;gap:.5rem;align-items:baseline}\n.blast-web footer{display:flex;gap:.75rem;align-items:center;padding:.4rem .75rem;border-top:1px solid var(--blast-line);color:var(--blast-muted)}\n.blast-web footer #status{flex:1}\n.blast-web #status[data-error=true],.blast-web [data-blast-error]{color:var(--blast-error)}\n.blast-web button{font:inherit;color:inherit;background:transparent;border:0;border-radius:6px;cursor:pointer}\n.blast-web kbd{font:inherit;font-size:11px;padding:0 .3rem;margin-left:.25rem;border:1px solid var(--blast-line);border-radius:4px}\n.blast-web .actions,.blast-web .dropdown-panel{margin:.5rem;padding:.5rem;border:1px solid var(--blast-line);border-radius:8px;background:var(--blast-bg)}\n.blast-web .empty,.blast-web [role=status]:not(#status){padding:1rem;color:var(--blast-muted)}\n.blast-web .detail,.blast-web .extension-form{padding:.75rem}\n.blast-web .form-field{display:flex;flex-direction:column;gap:.25rem;margin-bottom:.5rem}\n.blast-web .visually-hidden{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0)}\n";
@@ -0,0 +1,28 @@
1
+ /** Default look of the built-in renderer; every colour is a `--blast-*` custom property. */
2
+ export const defaultStyles = `
3
+ .blast-web{--blast-bg:#fcfcfc;--blast-fg:#171717;--blast-muted:#646464;--blast-line:#e2e2e2;--blast-selection:#ededed;--blast-accent:#ff6338;--blast-error:#d93036;--blast-radius:12px;font:14px/1.4 system-ui,sans-serif;background:var(--blast-bg);color:var(--blast-fg);border:1px solid var(--blast-line);border-radius:var(--blast-radius);overflow:hidden}
4
+ .blast-web[data-theme=dark]{--blast-bg:#161616;--blast-fg:#f2f2f2;--blast-muted:#a0a0a0;--blast-line:#343434;--blast-selection:#282828}
5
+ @media(prefers-color-scheme:dark){.blast-web:not([data-theme=light]){--blast-bg:#161616;--blast-fg:#f2f2f2;--blast-muted:#a0a0a0;--blast-line:#343434;--blast-selection:#282828}}
6
+ .blast-web main{display:flex;flex-direction:column;min-height:16rem}
7
+ .blast-web header{padding:.5rem .75rem;border-bottom:1px solid var(--blast-line)}
8
+ .blast-web h1{margin:0 0 .25rem;font-size:12px;font-weight:600;color:var(--blast-muted)}
9
+ .blast-web .search-line{display:flex;gap:.5rem;align-items:center}
10
+ .blast-web input,.blast-web textarea,.blast-web select{font:inherit;color:inherit;background:transparent;border:0;outline:0}
11
+ .blast-web #search,.blast-web .search-line input{flex:1;padding:.25rem 0}
12
+ .blast-web ul{margin:0;padding:.25rem;list-style:none;flex:1;overflow:auto}
13
+ .blast-web li{display:flex;gap:.5rem;align-items:center;padding:.4rem .5rem;border-radius:8px}
14
+ .blast-web li.selected,.blast-web .selected{background:var(--blast-selection)}
15
+ .blast-web .section-header{color:var(--blast-muted);font-size:12px}
16
+ .blast-web small,.blast-web .root-accessory{color:var(--blast-muted)}
17
+ .blast-web .root-copy{flex:1;display:flex;gap:.5rem;align-items:baseline}
18
+ .blast-web footer{display:flex;gap:.75rem;align-items:center;padding:.4rem .75rem;border-top:1px solid var(--blast-line);color:var(--blast-muted)}
19
+ .blast-web footer #status{flex:1}
20
+ .blast-web #status[data-error=true],.blast-web [data-blast-error]{color:var(--blast-error)}
21
+ .blast-web button{font:inherit;color:inherit;background:transparent;border:0;border-radius:6px;cursor:pointer}
22
+ .blast-web kbd{font:inherit;font-size:11px;padding:0 .3rem;margin-left:.25rem;border:1px solid var(--blast-line);border-radius:4px}
23
+ .blast-web .actions,.blast-web .dropdown-panel{margin:.5rem;padding:.5rem;border:1px solid var(--blast-line);border-radius:8px;background:var(--blast-bg)}
24
+ .blast-web .empty,.blast-web [role=status]:not(#status){padding:1rem;color:var(--blast-muted)}
25
+ .blast-web .detail,.blast-web .extension-form{padding:.75rem}
26
+ .blast-web .form-field{display:flex;flex-direction:column;gap:.25rem;margin-bottom:.5rem}
27
+ .blast-web .visually-hidden{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0)}
28
+ `;
@@ -0,0 +1,21 @@
1
+ import type { BrowserFetch } from "./browser-install.js";
2
+ export type BrowserFile = string | Uint8Array;
3
+ export type FetchedExtension = {
4
+ files: Map<string, BrowserFile>;
5
+ manifest: Record<string, unknown>;
6
+ lockfile?: string;
7
+ root: string;
8
+ };
9
+ export type FetchExtensionOptions = {
10
+ fetch?: BrowserFetch;
11
+ /** Base URL of `<name>/__files` fixture listings; omit to always use GitHub. */
12
+ fixturesUrl?: string | undefined;
13
+ };
14
+ /** `raycast/extensions/<name>` is the embedding shorthand for a Store extension. */
15
+ export declare function resolveExtensionSource(value: string): string;
16
+ /** Fetches an extension's files from a local fixture server or GitHub. */
17
+ export declare function fetchExtensionFiles(source: string, options?: FetchExtensionOptions): Promise<FetchedExtension>;
18
+ /** The source file a command's bundle starts from. */
19
+ export declare function commandEntry(files: Map<string, BrowserFile>, name: string): string;
20
+ /** Command names a manifest declares, in order. */
21
+ export declare function manifestCommands(manifest: Record<string, unknown>): string[];
@@ -0,0 +1,86 @@
1
+ import { canUseLocalFixture, isTextExtension, parseExtensionSource } from "./source.js";
2
+ const githubApiPath = (path) => path
3
+ .split("/")
4
+ .map((part) => encodeURIComponent(part))
5
+ .join("/");
6
+ const rateLimited = (response) => response.status === 403 && response.headers.get("x-ratelimit-remaining") === "0";
7
+ /** `raycast/extensions/<name>` is the embedding shorthand for a Store extension. */
8
+ export function resolveExtensionSource(value) {
9
+ const match = /^raycast\/extensions\/([^/]+)$/.exec(value.trim());
10
+ return match?.[1] ?? value;
11
+ }
12
+ /** Fetches an extension's files from a local fixture server or GitHub. */
13
+ export async function fetchExtensionFiles(source, options = {}) {
14
+ const request = options.fetch ?? ((input, init) => fetch(input, init));
15
+ const parsed = parseExtensionSource(resolveExtensionSource(source));
16
+ const files = new Map();
17
+ const read = async (path, url) => {
18
+ const response = await request(url);
19
+ if (!response.ok) {
20
+ if (rateLimited(response))
21
+ throw new Error("GitHub API rate limit reached; try again later or use a local fixture");
22
+ throw new Error(`Unable to fetch ${path} (${response.status})`);
23
+ }
24
+ const bytes = new Uint8Array(await response.arrayBuffer());
25
+ files.set(`/${path}`, isTextExtension(path) ? new TextDecoder().decode(bytes) : bytes);
26
+ };
27
+ let root = parsed.path;
28
+ let paths = [];
29
+ if (canUseLocalFixture(parsed, options.fixturesUrl !== undefined)) {
30
+ const localBase = `${options.fixturesUrl}${encodeURIComponent(parsed.path.replace(/^extensions\//, ""))}`;
31
+ const listing = await request(`${localBase}/__files`).catch(() => undefined);
32
+ if (listing?.ok) {
33
+ paths = (await listing.json());
34
+ root = parsed.path.replace(/^extensions\//, "");
35
+ for (const path of paths)
36
+ await read(path, `${localBase}/${path}`);
37
+ }
38
+ }
39
+ if (paths.length === 0) {
40
+ const list = async (path) => {
41
+ const response = await request(`https://api.github.com/repos/${encodeURIComponent(parsed.owner)}/${encodeURIComponent(parsed.repo)}/contents/${githubApiPath(path)}?ref=${encodeURIComponent(parsed.ref)}`, { headers: { Accept: "application/vnd.github+json" } });
42
+ if (!response.ok) {
43
+ if (rateLimited(response))
44
+ throw new Error("GitHub API rate limit reached; try again later or use a local fixture");
45
+ throw new Error(`Unable to list GitHub extension files (${response.status})`);
46
+ }
47
+ const entries = (await response.json());
48
+ const result = [];
49
+ for (const entry of entries) {
50
+ if (!entry.path || entry.path.split("/").includes("node_modules"))
51
+ continue;
52
+ if (entry.type === "dir")
53
+ result.push(...(await list(entry.path)));
54
+ else if (entry.type === "file")
55
+ result.push(entry.path.slice(parsed.path.length + 1));
56
+ }
57
+ return result;
58
+ };
59
+ paths = await list(parsed.path);
60
+ if (!paths.includes("package.json"))
61
+ throw new Error("Extension package.json was not found");
62
+ for (const path of paths)
63
+ await read(path, `https://raw.githubusercontent.com/${parsed.owner}/${parsed.repo}/${encodeURIComponent(parsed.ref)}/${parsed.path}/${path}`);
64
+ }
65
+ const manifest = JSON.parse(String(files.get("/package.json")));
66
+ const lockfile = files.get("/package-lock.json");
67
+ return { files, manifest, root, ...(typeof lockfile === "string" ? { lockfile } : {}) };
68
+ }
69
+ /** The source file a command's bundle starts from. */
70
+ export function commandEntry(files, name) {
71
+ for (const extension of ["tsx", "ts", "jsx", "js"]) {
72
+ const path = `/src/${name}.${extension}`;
73
+ if (files.has(path))
74
+ return path;
75
+ }
76
+ if (files.has("/src/index.tsx"))
77
+ return "/src/index.tsx";
78
+ throw new Error(`Command source not found for ${name}`);
79
+ }
80
+ /** Command names a manifest declares, in order. */
81
+ export function manifestCommands(manifest) {
82
+ const commands = manifest.commands;
83
+ return Array.isArray(commands)
84
+ ? commands.flatMap((command) => typeof command?.name === "string" ? [command.name] : [])
85
+ : [];
86
+ }
@@ -0,0 +1,18 @@
1
+ import type { BrowserStorage, CapabilityContext, CapabilityRequest, CapabilityResult, ClipboardBridge } from "./capabilities.js";
2
+ /**
3
+ * How the embedding page answers the capabilities an extension asks for.
4
+ * Anything left out falls back to the package default (decision 0015).
5
+ */
6
+ export type WebHandlers = {
7
+ storage?: BrowserStorage;
8
+ clipboard?: ClipboardBridge;
9
+ openUrl?: (url: string) => void;
10
+ /**
11
+ * Answers any capability before the defaults see it: return a result to
12
+ * grant it, or `{ ok: false, error: { code: "DENIED", ... } }` to deny it.
13
+ * Return undefined to let the default handler answer.
14
+ */
15
+ capability?: (request: CapabilityRequest, context: CapabilityContext) => CapabilityResult | undefined | Promise<CapabilityResult | undefined>;
16
+ };
17
+ /** localStorage, the Clipboard API (the browser asks the user) and a new tab for URLs. */
18
+ export declare function createDefaultHandlers(): WebHandlers;