@triflare/fx 0.0.0-stage → 1.0.1

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 (39) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +73 -2
  3. package/bin/fx.js +1166 -0
  4. package/package.json +64 -4
  5. package/product.yml +28 -0
  6. package/src/brand.js +105 -0
  7. package/src/build.js +181 -0
  8. package/src/catalog.js +753 -0
  9. package/src/compiler.js +1658 -0
  10. package/src/config-edit.js +338 -0
  11. package/src/config.js +400 -0
  12. package/src/decompiler.js +551 -0
  13. package/src/dev.js +390 -0
  14. package/src/errors.js +56 -0
  15. package/src/extensions.js +747 -0
  16. package/src/index.js +35 -0
  17. package/src/language.js +12 -0
  18. package/src/lexer.js +202 -0
  19. package/src/log.js +70 -0
  20. package/src/package.js +202 -0
  21. package/src/packager.js +177 -0
  22. package/src/parser.js +608 -0
  23. package/src/product.js +27 -0
  24. package/src/project.js +156 -0
  25. package/src/sb3.js +340 -0
  26. package/src/sprite.js +81 -0
  27. package/vendor/pm-packager-dist/.fx-build.json +6 -0
  28. package/vendor/pm-packager-dist/LICENSE +202 -0
  29. package/vendor/pm-packager-dist/NOTICE +6 -0
  30. package/vendor/pm-packager-dist/dist/290e09e569a1cab8e61ba93b0d23863f.png +0 -0
  31. package/vendor/pm-packager-dist/dist/icns.js +1 -0
  32. package/vendor/pm-packager-dist/dist/packager.js +154 -0
  33. package/vendor/pm-packager-dist/dist/scaffolding/addons.js +2 -0
  34. package/vendor/pm-packager-dist/dist/scaffolding/extension worker.js +2 -0
  35. package/vendor/pm-packager-dist/dist/scaffolding/scaffolding-full.js +93 -0
  36. package/vendor/pm-packager-dist/dist/scaffolding/scaffolding-min.js +93 -0
  37. package/vendor/pm-packager-dist/dist/scaffolding/vendors~iframe-extension-worker.js +2 -0
  38. package/vendor/pm-packager-dist/dist/vendors~sha256.js +3 -0
  39. package/vendor/pm-packager-dist/package.json +3 -0
