@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.
- package/LICENSE +201 -0
- package/README.md +73 -2
- package/bin/fx.js +1166 -0
- package/package.json +64 -4
- package/product.yml +28 -0
- package/src/brand.js +105 -0
- package/src/build.js +181 -0
- package/src/catalog.js +753 -0
- package/src/compiler.js +1658 -0
- package/src/config-edit.js +338 -0
- package/src/config.js +400 -0
- package/src/decompiler.js +551 -0
- package/src/dev.js +390 -0
- package/src/errors.js +56 -0
- package/src/extensions.js +747 -0
- package/src/index.js +35 -0
- package/src/language.js +12 -0
- package/src/lexer.js +202 -0
- package/src/log.js +70 -0
- package/src/package.js +202 -0
- package/src/packager.js +177 -0
- package/src/parser.js +608 -0
- package/src/product.js +27 -0
- package/src/project.js +156 -0
- package/src/sb3.js +340 -0
- package/src/sprite.js +81 -0
- package/vendor/pm-packager-dist/.fx-build.json +6 -0
- package/vendor/pm-packager-dist/LICENSE +202 -0
- package/vendor/pm-packager-dist/NOTICE +6 -0
- package/vendor/pm-packager-dist/dist/290e09e569a1cab8e61ba93b0d23863f.png +0 -0
- package/vendor/pm-packager-dist/dist/icns.js +1 -0
- package/vendor/pm-packager-dist/dist/packager.js +154 -0
- package/vendor/pm-packager-dist/dist/scaffolding/addons.js +2 -0
- package/vendor/pm-packager-dist/dist/scaffolding/extension worker.js +2 -0
- package/vendor/pm-packager-dist/dist/scaffolding/scaffolding-full.js +93 -0
- package/vendor/pm-packager-dist/dist/scaffolding/scaffolding-min.js +93 -0
- package/vendor/pm-packager-dist/dist/scaffolding/vendors~iframe-extension-worker.js +2 -0
- package/vendor/pm-packager-dist/dist/vendors~sha256.js +3 -0
- 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
|
+
}
|