@triflare/fx 1.0.0 → 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/bin/fx.js +238 -21
- package/package.json +1 -1
- package/product.yml +1 -1
- package/src/decompiler.js +57 -10
- package/src/extensions.js +73 -33
- package/src/index.js +5 -1
package/bin/fx.js
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
* Triflare Fx command line interface.
|
|
4
4
|
*
|
|
5
5
|
* fx build [input.tfx | dir] [-o out.pmp] [--no-config] (alias: b)
|
|
6
|
-
* fx decompile <input.pmp> [-o out.tfx] [--assets <dir>]
|
|
6
|
+
* fx decompile <input.pmp> [-o out.tfx] [--assets <dir>] [--fetch-extensions]
|
|
7
|
+
* (alias: d)
|
|
7
8
|
* fx check [input.tfx | dir] (alias: c)
|
|
8
9
|
* fx catalog [dir] [--json] (alias: l)
|
|
9
10
|
* fx init [dir] (alias: i)
|
|
@@ -15,7 +16,7 @@ import process from "node:process";
|
|
|
15
16
|
import { build } from "../src/build.js";
|
|
16
17
|
import { parse } from "../src/parser.js";
|
|
17
18
|
import { discoverSources, mergePrograms } from "../src/project.js";
|
|
18
|
-
import {
|
|
19
|
+
import { decompileProject } from "../src/decompiler.js";
|
|
19
20
|
import { unzipSync } from "../src/sb3.js";
|
|
20
21
|
import { CATALOG, blockNames } from "../src/catalog.js";
|
|
21
22
|
import {
|
|
@@ -26,7 +27,7 @@ import {
|
|
|
26
27
|
readProjectExtensions,
|
|
27
28
|
removeProjectExtension,
|
|
28
29
|
} from "../src/config.js";
|
|
29
|
-
import { loadExtensions } from "../src/extensions.js";
|
|
30
|
+
import { loadExtensions, readExtensionDataUri } from "../src/extensions.js";
|
|
30
31
|
import { readSprite, spriteArchive, spriteSource } from "../src/sprite.js";
|
|
31
32
|
import { packageProject } from "../src/packager.js";
|
|
32
33
|
import {
|
|
@@ -40,6 +41,7 @@ import { startDevServer } from "../src/dev.js";
|
|
|
40
41
|
import {
|
|
41
42
|
brandText,
|
|
42
43
|
configFilename,
|
|
44
|
+
configFilenames,
|
|
43
45
|
outputDirectory,
|
|
44
46
|
sourceDirectory,
|
|
45
47
|
sourceExtension,
|
|
@@ -64,6 +66,7 @@ function parseArgs(argv) {
|
|
|
64
66
|
"extract",
|
|
65
67
|
"lan",
|
|
66
68
|
"strict-port",
|
|
69
|
+
"fetch-extensions",
|
|
67
70
|
]);
|
|
68
71
|
for (let i = 0; i < argv.length; i++) {
|
|
69
72
|
const token = argv[i];
|
|
@@ -96,7 +99,7 @@ Usage:
|
|
|
96
99
|
${command} build [<input${EXTENSION}>|<dir>] [-o <output.pmp>] [--no-config]
|
|
97
100
|
${command} package [<input${EXTENSION}>|<input.pmp>|<dir>] [-o <output>] [--target <target>] [--extract]
|
|
98
101
|
${command} dev [<input${EXTENSION}>|<dir>] [--host [host]] [--lan] [--port <port>] [--strict-port]
|
|
99
|
-
${command} decompile <input.pmp> [-o <output${EXTENSION}>] [--assets <dir>]
|
|
102
|
+
${command} decompile <input.pmp> [-o <output${EXTENSION}>] [--assets <dir>] [--fetch-extensions]
|
|
100
103
|
${command} check [<input${EXTENSION}>|<dir>]
|
|
101
104
|
${command} catalog [<dir>] [--json]
|
|
102
105
|
${command} init [dir]
|
|
@@ -107,7 +110,8 @@ Commands:
|
|
|
107
110
|
package (p) Package a source, project archive or directory into HTML or a ZIP
|
|
108
111
|
(Windows/macOS/Linux desktop builds use Electron).
|
|
109
112
|
dev Serve the packaged HTML and reload when the output changes.
|
|
110
|
-
decompile (d) Convert a .pmp back into ${product.name} source
|
|
113
|
+
decompile (d) Convert a .pmp back into ${product.name} source, restoring
|
|
114
|
+
extensions into ${configFilename(product)}.
|
|
111
115
|
check (c) Parse a file (or every ${EXTENSION} file in a directory) for errors.
|
|
112
116
|
catalog (l) List ${product.name} blocks, including those from ${configFilename(product)}
|
|
113
117
|
extensions (paths or URLs).
|
|
@@ -133,6 +137,7 @@ Options:
|
|
|
133
137
|
-o, --output Output file path
|
|
134
138
|
--no-config Ignore ${configFilename(product)}
|
|
135
139
|
--assets <dir> Extract project assets into <dir> when decompiling
|
|
140
|
+
--fetch-extensions Load remote (http/https) extensions when decompiling
|
|
136
141
|
--target <t> Packaging target (html, zip, windows, macos, linux, …)
|
|
137
142
|
--extract Unzip a packaged desktop/zip archive
|
|
138
143
|
--host [host] Dev server host; with no value listen on all interfaces
|
|
@@ -159,7 +164,7 @@ function fail(message) {
|
|
|
159
164
|
process.exit(1);
|
|
160
165
|
}
|
|
161
166
|
|
|
162
|
-
async function loadProjectExtensions(dir, config
|
|
167
|
+
async function loadProjectExtensions(dir, config) {
|
|
163
168
|
const catalog = {};
|
|
164
169
|
const extensionUrls = {};
|
|
165
170
|
const unsandboxed = [];
|
|
@@ -168,7 +173,6 @@ async function loadProjectExtensions(dir, config, { localFileWarning } = {}) {
|
|
|
168
173
|
const loaded = await loadExtensions(config.extensions, {
|
|
169
174
|
baseDir: dir,
|
|
170
175
|
expectUnsandboxed: config.unsandboxed,
|
|
171
|
-
localFileWarning,
|
|
172
176
|
});
|
|
173
177
|
Object.assign(catalog, loaded.catalog);
|
|
174
178
|
Object.assign(extensionUrls, loaded.extensionUrls);
|
|
@@ -471,9 +475,7 @@ async function cmdCatalog(args) {
|
|
|
471
475
|
: loadConfigFrom(args._[0] || process.cwd());
|
|
472
476
|
const config = loaded.project;
|
|
473
477
|
const extBase = loaded.path ? path.dirname(loaded.path) : process.cwd();
|
|
474
|
-
const { catalog } = await loadProjectExtensions(extBase, config || {}
|
|
475
|
-
localFileWarning: false,
|
|
476
|
-
});
|
|
478
|
+
const { catalog } = await loadProjectExtensions(extBase, config || {});
|
|
477
479
|
|
|
478
480
|
const merged = { ...CATALOG, ...catalog };
|
|
479
481
|
const names = Object.keys(merged).sort();
|
|
@@ -499,12 +501,228 @@ async function cmdCatalog(args) {
|
|
|
499
501
|
}
|
|
500
502
|
}
|
|
501
503
|
|
|
502
|
-
function
|
|
504
|
+
function truncate(text, max) {
|
|
505
|
+
const value = String(text);
|
|
506
|
+
return value.length > max ? `${value.slice(0, max)}…` : value;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/** Sanitize an extension id into a portable file name stem. */
|
|
510
|
+
function extensionFileStem(id) {
|
|
511
|
+
return (
|
|
512
|
+
String(id)
|
|
513
|
+
.replace(/[^A-Za-z0-9._-]+/g, "-")
|
|
514
|
+
.replace(/^[.-]+|[.-]+$/g, "") || "extension"
|
|
515
|
+
);
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/**
|
|
519
|
+
* Recover the extensions a decompiled project references.
|
|
520
|
+
*
|
|
521
|
+
* An archive only remembers extension sources through `project.extensionURLs`:
|
|
522
|
+
* Triflare Fx stores local extension files there as data URIs, and PenguinMod
|
|
523
|
+
* projects keep plain URLs. Data URIs are written next to the output and
|
|
524
|
+
* referenced from the config file; URLs stay URLs.
|
|
525
|
+
*
|
|
526
|
+
* A decompiled archive is untrusted and the output directory is often the
|
|
527
|
+
* user's workspace, so embedded sources are never written over an existing
|
|
528
|
+
* path and never through a symlink. An extension that cannot be written is
|
|
529
|
+
* still loaded from the archive in memory, so its blocks decompile normally,
|
|
530
|
+
* and its own data URI is recorded in the config so later builds keep it.
|
|
531
|
+
*
|
|
532
|
+
* @param {object} project parsed project.json
|
|
533
|
+
* @param {string|null} outDir directory that receives the config and extension files
|
|
534
|
+
* @param {{fetchRemote?: boolean}} [options] `fetchRemote` loads plain URLs
|
|
535
|
+
* instead of skipping them, which costs a network request per extension.
|
|
536
|
+
* @returns {{entries: string[], loadEntries: string[], files: string[], warnings: string[]}}
|
|
537
|
+
*/
|
|
538
|
+
function restoreExtensions(project, outDir, { fetchRemote = false } = {}) {
|
|
539
|
+
const result = { entries: [], loadEntries: [], files: [], warnings: [] };
|
|
540
|
+
const urls = project && project.extensionURLs;
|
|
541
|
+
if (!urls || typeof urls !== "object") return result;
|
|
542
|
+
|
|
543
|
+
// Resolve the directory that receives embedded sources once, refusing a
|
|
544
|
+
// symlinked `extensions/` (which would redirect every write) and verifying
|
|
545
|
+
// that it really is a plain child of the output directory.
|
|
546
|
+
let extensionDir = null;
|
|
547
|
+
let extensionDirProblem = null;
|
|
548
|
+
const resolveExtensionDir = () => {
|
|
549
|
+
if (extensionDir || extensionDirProblem) return;
|
|
550
|
+
const dir = path.join(outDir, "extensions");
|
|
551
|
+
const existing = fs.lstatSync(dir, { throwIfNoEntry: false });
|
|
552
|
+
if (existing && !existing.isDirectory()) {
|
|
553
|
+
extensionDirProblem = existing.isSymbolicLink()
|
|
554
|
+
? `"${dir}" is a symlink, so the extension would land outside the output directory`
|
|
555
|
+
: `"${dir}" is not a directory`;
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
559
|
+
const expected = path.join(fs.realpathSync(outDir), "extensions");
|
|
560
|
+
const actual = fs.realpathSync(dir);
|
|
561
|
+
if (actual !== expected) {
|
|
562
|
+
extensionDirProblem = `"${dir}" resolves to ${actual}, outside the output directory`;
|
|
563
|
+
return;
|
|
564
|
+
}
|
|
565
|
+
extensionDir = dir;
|
|
566
|
+
};
|
|
567
|
+
|
|
568
|
+
const used = new Set();
|
|
569
|
+
for (const [id, value] of Object.entries(urls)) {
|
|
570
|
+
const uri = typeof value === "string" ? value.trim() : "";
|
|
571
|
+
if (!uri) continue;
|
|
572
|
+
if (/^https?:\/\//i.test(uri)) {
|
|
573
|
+
result.entries.push(uri);
|
|
574
|
+
if (fetchRemote) result.loadEntries.push(uri);
|
|
575
|
+
else
|
|
576
|
+
result.warnings.push(
|
|
577
|
+
`extension ${id} points at a remote URL (${truncate(uri, 48)}); ` +
|
|
578
|
+
"not fetching it, so its blocks stay unsupported — pass " +
|
|
579
|
+
"--fetch-extensions to load it",
|
|
580
|
+
);
|
|
581
|
+
continue;
|
|
582
|
+
}
|
|
583
|
+
if (!/^data:/i.test(uri)) {
|
|
584
|
+
result.warnings.push(
|
|
585
|
+
`extension ${id} uses an unsupported URL (${truncate(uri, 48)}); ` +
|
|
586
|
+
`add it to ${configFilename(product)} by hand`,
|
|
587
|
+
);
|
|
588
|
+
continue;
|
|
589
|
+
}
|
|
590
|
+
const source = readExtensionDataUri(uri);
|
|
591
|
+
if (source == null) {
|
|
592
|
+
result.warnings.push(
|
|
593
|
+
`extension ${id} has an unreadable data URI; ` +
|
|
594
|
+
`add it to ${configFilename(product)} by hand`,
|
|
595
|
+
);
|
|
596
|
+
continue;
|
|
597
|
+
}
|
|
598
|
+
// When no file can be written, the archive's own copy is the only faithful
|
|
599
|
+
// reference left, so the config keeps its data URI: a later build still has
|
|
600
|
+
// the extension without overwriting, or depending on, a workspace file.
|
|
601
|
+
const keepEmbedded = (warning) => {
|
|
602
|
+
result.loadEntries.push(uri);
|
|
603
|
+
if (outDir) result.entries.push(uri);
|
|
604
|
+
result.warnings.push(warning);
|
|
605
|
+
};
|
|
606
|
+
if (!outDir) {
|
|
607
|
+
keepEmbedded(
|
|
608
|
+
`extension ${id} is embedded in the archive; pass -o to write it out ` +
|
|
609
|
+
`and reference it from ${configFilename(product)}`,
|
|
610
|
+
);
|
|
611
|
+
continue;
|
|
612
|
+
}
|
|
613
|
+
resolveExtensionDir();
|
|
614
|
+
if (extensionDirProblem) {
|
|
615
|
+
keepEmbedded(
|
|
616
|
+
`extension ${id} was not written: ${extensionDirProblem}; its blocks ` +
|
|
617
|
+
"still decompile and the config keeps its source, but the extension " +
|
|
618
|
+
"file is missing from the output",
|
|
619
|
+
);
|
|
620
|
+
continue;
|
|
621
|
+
}
|
|
622
|
+
const base = extensionFileStem(id);
|
|
623
|
+
let stem = base;
|
|
624
|
+
for (let suffix = 2; used.has(stem); suffix += 1)
|
|
625
|
+
stem = `${base}-${suffix}`;
|
|
626
|
+
used.add(stem);
|
|
627
|
+
const file = path.join(extensionDir, `${stem}.js`);
|
|
628
|
+
const relative = `extensions/${stem}.js`;
|
|
629
|
+
const existing = fs.lstatSync(file, { throwIfNoEntry: false });
|
|
630
|
+
if (existing) {
|
|
631
|
+
// Never replace a workspace file. An identical regular file is already
|
|
632
|
+
// the copy we would have written, so reference it and leave it alone.
|
|
633
|
+
if (existing.isFile() && fs.readFileSync(file, "utf8") === source) {
|
|
634
|
+
result.files.push(relative);
|
|
635
|
+
result.entries.push(relative);
|
|
636
|
+
result.loadEntries.push(file);
|
|
637
|
+
} else {
|
|
638
|
+
keepEmbedded(
|
|
639
|
+
`extension ${id} not written to ${relative}: ` +
|
|
640
|
+
(existing.isSymbolicLink()
|
|
641
|
+
? "the path is a symlink"
|
|
642
|
+
: "a different file already exists") +
|
|
643
|
+
"; the config keeps the archive's copy, so delete or rename it to " +
|
|
644
|
+
"write the extension file",
|
|
645
|
+
);
|
|
646
|
+
}
|
|
647
|
+
continue;
|
|
648
|
+
}
|
|
649
|
+
try {
|
|
650
|
+
// `wx` fails instead of following a path that appeared in the meantime.
|
|
651
|
+
fs.writeFileSync(file, source, { flag: "wx" });
|
|
652
|
+
} catch (err) {
|
|
653
|
+
keepEmbedded(
|
|
654
|
+
`extension ${id} not written to ${relative}: ` +
|
|
655
|
+
(err.code === "EEXIST"
|
|
656
|
+
? "the path appeared while decompiling"
|
|
657
|
+
: `${err.code || err.message}`) +
|
|
658
|
+
"; the config keeps the archive's copy, so delete or rename it to " +
|
|
659
|
+
"write the extension file",
|
|
660
|
+
);
|
|
661
|
+
continue;
|
|
662
|
+
}
|
|
663
|
+
result.files.push(relative);
|
|
664
|
+
result.entries.push(relative);
|
|
665
|
+
result.loadEntries.push(file);
|
|
666
|
+
}
|
|
667
|
+
return result;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
/**
|
|
671
|
+
* Record restored extensions in `fx.yml`, creating one next to the decompiled
|
|
672
|
+
* source when the directory has none yet.
|
|
673
|
+
*
|
|
674
|
+
* @param {string} outDir
|
|
675
|
+
* @param {string[]} entries
|
|
676
|
+
* @returns {{file: string, added: number}|null}
|
|
677
|
+
*/
|
|
678
|
+
function writeRestoredConfig(outDir, entries) {
|
|
679
|
+
if (entries.length === 0) return null;
|
|
680
|
+
const existing = configFilenames()
|
|
681
|
+
.map((name) => path.join(outDir, name))
|
|
682
|
+
.find((file) => fs.existsSync(file));
|
|
683
|
+
if (existing) {
|
|
684
|
+
let added = 0;
|
|
685
|
+
for (const entry of entries)
|
|
686
|
+
if (addProjectExtension(existing, entry).added) added += 1;
|
|
687
|
+
return { file: existing, added };
|
|
688
|
+
}
|
|
689
|
+
const file = path.join(outDir, configFilenames()[0]);
|
|
690
|
+
const lines = ["project:", " extensions:"];
|
|
691
|
+
for (const entry of entries) lines.push(` - ${JSON.stringify(entry)}`);
|
|
692
|
+
lines.push("");
|
|
693
|
+
fs.writeFileSync(file, lines.join("\n"));
|
|
694
|
+
return { file, added: entries.length };
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
async function cmdDecompile(args) {
|
|
503
698
|
const input = args._[0];
|
|
504
699
|
if (!input)
|
|
505
700
|
fail(`decompile requires an input .pmp file\n\n${usage(process.stderr)}`);
|
|
506
701
|
const data = fs.readFileSync(input);
|
|
507
702
|
const files = unzipSync(data);
|
|
703
|
+
const projectJson = files.get("project.json");
|
|
704
|
+
if (!projectJson) fail("Not a Scratch project: missing project.json");
|
|
705
|
+
const project = JSON.parse(projectJson.toString("utf8"));
|
|
706
|
+
|
|
707
|
+
const output = args.flags.o || args.flags.output;
|
|
708
|
+
const outDir = output ? path.dirname(path.resolve(output)) : null;
|
|
709
|
+
const restored = restoreExtensions(project, outDir, {
|
|
710
|
+
fetchRemote: Boolean(args.flags["fetch-extensions"]),
|
|
711
|
+
});
|
|
712
|
+
for (const warning of restored.warnings)
|
|
713
|
+
process.stderr.write(`warning: ${warning}\n`);
|
|
714
|
+
|
|
715
|
+
// Extension sources also feed the catalog, so extension blocks decompile as
|
|
716
|
+
// real calls instead of "unsupported block".
|
|
717
|
+
let catalog = {};
|
|
718
|
+
if (restored.loadEntries.length > 0) {
|
|
719
|
+
const loaded = await loadExtensions(restored.loadEntries, {
|
|
720
|
+
baseDir: outDir || process.cwd(),
|
|
721
|
+
});
|
|
722
|
+
catalog = loaded.catalog;
|
|
723
|
+
for (const warning of loaded.warnings)
|
|
724
|
+
process.stderr.write(`warning: ${warning}\n`);
|
|
725
|
+
}
|
|
508
726
|
|
|
509
727
|
const assetDir = args.flags.assets;
|
|
510
728
|
let assetPrefix = "";
|
|
@@ -523,24 +741,23 @@ function cmdDecompile(args) {
|
|
|
523
741
|
fs.writeFileSync(destination, buffer);
|
|
524
742
|
}
|
|
525
743
|
// Reference assets relative to the directory the source file will live in.
|
|
526
|
-
const outDir = path.dirname(
|
|
527
|
-
path.resolve(
|
|
528
|
-
args.flags.o ||
|
|
529
|
-
args.flags.output ||
|
|
530
|
-
path.join(process.cwd(), `out${EXTENSION}`),
|
|
531
|
-
),
|
|
532
|
-
);
|
|
533
744
|
const rel = path
|
|
534
|
-
.relative(outDir, path.resolve(assetDir))
|
|
745
|
+
.relative(outDir || process.cwd(), path.resolve(assetDir))
|
|
535
746
|
.replace(/\\/g, "/");
|
|
536
747
|
assetPrefix = rel ? `${rel}/` : "";
|
|
537
748
|
}
|
|
538
749
|
|
|
539
|
-
const source =
|
|
540
|
-
const output = args.flags.o || args.flags.output;
|
|
750
|
+
const source = decompileProject(project, { assetPrefix, catalog });
|
|
541
751
|
if (output) {
|
|
542
752
|
fs.writeFileSync(output, source);
|
|
753
|
+
const config = writeRestoredConfig(outDir, restored.entries);
|
|
543
754
|
log.success(`Decompiled ${input} -> ${output}`);
|
|
755
|
+
if (config)
|
|
756
|
+
log.success(
|
|
757
|
+
`Restored ${config.added} extension(s) -> ${
|
|
758
|
+
path.relative(process.cwd(), config.file) || config.file
|
|
759
|
+
}`,
|
|
760
|
+
);
|
|
544
761
|
} else {
|
|
545
762
|
process.stdout.write(source);
|
|
546
763
|
}
|
package/package.json
CHANGED
package/product.yml
CHANGED
package/src/decompiler.js
CHANGED
|
@@ -62,10 +62,25 @@ function numberText(value) {
|
|
|
62
62
|
return String(value);
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
+
/** Reverse lookup for a merged catalog: opcode -> canonical catalog name. */
|
|
66
|
+
function buildByOpcode(catalog) {
|
|
67
|
+
const map = new Map();
|
|
68
|
+
for (const [name, entry] of Object.entries(catalog)) {
|
|
69
|
+
if (!map.has(entry.opcode)) map.set(entry.opcode, name);
|
|
70
|
+
}
|
|
71
|
+
return map;
|
|
72
|
+
}
|
|
73
|
+
|
|
65
74
|
class Decompiler {
|
|
66
75
|
constructor(project, options = {}) {
|
|
67
76
|
this.project = project;
|
|
68
77
|
this.assetPrefix = options.assetPrefix || "";
|
|
78
|
+
// Extension entries come from `loadExtensions`, so projects that use
|
|
79
|
+
// extensions decompile into real calls instead of "unsupported block".
|
|
80
|
+
this.catalog = options.catalog
|
|
81
|
+
? { ...CATALOG, ...options.catalog }
|
|
82
|
+
: CATALOG;
|
|
83
|
+
this.byOpcode = options.catalog ? buildByOpcode(this.catalog) : BY_OPCODE;
|
|
69
84
|
this.returningProccodes = new Set();
|
|
70
85
|
for (const target of project.targets) {
|
|
71
86
|
for (const block of Object.values(target.blocks)) {
|
|
@@ -149,6 +164,12 @@ class Decompiler {
|
|
|
149
164
|
const field = Object.values(block.fields || {})[0];
|
|
150
165
|
return field ? `"${escapeString(field[0])}"` : '""';
|
|
151
166
|
}
|
|
167
|
+
// Extension menus are shadow blocks the catalog never describes; their
|
|
168
|
+
// chosen option is the only thing worth emitting.
|
|
169
|
+
if (block.shadow) {
|
|
170
|
+
const field = Object.values(block.fields || {})[0];
|
|
171
|
+
if (field) return `"${escapeString(field[0])}"`;
|
|
172
|
+
}
|
|
152
173
|
if (block.opcode === "data_variable") return block.fields.VARIABLE[0];
|
|
153
174
|
if (block.opcode === "data_listcontents") return block.fields.LIST[0];
|
|
154
175
|
if (
|
|
@@ -191,13 +212,13 @@ class Decompiler {
|
|
|
191
212
|
return `(not ${this.renderExpression(block.inputs.OPERAND)})`;
|
|
192
213
|
}
|
|
193
214
|
|
|
194
|
-
const name =
|
|
215
|
+
const name = this.byOpcode.get(block.opcode);
|
|
195
216
|
if (name) return this.renderCatalogCall(name, block);
|
|
196
217
|
return `/* unknown ${block.opcode} */`;
|
|
197
218
|
}
|
|
198
219
|
|
|
199
220
|
renderCatalogCall(name, block) {
|
|
200
|
-
const entry =
|
|
221
|
+
const entry = this.catalog[name];
|
|
201
222
|
const args = [];
|
|
202
223
|
for (const key of Object.keys(entry.fields || {})) {
|
|
203
224
|
const field = block.fields[key];
|
|
@@ -279,12 +300,22 @@ class Decompiler {
|
|
|
279
300
|
);
|
|
280
301
|
return `${pad}${listName}.${method}(${args.join(", ")});`;
|
|
281
302
|
}
|
|
282
|
-
const name =
|
|
283
|
-
if (name
|
|
303
|
+
const name = this.byOpcode.get(block.opcode);
|
|
304
|
+
if (!name) return `${pad}// unsupported block ${block.opcode}`;
|
|
305
|
+
const entry = this.catalog[name];
|
|
306
|
+
const substacks = Object.entries(entry.inputs || {})
|
|
307
|
+
.filter(([, spec]) => spec.type === "substack")
|
|
308
|
+
.map(([input]) => input);
|
|
309
|
+
if (substacks.length === 0)
|
|
284
310
|
return `${pad}${this.renderCatalogCall(name, block)};`;
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
311
|
+
const primary = firstOf(block.inputs[substacks[0]]);
|
|
312
|
+
const alternate = substacks[1]
|
|
313
|
+
? firstOf(block.inputs[substacks[1]])
|
|
314
|
+
: null;
|
|
315
|
+
const otherwise = alternate
|
|
316
|
+
? ` else {${sub(firstOf(block.inputs[substacks[1]]))}\n${pad}}`
|
|
317
|
+
: "";
|
|
318
|
+
return `${pad}${this.renderCatalogCall(name, block)} {${sub(primary)}${otherwise}\n${pad}}`;
|
|
288
319
|
}
|
|
289
320
|
}
|
|
290
321
|
}
|
|
@@ -321,10 +352,25 @@ class Decompiler {
|
|
|
321
352
|
return `${pad}when ${kind}(${this.renderExpression(block.inputs.VALUE)}) {\n${body(block.next)}\n}`;
|
|
322
353
|
}
|
|
323
354
|
default:
|
|
324
|
-
return
|
|
355
|
+
return this.renderExtensionHat(block, pad, body);
|
|
325
356
|
}
|
|
326
357
|
}
|
|
327
358
|
|
|
359
|
+
/** Render hats contributed by extensions, e.g. `when signals.onBang("x") {`. */
|
|
360
|
+
renderExtensionHat(block, pad, body) {
|
|
361
|
+
const name = this.byOpcode.get(block.opcode);
|
|
362
|
+
const entry = name ? this.catalog[name] : null;
|
|
363
|
+
if (!entry || entry.kind !== "hat") return null;
|
|
364
|
+
const args = (entry.args || []).map((spec) => {
|
|
365
|
+
if (spec.field) {
|
|
366
|
+
const field = (block.fields || {})[spec.field];
|
|
367
|
+
return `"${escapeString(field ? field[0] : "")}"`;
|
|
368
|
+
}
|
|
369
|
+
return this.renderExpression((block.inputs || {})[spec.input]);
|
|
370
|
+
});
|
|
371
|
+
return `${pad}when ${name}(${args.join(", ")}) {\n${body(block.next)}\n}`;
|
|
372
|
+
}
|
|
373
|
+
|
|
328
374
|
renderFunction(defBlock) {
|
|
329
375
|
const protoId = firstOf(defBlock.inputs.custom_block);
|
|
330
376
|
const proto = this.target.blocks[protoId];
|
|
@@ -474,7 +520,8 @@ function indentText(text, prefix) {
|
|
|
474
520
|
/**
|
|
475
521
|
* Decompile project JSON into Triflare Fx source.
|
|
476
522
|
* @param {object} project
|
|
477
|
-
* @param {{assetPrefix?: string}} [options]
|
|
523
|
+
* @param {{assetPrefix?: string, catalog?: Record<string, object>}} [options]
|
|
524
|
+
* `catalog` adds extension entries from `loadExtensions`.
|
|
478
525
|
* @returns {string}
|
|
479
526
|
*/
|
|
480
527
|
export function decompileProject(project, options) {
|
|
@@ -484,7 +531,7 @@ export function decompileProject(project, options) {
|
|
|
484
531
|
/**
|
|
485
532
|
* Decompile a `.pmp`/`.sb3` buffer (or project JSON) into Triflare Fx source.
|
|
486
533
|
* @param {Buffer|Uint8Array|object} input
|
|
487
|
-
* @param {{assetPrefix?: string}} [options]
|
|
534
|
+
* @param {{assetPrefix?: string, catalog?: Record<string, object>}} [options]
|
|
488
535
|
* @returns {string}
|
|
489
536
|
*/
|
|
490
537
|
export function decompile(input, options) {
|
package/src/extensions.js
CHANGED
|
@@ -534,9 +534,55 @@ async function evaluateExtension(
|
|
|
534
534
|
|
|
535
535
|
// ------------------------------------------------------------------- loading
|
|
536
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
|
+
|
|
537
575
|
/** Resolve a config entry to source text. */
|
|
538
576
|
async function resolveSource(entry, baseDir, fetchImpl) {
|
|
539
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
|
+
}
|
|
540
586
|
if (/^https?:\/\//i.test(value)) {
|
|
541
587
|
const fetchFn = fetchImpl || globalThis.fetch;
|
|
542
588
|
if (typeof fetchFn !== "function")
|
|
@@ -597,23 +643,16 @@ async function evaluateExtensionAny(source, filename) {
|
|
|
597
643
|
/**
|
|
598
644
|
* Load a list of extension references.
|
|
599
645
|
*
|
|
600
|
-
* @param {Array<string>} list Paths
|
|
601
|
-
* @param {{baseDir?: string, fetchImpl?: typeof fetch, expectUnsandboxed?: string|string[]
|
|
646
|
+
* @param {Array<string>} list Paths, `http(s)://` URLs or `data:` URIs.
|
|
647
|
+
* @param {{baseDir?: string, fetchImpl?: typeof fetch, expectUnsandboxed?: string|string[]}} [options]
|
|
602
648
|
* `expectUnsandboxed` acknowledges extensions that are known to require
|
|
603
|
-
* unsandboxed mode, silencing the warning for them.
|
|
604
|
-
* (default true) emits the build-specific notice that local files are not
|
|
605
|
-
* recorded in `extensionURLs`; listing commands turn it off.
|
|
649
|
+
* unsandboxed mode, silencing the warning for them.
|
|
606
650
|
* @returns {Promise<{catalog: Record<string, object>, extensionUrls: Record<string, string>, warnings: string[], ids: string[], unsandboxed: string[]}>}
|
|
607
651
|
* `unsandboxed` holds the ids of extensions that only load unsandboxed.
|
|
608
652
|
*/
|
|
609
653
|
export async function loadExtensions(
|
|
610
654
|
list,
|
|
611
|
-
{
|
|
612
|
-
baseDir = process.cwd(),
|
|
613
|
-
fetchImpl,
|
|
614
|
-
expectUnsandboxed = [],
|
|
615
|
-
localFileWarning = true,
|
|
616
|
-
} = {},
|
|
655
|
+
{ baseDir = process.cwd(), fetchImpl, expectUnsandboxed = [] } = {},
|
|
617
656
|
) {
|
|
618
657
|
const catalog = {};
|
|
619
658
|
const extensionUrls = {};
|
|
@@ -631,62 +670,63 @@ export async function loadExtensions(
|
|
|
631
670
|
|
|
632
671
|
for (const entry of list || []) {
|
|
633
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;
|
|
634
676
|
try {
|
|
635
|
-
const { source, remote, url, file } = await resolveSource(
|
|
677
|
+
const { source, remote, url, file, dataUri } = await resolveSource(
|
|
636
678
|
entry,
|
|
637
679
|
baseDir,
|
|
638
680
|
fetchImpl,
|
|
639
681
|
);
|
|
640
682
|
const { infos, unsandboxed: needsUnsandboxed } =
|
|
641
|
-
await evaluateExtensionAny(
|
|
683
|
+
await evaluateExtensionAny(
|
|
684
|
+
source,
|
|
685
|
+
remote ? url : (file ?? "extension"),
|
|
686
|
+
);
|
|
642
687
|
if (infos.length === 0) {
|
|
643
688
|
warnings.push(
|
|
644
|
-
`extension ${
|
|
689
|
+
`extension ${name} did not call Scratch.extensions.register()`,
|
|
645
690
|
);
|
|
646
691
|
continue;
|
|
647
692
|
}
|
|
648
693
|
for (const info of infos) {
|
|
649
|
-
const count = registerInfo(catalog, info,
|
|
694
|
+
const count = registerInfo(catalog, info, name);
|
|
650
695
|
if (!ids.includes(info.id)) ids.push(info.id);
|
|
651
696
|
|
|
652
|
-
for (const
|
|
653
|
-
if (!declared.has(
|
|
654
|
-
declaredSeen.add(
|
|
655
|
-
if (!needsUnsandboxed) declaredNotNeeded.add(
|
|
697
|
+
for (const candidate of [info.id, label]) {
|
|
698
|
+
if (!declared.has(candidate)) continue;
|
|
699
|
+
declaredSeen.add(candidate);
|
|
700
|
+
if (!needsUnsandboxed) declaredNotNeeded.add(candidate);
|
|
656
701
|
}
|
|
657
702
|
|
|
658
703
|
if (needsUnsandboxed) {
|
|
659
704
|
if (!unsandboxed.includes(info.id)) unsandboxed.push(info.id);
|
|
660
|
-
needsDeclaration.push({ id: info.id, label });
|
|
705
|
+
needsDeclaration.push({ id: info.id, label, name });
|
|
661
706
|
}
|
|
662
707
|
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
`extension ${label} (id "${info.id}") is a local file; it will be referenced in ` +
|
|
668
|
-
"project.extensions but not in extensionURLs. Bundle it with the PenguinMod packager " +
|
|
669
|
-
"or host it before loading in the editor.",
|
|
670
|
-
);
|
|
671
|
-
}
|
|
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);
|
|
672
712
|
if (count === 0)
|
|
673
713
|
warnings.push(
|
|
674
|
-
`extension ${
|
|
714
|
+
`extension ${name} (id "${info.id}") exposes no usable blocks`,
|
|
675
715
|
);
|
|
676
716
|
}
|
|
677
717
|
} catch (err) {
|
|
678
718
|
warnings.push(
|
|
679
|
-
`failed to load extension ${
|
|
719
|
+
`failed to load extension ${name}: ${err && err.message ? err.message : err}`,
|
|
680
720
|
);
|
|
681
721
|
}
|
|
682
722
|
}
|
|
683
723
|
|
|
684
724
|
// Extensions that only load unsandboxed error out unless the editor is allowed
|
|
685
725
|
// to run them unsandboxed (or the project is packaged), so say so up front.
|
|
686
|
-
for (const { id, label } of needsDeclaration) {
|
|
726
|
+
for (const { id, label, name } of needsDeclaration) {
|
|
687
727
|
if (declared.has(id) || declared.has(label)) continue;
|
|
688
728
|
warnings.push(
|
|
689
|
-
`extension ${
|
|
729
|
+
`extension ${name} (id "${id}") requires unsandboxed mode; allow it to run ` +
|
|
690
730
|
`unsandboxed when opening the project in PenguinMod (or add "${id}" to ` +
|
|
691
731
|
"project.unsandboxed in fx.yml once you trust it)",
|
|
692
732
|
);
|
package/src/index.js
CHANGED
|
@@ -13,7 +13,11 @@ export { build, writeSb3 } from "./build.js";
|
|
|
13
13
|
export { discoverSources, mergePrograms } from "./project.js";
|
|
14
14
|
export { decompile, decompileProject } from "./decompiler.js";
|
|
15
15
|
export { zipSync, unzipSync, md5hex, imageSize, wavInfo } from "./sb3.js";
|
|
16
|
-
export {
|
|
16
|
+
export {
|
|
17
|
+
loadExtensions,
|
|
18
|
+
extensionDataUri,
|
|
19
|
+
readExtensionDataUri,
|
|
20
|
+
} from "./extensions.js";
|
|
17
21
|
export { readSprite, spriteArchive, spriteSource } from "./sprite.js";
|
|
18
22
|
export { packageProject, loadPackager, packagerDistPath } from "./packager.js";
|
|
19
23
|
export {
|