@@ -0,0 +1,747 @@
1
+ /**
2
+ * PenguinMod extension bindings.
3
+ *
4
+ * Triflare Fx keeps its catalog centralized, so third-party extensions are
5
+ * supported by *synthesizing* catalog entries at build time: the extension
6
+ * source is evaluated in an isolated QuickJS WebAssembly runtime that shims the
7
+ * `Scratch.*` API it expects, then `getInfo()` is read back and translated into
8
+ * the same descriptor shape used by `catalog.js`.
9
+ *
10
+ * A Triflare Fx call `myExtension.myBlock(...)` maps onto the runtime opcode
11
+ * `myExtension_myBlock`, exactly how scratch-vm names extension blocks.
12
+ *
13
+ * Extension sources are untrusted: they run with no filesystem, network or
14
+ * `process` access. Remote sources are fetched with the global `fetch`.
15
+ *
16
+ * Many PenguinMod gallery extensions refuse to load unless they run
17
+ * *unsandboxed* (`if (!Scratch.extensions.unsandboxed) throw ...`). Whether an
18
+ * extension actually runs sandboxed is decided by the editor (or packager), not
19
+ * by us, so compilation must not be the thing that blocks them: when a sandboxed
20
+ * evaluation throws we retry with an unsandboxed `Scratch` API. Extensions that
21
+ * only load that way are reported as `unsandboxed` so the CLI can warn that they
22
+ * must be allowed to run unsandboxed when the project is opened.
23
+ */
24
+ import fs from "node:fs";
25
+ import path from "node:path";
26
+ import { getQuickJS } from "quickjs-emscripten";
27
+
28
+ /** `Scratch.BlockType` (values used by scratch-vm / PenguinMod). */
29
+ export const BlockType = {
30
+ BOOLEAN: "Boolean",
31
+ BUTTON: "button",
32
+ LABEL: "label",
33
+ COMMAND: "command",
34
+ CONDITIONAL: "conditional",
35
+ EVENT: "event",
36
+ HAT: "hat",
37
+ LOOP: "loop",
38
+ REPORTER: "reporter",
39
+ XML: "xml",
40
+ };
41
+
42
+ /** `Scratch.ArgumentType`. */
43
+ export const ArgumentType = {
44
+ ANGLE: "angle",
45
+ BOOLEAN: "Boolean",
46
+ COLOR: "color",
47
+ NUMBER: "number",
48
+ STRING: "string",
49
+ MATRIX: "matrix",
50
+ NOTE: "note",
51
+ IMAGE: "image",
52
+ POLYGON: "polygon",
53
+ CUSTOM: "custom",
54
+ COSTUME: "costume",
55
+ SOUND: "sound",
56
+ VARIABLE: "variable",
57
+ LIST: "list",
58
+ BROADCAST: "broadcast",
59
+ SEPERATOR: "seperator",
60
+ };
61
+
62
+ /** `Scratch.ArgumentAlignment`. */
63
+ export const ArgumentAlignment = {
64
+ DEFAULT: null,
65
+ LEFT: "LEFT",
66
+ CENTER: "CENTRE",
67
+ RIGHT: "RIGHT",
68
+ };
69
+
70
+ /** `Scratch.BlockShape` (PenguinMod's richer value types). */
71
+ export const BlockShape = {
72
+ HEXAGONAL: 1,
73
+ ROUND: 2,
74
+ SQUARE: 3,
75
+ LEAF: 4,
76
+ PLUS: 5,
77
+ OCTAGONAL: 6,
78
+ BUMPED: 7,
79
+ INDENTED: 8,
80
+ SCRAPPED: 9,
81
+ ARROW: 10,
82
+ TICKET: 11,
83
+ };
84
+
85
+ /** `Scratch.NotchShape`. */
86
+ export const NotchShape = {
87
+ SWITCH: "switchCase",
88
+ HEXAGON: "hexagon",
89
+ ROUND: "round",
90
+ SQUARE: "square",
91
+ LEAF: "leaf",
92
+ PLUS: "plus",
93
+ OCTAGONAL: "octagonal",
94
+ BUMPED: "bumped",
95
+ INDENTED: "indented",
96
+ SCRAPPED: "scrapped",
97
+ ARROW: "arrow",
98
+ TICKET: "ticket",
99
+ JIGSAW: "jigsaw",
100
+ INVERTED: "inverted",
101
+ PINCER: "pincer",
102
+ };
103
+
104
+ /** `Scratch.TargetType`. */
105
+ export const TargetType = {
106
+ SPRITE: "sprite",
107
+ STAGE: "stage",
108
+ };
109
+
110
+ /** `Scratch.Clone`. */
111
+ export const Clone = {
112
+ simple: (original) => JSON.parse(JSON.stringify(original)),
113
+ };
114
+
115
+ /** `Scratch.Color`. */
116
+ export const Color = {
117
+ get RGB_BLACK() {
118
+ return { r: 0, g: 0, b: 0 };
119
+ },
120
+ get RGB_WHITE() {
121
+ return { r: 255, g: 255, b: 255 };
122
+ },
123
+ decimalToHex(decimal) {
124
+ let value = decimal;
125
+ if (value < 0) value += 0xffffff + 1;
126
+ let hex = Math.round(Number(value)).toString(16);
127
+ hex = `#${"000000".slice(0, 6 - hex.length)}${hex}`;
128
+ return hex;
129
+ },
130
+ decimalToRgb(decimal) {
131
+ const a = (decimal >> 24) & 0xff;
132
+ return {
133
+ r: (decimal >> 16) & 0xff,
134
+ g: (decimal >> 8) & 0xff,
135
+ b: decimal & 0xff,
136
+ a: a > 0 ? a : 255,
137
+ };
138
+ },
139
+ hexToRgb(hex) {
140
+ let value = hex;
141
+ if (value.startsWith("#")) value = value.slice(1);
142
+ if (value.length === 8) value = value.slice(0, 6);
143
+ const parsed = parseInt(value, 16);
144
+ if (Number.isNaN(parsed)) return null;
145
+ if (value.length === 6)
146
+ return {
147
+ r: (parsed >> 16) & 0xff,
148
+ g: (parsed >> 8) & 0xff,
149
+ b: parsed & 0xff,
150
+ };
151
+ if (value.length === 3) {
152
+ const r = (parsed >> 8) & 0xf;
153
+ const g = (parsed >> 4) & 0xf;
154
+ const b = parsed & 0xf;
155
+ return { r: (r << 4) | r, g: (g << 4) | g, b: (b << 4) | b };
156
+ }
157
+ return null;
158
+ },
159
+ rgbToHex(rgb) {
160
+ return Color.decimalToHex(Color.rgbToDecimal(rgb));
161
+ },
162
+ rgbToDecimal(rgb) {
163
+ return (rgb.r << 16) + (rgb.g << 8) + rgb.b;
164
+ },
165
+ hexToDecimal(hex) {
166
+ return Color.rgbToDecimal(Color.hexToRgb(hex));
167
+ },
168
+ };
169
+
170
+ /** `Scratch.Cast`. */
171
+ export const Cast = {
172
+ toString: (value) => String(value),
173
+ toNumber: (value) => {
174
+ const n = Number(value);
175
+ return Number.isNaN(n) ? 0 : n;
176
+ },
177
+ toBoolean: (value) =>
178
+ typeof value === "boolean" ? value : String(value) === "true",
179
+ toList: (value) => (Array.isArray(value) ? value : [value]),
180
+ };
181
+
182
+ // -------------------------------------------------------------- translation
183
+
184
+ /**
185
+ * Map a `Scratch.ArgumentType` onto a Triflare Fx input descriptor.
186
+ * @param {object} arg
187
+ * @returns {{type: string}}
188
+ */
189
+ function argumentSpec(arg) {
190
+ const type = arg && arg.type;
191
+ switch (type) {
192
+ case ArgumentType.BOOLEAN:
193
+ return { type: "boolean" };
194
+ case ArgumentType.NUMBER:
195
+ return { type: "number" };
196
+ case ArgumentType.ANGLE:
197
+ return { type: "angle" };
198
+ case ArgumentType.COLOR:
199
+ return { type: "color" };
200
+ // Matrix, note, costume, backdrop, sound and string all travel as text.
201
+ default:
202
+ return { type: "string" };
203
+ }
204
+ }
205
+
206
+ /**
207
+ * @param {string} blockType
208
+ * @returns {'command'|'reporter'|'boolean'|'hat'|null} null for non-block types
209
+ */
210
+ function blockKind(blockType) {
211
+ switch (blockType) {
212
+ case BlockType.BOOLEAN:
213
+ return "boolean";
214
+ case BlockType.REPORTER:
215
+ return "reporter";
216
+ case BlockType.HAT:
217
+ case BlockType.EVENT:
218
+ return "hat";
219
+ case BlockType.LABEL:
220
+ case BlockType.BUTTON:
221
+ case BlockType.XML:
222
+ return null;
223
+ default:
224
+ return "command";
225
+ }
226
+ }
227
+
228
+ /** Flatten the (possibly nested) `blocks` array from `getInfo()`. */
229
+ function flattenBlocks(blocks, out = []) {
230
+ for (const block of blocks || []) {
231
+ if (Array.isArray(block)) flattenBlocks(block, out);
232
+ else if (
233
+ block &&
234
+ typeof block === "object" &&
235
+ typeof block.opcode === "string"
236
+ )
237
+ out.push(block);
238
+ }
239
+ return out;
240
+ }
241
+
242
+ /**
243
+ * Turn one extension `getInfo()` result into catalog entries.
244
+ * @param {Record<string, object>} catalog
245
+ * @param {object} info
246
+ * @param {string} sourceName
247
+ * @returns {number} number of blocks registered
248
+ */
249
+ function registerInfo(catalog, info, sourceName) {
250
+ if (!info || typeof info !== "object" || typeof info.id !== "string") {
251
+ throw new Error(
252
+ `extension ${sourceName} did not provide a getInfo() with a string id`,
253
+ );
254
+ }
255
+ const id = info.id;
256
+ let count = 0;
257
+ for (const block of flattenBlocks(info.blocks)) {
258
+ const kind = blockKind(block.blockType);
259
+ if (kind == null) continue;
260
+
261
+ const inputs = {};
262
+ for (const [name, arg] of Object.entries(block.arguments || {})) {
263
+ inputs[name] = argumentSpec(arg);
264
+ }
265
+
266
+ const entry = {
267
+ opcode: `${id}_${block.opcode}`,
268
+ kind,
269
+ inputs,
270
+ extension: id,
271
+ };
272
+ if (kind === "hat") {
273
+ entry.args = Object.entries(block.arguments || {}).map(([name, arg]) => ({
274
+ input: name,
275
+ type: argumentSpec(arg).type,
276
+ }));
277
+ } else if (
278
+ block.blockType === BlockType.CONDITIONAL ||
279
+ block.blockType === BlockType.LOOP
280
+ ) {
281
+ inputs.SUBSTACK = { type: "substack" };
282
+ if ((block.branchCount || 1) > 1) inputs.SUBSTACK2 = { type: "substack" };
283
+ }
284
+
285
+ catalog[`${id}.${block.opcode}`] = entry;
286
+ count += 1;
287
+ }
288
+ return count;
289
+ }
290
+
291
+ // ------------------------------------------------------------------ sandbox
292
+
293
+ /**
294
+ * A permissive stand-in for a runtime object.
295
+ *
296
+ * Extensions routinely touch the VM at module scope — `Scratch.vm.runtime.on(…)`,
297
+ * `vm.runtime.registerSerializer(…)`, `Scratch.renderer.…` — none of which is
298
+ * needed to read `getInfo()`. Instead of throwing on a missing browser/VM
299
+ * global, hand back an object that tolerates arbitrary reads, writes and calls.
300
+ * @param {string} label human-readable name, used for `toString`/coercion
301
+ * @returns {any}
302
+ */
303
+ function permissive(label) {
304
+ const cache = new Map();
305
+ const fn = function () {
306
+ return proxy;
307
+ };
308
+ const proxy = new Proxy(fn, {
309
+ get(target, prop) {
310
+ if (prop === "then") return undefined; // stay non-thenable
311
+ if (prop === Symbol.toPrimitive) return () => label;
312
+ if (prop === "toString" || prop === "valueOf" || prop === "toJSON")
313
+ return () => label;
314
+ if (typeof prop === "symbol") return target[prop];
315
+ if (!cache.has(prop)) cache.set(prop, permissive(`${label}.${prop}`));
316
+ return cache.get(prop);
317
+ },
318
+ set() {
319
+ return true;
320
+ },
321
+ has() {
322
+ return true;
323
+ },
324
+ apply() {
325
+ return proxy;
326
+ },
327
+ construct() {
328
+ return proxy;
329
+ },
330
+ });
331
+ return proxy;
332
+ }
333
+
334
+ function makeSandbox(registered, { unsandboxed = false } = {}) {
335
+ const Scratch = {
336
+ extensions: {
337
+ register(extension) {
338
+ registered.push(extension);
339
+ },
340
+ // PenguinMod sets `unsandboxed: true` for unsandboxed loads and exposes
341
+ // `isPenguinMod`, so extensions can adapt to whichever loader runs them.
342
+ unsandboxed: Boolean(unsandboxed),
343
+ isPenguinMod: true,
344
+ },
345
+ BlockType,
346
+ ArgumentType,
347
+ ArgumentAlignment,
348
+ BlockShape,
349
+ NotchShape,
350
+ TargetType,
351
+ Clone,
352
+ Color,
353
+ Cast,
354
+ translate: (value) => {
355
+ if (typeof value === "string") return value;
356
+ if (
357
+ value &&
358
+ typeof value === "object" &&
359
+ typeof value.default === "string"
360
+ )
361
+ return value.default;
362
+ return value == null ? "" : String(value);
363
+ },
364
+ canFetch: () => Promise.resolve(false),
365
+ fetch: () =>
366
+ Promise.reject(
367
+ new Error("Scratch.fetch is not available while compiling"),
368
+ ),
369
+ canOpenWindow: () => Promise.resolve(false),
370
+ openWindow: () =>
371
+ Promise.reject(
372
+ new Error("Scratch.openWindow is not available while compiling"),
373
+ ),
374
+ canRedirect: () => Promise.resolve(false),
375
+ redirect: () =>
376
+ Promise.reject(
377
+ new Error("Scratch.redirect is not available while compiling"),
378
+ ),
379
+ canRecordAudio: () => Promise.resolve(false),
380
+ canRecordVideo: () => Promise.resolve(false),
381
+ canReadClipboard: () => Promise.resolve(false),
382
+ canNotify: () => Promise.resolve(false),
383
+ canGeolocate: () => Promise.resolve(false),
384
+ canEmbed: () => Promise.resolve(false),
385
+ // The editor lets the user allow/decline unsandboxing; mirror that here so
386
+ // metadata extraction can see the API the extension is written against.
387
+ canUnsandbox: () => Promise.resolve(Boolean(unsandboxed)),
388
+ canScreenshotCamera: () => Promise.resolve(false),
389
+ canDownload: () => Promise.resolve(false),
390
+ download: () =>
391
+ Promise.reject(
392
+ new Error("Scratch.download is not available while compiling"),
393
+ ),
394
+ vm: permissive("Scratch.vm"),
395
+ renderer: permissive("Scratch.renderer"),
396
+ gui: permissive("Scratch.gui"),
397
+ // Some extensions probe these at module scope.
398
+ runtime: permissive("Scratch.runtime"),
399
+ stage: permissive("Scratch.stage"),
400
+ };
401
+
402
+ const sandbox = {
403
+ Scratch,
404
+ ScratchExtensions: {
405
+ register() {
406
+ /* legacy ScratchX extensions unsupported */
407
+ },
408
+ },
409
+ console: permissive("console"),
410
+ // Metadata extraction is synchronous and extension timers must never fire:
411
+ // they would outlive the sandbox and could hold the CLI process open.
412
+ setTimeout: () => 0,
413
+ clearTimeout: () => {},
414
+ setInterval: () => 0,
415
+ clearInterval: () => {},
416
+ URL: permissive("URL"),
417
+ URLSearchParams: permissive("URLSearchParams"),
418
+ TextEncoder: permissive("TextEncoder"),
419
+ TextDecoder: permissive("TextDecoder"),
420
+ Uint8Array,
421
+ ArrayBuffer,
422
+ Math,
423
+ Date,
424
+ JSON,
425
+ };
426
+ // Browser globals unsandboxed extensions touch at module scope. None of them
427
+ // need to do anything to read `getInfo()`; they exist so setup code does not
428
+ // throw. The sandbox has no network, filesystem or real DOM access.
429
+ sandbox.vm = permissive("vm");
430
+ sandbox.document = permissive("document");
431
+ sandbox.navigator = permissive("navigator");
432
+ sandbox.location = permissive("location");
433
+ sandbox.localStorage = permissive("localStorage");
434
+ // NB: `alert` is deliberately left undefined. Extensions use it inside their
435
+ // unsandboxed guard (`if (!Scratch.extensions.unsandboxed) { alert(...); return; }`),
436
+ // so a permissive alert would let the sandboxed probe "succeed" without
437
+ // registering anything and skip the unsandboxed retry.
438
+ sandbox.Audio = permissive("Audio");
439
+ sandbox.Image = permissive("Image");
440
+ sandbox.requestAnimationFrame = permissive("requestAnimationFrame");
441
+ sandbox.cancelAnimationFrame = permissive("cancelAnimationFrame");
442
+
443
+ sandbox.window = sandbox;
444
+ sandbox.self = sandbox;
445
+ sandbox.globalThis = sandbox;
446
+ return sandbox;
447
+ }
448
+
449
+ /** Encode only our trusted shim definitions as source, without sharing objects. */
450
+ function shimSource(value) {
451
+ if (typeof value === "function") {
452
+ const source = value.toString();
453
+ return /^[\w$]+\(/.test(source) ? `function ${source}` : source;
454
+ }
455
+ if (value === null || typeof value !== "object") return JSON.stringify(value);
456
+ return `{${Object.entries(Object.getOwnPropertyDescriptors(value))
457
+ .map(([name, descriptor]) =>
458
+ descriptor.get
459
+ ? descriptor.get.toString()
460
+ : `${JSON.stringify(name)}: (${shimSource(descriptor.value)})`,
461
+ )
462
+ .join(",")}}`;
463
+ }
464
+
465
+ /** Evaluate source, getInfo() and serialization inside the same execution limit. */
466
+ async function evaluateExtension(
467
+ source,
468
+ filename,
469
+ { unsandboxed = false } = {},
470
+ ) {
471
+ const QuickJS = await getQuickJS();
472
+ const runtime = QuickJS.newRuntime();
473
+ runtime.setMemoryLimit(64 * 1024 * 1024);
474
+ runtime.setMaxStackSize(1024 * 1024);
475
+ const deadline = Date.now() + 5000;
476
+ runtime.setInterruptHandler(() => Date.now() >= deadline);
477
+ const context = runtime.newContext();
478
+ try {
479
+ // Every shim function is created in QuickJS. Only source text enters and
480
+ // JSON text leaves; extensions never receive host functions or Node APIs.
481
+ const shims = Object.entries({
482
+ BlockType,
483
+ ArgumentType,
484
+ ArgumentAlignment,
485
+ BlockShape,
486
+ NotchShape,
487
+ TargetType,
488
+ Clone,
489
+ Color,
490
+ Cast,
491
+ })
492
+ .map(([name, value]) => `const ${name} = ${shimSource(value)};`)
493
+ .join("\n");
494
+ const result = context.evalCode(
495
+ `(() => {
496
+ ${shims}
497
+ ${permissive.toString()}
498
+ ${makeSandbox.toString()}
499
+ const registered = [];
500
+ Object.assign(globalThis, makeSandbox(registered, {unsandboxed: ${unsandboxed}}));
501
+ const evaluate = eval;
502
+ const stringify = JSON.stringify;
503
+ evaluate(${JSON.stringify(source)});
504
+ const infos = [];
505
+ for (const extension of registered) {
506
+ if (!extension) continue;
507
+ infos.push(typeof extension.getInfo === "function" ? extension.getInfo() : extension);
508
+ }
509
+ return stringify(infos);
510
+ })()`,
511
+ filename,
512
+ );
513
+ try {
514
+ if (result.error) {
515
+ const error = context.dump(result.error);
516
+ const timedOut = Date.now() >= deadline;
517
+ const failure = new Error(
518
+ timedOut
519
+ ? "extension execution timed out"
520
+ : error?.message || "extension evaluation failed",
521
+ );
522
+ if (timedOut) failure.code = "EXTENSION_TIMEOUT";
523
+ throw failure;
524
+ }
525
+ return JSON.parse(context.getString(result.value));
526
+ } finally {
527
+ result.dispose();
528
+ }
529
+ } finally {
530
+ context.dispose();
531
+ runtime.dispose();
532
+ }
533
+ }
534
+
535
+ // ------------------------------------------------------------------- loading
536
+
537
+ const DATA_URI = /^data:([^,]*),([\s\S]*)$/i;
538
+ const BASE64_PAYLOAD = /^[A-Za-z0-9+/=\s]*$/;
539
+
540
+ /**
541
+ * Encode extension source as a `data:` URI.
542
+ *
543
+ * Archives only remember extension sources through `project.extensionURLs`, so
544
+ * local extension files are baked in as data URIs (the same trick the
545
+ * PenguinMod packager uses when it bakes extensions). Base64 survives quotes,
546
+ * comments and non-ASCII text without escaping.
547
+ * @param {string} source
548
+ * @returns {string}
549
+ */
550
+ export function extensionDataUri(source) {
551
+ const base64 = Buffer.from(String(source), "utf8").toString("base64");
552
+ return `data:text/javascript;base64,${base64}`;
553
+ }
554
+
555
+ /**
556
+ * Decode a `data:` URI back into extension source text.
557
+ * @param {string} uri
558
+ * @returns {string|null} source text, or null when the URI is unsupported
559
+ */
560
+ export function readExtensionDataUri(uri) {
561
+ const match = DATA_URI.exec(String(uri).trim());
562
+ if (!match) return null;
563
+ const [, meta, payload] = match;
564
+ try {
565
+ if (/;base64\s*$/i.test(meta)) {
566
+ if (!BASE64_PAYLOAD.test(payload)) return null;
567
+ return Buffer.from(payload, "base64").toString("utf8");
568
+ }
569
+ return decodeURIComponent(payload);
570
+ } catch {
571
+ return null;
572
+ }
573
+ }
574
+
575
+ /** Resolve a config entry to source text. */
576
+ async function resolveSource(entry, baseDir, fetchImpl) {
577
+ const value = String(entry);
578
+ if (/^data:/i.test(value)) {
579
+ const source = readExtensionDataUri(value);
580
+ if (source == null)
581
+ throw new Error(
582
+ "unsupported data URI; expected base64 or percent-encoded JavaScript",
583
+ );
584
+ return { source, remote: false, dataUri: true, url: value };
585
+ }
586
+ if (/^https?:\/\//i.test(value)) {
587
+ const fetchFn = fetchImpl || globalThis.fetch;
588
+ if (typeof fetchFn !== "function")
589
+ throw new Error("global fetch is unavailable; pass fetchImpl");
590
+ const response = await fetchFn(value);
591
+ if (!response.ok)
592
+ throw new Error(`HTTP ${response.status} ${response.statusText}`.trim());
593
+ return { source: await response.text(), remote: true, url: value };
594
+ }
595
+ const resolved = path.isAbsolute(value)
596
+ ? value
597
+ : path.resolve(baseDir, value);
598
+ return {
599
+ source: fs.readFileSync(resolved, "utf8"),
600
+ remote: false,
601
+ url: value,
602
+ file: resolved,
603
+ };
604
+ }
605
+
606
+ /**
607
+ * Evaluate extension source, retrying unsandboxed when the sandboxed pass
608
+ * throws. Extensions that guard on `Scratch.extensions.unsandboxed` can only be
609
+ * loaded that way, and it is the editor (or packager) that decides it.
610
+ * @param {string} source
611
+ * @param {string} filename
612
+ * @returns {{infos: object[], unsandboxed: boolean}}
613
+ */
614
+ async function evaluateExtensionAny(source, filename) {
615
+ try {
616
+ return {
617
+ infos: await evaluateExtension(source, filename, { unsandboxed: false }),
618
+ unsandboxed: false,
619
+ };
620
+ } catch (sandboxedError) {
621
+ if (sandboxedError.code === "EXTENSION_TIMEOUT") throw sandboxedError;
622
+ try {
623
+ return {
624
+ infos: await evaluateExtension(source, filename, { unsandboxed: true }),
625
+ unsandboxed: true,
626
+ };
627
+ } catch (unsandboxedError) {
628
+ const sandboxedMessage = sandboxedError && sandboxedError.message;
629
+ const unsandboxedMessage = unsandboxedError && unsandboxedError.message;
630
+ // Neither mode works. Prefer the unsandboxed failure (the sandboxed one is
631
+ // usually just the extension's own guard) and keep both when they differ.
632
+ if (unsandboxedMessage && unsandboxedMessage !== sandboxedMessage) {
633
+ throw new Error(
634
+ `${unsandboxedMessage} (sandboxed: ${sandboxedMessage})`,
635
+ { cause: unsandboxedError },
636
+ );
637
+ }
638
+ throw unsandboxedError;
639
+ }
640
+ }
641
+ }
642
+
643
+ /**
644
+ * Load a list of extension references.
645
+ *
646
+ * @param {Array<string>} list Paths, `http(s)://` URLs or `data:` URIs.
647
+ * @param {{baseDir?: string, fetchImpl?: typeof fetch, expectUnsandboxed?: string|string[]}} [options]
648
+ * `expectUnsandboxed` acknowledges extensions that are known to require
649
+ * unsandboxed mode, silencing the warning for them.
650
+ * @returns {Promise<{catalog: Record<string, object>, extensionUrls: Record<string, string>, warnings: string[], ids: string[], unsandboxed: string[]}>}
651
+ * `unsandboxed` holds the ids of extensions that only load unsandboxed.
652
+ */
653
+ export async function loadExtensions(
654
+ list,
655
+ { baseDir = process.cwd(), fetchImpl, expectUnsandboxed = [] } = {},
656
+ ) {
657
+ const catalog = {};
658
+ const extensionUrls = {};
659
+ const warnings = [];
660
+ const ids = [];
661
+ const unsandboxed = [];
662
+ const declared = new Set(
663
+ (Array.isArray(expectUnsandboxed) ? expectUnsandboxed : [expectUnsandboxed])
664
+ .filter((value) => value != null && value !== "")
665
+ .map(String),
666
+ );
667
+ const declaredSeen = new Set();
668
+ const declaredNotNeeded = new Set();
669
+ const needsDeclaration = [];
670
+
671
+ for (const entry of list || []) {
672
+ const label = String(entry);
673
+ // Data URIs are enormous; keep warning lines readable without breaking the
674
+ // path/URL matching that `expectUnsandboxed` relies on.
675
+ const name = label.length > 64 ? `${label.slice(0, 60)}…` : label;
676
+ try {
677
+ const { source, remote, url, file, dataUri } = await resolveSource(
678
+ entry,
679
+ baseDir,
680
+ fetchImpl,
681
+ );
682
+ const { infos, unsandboxed: needsUnsandboxed } =
683
+ await evaluateExtensionAny(
684
+ source,
685
+ remote ? url : (file ?? "extension"),
686
+ );
687
+ if (infos.length === 0) {
688
+ warnings.push(
689
+ `extension ${name} did not call Scratch.extensions.register()`,
690
+ );
691
+ continue;
692
+ }
693
+ for (const info of infos) {
694
+ const count = registerInfo(catalog, info, name);
695
+ if (!ids.includes(info.id)) ids.push(info.id);
696
+
697
+ for (const candidate of [info.id, label]) {
698
+ if (!declared.has(candidate)) continue;
699
+ declaredSeen.add(candidate);
700
+ if (!needsUnsandboxed) declaredNotNeeded.add(candidate);
701
+ }
702
+
703
+ if (needsUnsandboxed) {
704
+ if (!unsandboxed.includes(info.id)) unsandboxed.push(info.id);
705
+ needsDeclaration.push({ id: info.id, label, name });
706
+ }
707
+
708
+ // Remote URLs travel as-is; local (and already embedded) sources are
709
+ // recorded as data URIs so the archive carries the extension with it.
710
+ extensionUrls[info.id] =
711
+ remote || dataUri ? url : extensionDataUri(source);
712
+ if (count === 0)
713
+ warnings.push(
714
+ `extension ${name} (id "${info.id}") exposes no usable blocks`,
715
+ );
716
+ }
717
+ } catch (err) {
718
+ warnings.push(
719
+ `failed to load extension ${name}: ${err && err.message ? err.message : err}`,
720
+ );
721
+ }
722
+ }
723
+
724
+ // Extensions that only load unsandboxed error out unless the editor is allowed
725
+ // to run them unsandboxed (or the project is packaged), so say so up front.
726
+ for (const { id, label, name } of needsDeclaration) {
727
+ if (declared.has(id) || declared.has(label)) continue;
728
+ warnings.push(
729
+ `extension ${name} (id "${id}") requires unsandboxed mode; allow it to run ` +
730
+ `unsandboxed when opening the project in PenguinMod (or add "${id}" to ` +
731
+ "project.unsandboxed in fx.yml once you trust it)",
732
+ );
733
+ }
734
+ for (const name of declared) {
735
+ if (declaredNotNeeded.has(name)) {
736
+ warnings.push(
737
+ `project.unsandboxed entry "${name}" loads in the sandbox; remove it from fx.yml`,
738
+ );
739
+ } else if (!declaredSeen.has(name)) {
740
+ warnings.push(
741
+ `project.unsandboxed entry "${name}" does not match any loaded extension`,
742
+ );
743
+ }
744
+ }
745
+
746
+ return { catalog, extensionUrls, warnings, ids, unsandboxed };
747
+ }