feat(protection): add program-specialized packager
Build offline specialized players from the matching Scratch VM, with audited linking, pruning and scheduling-property renaming across browser, Node and standalone outputs.
This commit is contained in:
74 files changed
+4366
-2804
No files matched your search
@@ -0,0 +1,37 @@
|
||||
name: Set up specialized build sources
|
||||
description: Install a pinned sibling VM and the packager from their lockfiles
|
||||
inputs:
|
||||
vm-ref:
|
||||
description: Full commit SHA of the matching modified scratch-vm
|
||||
required: true
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Require a pinned VM revision
|
||||
shell: bash
|
||||
env:
|
||||
SPECIALIZED_VM_REF: ${{ inputs.vm-ref }}
|
||||
run: |
|
||||
if [[ ! "$SPECIALIZED_VM_REF" =~ ^[0-9a-f]{40}$ ]]; then
|
||||
echo 'Set the SPECIALIZED_VM_REF repository variable to the matching scratch-vm commit SHA.' >&2
|
||||
exit 1
|
||||
fi
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
repository: ScratchCN/Scratch-vm
|
||||
ref: ${{ inputs.vm-ref }}
|
||||
path: scratch-vm
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24
|
||||
cache: npm
|
||||
cache-dependency-path: |
|
||||
scratch-vm/package-lock.json
|
||||
tw-packager/package-lock.json
|
||||
- name: Install locked sources
|
||||
shell: bash
|
||||
working-directory: tw-packager
|
||||
run: |
|
||||
npm ci --prefix ../scratch-vm
|
||||
npm ci
|
||||
+22
-11
@@ -2,32 +2,43 @@ name: Build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ master ]
|
||||
branches: [ main ]
|
||||
pull_request:
|
||||
branches: [ master ]
|
||||
branches: [ main ]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
defaults:
|
||||
run:
|
||||
working-directory: tw-packager
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v6
|
||||
path: tw-packager
|
||||
- uses: ./tw-packager/.github/actions/setup-specialized
|
||||
with:
|
||||
node-version: 24
|
||||
cache: npm
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
vm-ref: ${{ vars.SPECIALIZED_VM_REF }}
|
||||
- name: Build
|
||||
run: npm run build
|
||||
run: npm run build-node-prod
|
||||
- name: Test
|
||||
run: npm test
|
||||
run: |
|
||||
npm test -- --runInBand
|
||||
npm run test:specialized
|
||||
node test/specialized-differential.mjs --linked
|
||||
npm run test:specialized-build
|
||||
node test/specialized-worker-input.mjs
|
||||
npx playwright install --with-deps chromium firefox webkit
|
||||
npm run test:specialized-browser
|
||||
npm run test:specialized-performance
|
||||
- name: Build standalone
|
||||
run: npm run build-standalone-prod
|
||||
- name: Test offline standalone
|
||||
run: node test/specialized-standalone.mjs
|
||||
- name: Upload standalone artifact
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: standalone
|
||||
path: dist/standalone.html
|
||||
path: tw-packager/dist/standalone.html
|
||||
@@ -3,7 +3,7 @@ name: Deploy to GitHub Pages
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches: [master]
|
||||
branches: [main]
|
||||
|
||||
concurrency:
|
||||
group: "deploy"
|
||||
@@ -17,21 +17,30 @@ permissions:
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
timeout-minutes: 45
|
||||
defaults:
|
||||
run:
|
||||
working-directory: tw-packager
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v6
|
||||
path: tw-packager
|
||||
- uses: ./tw-packager/.github/actions/setup-specialized
|
||||
with:
|
||||
node-version: 24
|
||||
cache: npm
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
vm-ref: ${{ vars.SPECIALIZED_VM_REF }}
|
||||
- name: Test
|
||||
run: npm test
|
||||
run: |
|
||||
npm test -- --runInBand
|
||||
npm run build-node-prod
|
||||
npm run test:specialized
|
||||
node test/specialized-differential.mjs --linked
|
||||
npm run test:specialized-build
|
||||
node test/specialized-worker-input.mjs
|
||||
npx playwright install --with-deps chromium firefox webkit
|
||||
npm run test:specialized-browser
|
||||
npm run test:specialized-performance
|
||||
- name: Build website
|
||||
run: |
|
||||
mkdir -p web
|
||||
@@ -42,13 +51,14 @@ jobs:
|
||||
- name: Build standalone
|
||||
run: |
|
||||
npm run build-standalone-prod
|
||||
node test/specialized-standalone.mjs
|
||||
cp dist/standalone.html web
|
||||
- name: Configure GitHub Pages
|
||||
uses: actions/configure-pages@v5
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-pages-artifact@v4
|
||||
with:
|
||||
path: ./web/
|
||||
path: ./tw-packager/web/
|
||||
|
||||
deploy:
|
||||
environment:
|
||||
|
||||
@@ -12,11 +12,15 @@ permissions:
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: tw-packager
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24
|
||||
- run: npm ci
|
||||
- run: npm test
|
||||
path: tw-packager
|
||||
persist-credentials: false
|
||||
- uses: ./tw-packager/.github/actions/setup-specialized
|
||||
with:
|
||||
vm-ref: ${{ vars.SPECIALIZED_VM_REF }}
|
||||
- run: npm publish
|
||||
@@ -1,5 +1,6 @@
|
||||
node_modules
|
||||
dist
|
||||
generated/
|
||||
*.zip
|
||||
|
||||
# Related to Node.js API and related demo scripts
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
src
|
||||
scripts
|
||||
generated
|
||||
static
|
||||
test
|
||||
webpack.config.js
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# SB3C Packager
|
||||
|
||||
An independent modified version of [TurboWarp Packager](https://github.com/TurboWarp/packager), focused on ahead-of-time compiled `.sb3c` projects, VM-access protection, reproducible JavaScript obfuscation, and package integrity verification.
|
||||
An independent modified version of [TurboWarp Packager](https://github.com/TurboWarp/packager), focused on offline, program-specific players that jointly compile Scratch scripts and the runtime. See [the build architecture](docs/specialized-builds.md).
|
||||
|
||||
Source: https://github.com/LuYifei2011/tw-packager
|
||||
|
||||
@@ -12,9 +12,10 @@ Compilation and obfuscation are not encryption or DRM. They can make casual insp
|
||||
|
||||
## Development
|
||||
|
||||
Install dependencies:
|
||||
Keep the matching modified `scratch-vm` checkout beside this repository. Install dependencies in both checkouts (Node 22.15 or newer):
|
||||
|
||||
```
|
||||
npm ci --prefix ../scratch-vm
|
||||
npm ci
|
||||
```
|
||||
|
||||
@@ -50,12 +51,7 @@ We strive to make the packager easy to fork, even for mods that aren't based on
|
||||
|
||||
### Packages
|
||||
|
||||
If you want to change the scratch-vm/scratch-render/scratch-audio/scratch-storage/etc. used, this is simple:
|
||||
|
||||
- `npm install` or `npm link` your package. The package name does not matter.
|
||||
- Update src/scaffolding/scratch-libraries.js to import the packages with the name you have. (some of our packages are prefixed with `@turbowarp/` while others are still just `scratch-vm` -- just make sure they match yours)
|
||||
|
||||
Then just rebuild. You can even install a vanilla scratch-vm and all core functionality will still work (but optional features such as interpolation, high quality pen, stage size, etc. may not work)
|
||||
The compiler frontend, source preparation and player must use the same sibling `scratch-vm` checkout. A generic upstream VM does not provide the specialized IR API. Dependency changes require regenerating the source pack, reviewing initialization/property boundaries and running the differential, artifact and browser checks described in [the build documentation](docs/specialized-builds.md). The lockfile and audited dependency source hashes form part of the build baseline.
|
||||
|
||||
Note that npm is a very buggy piece of software and our dependency tree is very large. Occasionally you might get errors about missing dependencies, which should go away if you run `npm install`.
|
||||
|
||||
@@ -63,14 +59,14 @@ Note that npm is a very buggy piece of software and our dependency tree is very
|
||||
|
||||
The packager is deployed as a simple static website. You can host it anywhere by just copying the `dist` folder after a build.
|
||||
|
||||
This fork includes `.github/workflows/deploy.yml` for GitHub Pages. In the repository's **Settings → Pages**, set **Source** to **GitHub Actions**. A push to `master`, or a manual run of “Deploy to GitHub Pages”, will test the project, build the production website and standalone version, upload the Pages artifact, and deploy it to `https://luyifei2011.github.io/tw-packager/`.
|
||||
This fork includes `.github/workflows/deploy.yml` for GitHub Pages. In the repository's **Settings → Pages**, set **Source** to **GitHub Actions**. A push to `main`, or a manual run of “Deploy to GitHub Pages”, will test the project, build the production website and standalone version, upload the Pages artifact, and deploy it to `https://luyifei2011.github.io/tw-packager/`.
|
||||
|
||||
We use GitHub Actions and GitHub Pages to manage our deployment. If you want to do this too:
|
||||
|
||||
- Fork the repository on GitHub and push your changes.
|
||||
- Go to your fork's settings on GitHub and enable GitHub Pages with the source set to GitHub Actions.
|
||||
- Go to the "Actions" tab and enable GitHub Actions if it isn't already enabled.
|
||||
- Push commits to the "master" branch.
|
||||
- Push commits to the "main" branch.
|
||||
- In a few minutes, your site will automatically be built and deployed to GitHub Pages.
|
||||
|
||||
### Branding
|
||||
@@ -87,7 +83,7 @@ Set the environment variable `ENABLE_SERVICE_WORKER` to `1` to enable service wo
|
||||
|
||||
## Standalone builds
|
||||
|
||||
The packager supports generating "standalone builds" that are single HTML files containing the entire packager. Large files such as Electron binaries will still be downloaded from a remote server as needed. You can download prebuilt standalone builds from [our GitHub releases](https://github.com/TurboWarp/packager/releases). These can be useful if our website is blocked or you don't have a reliable internet connection. Note that standalone builds do not contain an update checker, so do check on your own occasionally.
|
||||
The packager supports generating "standalone builds" that are single HTML files containing the entire packager, including its compiler, resolved runtime sources and linker. HTML builds work offline; desktop wrapper binaries and extensions not supplied locally are fetched during construction. Standalone builds do not contain an update checker.
|
||||
|
||||
To make a production standalone build locally:
|
||||
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
# Program-specific player builds
|
||||
|
||||
`compileProject` now returns `{code, assets, buildId, diagnostics}`. There is no compiled package loader in the production player. HTML, ZIP and desktop wrappers consume the same linked VM/program source; resources contain initial state and assets, not executable scripts. The ordinary VM compiler remains available for development and differential tests.
|
||||
|
||||
## Build implementation
|
||||
|
||||
1. `scratch-vm.exportSpecializedProjectBuildInput()` exports optimized structured IR, event descriptors, initial project state and extension requirements. Export also extracts gamepad hints before deleting source blocks and comments.
|
||||
2. `scratch-vm/src/compiler/continuation.js` lowers the IR directly. It reuses the existing typed expression and atomic operation emitters. Calls, returns and waits save per-thread continuations; compatible warp loops stay structured, including bounded inlining of synchronous, acyclic warp calls. Terminal void warp calls reuse frames; arguments that can suspend still spill in evaluation order. Straight-line yielding loops use one resume region. It never parses old factory strings.
|
||||
3. `scripts/prepare-runtime-sources.mjs` prepares resolved ESM modules from the locked sibling VM and dependencies. First-party CommonJS is converted before linking. Runtime, Thread and Sequencer have production definitions with the editor/compiler entry paths removed. The generated source pack is a build-tool asset, never a player asset.
|
||||
4. `src/specialized/link.mjs` replaces Sequencer's step method with shared dispatch methods and emits static hat/primitive entries. Each group contains at most 256 regions with a 12,000-character input-code budget; an indivisible native loop may exceed that budget. Groups are actual separate VM methods. Encoded group/local PCs let both dispatch levels use jump tables. Iteration crosses groups without introducing a yield. Slot permutations and declared internal property names use the same build seed. Pure synchronous regions cache proven VM variable references/values and commit before runtime exits and on exceptions; calls that can observe VM state prevent this optimization.
|
||||
5. Rollup links the player from memory, then joint property transformation and Terser compression run on the combined result. Both browser Worker and Node use this implementation. The standalone builder embeds the source pack, Rollup and parser WASM; it also works from a local HTML file without network access.
|
||||
|
||||
The implementation renames the explicitly owned continuation, lifecycle, worker and target-identity fields in both VM and generated code. The scheduling manifest also covers three Sequencer methods (`stepThread`, `stepThreads`, `retireThread`) and eleven Thread stack/identity methods. Build-only annotations bind the actual classes and monitor-execution parameters across Rollup; they are removed before the player is emitted. The audit follows immutable aliases, destructuring and optional member accesses. Literal reflection, dynamic access through known scheduling receivers, reassigned aliases, unrelated declarations and unproven receivers reserve the affected names. `diagnostics.schedulingProperties` reports each scheduling method's mapping or reservation; `diagnostics.reservedPropertyReasons` explains reservations. Missing owner methods or changed parameter/static/async/generator signatures fail the build until the manifest is reviewed.
|
||||
|
||||
This is a bounded scheduling audit, not whole-program ownership inference. Ambiguous legacy fields such as `status`, `target`, `runtime` and `value`, host APIs and extension RPC fields keep stable names. The boundary is recorded in `diagnostics.propertyBoundary`; future expansion requires additional ownership/escape analysis. With `sourceMap: true`, `compileProject` returns a separate `sourceMap` object. Property/slot maps and analysis are separate return data. None are embedded in the player; enabling maps does not change its code.
|
||||
|
||||
Capabilities are selected from emitted operations, assets, hats, monitors and extensions. Unused sound, video and pen modules are removed. Builtin block classes retain only referenced methods, transitive helpers and initialization. Builtin extensions with static registration metadata are pruned by required opcodes and direct IR calls. Dynamic registration metadata conservatively retains its extension as a unit. `diagnostics` lists retained and removed modules/members, capability reasons, shared dispatch groups, fixed-extension dependencies and surviving third-party CommonJS wrappers.
|
||||
|
||||
The wrapper allowlist contains only JSZip's `utils.js`/`base64.js` cycle, whose `require` bindings capture partially initialized mutable export objects. Their source hashes, dependency cycle and initialization reason are recorded in the build report; source changes require a fresh review. Player-only ZIP preparation excludes Node stream adapters and obsolete Promise polyfills. Text utility module closures are converted to ESM. TWGL 4.4.0's pinned 15-module acyclic webpack bundle is unpacked into ESM at build-tool preparation time, removing its factory table and loader. Both transformations check their source shape, and the TWGL transform checks its exact source hash. CommonJS uses the explicit allowlist, rather than automatic conditional-require wrapping. Any surviving unaudited wrapper fails the build.
|
||||
|
||||
## Playback API
|
||||
|
||||
The only global integration object is frozen `window.player`:
|
||||
|
||||
```js
|
||||
player.on('loaded', () => player.start());
|
||||
player.on('error', ({message}) => console.error(message));
|
||||
player.postInput('keyboard', {key: 'a', isDown: true});
|
||||
player.pause();
|
||||
player.resume();
|
||||
player.stop();
|
||||
player.dispose();
|
||||
```
|
||||
|
||||
The complete method set is `start`, `stop`, `pause`, `resume`, `postInput`, `on`, `off`, `dispose`. Event types are `loaded`, `state`, `problem`, `error`. Device inputs accept scalar keyboard, mouse, mouse-wheel and user-data fields. Event payloads do not expose VM/Thread references. Disposal terminates extension Workers, closes audio/video/cloud resources and removes player/addon input listeners.
|
||||
|
||||
Promise continuations capture the thread's lifetime object. Stopping, retiring or restarting a thread invalidates it. Promise completion during pause does not bypass the pause scheduler. The implementation never falls back to the old compiled-package protection chain.
|
||||
|
||||
## Extensions and compatibility
|
||||
|
||||
Builtin extensions are selected from project requirements. Third-party code is frozen from build-time URLs or supplied `{url, source, dependencies}` entries. Dependency keys are absolute URLs. Static ESM imports and literal `importScripts` calls are recursively resolved during the build. No extension code is resolved from a CDN by the player. Data-fetching blocks and normal cloud variables can still use the network as part of project behavior.
|
||||
|
||||
Third-party extensions register synchronously in an isolated Worker; their block operations may return Promises and branch indices. Metadata is collected separately during compilation and checked again during playback. Node metadata collection uses a separate permission-restricted process and a context with dynamic code generation disabled. Runtime extension Workers receive no VM or compiler objects. Dynamic registration/loading, compiler mutation, unsandboxed VM access and runtime code evaluation are rejected.
|
||||
|
||||
Old `.sb3c`/`.sb3c2` packages require the original SB3 for rebuilding. Old `compiler.aot`, `compiledProjectHooks`, signature/network-protection settings, `bakeExtensions`, custom JavaScript and unsafe cloud evaluation no longer have implementation paths. Loading legacy settings reports migration and resets incompatible fields. Custom CSS and normal cloud providers remain supported.
|
||||
|
||||
## Validation
|
||||
|
||||
```sh
|
||||
npm ci
|
||||
npm run build-node
|
||||
npm test -- --runInBand
|
||||
npm run test:specialized
|
||||
node --test test/specialized-structure.mjs
|
||||
node --test test/specialized-properties.mjs
|
||||
node test/specialized-build.mjs
|
||||
node test/specialized-worker-input.mjs
|
||||
node test/specialized-browser.mjs
|
||||
node test/specialized-performance.mjs
|
||||
npm run build-standalone
|
||||
node test/specialized-standalone.mjs
|
||||
```
|
||||
|
||||
Playwright's Chromium, Firefox and WebKit binaries must be installed for browser tests. Build/performance artifacts go to the sibling `work/acceptance` directory and are not published. Use Node 22.15 or newer. The lockfile pins the Node and browser Rollup versions; the WASM bootstrap patch deliberately fails if that pinned entry changes.
|
||||
|
||||
CI checks out both repositories as siblings. Set the `SPECIALIZED_VM_REF` repository variable to the full commit SHA containing the matching VM changes; workflows reject an absent or floating revision. No commit is invented or automatically pushed by local builds. `node test/specialized-differential.mjs --linked` runs the same fixture comparison against the complete linked/pruned/renamed production VM, in addition to the faster continuation-only harness.
|
||||
|
||||
The execution harness covers all 112 existing VM fixtures and compares variables, lists and SAY event order with JS AOT. The linked variant also exercises serialization, pruning and joint transformation; it caught headless target-layer loss and inline legacy primitive registration, both corrected in the implementation. Separate lifecycle tests cover stale Promise completions after stop/restart and completion during pause. Artifact tests reject direct/aliased/computed dynamic evaluation, runtime imports and generators, and inspect source removal, capability removal, seed determinism and dispatch structure. Browser tests prohibit JavaScript dynamic evaluation and exercise playback, API isolation, async extensions, actual WebGL pen pixels and Node/Worker byte equality.
|
||||
|
||||
The performance harness measures the actual jointly linked VM in the same JS realm as JS AOT, after three warmups and over five measured rounds. It records medians, nearest-rank p95, linked-player startup, build time and process heap. Its 10% median CPU regression gate fails the test on regression. These CPU fixtures cover arithmetic, warp procedure calls among cold scripts, lists, broadcasts, clones and pen state updates. **Passing these small fixtures does not establish real-project performance parity. GPU drawing, per-player peak memory and native desktop startup on every supported OS remain separate release gates.** An additional renderer trace test compares movement, effects and pen operations; an actual dispatcher substitution test rejects a dispatcher from another build and verifies that both original builds preserve behavior. Broader renderer scenarios remain part of project-level regression testing.
|
||||
|
||||
The actual macOS WebView archive has been built and its executable launched. GUI capture was unavailable in the test host (ScreenCaptureKit error -3811), so native visual playback has not been claimed. Windows/Linux native launch remains to be checked on those platforms. The offline standalone builder was exercised from `file://` with networking disabled, including generating and playing its downloaded HTML.
|
||||
|
||||
### Initial local acceptance recorded on 2026-09-03 (limited fixture scope)
|
||||
|
||||
- Continuation differential: 112/112 existing fixtures passed. Final complete linked-player differential: 112/112 passed across three isolated process shards (38 + 37 + 37). `--shard=0/3` through `--shard=2/3` partition the sorted fixture list without sharing VM globals.
|
||||
- Lifecycle, structure and renderer trace: 7/7 tests passed. Existing package tests: 54 passed, one pre-existing skip. Modified VM files passed ESLint; both repositories passed `git diff --check`.
|
||||
- Chromium, Firefox and WebKit each passed bare player, embedded HTML, web ZIP, fixed asynchronous extension/branch, actual pen pixels and Node/Worker byte parity checks. Offline standalone UI construction and playback passed.
|
||||
- Final five-round median CPU changes relative to JS AOT: arithmetic -9.4%, lists +1.6%, broadcasts -56.1%, clones -47.1%, pen state updates -13.3%. All met the +10% gate. Startup timing excludes writing the test artifact to disk. The scope exclusions above still apply.
|
||||
- Production Node build and package dry-run passed; the package contains the prepared linker and excludes obsolete Scaffolding API documentation and duplicate intermediate source packs. CI configuration was syntax-checked locally; remote CI/deployment was not run.
|
||||
|
||||
### Real-project regression: Scratch Benchmark 441947766
|
||||
|
||||
The reported computation score was 2060 versus 36233 in TurboWarp. The original tiny-loop gate missed this regression: it did not exercise a large program with frequently called warp procedures. The old backend split non-suspending calls into frames and put all switch groups in one oversized function. On the original workload procedures, the initial same-realm reproduction measured roughly 10x execution time for terrain generation and 9x for raycasting.
|
||||
|
||||
The corrections above retain structured computation, remove unused inlined procedure regions, split actual dispatcher methods and reduce frame/variable lookup overhead. This is still **not a completed +10% real-project release gate**. In the final five-round Node 26.7.0 check with seed `scratch-benchmark`, median execution-time changes were PRNG -14.9%, terrain -49.6%, raycasting +32.7%, quicksort -1.5%, list search +4.8% and string parsing -8.7%. All workload states matched, but raycasting failed the gate. Node 24 and Node 26 differ, so results must include the engine and seed. Earlier fixture-only performance numbers must not be generalized to all projects. Browser automation policy blocked opening the preview during this investigation; updated full-browser scores have not been verified.
|
||||
|
||||
After these corrections, the complete linked-player differential passed 112/112 fixtures, and lifecycle/structure/renderer checks passed 7/7 tests. The six smaller performance fixtures also passed, including the added million-iteration warp-call loop among 80 cold scripts. Prepared browser Worker/linker resources and the Node bundle were rebuilt. Existing exported HTML files must be rebuilt to receive the changes.
|
||||
|
||||
The subsequent scheduling-renaming expansion passed the complete linked differential again (112/112), 18 lifecycle/structure/rendering/property tests, and 61 package tests (one existing skip). The property suite includes linked pause/resume/stop integration and rejection of changed owner signatures. Artifact checks confirmed all 14 scheduling names are mapped in the plain and fixed-extension fixtures, with no ownership markers in the player. Node and browser Worker/linker resources were rebuilt; this follow-up was validated through Node, not a new browser run.
|
||||
|
||||
In a sequential before/after run on Node 26.7.0 with seed `scratch-benchmark`, three warmups and five measured rounds, the renamed player's CPU time relative to JS AOT was PRNG -14.9%, terrain -46.4%, raycasting +29.9%, quicksort +2.1%, list search +3.9% and string parsing -7.0%. All six workload states matched. Relative to the signed checkpoint, its own workload medians changed between -4.4% and +8.5%; this is a bounded regression check, not evidence of a stable speedup. In particular, raycasting's absolute median changed from 0.6254 ms to 0.6412 ms even though its ratio to AOT improved from +38.6% to +29.9%. The real-project gate remains failed; the six small performance fixtures passed. Scheduling renaming does not resolve the remaining dispatch overhead.
|
||||
|
||||
Run the additional gate using the original SB3 downloaded from [Scratch Benchmark](https://scratch.mit.edu/projects/441947766/):
|
||||
|
||||
```sh
|
||||
npm run test:specialized-scratch-benchmark -- /path/to/original.sb3 ../work/acceptance/scratch-benchmark-report.json scratch-benchmark
|
||||
```
|
||||
|
||||
The runner retains the full program in the final linked/minified code, invokes the original CPU workload procedures, compares resulting variables/lists, alternates AOT/specialized measurement order, and records source/code hashes, seed, engine, five sample sets and p95. It excludes the project's wall-clock score calculation, graphics, assets and cloud I/O. It exits unsuccessfully when any workload exceeds +10%; there is no fallback to the old protection chain. `BENCH_PROFILE=1` additionally writes an independent CPU profile, code and property map next to the report for development.
|
||||
+21
-42
@@ -112,59 +112,38 @@ Some options expect an image as an argument. In the Node.js module, there is a s
|
||||
packager.options.app.icon = new Packager.Image('image/png', fs.readFileSync('icon.png'));
|
||||
```
|
||||
|
||||
### Ahead-of-time compilation and protection
|
||||
### Program-specific compilation
|
||||
|
||||
The packager can compile a project into a `.sb3c` package before embedding it. VM member-access protection is enabled by default when this mode is selected; `javascript-obfuscator` can be added as a second stage.
|
||||
Every export jointly links the optimized project and its specialized VM. Node 22.15 or newer is required.
|
||||
|
||||
```js
|
||||
packager.options.compiler.aot.enabled = true;
|
||||
packager.options.compiler.aot.obfuscate = true;
|
||||
packager.options.compiler.aot.javascriptObfuscator = true;
|
||||
packager.options.compiler.aot.shortIds = true;
|
||||
packager.options.compiler.aot.seed = 'public-reproducible-seed';
|
||||
packager.options.compiler.seed = 'reproducible-build';
|
||||
packager.options.compiler.warpTimer = false;
|
||||
```
|
||||
|
||||
Object.assign(packager.options.compiler.aot.javascriptObfuscatorOptions, {
|
||||
identifierNamesGenerator: 'mangled-shuffled',
|
||||
stringArray: true,
|
||||
stringArrayEncoding: 'rc4',
|
||||
stringArrayThreshold: 0.75,
|
||||
controlFlowFlattening: true,
|
||||
controlFlowFlatteningThreshold: 0.5,
|
||||
deadCodeInjection: false,
|
||||
numbersToExpressions: true,
|
||||
splitStrings: true,
|
||||
splitStringsChunkLength: 8,
|
||||
selfDefending: true,
|
||||
debugProtection: true,
|
||||
debugProtectionInterval: 3000
|
||||
The seed is optional; an empty seed selects fresh cryptographic random build material. A seed is not a secret or an encryption key.
|
||||
|
||||
To obtain the linked JavaScript and resource archive without a page wrapper:
|
||||
|
||||
```js
|
||||
const {compileProject} = require('@sccn/tw-packager');
|
||||
const player = await compileProject(fs.readFileSync('project.sb3'), {
|
||||
seed: 'reproducible-build',
|
||||
autoplay: false
|
||||
});
|
||||
fs.writeFileSync('player.js', player.code);
|
||||
for (const asset of player.assets) fs.writeFileSync(asset.name, asset.data);
|
||||
```
|
||||
|
||||
`renameGlobals`, `renameProperties`, and source maps are intentionally unavailable for compiled factories because they can break VM bindings or the artifact format. Self-defending mode applies to each independently evaluated compiled factory and must be the final factory-source transformation. Debug protection is different: the packager generates one global player sidecar, so entry scripts and procedures do not each install their own guard or interval. An interval of `0` runs the guard once; repeating values must be at least 1000 ms, and 2000–4000 ms is the practical range suggested by JavaScript Obfuscator.
|
||||
Load `player.js` with a normal script tag alongside `project.zip`. `player.buildId` identifies the final JavaScript and resource archive; `player.diagnostics` contains capability reasons, retained/removed modules, internal property mappings and continuation groups. `diagnostics.schedulingProperties` lists the audited Thread/Sequencer methods and their final names; `diagnostics.reservedPropertyReasons` explains names retained for dynamic access, reflection or uncertain ownership. Keep diagnostics as development material, outside the player.
|
||||
|
||||
These transformations are deterrence, not encryption or a security boundary. They raise the cost of casual inspection and automated analysis, but executable JavaScript and the accessor still reach the user's machine. Never include private keys, long-term secrets, authorization logic, or other client-side trust decisions in a project.
|
||||
Pass `sourceMap: true` to receive a separate `player.sourceMap` object for local debugging. It contains source information and must not be distributed with a protected player. The default result omits it, and neither mode inserts a source-map URL into the code.
|
||||
|
||||
For release builds, keep providers and secrets outside the serializable options object. `compiledProjectHooks` supports project-field seed selection, local or remote seed derivation, custom stages, `javascript-obfuscator` options, and integrity signing:
|
||||
The browser exposes a frozen `window.player` with `start`, `stop`, `pause`, `resume`, `postInput(device, data)`, `on(event, callback)`, `off(event, callback)` and `dispose`. Events are `loaded`, `state`, `problem`, and `error`; event payloads contain no VM references. Wait for `loaded` before starting a player with autoplay disabled.
|
||||
|
||||
```js
|
||||
packager.compiledProjectHooks = {
|
||||
seedMaterial: ({project}) => ({
|
||||
extensions: project.extensions,
|
||||
targetNames: project.targets.map(target => target.name)
|
||||
}),
|
||||
deriveSeed: async ({materialSha256}) => {
|
||||
const {seed, keyId} = await seedService.derive({materialSha256});
|
||||
return {seed, keyId, derivationId: 'release-hmac-v1'};
|
||||
},
|
||||
javascriptObfuscatorOptions: {compact: true},
|
||||
signManifest,
|
||||
// Source for the verifier embedded in the generated player. It must be a
|
||||
// self-contained async function accepting {payload, manifest, signature}.
|
||||
signatureVerifierSource: verifyManifest.toString()
|
||||
};
|
||||
```
|
||||
Third-party extensions run in isolated Workers. Supply URLs in `options.extensions`, or `{url, source, dependencies}` objects to `compileProject`; `dependencies` maps absolute code URLs to source strings. Static imports and literal `importScripts` dependencies are frozen during the build. Registration must finish synchronously; block operations may be asynchronous. Extensions requiring full VM access, compiler mutation, dynamic code imports or new runtime extensions fail compilation.
|
||||
|
||||
The generated package includes a SHA-256 integrity manifest. When VM member-access protection is enabled, the matching accessor sidecar is embedded separately in the player and injected through the compiled artifact's declared binding. The artifact and accessor must always be published together; the packager handles this automatically.
|
||||
Old `.sb3c`/`.sb3c2` packages, `compiler.aot`, `compiledProjectHooks`, signing/network protection, `bakeExtensions`, custom JavaScript, and unsafe cloud evaluation have been removed. Rebuild from the original SB3 and reset legacy settings. Ordinary cloud variables remain available through the packager's cloud options.
|
||||
|
||||
Note that a Packager is a single-use object; you must make a new Packager each time you want to package a project.
|
||||
|
||||
|
||||
Generated
+817
-972
File diff suppressed because it is too large.
Load diff
+30
-9
@@ -1,21 +1,28 @@
|
||||
{
|
||||
"name": "@luyifei2011/tw-packager",
|
||||
"name": "@sccn/tw-packager",
|
||||
"version": "3.13.0",
|
||||
"description": "Compiles, protects, and packages Scratch projects as HTML, archives, or desktop applications.",
|
||||
"main": "dist/packager.js",
|
||||
"scripts": {
|
||||
"start": "webpack serve",
|
||||
"start": "npm run prepare:specialized && webpack serve",
|
||||
"clean": "rimraf dist",
|
||||
"build": "npm run clean && webpack",
|
||||
"build": "npm run clean && npm run prepare:specialized && webpack",
|
||||
"build-prod": "cross-env NODE_ENV=production npm run build",
|
||||
"watch": "webpack --watch",
|
||||
"watch": "npm run prepare:specialized && webpack --watch",
|
||||
"build-standalone": "cross-env STANDALONE=1 npm run build && node src/build/generate-standalone.js",
|
||||
"build-standalone-prod": "cross-env NODE_ENV=production npm run build-standalone",
|
||||
"build-node": "cross-env BUILD_MODE=node npm run build",
|
||||
"build-node-prod": "cross-env NODE_ENV=production npm run build-node",
|
||||
"test": "jest",
|
||||
"prepublishOnly": "npm run test && npm run build-node-prod && node src/build/nodejs/update-readme.js node",
|
||||
"postpublish": "node src/build/nodejs/update-readme.js restore"
|
||||
"prepublishOnly": "npm run test && npm run build-node-prod && npm run test:specialized && npm run test:specialized-linked && npm run test:specialized-build && npm run test:specialized-performance && node src/build/nodejs/update-readme.js node",
|
||||
"postpublish": "node src/build/nodejs/update-readme.js restore",
|
||||
"prepare:specialized": "node scripts/prepare-runtime-sources.mjs && node scripts/build-linker.mjs",
|
||||
"test:specialized": "node test/specialized-differential.mjs && node --test test/specialized-lifecycle.mjs test/specialized-structure.mjs test/specialized-rendering.mjs test/specialized-properties.mjs",
|
||||
"test:specialized-linked": "node test/specialized-differential.mjs --linked",
|
||||
"test:specialized-build": "node test/specialized-build.mjs",
|
||||
"test:specialized-browser": "node test/specialized-browser.mjs",
|
||||
"test:specialized-performance": "node test/specialized-performance.mjs",
|
||||
"test:specialized-scratch-benchmark": "node test/specialized-scratch-benchmark.mjs"
|
||||
},
|
||||
"author": "LuYifei2011",
|
||||
"homepage": "https://luyifei2011.github.io/tw-packager/",
|
||||
@@ -36,12 +43,24 @@
|
||||
"@turbowarp/json": "^0.1.1",
|
||||
"@turbowarp/jszip": "^3.11.0",
|
||||
"@turbowarp/sbdl": "^7.0.0",
|
||||
"@xmldom/xmldom": "^0.9.12",
|
||||
"cross-fetch": "^4.1.0",
|
||||
"pngjs": "^6.0.0",
|
||||
"rollup": "4.63.1",
|
||||
"sha.js": "^2.4.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.16.5",
|
||||
"@babel/generator": "^7.28.0",
|
||||
"@babel/parser": "^7.28.0",
|
||||
"@babel/preset-env": "^7.16.5",
|
||||
"@babel/traverse": "^7.28.0",
|
||||
"@babel/types": "^7.28.0",
|
||||
"@playwright/test": "^1.62.1",
|
||||
"@rollup/browser": "4.63.1",
|
||||
"@rollup/plugin-commonjs": "^28.0.6",
|
||||
"@rollup/plugin-json": "^6.1.0",
|
||||
"@rollup/plugin-node-resolve": "^16.0.1",
|
||||
"@turbowarp/scratch-storage": "^2.0.0",
|
||||
"@turbowarp/scratch-svg-renderer": "^1.1.0",
|
||||
"babel-jest": "^27.4.5",
|
||||
@@ -52,16 +71,15 @@
|
||||
"fastestsmallesttextencoderdecoder": "^1.0.22",
|
||||
"file-loader": "^6.2.0",
|
||||
"html-webpack-plugin": "^4.5.2",
|
||||
"javascript-obfuscator": "^5.5.0",
|
||||
"jest": "^27.4.5",
|
||||
"rimraf": "^2.7.1",
|
||||
"scratch-audio": "git+https://github.com/TurboWarp/scratch-audio.git#develop",
|
||||
"scratch-render": "git+https://github.com/TurboWarp/scratch-render.git#develop",
|
||||
"scratch-vm": "git+https://github.com/LuYifei2011/scratch-vm.git#compiled-project-artifacts",
|
||||
"sb3c-vm-obfuscator": "git+https://github.com/LuYifei2011/sb3c-vm-obfuscator.git#main",
|
||||
"scratch-vm": "file:../scratch-vm",
|
||||
"style-loader": "^2.0.0",
|
||||
"svelte": "^3.44.3",
|
||||
"svelte-loader": "^3.1.2",
|
||||
"terser": "^5.43.1",
|
||||
"url-loader": "^4.1.1",
|
||||
"webpack": "4.47.0",
|
||||
"webpack-bundle-analyzer": "^4.5.0",
|
||||
@@ -73,5 +91,8 @@
|
||||
"webpack@4.47.0": {
|
||||
"terser-webpack-plugin": "^4.2.3"
|
||||
}
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.15.0"
|
||||
}
|
||||
}
|
||||
@@ -1,381 +0,0 @@
|
||||
# Scaffolding API
|
||||
|
||||
## Scaffolding's API is in an ALPHA state. It WILL change in ways that are backwards incompatible. Please pin to exact versions to avoid pain later!
|
||||
|
||||
## This documentation is very work in progress. Report any errors you find or any issues you encounter.
|
||||
|
||||
Scaffolding is the project player used by the packager.
|
||||
|
||||
Here's what Scaffolding does for you:
|
||||
|
||||
- Loads the entire Scratch VM and connects everything together
|
||||
- Forwards user input to Scratch and implements things like sprite dragging
|
||||
- Removes unnecessary parts of Scratch that you don't need just for running projects
|
||||
- Implements the "ask and wait" block
|
||||
- Implements highly optimized (much faster than scratch-gui) variable and list monitors with context menus and file dropping
|
||||
- Implements cloud variables using a Scratch-compatible WebSocket server or local storage
|
||||
- Implements video sensing
|
||||
|
||||
Here's what Scaffolding doesn't do for you:
|
||||
|
||||
- Green flag screen
|
||||
- Controls
|
||||
- A progress bar
|
||||
|
||||
## Versions
|
||||
|
||||
There are two versions of Scaffolding:
|
||||
|
||||
- scaffolding-full (4.6MB) - contains every part of Scratch
|
||||
- scaffolding-min (2.5MB) - contains all of Scratch EXCEPT sound files used by the music extension. Significantly smaller than scaffolding-full.
|
||||
|
||||
Scratch is a large application so either script is very large. If you don't need the music extension to function, use scaffolding-min.
|
||||
|
||||
## Supported environments
|
||||
|
||||
Scaffolding strives to support any web browser released in 2018 or later.
|
||||
|
||||
Scaffolding only runs in web browsers. It will not run in Node.js.
|
||||
|
||||
## Content-Security-Policy
|
||||
|
||||
Meaningfully sandboxing Scaffolding with a Content-Security-Policy is hard. It needs to be able to fetch things from `data:` and `blob:` URLs, and the compiler (enabled by default) requires `unsafe-eval`.
|
||||
|
||||
This section will be updated later with more guidance.
|
||||
|
||||
## Installing
|
||||
|
||||
Scaffolding is distributed as one big JavaScript file. Any supplemental CSS etc. is stored inside the JS file.
|
||||
|
||||
We make no promise of having a stable API between even minor releases as Scaffolding is effectively just an implementation detail of the packager, which is why it's very important to lock your application to specific versions.
|
||||
|
||||
You can only load one version of Scaffolding on a page, but you can create as many Scaffolding project players as you'd like.
|
||||
|
||||
### From a CDN
|
||||
|
||||
```html
|
||||
<!-- for scaffolding-min: -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/@turbowarp/packager@0.0.0/dist/scaffolding/scaffolding-min.js"></script>
|
||||
|
||||
<!-- for scaffolding-full -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/@turbowarp/packager@0.0.0/dist/scaffolding/scaffolding-full.js"></script>
|
||||
```
|
||||
|
||||
Replace `0.0.0` with the latest release from https://github.com/TurboWarp/packager/releases, for example `1.0.0`.
|
||||
|
||||
DO NOT use versions like `@1` or `@latest` -- your website will break if (when) Scaffolding's API changes!
|
||||
|
||||
If you don't want to use a CDN, you can download the JS file linked by the script tag to a server you control and simply load that script instead.
|
||||
|
||||
### From npm
|
||||
|
||||
```bash
|
||||
npm install --save-exact @turbowarp/packager
|
||||
```
|
||||
|
||||
```js
|
||||
// for scaffolding-min:
|
||||
require('@turbowarp/packager/dist/scaffolding/scaffolding-min.js');
|
||||
// for scaffolding-full:
|
||||
require('@turbowarp/packager/dist/scaffolding/scaffolding-full.js');
|
||||
|
||||
// or, if you prefer ES6 imports,
|
||||
// for scaffolding-min:
|
||||
import '@turbowarp/packager/dist/scaffolding/scaffolding-min.js';
|
||||
// for scaffolding-full:
|
||||
import '@turbowarp/packager/dist/scaffolding/scaffolding-full.js';
|
||||
```
|
||||
|
||||
Note that regardless of how you import Scaffolding, it is exported on `window.Scaffolding`, not on the module. This is strange and weird; it may change in the future.
|
||||
|
||||
## Usage
|
||||
|
||||
For an example, see [../static/example.html](../static/example.html). You can also examine the output of files generated by the packager.
|
||||
|
||||
At this point you should have Scaffolding installed on `window.Scaffolding`.
|
||||
|
||||
### Setup
|
||||
|
||||
Create an instance of Scaffolding:
|
||||
|
||||
```js
|
||||
const scaffolding = new Scaffolding.Scaffolding();
|
||||
```
|
||||
|
||||
You should be able to have multiple of these on one page if you want.
|
||||
|
||||
### Configuration
|
||||
|
||||
Certain Scaffolding options must be configured before you can finish setting up the Scratch environment. These are optional. The options that must be configured at this point are listed below along with their defaults.
|
||||
|
||||
```js
|
||||
scaffolding.width = 480; // Custom stage width
|
||||
scaffolding.height = 360; // Custom stage height
|
||||
scaffolding.resizeMode = 'preserve-ratio'; // or 'dynamic-resize' or 'stretch'
|
||||
scaffolding.editableLists = false; // Affects list monitors
|
||||
```
|
||||
|
||||
### Finalize setup
|
||||
|
||||
This will actually create the Scratch VM, Renderer, Audio Engine, etc. and link them all together.
|
||||
|
||||
```js
|
||||
scaffolding.setup();
|
||||
```
|
||||
|
||||
### Put it in the DOM somewhere
|
||||
|
||||
You are responsible for making a location in the DOM for Scaffolding and using CSS to appropriately size it.
|
||||
|
||||
If your HTML looks like this:
|
||||
|
||||
```html
|
||||
<style>
|
||||
#project {
|
||||
width: 480px;
|
||||
height: 360px;
|
||||
}
|
||||
</style>
|
||||
<div id="project"></div>
|
||||
```
|
||||
|
||||
You would append Scaffolding to the DOM like this:
|
||||
|
||||
```js
|
||||
scaffolding.appendTo(document.getElementById('project'));
|
||||
```
|
||||
|
||||
Scaffolding will automatically resize the project player to fit inside the space you gave it. It listens for the window 'resize' event to do this. If you are resizing the element in a way that doesn't involve a window resize, you can manually call `scaffolding.relayout()` to force a resize. This can be slow, so don't do it when you don't need to.
|
||||
|
||||
### Tell it where to fetch projects and assets from
|
||||
|
||||
You have to manually configure scratch-storage to know where to fetch files from. If you want to load projects from scratch.mit.edu, you would do:
|
||||
|
||||
```js
|
||||
const storage = scaffolding.storage;
|
||||
storage.addWebStore(
|
||||
[storage.AssetType.ImageVector, storage.AssetType.ImageBitmap, storage.AssetType.Sound],
|
||||
(asset) => `https://assets.scratch.mit.edu/internalapi/asset/${asset.assetId}.${asset.dataFormat}/get/`
|
||||
);
|
||||
```
|
||||
|
||||
Downloading shared Scratch projects can be done manually with something like this:
|
||||
|
||||
```js
|
||||
const id = '437419376';
|
||||
const projectMetadata = await (await fetch(`https://trampoline.turbowarp.org/api/projects/${id}`)).json();
|
||||
const token = projectMetadata.project_token;
|
||||
const projectData = await (await fetch(`https://projects.scratch.mit.edu/${id}?token=${token}`)).arrayBuffer();
|
||||
```
|
||||
|
||||
### Configure cloud variables
|
||||
|
||||
By default cloud variables are just normal variables. You must reconfigure this manually if you don't want that.
|
||||
|
||||
To synchronize cloud variables globally:
|
||||
|
||||
```js
|
||||
const cloudVariableProvider = scaffolding.addCloudProvider(new Scaffolding.Cloud.WebSocketProvider('wss://clouddata.example.com', 'PROJECT ID HERE'));
|
||||
```
|
||||
|
||||
To store cloud variables locally:
|
||||
|
||||
```js
|
||||
const localStorageProvider = scaffolding.addCloudProvider(new Scaffolding.Cloud.LocalStorageProvider('UNIQUE STORAGE KEY (such as project ID) HERE'));
|
||||
```
|
||||
|
||||
To use a different provider for different variables, there is an override system.
|
||||
|
||||
```js
|
||||
scaffolding.addCloudProviderOverride("☁ my variable", cloudVariableProvider);
|
||||
scaffolding.addCloudProviderOverride("☁ other variable", localStorageProvider);
|
||||
```
|
||||
|
||||
### Set username
|
||||
|
||||
To change the value of the username block from the default empty string:
|
||||
|
||||
```js
|
||||
scaffolding.setUsername("ExampleUser");
|
||||
```
|
||||
|
||||
### Set accent color
|
||||
|
||||
To change the accent color used in prompts, context menus, etc.:
|
||||
|
||||
```js
|
||||
scaffolding.setAccentColor("#abcdef");
|
||||
```
|
||||
|
||||
The color MUST be a hex color code with 6 characters. 3 character codes will not work. The leading # is required. Color names will also not work.
|
||||
|
||||
### Load a project
|
||||
|
||||
At this point you can load a project. Scaffolding accepts an ArrayBuffer, Uint8Array, or string containing a full compressed project or a project.json.
|
||||
|
||||
Full compressed projects are full sb/sb2/sb3 files including assets.
|
||||
|
||||
project.json is just the JSON part of an sb2/sb3 file. In this case assets will be fetched separately.
|
||||
|
||||
```js
|
||||
scaffolding.loadProject(projectData)
|
||||
.then(() => {
|
||||
// ...
|
||||
})
|
||||
.catch((error) => {
|
||||
// ...
|
||||
});
|
||||
```
|
||||
|
||||
This returns a Promise that resolves when the project has finished loading or rejects if the project could not be loaded.
|
||||
|
||||
The project is not automatically started when loadProject completes.
|
||||
|
||||
If you configured scratch-storage to load projects from scratch.mit.edu, you can use:
|
||||
|
||||
```js
|
||||
scaffolding.storage.load(Scaffolding.Storage.AssetType.Project, "PROJECT ID HERE eg. 104")
|
||||
.then((projectDataAsset) => scaffolding.load(projectDataAsset.data))
|
||||
.then(() => {
|
||||
// ...
|
||||
})
|
||||
.catch((error) => {
|
||||
// ...
|
||||
});
|
||||
```
|
||||
|
||||
### Start the project
|
||||
|
||||
This will start the project *and* start scripts that are supposed to run when you press the green flag.
|
||||
|
||||
If you want to automatically start the project, you can do:
|
||||
|
||||
```js
|
||||
scaffolding.loadProject(/* ... */) // see previous step
|
||||
.then(() => {
|
||||
scaffolding.start();
|
||||
});
|
||||
```
|
||||
|
||||
If you want to display something like Scratch's green flag screen, you can create the screen yourself and call scaffolding.start() when the user clicks on it.
|
||||
|
||||
### Advanced topics
|
||||
|
||||
Scaffolding gives you direct access to various internal classes.
|
||||
|
||||
|Package|Instance|Constructor|
|
||||
|:-:|:-:|:-:|
|
||||
|scratch-vm|`scaffolding.vm`|`Scaffolding.VM`|
|
||||
|scratch-render|`scaffolding.renderer`|`Scaffolding.Renderer`|
|
||||
|scratch-storage|`scaffolding.storage`|`Scaffolding.Storage`|
|
||||
|scratch-audio|`scaffolding.audioEngine`|`Scaffolding.AudioEngine`|
|
||||
|JSZip|N/A|`Scaffolding.JSZip`|
|
||||
|
||||
Constructors are immediately available when scaffolding-[full|min].js is loaded. Instances are available after scaffolding.setup() is called.
|
||||
|
||||
The scratch-audio instance might be null if the browser doesn't support the [Web Audio API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Audio_API). This is pretty rare to not be supported, but a few people do turn it off for one reason or another.
|
||||
|
||||
The scratch-storage constructor is actually a wrapper used for implementing progress monitoring.
|
||||
|
||||
### VM Settings
|
||||
|
||||
```js
|
||||
// Press the green flag (like Scratch's green flag button)
|
||||
scaffolding.greenFlag();
|
||||
|
||||
// Stop all (like Scratch's stop sign button)
|
||||
scaffolding.stopAll();
|
||||
|
||||
// Turbo Mode
|
||||
scaffolding.vm.setTurboMode(true);
|
||||
|
||||
// Framerate
|
||||
scaffolding.vm.setFramerate(60);
|
||||
|
||||
// Interpolation
|
||||
scaffolding.vm.setInterpolation(true);
|
||||
|
||||
// High quality pen
|
||||
scaffolding.renderer.setUseHighQualityRender(true);
|
||||
|
||||
// Turn off the compiler
|
||||
scaffolding.vm.setRuntimeOptions({enabled: false});
|
||||
|
||||
// Infinite clones, Remove miscellaneous limits, Remove fencing
|
||||
scaffolding.vm.setRuntimeOptions({fencing: false, miscLimits: false, maxClones: Infinity});
|
||||
|
||||
// Load custom extension
|
||||
// Do this before calling loadProject() for best results.
|
||||
scaffolding.vm.extensionManager.loadExtensionURL('https://extensions.turbowarp.org/fetch.js');
|
||||
|
||||
// Do something when the project stops
|
||||
vm.runtime.on('PROJECT_RUN_STOP', () => {
|
||||
// ...
|
||||
});
|
||||
```
|
||||
|
||||
### Get or set variables and lists
|
||||
|
||||
```js
|
||||
scaffolding.getVariable("global variable name"); // -> "Hello, world!"
|
||||
scaffolding.getList("global list name"); // -> [1, 2, 3]
|
||||
|
||||
scaffolding.setVariable("global variable name", "New value");
|
||||
scaffolding.setList("global list name", [4, 5, 6]);
|
||||
```
|
||||
|
||||
Variables must be a number, string, or boolean, and lists can only contain numbers, strings, and booleans. The VM was not designed to handle these invalid values and will not behave properly. Invalid values will throw an error.
|
||||
|
||||
Lists returned by getList are returned by reference, so any changes you make to them will be reflected in the project. However, changes might not display on the any visible monitors unless you use setList.
|
||||
|
||||
### Progress monitoring
|
||||
|
||||
Scaffolding can't help you monitor the progress of the data you feed into loadProject() -- you have to do that on your own. That might mean using an XMLHttpRequest to fetch the data so you can listen to the progress event.
|
||||
|
||||
Scaffolding can help you monitor asset download progress.
|
||||
|
||||
```js
|
||||
scaffolding.storage.onprogress = (totalAssets, loadedAssets) => {
|
||||
// ...
|
||||
};
|
||||
```
|
||||
|
||||
Again, you are responsible for displaying a progress bar on your own.
|
||||
|
||||
### Fullscreen
|
||||
|
||||
Scaffolding doesn't have an API for fullscreen. Instead, just use the DOM fullscreen API on an element that contains scaffolding.
|
||||
|
||||
```js
|
||||
document.getElementById('project').requestFullScreen();
|
||||
```
|
||||
|
||||
### Advanced styling
|
||||
|
||||
You are free to override the styles for any class starting with `sc-`.
|
||||
|
||||
### Addons
|
||||
|
||||
In the same place as scaffolding-[full|min].js, there is a file called addons.js that contains some extra features. You can load it the same way you load the normal scaffolding script. It exports on window.ScaffoldingAddons instead.
|
||||
|
||||
```js
|
||||
ScaffoldingAddons.run(scaffolding, {
|
||||
// Enable what you want.
|
||||
gamepad: false,
|
||||
pointerlock: false,
|
||||
specialCloudBehaviors: false,
|
||||
unsafeCloudBehaviors: false,
|
||||
pause: false, // Adds API: scaffolding.vm.setPaused(true)
|
||||
});
|
||||
```
|
||||
|
||||
### Exporting sb3
|
||||
|
||||
Here's something to get you started:
|
||||
|
||||
```js
|
||||
Object.assign(document.createElement("a"), {
|
||||
href: URL.createObjectURL(await scaffolding.vm.saveProjectSb3()),
|
||||
download: 'project.sb3'
|
||||
}).click();
|
||||
```
|
||||
@@ -0,0 +1,28 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import {rollup} from 'rollup';
|
||||
import {nodeResolve} from '@rollup/plugin-node-resolve';
|
||||
import commonjs from '@rollup/plugin-commonjs';
|
||||
import json from '@rollup/plugin-json';
|
||||
|
||||
await fs.mkdir('generated', {recursive: true});
|
||||
for (const node of [true, false]) {
|
||||
const bundle = await rollup({input: `src/specialized/${node ? 'node' : 'worker'}.mjs`,
|
||||
external: node ? ['rollup', 'node:child_process'] : [],
|
||||
onwarn(warning) { if (warning.code !== 'CIRCULAR_DEPENDENCY') console.warn(warning.message); },
|
||||
plugins: [{name: 'build-environment', transform(code, id) {
|
||||
if (!/\.[cm]?js$/.test(id)) return;
|
||||
return {code: code.replace(/process\.env\.BABEL_TYPES_8_BREAKING/g, 'false')
|
||||
.replace(/process\.env\.BABEL_8_BREAKING/g, 'false').replace(/process\.env\.BABEL_TYPES_8_BREAKING/g, 'false')
|
||||
.replace(/process\.env\.NODE_ENV/g, '"production"'), map: null};
|
||||
}}, nodeResolve({browser: !node, preferBuiltins: node}), commonjs(), json()]});
|
||||
await bundle.write({file: `generated/${node ? 'specialized-link.cjs' : 'specialized-worker.js'}`,
|
||||
format: node ? 'cjs' : 'iife', inlineDynamicImports: true,
|
||||
banner: node ? '' : 'var global = globalThis; var process = {env: {}, platform: "browser"};'});
|
||||
await bundle.close();
|
||||
}
|
||||
const browserRollup = await fs.readFile('node_modules/@rollup/browser/dist/rollup.browser.js', 'utf8');
|
||||
const wasmBootstrap = /(\w+)=new URL\("bindings_wasm_bg.wasm",/;
|
||||
if (!wasmBootstrap.test(browserRollup)) throw new Error('Pinned Rollup WASM bootstrap changed');
|
||||
await fs.writeFile('generated/rollup.browser.js', browserRollup.replace(wasmBootstrap,
|
||||
'$1=globalThis.__specializedWasmBytes||new URL("bindings_wasm_bg.wasm",'));
|
||||
await fs.copyFile('node_modules/@rollup/browser/dist/bindings_wasm_bg.wasm', 'generated/bindings_wasm_bg.wasm');
|
||||
@@ -0,0 +1,420 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
import {createRequire} from 'node:module';
|
||||
import {rollup} from 'rollup';
|
||||
import commonjs from '@rollup/plugin-commonjs';
|
||||
import {nodeResolve} from '@rollup/plugin-node-resolve';
|
||||
import json from '@rollup/plugin-json';
|
||||
import {createHash} from 'node:crypto';
|
||||
import {unbundleTWGL} from './unbundle-twgl.mjs';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const {parse} = require('@babel/parser');
|
||||
const traverse = require('@babel/traverse').default;
|
||||
const generate = require('@babel/generator').default;
|
||||
const t = require('@babel/types');
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const vm = path.resolve(root, '../scratch-vm');
|
||||
const sourceRoot = path.join(root, 'src');
|
||||
const vmSource = path.join(vm, 'src');
|
||||
const nativeDependencies = ['scratch-render', 'scratch-audio', '@turbowarp/scratch-storage', '@turbowarp/scratch-svg-renderer']
|
||||
.map(name => path.join(root, 'node_modules', name, 'src') + path.sep);
|
||||
const output = path.join(root, 'generated/runtime-sources.json');
|
||||
const sources = {};
|
||||
const provenance = {};
|
||||
const ast = code => parse(code, {sourceType: 'unambiguous'});
|
||||
const method = (name, body) => ast(`class A { ${name} ${body} }`).program.body[0].body.body[0];
|
||||
const twglModules = unbundleTWGL(await fs.readFile(require.resolve('twgl.js'), 'utf8'));
|
||||
const auditedWrappers = new Map();
|
||||
for (const [file, hash] of [
|
||||
['utils.js', 'c58704c6a6ef17f3e78a1ef4dd6e7aa20712fe673a929eb4c08fc86fcc9341cd'],
|
||||
['base64.js', '5df6b73d9ed6c317c67835f3427d860c16fd93acf503eb471fd897167e747fdb']
|
||||
]) {
|
||||
const filename = path.join(root, 'node_modules/@turbowarp/jszip/lib', file);
|
||||
if (createHash('sha256').update(await fs.readFile(filename)).digest('hex') !== hash) {
|
||||
throw new Error(`JSZip ${file} changed; review circular export initialization before updating the source pack`);
|
||||
}
|
||||
auditedWrappers.set(filename, {sha256: hash,
|
||||
reason: 'JSZip utils -> base64 -> utils captures partially initialized mutable CommonJS exports; preserve circular export identity and first-use ordering',
|
||||
cycle: ['@turbowarp/jszip/lib/utils.js', '@turbowarp/jszip/lib/base64.js', '@turbowarp/jszip/lib/utils.js']});
|
||||
}
|
||||
|
||||
// Production source preparation happens once, in Node, before publishing the build tool.
|
||||
// The browser receives resolved ESM, so it needs neither require() nor filesystem plugins.
|
||||
function nativeModules(code, id) {
|
||||
const tree = ast(code);
|
||||
const imports = new Map();
|
||||
const getImport = name => {
|
||||
if (!imports.has(name)) imports.set(name, t.identifier(`__dependency${imports.size}`));
|
||||
return t.cloneNode(imports.get(name));
|
||||
};
|
||||
let exported = false;
|
||||
let namedExports = false;
|
||||
traverse(tree, {
|
||||
ReferencedIdentifier(p) {
|
||||
if (p.node.name === 'exports' && !p.scope.hasBinding('exports')) {
|
||||
namedExports = true; p.replaceWith(t.identifier('__exports'));
|
||||
}
|
||||
},
|
||||
CallExpression(p) {
|
||||
if (t.isIdentifier(p.node.callee, {name: 'require'})) {
|
||||
if (p.node.arguments.length !== 1 || !t.isStringLiteral(p.node.arguments[0])) {
|
||||
throw new Error(`Non-static first-party require in ${id}`);
|
||||
}
|
||||
p.replaceWith(getImport(p.node.arguments[0].value));
|
||||
}
|
||||
},
|
||||
ExpressionStatement(p) {
|
||||
const expression = p.node.expression;
|
||||
if (t.isAssignmentExpression(expression) && t.isMemberExpression(expression.left) &&
|
||||
t.isIdentifier(expression.left.object, {name: 'module'}) &&
|
||||
t.isIdentifier(expression.left.property, {name: 'exports'})) {
|
||||
if (exported) throw new Error(`Multiple CommonJS exports in ${id}`);
|
||||
exported = true;
|
||||
p.replaceWith(t.exportDefaultDeclaration(expression.right));
|
||||
}
|
||||
}
|
||||
});
|
||||
if (namedExports) {
|
||||
tree.program.body.unshift(...ast('const __exports = {};').program.body);
|
||||
tree.program.body.push(...ast('export default __exports;').program.body);
|
||||
}
|
||||
tree.program.body.unshift(...[...imports].map(([name, local]) =>
|
||||
t.importDeclaration([['@turbowarp/json', 'uuid', '@vernier/godirect', 'transformation-matrix'].includes(name) ?
|
||||
t.importNamespaceSpecifier(local) : t.importDefaultSpecifier(local)], t.stringLiteral(name))));
|
||||
return generate(tree, {comments: false}).code;
|
||||
}
|
||||
|
||||
function prepareVM(code, id) {
|
||||
if (/\/engine\/blocks-(execute|runtime)-cache.js$/.test(id)) code = code.replace("require('./blocks');", '');
|
||||
const tree = ast(code);
|
||||
if (id.endsWith('/virtual-machine.js')) {
|
||||
// The playback facade and in-bundle addons are the only consumers of this class.
|
||||
const roots = new Set(['constructor', 'start', 'greenFlag', 'stopAll', 'quit', 'clear',
|
||||
'attachStorage', 'attachRenderer', 'attachAudioEngine', 'attachV2BitmapAdapter',
|
||||
'setVideoProvider', 'setCloudProvider', 'postIOData', 'setTurboMode', 'setCompatibilityMode',
|
||||
'setFramerate', 'setInterpolation', 'setRuntimeOptions', 'setCompilerOptions', 'setStageSize',
|
||||
'setInEditor', 'convertToPackagedRuntime', 'loadProject', 'getTargetIdForDrawableId',
|
||||
'startDrag', 'stopDrag', 'postSpriteInfo', 'setLocale', 'setTranslate', 'getLocale',
|
||||
'setVariableValue', 'getVariableValue', 'setListValue', 'getListValue', 'setEditingTarget',
|
||||
'scanForPeripheral', 'connectPeripheral', 'disconnectPeripheral', 'getPeripheralIsConnected',
|
||||
'securityManager', 'runtime', 'renderer', 'editingTarget']);
|
||||
const cls = tree.program.body.find(n => t.isClassDeclaration(n));
|
||||
traverse(tree, {ExpressionStatement(p) {
|
||||
const node = p.node.expression;
|
||||
if (t.isAssignmentExpression(node) && t.isMemberExpression(node.left) &&
|
||||
t.isThisExpression(node.left.object) && ['exports', 'blockListener', 'flyoutBlockListener',
|
||||
'monitorBlockListener', 'variableListener'].includes(node.left.property.name)) p.remove();
|
||||
}});
|
||||
const load = cls.body.body.find(n => n.key.name === 'loadProject');
|
||||
load.async = true;
|
||||
load.body = ast(`async function load(input) {
|
||||
const project = typeof input === 'string' ? JSON.parse(input) :
|
||||
input instanceof ArrayBuffer || ArrayBuffer.isView(input) ? JSON.parse(new TextDecoder().decode(input)) : input;
|
||||
this.clear();
|
||||
for (const id of project.extensions || []) await this.extensionManager.loadExtensionURL((project.extensionURLs || {})[id] || id);
|
||||
const {targets} = await require('./serialization/sb3').deserialize(project, this.runtime);
|
||||
for (let index = 0; index < targets.length; index++) {
|
||||
const target = targets[index];
|
||||
target.sprite.__spTarget = index;
|
||||
this.runtime.addTarget(target);
|
||||
target.updateAllDrawableProperties();
|
||||
}
|
||||
this.runtime.executableTargets.sort((a,b) => a.layerOrder-b.layerOrder);
|
||||
targets.forEach(target => { delete target.layerOrder; });
|
||||
this.editingTarget = targets[1] || targets[0];
|
||||
this.runtime.setEditingTarget(this.editingTarget);
|
||||
this.runtime.ioDevices.cloud.setStage(this.runtime.getTargetForStage());
|
||||
this.runtime.handleProjectLoaded();
|
||||
}`).program.body[0].body;
|
||||
let changed = true;
|
||||
while (changed) {
|
||||
changed = false;
|
||||
for (const member of cls.body.body) if (roots.has(member.key.name)) {
|
||||
t.traverseFast(member, node => {
|
||||
if (t.isMemberExpression(node) && t.isThisExpression(node.object) &&
|
||||
!node.computed && !roots.has(node.property.name)) {
|
||||
roots.add(node.property.name); changed = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
cls.body.body = cls.body.body.filter(member => roots.has(member.key.name));
|
||||
}
|
||||
if (id.endsWith('/engine/sequencer.js')) {
|
||||
tree.program.body.unshift(...ast("const SpecializedBlockUtility = require('../compiler/specialized-block-utility'); const Cast = require('../util/cast'); const log = require('../util/log');").program.body);
|
||||
const cls = tree.program.body.find(n => t.isClassDeclaration(n));
|
||||
cls.body.body = cls.body.body.filter(n => !['stepThread', 'stepToBranch', 'stepToProcedure'].includes(n.key.name));
|
||||
cls.body.body.push(method('stepThread(thread)', `{ '__SPECIALIZED_STEP__'; }`));
|
||||
const retire = cls.body.body.find(n => n.key.name === 'retireThread');
|
||||
retire.body.body.unshift(...ast('thread.__spLifetime = null; thread.__spFrames = [];').program.body);
|
||||
}
|
||||
if (id.endsWith('/engine/runtime.js')) {
|
||||
const cls = tree.program.body.find(n => t.isClassDeclaration(n));
|
||||
cls.body.body = cls.body.body.filter(n => !['startHats', 'getOpcodeFunctionById'].includes(n.key.name));
|
||||
cls.body.body.push(method('startHats(opcode, fields, target)', `{ '__SPECIALIZED_HATS__'; }`));
|
||||
cls.body.body.push(method('__spPrimitive(opcode, args, utility)', `{ '__SPECIALIZED_PRIMITIVE__'; }`));
|
||||
for (const name of ['_pushThread', '_restartThread']) {
|
||||
const m = cls.body.body.find(n => n.key.name === name);
|
||||
// All threads use the linked sequencer. A new Thread has a new lifetime identity.
|
||||
t.traverseFast(m, node => {
|
||||
if (t.isCallExpression(node) && t.isMemberExpression(node.callee) &&
|
||||
t.isIdentifier(node.callee.property, {name: 'tryCompile'})) {
|
||||
node.callee.property.name = '__spInitialize';
|
||||
}
|
||||
});
|
||||
if (name === '_restartThread') {
|
||||
m.body.body.unshift(...ast('thread.__spLifetime = null;').program.body);
|
||||
t.traverseFast(m, node => {
|
||||
if (t.isReturnStatement(node) && t.isIdentifier(node.argument, {name: 'newThread'})) {
|
||||
node.argument = t.sequenceExpression([
|
||||
t.assignmentExpression('=', t.memberExpression(t.identifier('newThread'), t.identifier('executableHat')),
|
||||
t.memberExpression(t.identifier('thread'), t.identifier('executableHat'))), node.argument]);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
if (id.endsWith('/engine/thread.js')) {
|
||||
const cls = tree.program.body.find(n => t.isClassDeclaration(n, {id: {name: 'Thread'}})) ||
|
||||
tree.program.body.filter(n => t.isClassDeclaration(n)).at(-1);
|
||||
cls.body.body = cls.body.body.filter(n => !['tryCompile', 'getCompiledProcedure', 'stopThisScript'].includes(n.key.name));
|
||||
cls.body.body.push(method('__spInitialize()', `{
|
||||
this.triedToCompile = true; this.isCompiled = true;
|
||||
this.__spLifetime = {};
|
||||
this.__spFrames = [{__spPC: Number(this.topBlock), __spSlots: [], __spArgs: [], __spReturn: -1}];
|
||||
}`));
|
||||
}
|
||||
// Remove now-unused top-level require bindings before module resolution.
|
||||
traverse(tree, {Program(p) {
|
||||
p.scope.crawl();
|
||||
for (const binding of Object.values(p.scope.bindings)) {
|
||||
if (binding.referenced || ['SpecializedBlockUtility', 'Cast', 'log', 'execute'].includes(binding.identifier.name) || !binding.path.isVariableDeclarator()) continue;
|
||||
if (binding.path.node.init && t.isCallExpression(binding.path.node.init) &&
|
||||
t.isIdentifier(binding.path.node.init.callee, {name: 'require'})) binding.path.remove();
|
||||
}
|
||||
}});
|
||||
return generate(tree, {comments: false}).code;
|
||||
}
|
||||
|
||||
const stub = `const unavailable = () => { throw new Error('This player contains no dynamic compiler'); };
|
||||
unavailable.saveGlobalState = () => {}; unavailable.restoreGlobalState = () => {};
|
||||
export default unavailable;`;
|
||||
const aliases = {
|
||||
'scratch-vm': path.join(vmSource, 'index.js'),
|
||||
'scratch-render': path.join(root, 'node_modules/scratch-render/src/index.js'),
|
||||
'scratch-audio': path.join(root, 'node_modules/scratch-audio/src/index.js'),
|
||||
'@turbowarp/scratch-storage': path.join(root, 'node_modules/@turbowarp/scratch-storage/src/index.js'),
|
||||
'@turbowarp/jszip': path.join(root, 'node_modules/@turbowarp/jszip/lib/index.js'),
|
||||
'@turbowarp/scratch-svg-renderer': path.join(root, 'node_modules/@turbowarp/scratch-svg-renderer/src/index.js'),
|
||||
'transformation-matrix': path.join(root, 'node_modules/transformation-matrix/src/index.js'),
|
||||
'@vernier/godirect': path.join(vm, 'node_modules/@vernier/godirect/src/godirect.js'),
|
||||
'scratch-parser': path.join(sourceRoot, 'scaffolding/scratch-parser/index.js'),
|
||||
'text-encoding': path.join(sourceRoot, 'scaffolding/text-encoding/index.js'),
|
||||
'htmlparser2': path.join(sourceRoot, 'scaffolding/htmlparser2/index.js'),
|
||||
'scratch-translate-extension-languages': path.join(sourceRoot, 'scaffolding/scratch-translate-extension-languages/languages.json'),
|
||||
events: require.resolve('events/'),
|
||||
buffer: require.resolve('buffer/'),
|
||||
util: require.resolve('util/'),
|
||||
stream: require.resolve('stream-browserify'),
|
||||
crypto: require.resolve('crypto-browserify'),
|
||||
assert: require.resolve('assert/'),
|
||||
process: require.resolve('process/browser')
|
||||
};
|
||||
|
||||
const bundle = await rollup({
|
||||
input: ['\0specialized-entry', '\0specialized-extension-api'],
|
||||
treeshake: false,
|
||||
onwarn(warning) {
|
||||
if (['CIRCULAR_DEPENDENCY', 'THIS_IS_UNDEFINED'].includes(warning.code)) return;
|
||||
console.warn(warning.message);
|
||||
},
|
||||
plugins: [{
|
||||
name: 'prepared-runtime',
|
||||
resolveId(source, importer) {
|
||||
if (source.startsWith('\0specialized')) return source;
|
||||
if (source === 'twgl.js') return '\0specialized-twgl:9.js';
|
||||
if (source === 'setimmediate') return '\0specialized-immediate';
|
||||
if (aliases[source]) return aliases[source];
|
||||
if (source.startsWith('!ify-loader!')) return require.resolve(source.slice('!ify-loader!'.length));
|
||||
if (/^!?(worker-loader|raw-loader|arraybuffer-loader|base64-loader)[!?]/.test(source)) {
|
||||
return `\0asset:${path.resolve(path.dirname(importer), source.slice(source.lastIndexOf('!') + 1))}:${source.includes('raw-loader') ? 'raw' : source.includes('arraybuffer-loader') ? 'bytes' : source.includes('base64-loader') ? 'base64' : 'worker'}`;
|
||||
}
|
||||
if (source === 'fs' || source === 'path' || source === 'http' || source === 'https') return '\0specialized-empty';
|
||||
},
|
||||
async load(id) {
|
||||
if (id.startsWith('\0specialized-twgl:')) return nativeModules(twglModules[parseInt(id.split(':')[1], 10)], id);
|
||||
// Production players run in a browser, including desktop WebViews. ZIP
|
||||
// resources never expose Node streams and the supported engines have Promise.
|
||||
if (id.endsWith('/@turbowarp/jszip/lib/external.js')) return 'module.exports = {Promise};';
|
||||
if (id === '\0specialized-extension-api') return `
|
||||
import Cast from ${JSON.stringify(path.join(vmSource, 'util/cast.js'))};
|
||||
${['ArgumentType:argument-type', 'BlockType:block-type', 'BlockShape:tw-block-shape', 'TargetType:target-type'].map(item => {
|
||||
const [name, file] = item.split(':');
|
||||
return `import ${name} from ${JSON.stringify(path.join(vmSource, `extension-support/${file}.js`))};`;
|
||||
}).join('\n')}
|
||||
export default {Cast, ArgumentType, BlockType, BlockShape, TargetType};`;
|
||||
if (id.endsWith('/scaffolding/scratch-libraries.js')) return `
|
||||
export {default as VM} from 'scratch-vm';
|
||||
export {default as Renderer} from 'scratch-render';
|
||||
export {default as ScratchStorage} from '@turbowarp/scratch-storage';
|
||||
export {default as AudioEngine} from 'scratch-audio';
|
||||
export {default as BitmapAdapter} from ${JSON.stringify(path.join(root, 'node_modules/@turbowarp/scratch-svg-renderer/src/bitmap-adapter.js'))};`;
|
||||
if (id === '\0specialized-empty') return 'export default {};';
|
||||
if (id === '\0specialized-immediate') return `if (!globalThis.setImmediate) {
|
||||
globalThis.setImmediate = (callback, ...args) => {
|
||||
if (typeof callback !== 'function') throw new TypeError('setImmediate requires a function');
|
||||
return setTimeout(callback, 0, ...args);
|
||||
}; globalThis.clearImmediate = clearTimeout;
|
||||
} export default {};`;
|
||||
if (id.endsWith('/extension-support/tw-iframe-extension-worker.js')) return `export default class Unavailable { constructor() { throw new Error('Only worker extensions are supported'); } }`;
|
||||
if (id.endsWith('/extension-support/tw-unsandboxed-extension-runner.js')) return `export default {load() { throw new Error('Unsandboxed extensions are not supported'); }};`;
|
||||
if (id.endsWith('/addons/unsafe-cloud-behaviors.js')) return `export default function () { throw new Error('Dynamic cloud code is not supported'); }`;
|
||||
if (id === path.join(vmSource, 'index.js')) return `export {default} from './virtual-machine.js';`;
|
||||
if (id === '\0specialized-entry') return `import * as Scaffolding from ${JSON.stringify(path.join(sourceRoot, 'scaffolding/scaffolding.js'))};
|
||||
import * as ScaffoldingAddons from ${JSON.stringify(path.join(sourceRoot, 'addons/index.js'))};
|
||||
export {Scaffolding, ScaffoldingAddons};`;
|
||||
if (/\/compiler\/(compile|jsexecute|specialized-project)(\.js)?$/.test(id)) return stub;
|
||||
if (id.endsWith('/engine/execute.js')) return `export default function execute(sequencer, thread) {
|
||||
if (!thread.updateMonitor) throw new Error('No interpreter in specialized player');
|
||||
const runtime = sequencer.runtime;
|
||||
const block = runtime.monitorBlocks.getBlock(thread.topBlock);
|
||||
if (!block) { sequencer.retireThread(thread); return; }
|
||||
const args = {};
|
||||
for (const [key, field] of Object.entries(block.fields || {})) args[key] = key === 'VARIABLE' || key === 'LIST' ? field : field.value;
|
||||
const utility = {target: thread.target, runtime, thread,
|
||||
ioQuery: (device, operation, args = []) => runtime.ioDevices[device][operation](...args)};
|
||||
const value = runtime.__spPrimitive(block.opcode, args, utility);
|
||||
if (value !== undefined) runtime.requestUpdateMonitor({id: thread.topBlock, value,
|
||||
spriteName: block.targetId ? thread.target.getName() : null});
|
||||
sequencer.retireThread(thread);
|
||||
}`;
|
||||
if (id.startsWith('\0asset:')) {
|
||||
const [, filename, kind] = id.split(':');
|
||||
if (kind === 'worker') return `export default class WorkerUnavailable { constructor() { throw new Error('Extension worker source was not linked'); } }`;
|
||||
const bytes = await fs.readFile(filename);
|
||||
if (kind === 'raw') return `export default ${JSON.stringify(bytes.toString())};`;
|
||||
if (kind === 'base64') return `export default ${JSON.stringify(bytes.toString('base64'))};`;
|
||||
return `export default Uint8Array.from(atob(${JSON.stringify(bytes.toString('base64'))}), c => c.charCodeAt(0)).buffer;`;
|
||||
}
|
||||
if (/\.(svg|png|jpg|mp3|wav)$/.test(id)) {
|
||||
const bytes = await fs.readFile(id);
|
||||
if (id.includes('/scratch-storage/') || id.includes('/@turbowarp/scratch-storage/')) {
|
||||
return `export default Uint8Array.from(atob(${JSON.stringify(bytes.toString('base64'))}), c => c.charCodeAt(0));`;
|
||||
}
|
||||
const ext = path.extname(id).slice(1);
|
||||
const type = {svg: 'image/svg+xml', png: 'image/png', jpg: 'image/jpeg', mp3: 'audio/mpeg', wav: 'audio/wav'}[ext];
|
||||
return `export default ${JSON.stringify(`data:${type};base64,${bytes.toString('base64')}`)};`;
|
||||
}
|
||||
if (id.endsWith('.css')) {
|
||||
const css = await fs.readFile(id, 'utf8');
|
||||
const classes = {};
|
||||
const transformed = css.replace(/\.([a-zA-Z_][\w-]*)/g, (_, name) => {
|
||||
classes[name.replace(/-([a-z])/g, (_, char) => char.toUpperCase())] = `sc-${name}`;
|
||||
return `.sc-${name}`;
|
||||
});
|
||||
return `const style = document.createElement('style'); style.textContent = ${JSON.stringify(transformed)};
|
||||
document.head.appendChild(style); export default ${JSON.stringify(classes)};`;
|
||||
}
|
||||
},
|
||||
async transform(code, id) {
|
||||
if (id.endsWith('/@turbowarp/jszip/lib/support.js')) {
|
||||
const tree = ast(code);
|
||||
tree.program.body = tree.program.body.filter(node => !t.isTryStatement(node));
|
||||
tree.program.body.push(...ast('exports.nodestream = false;').program.body);
|
||||
code = generate(tree, {comments: false}).code;
|
||||
}
|
||||
if (id.endsWith('/@turbowarp/jszip/lib/stream/StreamHelper.js')) {
|
||||
const tree = ast(code);
|
||||
tree.program.body = tree.program.body.filter(node => !(t.isIfStatement(node) &&
|
||||
t.isMemberExpression(node.test) && t.isIdentifier(node.test.object, {name: 'support'}) &&
|
||||
t.isIdentifier(node.test.property, {name: 'nodestream'})));
|
||||
code = generate(tree, {comments: false}).code;
|
||||
}
|
||||
if (/\.[cm]?js$/.test(id)) {
|
||||
const globals = new Set();
|
||||
const tree = ast(code);
|
||||
traverse(tree, {ReferencedIdentifier(p) {
|
||||
if (['global', 'process', 'Buffer'].includes(p.node.name) && !p.scope.hasBinding(p.node.name)) globals.add(p.node.name);
|
||||
}});
|
||||
code = [...globals].map(name => name === 'global' ? 'const global = globalThis;' :
|
||||
name === 'Buffer' ? "const Buffer = require('buffer').Buffer;" : "const process = require('process');").join('\n') + '\n' + code;
|
||||
}
|
||||
if (/node_modules\/js-md5\/src\/md5.js$/.test(id)) {
|
||||
code = code.replace(/var NODE_JS = [^;]+;/, 'var NODE_JS = false;');
|
||||
return {code, map: null};
|
||||
}
|
||||
const textUtility = /node_modules\/(grapheme-breaker|linebreak)\/(?!node_modules\/).*\.js$/.test(id);
|
||||
if (textUtility) {
|
||||
const matches = [...code.matchAll(/fs\.readFileSync\(__dirname \+ '([^']+)'(?:, '([^']+)')?\)/g)];
|
||||
for (const match of matches) {
|
||||
const bytes = await fs.readFile(path.join(path.dirname(id), match[1]));
|
||||
code = code.replace(match[0], match[2] === 'base64' ? JSON.stringify(bytes.toString('base64')) :
|
||||
`require('buffer').Buffer.from(${JSON.stringify(bytes.toString('base64'))}, 'base64')`);
|
||||
}
|
||||
// CoffeeScript's outer one-shot closure is only a module scope. Unwrap
|
||||
// it before converting its exports; nested implementation closures stay.
|
||||
const tree = ast(code);
|
||||
const statement = tree.program.body.at(-1);
|
||||
const call = statement?.expression;
|
||||
if (t.isCallExpression(call) && t.isMemberExpression(call.callee) &&
|
||||
t.isFunctionExpression(call.callee.object) && t.isIdentifier(call.callee.property, {name: 'call'}) &&
|
||||
call.arguments.length === 1 && t.isThisExpression(call.arguments[0])) {
|
||||
tree.program.body.splice(-1, 1, ...call.callee.object.body.body);
|
||||
}
|
||||
code = generate(tree, {comments: false}).code;
|
||||
}
|
||||
const acyclicUtility = textUtility || /\/node_modules\/(pako|tiny-inflate|base64-js|unicode-trie)\//.test(id);
|
||||
if (!id.startsWith(vmSource) && !id.startsWith(sourceRoot) && !acyclicUtility && !nativeDependencies.some(prefix => id.startsWith(prefix))) return {code, map: null};
|
||||
if (!/\.[cm]?js$/.test(id)) return;
|
||||
if (id.endsWith('/addons/index.js')) code = code.replace('window.ScaffoldingAddons = {\n run\n};', 'export {run};');
|
||||
if (id.startsWith(vmSource)) code = prepareVM(code, id);
|
||||
code = code.replace(/process\.env\.NODE_ENV/g, '"production"');
|
||||
const result = nativeModules(code, id);
|
||||
return {code: result, map: null};
|
||||
}
|
||||
}, nodeResolve({browser: true, preferBuiltins: false, mainFields: ['browser', 'module', 'main']}),
|
||||
json(), commonjs({strictRequires: [...auditedWrappers.keys()], transformMixedEsModules: true, requireReturnsDefault: 'auto'})]
|
||||
});
|
||||
const generated = await bundle.generate({format: 'es', preserveModules: true, entryFileNames: '[name].js',
|
||||
chunkFileNames: '[name]-[hash].js', generatedCode: 'es2015'});
|
||||
let entry, extensionApi;
|
||||
const wrapperAudit = {};
|
||||
for (const item of generated.output) {
|
||||
if (item.type !== 'chunk') throw new Error(`Unexpected source-pack asset ${item.fileName}`);
|
||||
sources[item.fileName] = item.code;
|
||||
provenance[item.fileName] = Object.keys(item.modules).map(id => id.startsWith('\0specialized-twgl:') ?
|
||||
`tw-packager/node_modules/twgl.js/dist/4.x/twgl-full.js#module-${parseInt(id.split(':')[1], 10)}` :
|
||||
id.replace(vm, 'scratch-vm').replace(root, 'tw-packager'));
|
||||
if (item.exports.includes('__require')) {
|
||||
const audits = Object.keys(item.modules).map(id => auditedWrappers.get(id)).filter(Boolean);
|
||||
if (audits.length !== 1) throw new Error(`Unaudited CommonJS wrapper: ${item.fileName}`);
|
||||
wrapperAudit[item.fileName] = audits[0];
|
||||
}
|
||||
if (item.facadeModuleId === '\0specialized-entry') entry = item.fileName;
|
||||
if (item.facadeModuleId === '\0specialized-extension-api') extensionApi = item.fileName;
|
||||
}
|
||||
// Share the existing scalar semantics, while excluding generator/execution helpers.
|
||||
const helperTree = ast(await fs.readFile(path.join(vmSource, 'compiler/jsexecute.js'), 'utf8'));
|
||||
const helperParts = [];
|
||||
const excluded = new Set(['startHats', 'waitThreads', 'executeInCompatibilityLayer', 'retire',
|
||||
'timer', 'distance', 'yieldThenCall', 'yieldThenCallGenerator', 'createBranchInfo']);
|
||||
traverse(helperTree, {AssignmentExpression(p) {
|
||||
const {left, right} = p.node;
|
||||
if (!t.isTemplateLiteral(right) || right.expressions.length) return;
|
||||
if (t.isIdentifier(left, {name: 'baseRuntime'}) ||
|
||||
(t.isMemberExpression(left) && t.isIdentifier(left.object, {name: 'runtimeFunctions'}) && !excluded.has(left.property.name))) {
|
||||
helperParts.push(right.quasis[0].value.cooked);
|
||||
}
|
||||
}});
|
||||
const helperAST = ast(helperParts.join('\n'));
|
||||
helperAST.program.body = helperAST.program.body.filter(n => !t.isVariableDeclaration(n) ||
|
||||
!n.declarations.some(d => ['stuckCounter', 'isStuck', 'colorToList'].includes(d.id.name)));
|
||||
helperAST.program.body = helperAST.program.body.map(n => t.isVariableDeclaration(n) ? t.exportNamedDeclaration(n) : n);
|
||||
const helpers = generate(helperAST, {comments: false}).code;
|
||||
const fingerprint = createHash('sha256').update(JSON.stringify({sources, helpers})).digest('hex');
|
||||
await fs.mkdir(path.dirname(output), {recursive: true});
|
||||
await fs.writeFile(output, JSON.stringify({version: 1, fingerprint, entry, extensionApi, sources, provenance, helpers, wrapperAudit}));
|
||||
await bundle.close();
|
||||
console.log(`Prepared ${Object.keys(sources).length} runtime modules (${fingerprint.slice(0, 12)})`);
|
||||
@@ -0,0 +1,39 @@
|
||||
import {createHash} from 'node:crypto';
|
||||
import {parse} from '@babel/parser';
|
||||
import traverseModule from '@babel/traverse';
|
||||
import generateModule from '@babel/generator';
|
||||
import * as t from '@babel/types';
|
||||
|
||||
const traverse = traverseModule.default;
|
||||
const generate = generateModule.default;
|
||||
|
||||
// twgl 4.4.0's npm package only ships a webpack bundle. Its 15 modules have
|
||||
// an acyclic static graph; the webpack cache/factories add no required ordering.
|
||||
// Fail closed when upstream changes instead of guessing another bundle format.
|
||||
export function unbundleTWGL(code) {
|
||||
if (createHash('sha256').update(code).digest('hex') !==
|
||||
'60a924fb69acaba30567e9bd349890aa27b74664e4015c44634838c016191e8c') {
|
||||
throw new Error('TWGL source changed; review its module graph before updating the source pack');
|
||||
}
|
||||
let functions;
|
||||
traverse(parse(code), {CallExpression(p) {
|
||||
const array = p.node.arguments[0];
|
||||
if (t.isArrayExpression(array) && array.elements.length === 15 &&
|
||||
array.elements.every(item => t.isFunctionExpression(item))) functions = array.elements;
|
||||
}});
|
||||
if (!functions) throw new Error('Missing audited TWGL module table');
|
||||
return functions.map(fn => {
|
||||
const tree = t.file(t.program(fn.body.body));
|
||||
traverse(tree, {CallExpression(p) {
|
||||
if (!t.isIdentifier(p.node.callee, {name: '__webpack_require__'})) return;
|
||||
const [id] = p.node.arguments;
|
||||
if (p.node.arguments.length !== 1 || !t.isNumericLiteral(id) || id.value < 0 || id.value >= 15) {
|
||||
throw new Error('Non-static TWGL dependency');
|
||||
}
|
||||
p.replaceWith(t.callExpression(t.identifier('require'), [t.stringLiteral(`\0specialized-twgl:${id.value}.js`)]));
|
||||
}});
|
||||
const result = generate(tree, {comments: false}).code;
|
||||
if (result.includes('__webpack_require__')) throw new Error('Unconverted TWGL bootstrap reference');
|
||||
return result;
|
||||
});
|
||||
}
|
||||
@@ -22,6 +22,8 @@ export default async function (scaffolding, pointerlock) {
|
||||
vm.runtime.once("PROJECT_LOADED", resolve);
|
||||
});
|
||||
|
||||
if (scaffolding._disposed) return;
|
||||
|
||||
const vmStarted = () => vm.runtime._steppingInterval !== null;
|
||||
|
||||
const scratchKeyToKey = (key) => {
|
||||
@@ -98,6 +100,10 @@ export default async function (scaffolding, pointerlock) {
|
||||
|
||||
GamepadLib.setConsole(console);
|
||||
const gamepad = new GamepadLib();
|
||||
scaffolding._listeners.push(() => {
|
||||
gamepad.removeEventHandlers();
|
||||
cancelAnimationFrame(gamepad.animationFrame);
|
||||
});
|
||||
|
||||
const parsedOptions = parseOptionsComment();
|
||||
gamepad.getUserHints = () => {
|
||||
@@ -140,6 +146,7 @@ export default async function (scaffolding, pointerlock) {
|
||||
const virtualCursorHide = () => {
|
||||
virtualCursorSetVisible(false);
|
||||
};
|
||||
scaffolding._listeners.push(() => { clearTimeout(hideCursorTimeout); showRealCursor(); virtualCursorElement.remove(); });
|
||||
const virtualCursorSetDown = (down) => {
|
||||
virtualCursorSetVisible(true);
|
||||
virtualCursorElement.classList.toggle("sa-gamepad-cursor-down", down);
|
||||
@@ -152,7 +159,7 @@ export default async function (scaffolding, pointerlock) {
|
||||
virtualCursorElement.style.transform = `translate(${stageX}px, ${stageY}px)`;
|
||||
};
|
||||
|
||||
document.addEventListener("mousemove", () => {
|
||||
scaffolding._listen(document, "mousemove", () => {
|
||||
virtualCursorSetVisible(false);
|
||||
showRealCursor();
|
||||
});
|
||||
@@ -169,6 +176,7 @@ export default async function (scaffolding, pointerlock) {
|
||||
}
|
||||
});
|
||||
resizeObserver.observe(canvas);
|
||||
scaffolding._listeners.push(() => resizeObserver.disconnect());
|
||||
getCanvasSize = () => [canvasWidth, canvasHeight];
|
||||
} else {
|
||||
getCanvasSize = () => {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import gamepad from './gamepad';
|
||||
import pointerlock from './pointerlock';
|
||||
import specialCloudBehaviors from './special-cloud-behaviors';
|
||||
import unsafeCloudBehaviors from './unsafe-cloud-behaviors';
|
||||
import pause from './pause';
|
||||
|
||||
const run = (scaffolding, options) => {
|
||||
@@ -13,7 +12,6 @@ const run = (scaffolding, options) => {
|
||||
if (options.gamepad) gamepad(api);
|
||||
if (options.pointerlock) pointerlock(api);
|
||||
if (options.specialCloudBehaviors) specialCloudBehaviors(api);
|
||||
if (options.unsafeCloudBehaviors) unsafeCloudBehaviors(api);
|
||||
if (options.pause) pause(api);
|
||||
};
|
||||
|
||||
|
||||
+3
-3
@@ -17,7 +17,7 @@ export default function ({ scaffolding }) {
|
||||
|
||||
if (paused) {
|
||||
audioContextStateChange = audioContextStateChange.then(() => {
|
||||
return vm.runtime.audioEngine.audioContext.suspend();
|
||||
return vm.runtime.audioEngine && vm.runtime.audioEngine.audioContext.suspend();
|
||||
});
|
||||
if (!vm.runtime.ioDevices.clock._paused) {
|
||||
vm.runtime.ioDevices.clock.pause();
|
||||
@@ -40,7 +40,7 @@ export default function ({ scaffolding }) {
|
||||
vm.runtime.emit('RUNTIME_PAUSED');
|
||||
} else {
|
||||
audioContextStateChange = audioContextStateChange.then(() => {
|
||||
return vm.runtime.audioEngine.audioContext.resume();
|
||||
return vm.runtime.audioEngine && vm.runtime.audioEngine.audioContext.resume();
|
||||
});
|
||||
vm.runtime.ioDevices.clock.resume();
|
||||
|
||||
@@ -125,4 +125,4 @@ export default function ({ scaffolding }) {
|
||||
|
||||
vm.setPaused = setPaused;
|
||||
vm.isPaused = () => paused;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
const run = ({ scaffolding }) => {
|
||||
const canvas = scaffolding._canvas;
|
||||
const vm = scaffolding.vm;
|
||||
scaffolding._listeners.push(() => { if (document.pointerLockElement === canvas) document.exitPointerLock(); });
|
||||
const mouse = vm.runtime.ioDevices.mouse;
|
||||
let isLocked = false;
|
||||
|
||||
@@ -21,7 +22,7 @@ const run = ({ scaffolding }) => {
|
||||
vm.postIOData('mouse', data);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", (e) => {
|
||||
scaffolding._listen(document, "mousedown", (e) => {
|
||||
if (canvas.contains(e.target)) {
|
||||
e.stopPropagation();
|
||||
if (isLocked) {
|
||||
@@ -31,7 +32,7 @@ const run = ({ scaffolding }) => {
|
||||
}
|
||||
}
|
||||
}, true);
|
||||
document.addEventListener("mouseup", (e) => {
|
||||
scaffolding._listen(document, "mouseup", (e) => {
|
||||
e.stopPropagation();
|
||||
if (isLocked) {
|
||||
postMouseData(e, false);
|
||||
@@ -39,23 +40,23 @@ const run = ({ scaffolding }) => {
|
||||
canvas.requestPointerLock();
|
||||
}
|
||||
}, true);
|
||||
document.addEventListener("mousemove", (e) => {
|
||||
scaffolding._listen(document, "mousemove", (e) => {
|
||||
e.stopPropagation();
|
||||
if (isLocked) {
|
||||
postMouseData(e);
|
||||
}
|
||||
}, true);
|
||||
|
||||
scaffolding.addEventListener('PROJECT_RUN_START', () => {
|
||||
scaffolding._listen(scaffolding, 'PROJECT_RUN_START', () => {
|
||||
if (!isLocked) {
|
||||
canvas.requestPointerLock();
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('pointerlockchange', () => {
|
||||
scaffolding._listen(document, 'pointerlockchange', () => {
|
||||
isLocked = document.pointerLockElement === canvas;
|
||||
});
|
||||
document.addEventListener('pointerlockerror', (e) => {
|
||||
scaffolding._listen(document, 'pointerlockerror', (e) => {
|
||||
console.error('Pointer lock error', e);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
const isPromise = (v) => !!v && typeof v.then === 'function';
|
||||
|
||||
const jsValueToScratchValue = (v) => {
|
||||
if (typeof v === 'boolean' || typeof v === 'number' || typeof v === 'string') {
|
||||
return v;
|
||||
}
|
||||
return '' + v;
|
||||
};
|
||||
|
||||
class UnsafeCloudBehaviorsProvider {
|
||||
enable () {
|
||||
|
||||
}
|
||||
|
||||
setEvalValue (value) {
|
||||
this.manager.setVariable(this, '☁ eval output', jsValueToScratchValue(value));
|
||||
}
|
||||
|
||||
setEvalError (error) {
|
||||
console.error('Error evaluating ☁ eval', error);
|
||||
this.manager.setVariable(this, '☁ eval error', jsValueToScratchValue(error));
|
||||
}
|
||||
|
||||
evaluateAsync (js) {
|
||||
try {
|
||||
const value = eval(js);
|
||||
if (isPromise(value)) {
|
||||
value
|
||||
.then((v) => this.setEvalValue(v))
|
||||
.catch((e) => this.setEvalError(e));
|
||||
} else {
|
||||
this.setEvalValue(value)
|
||||
}
|
||||
} catch (e) {
|
||||
this.setEvalError(e);
|
||||
}
|
||||
}
|
||||
|
||||
handleUpdateVariable (name, value) {
|
||||
if (name === '☁ eval') {
|
||||
this.evaluateAsync(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default function ({ scaffolding }) {
|
||||
const provider = new UnsafeCloudBehaviorsProvider();
|
||||
scaffolding.addCloudProvider(provider);
|
||||
scaffolding.addCloudProviderOverride('☁ eval', provider);
|
||||
scaffolding.addCloudProviderOverride('☁ eval output', provider);
|
||||
scaffolding.addCloudProviderOverride('☁ eval error', provider);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
module.exports = function (bytes) {
|
||||
return `module.exports = Uint8Array.from(atob(${JSON.stringify(bytes.toString('base64'))}), c => c.charCodeAt(0));`;
|
||||
};
|
||||
module.exports.raw = true;
|
||||
@@ -1,31 +0,0 @@
|
||||
const fs = require('fs');
|
||||
const crypto = require('crypto');
|
||||
const glob = require('glob');
|
||||
const path = require('path');
|
||||
|
||||
const hash = crypto.createHash('sha256');
|
||||
|
||||
const getAllFiles = (g) => glob.sync(g, {
|
||||
cwd: root
|
||||
});
|
||||
|
||||
const root = path.join(__dirname, '..', '..');
|
||||
const files = [
|
||||
__filename,
|
||||
...getAllFiles('./src/scaffolding/**/*'),
|
||||
...getAllFiles('./src/addons/**/*'),
|
||||
...getAllFiles('./src/common/**/*'),
|
||||
path.join(root, 'webpack.config.js'),
|
||||
path.join(root, 'package.json'),
|
||||
path.join(root, 'package-lock.json')
|
||||
];
|
||||
for (const file of files) {
|
||||
const stat = fs.statSync(file);
|
||||
if (!stat.isDirectory()) {
|
||||
hash.update(fs.readFileSync(file, 'utf-8'));
|
||||
}
|
||||
}
|
||||
|
||||
const hex = hash.digest('hex');
|
||||
console.log('Scaffolding build ID: ' + hex);
|
||||
module.exports = hex;
|
||||
@@ -19,7 +19,8 @@ class GenerateServiceWorkerPlugin {
|
||||
const assetNames = Array.from(allAssetNames)
|
||||
.filter((name) => {
|
||||
if (name.endsWith('.map')) return false;
|
||||
return name.startsWith('assets/') || name.startsWith('js/') || CACHE_PAGES.includes(name);
|
||||
return name.startsWith('assets/') || name.startsWith('js/') || CACHE_PAGES.includes(name) ||
|
||||
['specialized-worker.js', 'rollup.browser.js', 'bindings_wasm_bg.wasm'].includes(name);
|
||||
});
|
||||
const stringifiedAssets = JSON.stringify(assetNames);
|
||||
const hash = crypto.createHash('sha256').update(stringifiedAssets).digest('hex');
|
||||
|
||||
@@ -1,18 +1,12 @@
|
||||
const pathUtil = require('path');
|
||||
const fs = require('fs');
|
||||
const glob = require('glob');
|
||||
|
||||
const dist = pathUtil.join(__dirname, '..', '..', 'dist');
|
||||
console.log(`dist: ${dist}`);
|
||||
|
||||
const scaffoldingFiles = glob.sync('scaffolding/*.js', {
|
||||
cwd: dist
|
||||
});
|
||||
console.log(`scaffolding: ${scaffoldingFiles.join(', ')}`);
|
||||
const scaffoldingAssets = {};
|
||||
for (const path of scaffoldingFiles) {
|
||||
if (path.includes('extension-worker')) continue;
|
||||
scaffoldingAssets[path] = fs.readFileSync(pathUtil.join(dist, path), 'utf-8');
|
||||
const buildAssets = {};
|
||||
for (const name of ['specialized-worker.js', 'rollup.browser.js', 'bindings_wasm_bg.wasm']) {
|
||||
buildAssets[name] = fs.readFileSync(pathUtil.join(dist, name)).toString('base64');
|
||||
}
|
||||
|
||||
const indexPath = pathUtil.join(dist, 'index.html');
|
||||
@@ -29,13 +23,14 @@ console.log(`favicon.ico: ${faviconData ? faviconPath : 'none'}`);
|
||||
|
||||
const makeSafeForInlineScript = (content) => content.replace(/<\/script>/g, '\\u003c/script>');
|
||||
let standaloneJS = '';
|
||||
standaloneJS += Object.entries(scaffoldingAssets).map(([name, content]) => (
|
||||
`<script type="p4-standalone-asset" data-name="${name}">${makeSafeForInlineScript(content)}</script>`
|
||||
standaloneJS += Object.entries(buildAssets).map(([name, content]) => (
|
||||
`<script type="p4-standalone-asset" data-name="${name}">${content}</script>`
|
||||
)).join('');
|
||||
standaloneJS += `<script>
|
||||
window.__ASSETS__ = {};
|
||||
for (const el of Array.from(document.querySelectorAll('script[type="p4-standalone-asset"]'))) {
|
||||
__ASSETS__[el.dataset.name] = el.textContent;
|
||||
__ASSETS__[el.dataset.name] = el.dataset.name.endsWith('.wasm') ? el.textContent :
|
||||
new TextDecoder().decode(Uint8Array.from(atob(el.textContent), c => c.charCodeAt(0)));
|
||||
el.remove();
|
||||
}
|
||||
</script>`;
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
module.exports = source => `var self = typeof globalThis === 'undefined' ? this : globalThis;\n${source}`;
|
||||
@@ -29,16 +29,6 @@ export class UnknownNetworkError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error indicating the packager is outdated and must be updated before it can continue.
|
||||
*/
|
||||
export class OutdatedPackagerError extends Error {
|
||||
constructor (message) {
|
||||
super(message);
|
||||
this.name = 'OutdatedPackagerError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error indicating an HTTP status error.
|
||||
*/
|
||||
|
||||
+13
-90
@@ -418,99 +418,12 @@
|
||||
"enableCompiler": {
|
||||
"string": "Enable Compiler"
|
||||
},
|
||||
"aotCompile": {
|
||||
"string": "Enable compiled project mode"
|
||||
},
|
||||
"compiledProtection": {
|
||||
"string": "Compiled Project Protection"
|
||||
},
|
||||
"compiledProtectionDescription": {
|
||||
"string": "A featured build mode of SB3C Packager: compile the block graph ahead of time, optionally replace VM member accesses, and apply additional JavaScript transformations."
|
||||
},
|
||||
"obfuscateCompiledProject": {
|
||||
"string": "Obfuscate compiled project"
|
||||
},
|
||||
"javascriptObfuscator": {
|
||||
"string": "Apply JavaScript Obfuscator to compiled factories"
|
||||
},
|
||||
"javascriptObfuscatorOptions": {
|
||||
"string": "JavaScript Obfuscator options"
|
||||
},
|
||||
"compactOutput": {
|
||||
"string": "Compact output"
|
||||
},
|
||||
"simplifyCode": {
|
||||
"string": "Simplify and transform expressions"
|
||||
},
|
||||
"identifierNamesGenerator": {
|
||||
"string": "Identifier style"
|
||||
},
|
||||
"stringArray": {
|
||||
"string": "Move string literals into encoded arrays"
|
||||
},
|
||||
"stringArrayEncoding": {
|
||||
"string": "String encoding"
|
||||
},
|
||||
"encodingNone": {
|
||||
"string": "No additional encoding"
|
||||
},
|
||||
"transformationThreshold": {
|
||||
"string": "Transformation probability (0–1)"
|
||||
},
|
||||
"controlFlowFlattening": {
|
||||
"string": "Control-flow flattening"
|
||||
},
|
||||
"deadCodeInjection": {
|
||||
"string": "Dead-code injection"
|
||||
},
|
||||
"splitStrings": {
|
||||
"string": "Split string literals"
|
||||
},
|
||||
"splitStringsChunkLength": {
|
||||
"string": "String chunk length"
|
||||
},
|
||||
"numbersToExpressions": {
|
||||
"string": "Convert numbers to expressions"
|
||||
},
|
||||
"transformObjectKeys": {
|
||||
"string": "Transform object keys"
|
||||
},
|
||||
"unicodeEscapeSequence": {
|
||||
"string": "Use Unicode escape sequences"
|
||||
},
|
||||
"disableConsoleOutput": {
|
||||
"string": "Disable console output"
|
||||
},
|
||||
"selfDefending": {
|
||||
"string": "Self-defending output"
|
||||
},
|
||||
"selfDefendingWarning": {
|
||||
"string": "Self-defending output must not be reformatted or minified again. It increases output size and can make compatibility failures harder to diagnose."
|
||||
},
|
||||
"debugProtection": {
|
||||
"string": "Developer Tools debug protection"
|
||||
},
|
||||
"debugProtectionInterval": {
|
||||
"string": "Repeat interval in milliseconds (0 means once)"
|
||||
},
|
||||
"debugProtectionGlobal": {
|
||||
"string": "The anti-debugging guard is generated once as a global player script instead of being duplicated in every compiled procedure."
|
||||
},
|
||||
"debugProtectionWarning": {
|
||||
"string": "Debug protection can freeze or severely disrupt browser Developer Tools, especially with a repeating interval. Use 2000–4000 ms only when necessary and test the packaged project carefully."
|
||||
},
|
||||
"javascriptObfuscatorPerformance": {
|
||||
"string": "Control-flow flattening, dead-code injection, RC4 strings, and self-defending output can substantially increase build time, file size, startup time, and runtime overhead."
|
||||
},
|
||||
"obfuscationSeed": {
|
||||
"string": "Obfuscation seed"
|
||||
},
|
||||
"derivedFromProject": {
|
||||
"string": "Derived from project when empty"
|
||||
},
|
||||
"aotCompileWarning": {
|
||||
"string": "Compilation and obfuscation only raise the cost of casual reading, copying, and automated analysis. They do not encrypt or completely hide the code: the player must receive executable JavaScript and a determined user can inspect, instrument, or modify it. Do not embed secrets, private keys, or security decisions in the project."
|
||||
},
|
||||
"warpTimer": {
|
||||
"string": "Warp Timer"
|
||||
},
|
||||
@@ -527,9 +440,7 @@
|
||||
"customExtensions": {
|
||||
"string": "Custom extensions (one URL per line)"
|
||||
},
|
||||
"customExtensionsSecurity": {
|
||||
"string": "Custom extensions are always run unsandboxed in the packager. Depending on the environment you're packaging for, this grants extensions full control over your computer, including the ability to install viruses. Don't use extensions you don't trust."
|
||||
},
|
||||
"customExtensionsSecurity": "Extension code and dependencies are frozen at build time and run in an isolated Worker. Extensions requiring full VM access are unsupported.",
|
||||
"bakeExtensions": {
|
||||
"string": "Try to embed cached copy of custom extensions instead of downloading them each time the project is run",
|
||||
"developer_comment": "Option that tries to store "
|
||||
@@ -726,6 +637,18 @@
|
||||
"downloadsWillAppearHere": {
|
||||
"string": "Downloads will appear here",
|
||||
"developer_comment": "Placeholder that appears when no project has been packaged yet so there is nothing to download"
|
||||
},
|
||||
"specializedProtection": {
|
||||
"string": "Program-specific player"
|
||||
},
|
||||
"specializedProtectionDescription": {
|
||||
"string": "Project scripts and the runtime are compiled together into one offline player."
|
||||
},
|
||||
"buildSeed": {
|
||||
"string": "Build seed"
|
||||
},
|
||||
"randomBuildSeed": {
|
||||
"string": "Random for each build when empty"
|
||||
}
|
||||
},
|
||||
"downloads": {
|
||||
|
||||
+6
-31
@@ -51,7 +51,7 @@
|
||||
"customCSS": "自定义 CSS(如果你不确定你在做什么,别动它)",
|
||||
"customCursor": "自定义指针",
|
||||
"customExtensions": "自定义扩展(每行一个 URL)",
|
||||
"customExtensionsSecurity": "自定义扩展总是在打包程序中未被隔离地运行。根据您打包的环境,这将授予扩展对计算机的完全控制权,包括安装病毒的能力。不要使用不信任的扩展。",
|
||||
"customExtensionsSecurity": "扩展及代码依赖在构建时固定,并在独立 Worker 中运行。不支持需要访问完整 VM 的扩展。",
|
||||
"customJS": "自定义 JS (如果你不确定你在做什么,别动它)",
|
||||
"customUsernameWarning": "如果您修改了默认用户名,请小心,因为它可能造成与默认云变量服务器的连接被阻止。只有默认用户名是确保可用的。",
|
||||
"defaultControls": "默认",
|
||||
@@ -142,40 +142,15 @@
|
||||
"version": "版本",
|
||||
"versionHelp": "更新作品版本是完全可选的,不会影响作品的。根据环境的不同,它可能会出现在关于页面等地方。格式应该像 X.Y.Z 这样",
|
||||
"warpTimer": "循环计时器",
|
||||
"aotCompile": "打包前编译项目",
|
||||
"compiledProtection": "编译项目保护(特色功能)",
|
||||
"compiledProtectionDescription": "SB3C Packager 的特色构建模式:预先编译积木图,并可替换 VM 成员访问、继续进行 JavaScript 变换。",
|
||||
"obfuscateCompiledProject": "混淆编译项目",
|
||||
"javascriptObfuscator": "对编译后的 factory 使用 JavaScript Obfuscator",
|
||||
"javascriptObfuscatorOptions": "JavaScript Obfuscator 选项",
|
||||
"compactOutput": "紧凑输出",
|
||||
"simplifyCode": "简化并变换表达式",
|
||||
"identifierNamesGenerator": "标识符样式",
|
||||
"stringArray": "将字符串字面量移入编码数组",
|
||||
"stringArrayEncoding": "字符串编码",
|
||||
"encodingNone": "不额外编码",
|
||||
"transformationThreshold": "变换概率(0–1)",
|
||||
"controlFlowFlattening": "控制流平坦化",
|
||||
"deadCodeInjection": "死代码注入",
|
||||
"splitStrings": "拆分字符串字面量",
|
||||
"splitStringsChunkLength": "字符串分块长度",
|
||||
"numbersToExpressions": "将数字转换为表达式",
|
||||
"transformObjectKeys": "变换对象键",
|
||||
"unicodeEscapeSequence": "使用 Unicode 转义序列",
|
||||
"disableConsoleOutput": "禁用控制台输出",
|
||||
"selfDefending": "自我保护输出",
|
||||
"selfDefendingWarning": "自我保护输出不能再次格式化或压缩;它会增大产物,也会让兼容性问题更难诊断。",
|
||||
"debugProtection": "开发者工具防调试",
|
||||
"debugProtectionInterval": "重复间隔(毫秒,0 表示只执行一次)",
|
||||
"debugProtectionGlobal": "防调试守卫只生成一份全局播放器脚本,不会重复注入每个编译入口和自定义过程。",
|
||||
"debugProtectionWarning": "防调试可能冻结或严重干扰浏览器开发者工具,定时重复时风险更高。仅在必要时使用 2000–4000 毫秒,并充分测试打包结果。",
|
||||
"javascriptObfuscatorPerformance": "控制流平坦化、死代码注入、RC4 字符串和自我保护可能显著增加构建时间、文件大小、启动时间和运行开销。",
|
||||
"obfuscationSeed": "混淆种子",
|
||||
"derivedFromProject": "留空时从项目派生",
|
||||
"aotCompileWarning": "编译和混淆只能提高直接阅读、复制及自动分析的成本,并不是加密,也无法完全隐藏代码:播放器必须获得可执行 JavaScript,有意分析者仍可检查、插桩或修改它。不要把密钥、私钥或安全判断写进项目。",
|
||||
"windowControls": "窗口控制栏",
|
||||
"zip": "Zip 压缩包(适合嵌入网站)",
|
||||
"zip-one-asset": "Zip 压缩包,把所有素材打包在一个文件中(不推荐)"
|
||||
"zip-one-asset": "Zip 压缩包,把所有素材打包在一个文件中(不推荐)",
|
||||
"specializedProtection": "程序专用化保护",
|
||||
"specializedProtectionDescription": "作品脚本与运行时联合编译,生成专用于此作品的离线播放器。",
|
||||
"buildSeed": "构建 seed",
|
||||
"randomBuildSeed": "留空时每次构建随机生成"
|
||||
},
|
||||
"p4": {
|
||||
"browserNotSupported": "不支持浏览器",
|
||||
|
||||
@@ -83,7 +83,12 @@
|
||||
"username": "用戶名稱(以「#」代表一個隨機數)",
|
||||
"warpTimer": "防卡死",
|
||||
"zip": "Zip(用於網站的最佳選擇)",
|
||||
"zip-one-asset": "Zip,封裝所有素材(不推薦)"
|
||||
"zip-one-asset": "Zip,封裝所有素材(不推薦)",
|
||||
"specializedProtection": "程式專用化保護",
|
||||
"specializedProtectionDescription": "作品腳本與執行環境聯合編譯,產生此作品專用的離線播放器。",
|
||||
"buildSeed": "建置 seed",
|
||||
"randomBuildSeed": "每次建置隨機產生",
|
||||
"customExtensionsSecurity": "擴充功能及程式碼相依套件會在建置時固定,並在獨立 Worker 中執行。不支援需要存取完整 VM 的擴充功能。"
|
||||
},
|
||||
"p4": {
|
||||
"close": "關閉",
|
||||
@@ -129,4 +134,4 @@
|
||||
"light": "明亮主題",
|
||||
"system": "使用系統主題"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
<script>
|
||||
import Section from './Section.svelte';
|
||||
import Button from './Button.svelte';
|
||||
import {CannotAccessProjectError, OutdatedPackagerError, UnknownNetworkError, UserError} from '../common/errors';
|
||||
import {CannotAccessProjectError, UnknownNetworkError, UserError} from '../common/errors';
|
||||
import {error} from './stores';
|
||||
import {FEEDBACK_PRIMARY} from '../packager/brand';
|
||||
import {_} from '../locales/';
|
||||
@@ -54,7 +54,6 @@
|
||||
}
|
||||
};
|
||||
|
||||
const refresh = () => location.reload();
|
||||
</script>
|
||||
|
||||
<style>
|
||||
@@ -108,13 +107,6 @@
|
||||
<p>
|
||||
<Button on:click={closeModal} text={$_('p4.close')} />
|
||||
</p>
|
||||
{:else if $error instanceof OutdatedPackagerError}
|
||||
<p>{$_('p4.outdated')}</p>
|
||||
<p class="technical">{$error}</p>
|
||||
<p>
|
||||
<Button on:click={refresh} text={$_('p4.refresh')} />
|
||||
<Button secondary on:click={closeModal} text={$_('p4.close')} />
|
||||
</p>
|
||||
{:else if $error instanceof CannotAccessProjectError}
|
||||
<p>
|
||||
{$_('p4.cannotAccessProject')}
|
||||
|
||||
+11
-183
@@ -11,7 +11,7 @@
|
||||
import LearnMore from './LearnMore.svelte';
|
||||
import ColorPicker from './ColorPicker.svelte';
|
||||
import Downloads from './Downloads.svelte';
|
||||
import writablePersistentStore from './persistent-store';
|
||||
import writablePackagerOptionsStore from './packager-options-store';
|
||||
import fileStore from './file-store';
|
||||
import {progress, currentTask, error} from './stores';
|
||||
import Preview from './preview';
|
||||
@@ -22,6 +22,7 @@
|
||||
import {recursivelySerializeBlobs, recursivelyDeserializeBlobs} from './blob-serializer';
|
||||
import {readAsText} from '../common/readers';
|
||||
import merge from './merge';
|
||||
import {validatePackagerOptions} from '../packager/compile-project';
|
||||
import DropArea from './DropArea.svelte';
|
||||
import {APP_NAME} from '../packager/brand';
|
||||
|
||||
@@ -40,7 +41,7 @@
|
||||
defaultOptions.app.packageName = Packager.getDefaultPackageNameFromFileName(projectData.title);
|
||||
defaultOptions.app.windowTitle = Packager.getWindowTitleFromFileName(projectData.title);
|
||||
defaultOptions.extensions = projectData.project.analysis.extensions;
|
||||
const options = writablePersistentStore(`PackagerOptions.${projectData.uniqueId}`, defaultOptions);
|
||||
const options = writablePackagerOptionsStore(`PackagerOptions.${projectData.uniqueId}`, defaultOptions);
|
||||
|
||||
// Compatibility with https://github.com/TurboWarp/packager/commit/f66199abd1c896c11aa69247275a1594fdfc95b8
|
||||
$options.extensions = $options.extensions.map(i => {
|
||||
@@ -103,9 +104,7 @@
|
||||
$options.compiler.enabled !== defaultOptions.compiler.enabled ||
|
||||
$options.compiler.warpTimer !== defaultOptions.compiler.warpTimer ||
|
||||
$options.extensions.length !== 0 ||
|
||||
$options.bakeExtensions !== defaultOptions.bakeExtensions ||
|
||||
$options.custom.css !== '' ||
|
||||
$options.custom.js !== '' ||
|
||||
$options.projectId !== defaultOptions.projectId ||
|
||||
$options.packagedRuntime !== defaultOptions.packagedRuntime ||
|
||||
$options.maxTextureDimension !== defaultOptions.maxTextureDimension
|
||||
@@ -260,6 +259,7 @@
|
||||
const text = await readAsText(file);
|
||||
const parsed = ExtendedJSON.parse(text);
|
||||
const deserialized = recursivelyDeserializeBlobs(parsed);
|
||||
validatePackagerOptions(deserialized);
|
||||
const copiedDefaultOptions = deepClone(defaultOptions);
|
||||
const mergedWithDefaults = merge(deserialized, copiedDefaultOptions);
|
||||
if (mergedWithDefaults.maxClones === ALMOST_INFINITY) {
|
||||
@@ -457,158 +457,14 @@
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
accent="#855CD6"
|
||||
reset={() => resetOptions(['compiler.aot'])}
|
||||
>
|
||||
<Section accent="#855CD6" reset={() => resetOptions(['compiler.seed'])}>
|
||||
<div>
|
||||
<h2>{$_('options.compiledProtection')}</h2>
|
||||
<p>{$_('options.compiledProtectionDescription')}</p>
|
||||
<div class="option">
|
||||
<label>
|
||||
<input type="checkbox" bind:checked={$options.compiler.aot.enabled}>
|
||||
{$_('options.aotCompile')}
|
||||
</label>
|
||||
</div>
|
||||
{#if $options.compiler.aot.enabled}
|
||||
<div class="suboptions" transition:slide|local>
|
||||
<div class="option">
|
||||
<label>
|
||||
<input type="checkbox" bind:checked={$options.compiler.aot.obfuscate}>
|
||||
{$_('options.obfuscateCompiledProject')}
|
||||
</label>
|
||||
</div>
|
||||
<div class="option">
|
||||
<label>
|
||||
<input type="checkbox" bind:checked={$options.compiler.aot.javascriptObfuscator}>
|
||||
{$_('options.javascriptObfuscator')}
|
||||
</label>
|
||||
</div>
|
||||
<label class="option">
|
||||
{$_('options.obfuscationSeed')}
|
||||
<input type="text" bind:value={$options.compiler.aot.seed} placeholder={$_('options.derivedFromProject')}>
|
||||
</label>
|
||||
|
||||
{#if $options.compiler.aot.javascriptObfuscator}
|
||||
<details open>
|
||||
<summary>{$_('options.javascriptObfuscatorOptions')}</summary>
|
||||
<label class="option">
|
||||
<input type="checkbox"
|
||||
checked={$options.compiler.aot.javascriptObfuscatorOptions.selfDefending ||
|
||||
$options.compiler.aot.javascriptObfuscatorOptions.compact}
|
||||
disabled={$options.compiler.aot.javascriptObfuscatorOptions.selfDefending}
|
||||
on:change={(event) => {
|
||||
$options.compiler.aot.javascriptObfuscatorOptions.compact = event.target.checked;
|
||||
$options = $options;
|
||||
}}>
|
||||
{$_('options.compactOutput')}
|
||||
</label>
|
||||
<label class="option">
|
||||
<input type="checkbox" bind:checked={$options.compiler.aot.javascriptObfuscatorOptions.simplify}>
|
||||
{$_('options.simplifyCode')}
|
||||
</label>
|
||||
<label class="option">
|
||||
{$_('options.identifierNamesGenerator')}
|
||||
<select bind:value={$options.compiler.aot.javascriptObfuscatorOptions.identifierNamesGenerator}>
|
||||
<option value="hexadecimal">hexadecimal</option>
|
||||
<option value="mangled">mangled</option>
|
||||
<option value="mangled-shuffled">mangled-shuffled</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="option">
|
||||
<input type="checkbox" bind:checked={$options.compiler.aot.javascriptObfuscatorOptions.stringArray}>
|
||||
{$_('options.stringArray')}
|
||||
</label>
|
||||
{#if $options.compiler.aot.javascriptObfuscatorOptions.stringArray}
|
||||
<label class="option nested">
|
||||
{$_('options.stringArrayEncoding')}
|
||||
<select bind:value={$options.compiler.aot.javascriptObfuscatorOptions.stringArrayEncoding}>
|
||||
<option value="none">{$_('options.encodingNone')}</option>
|
||||
<option value="base64">base64</option>
|
||||
<option value="rc4">rc4</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="option nested">
|
||||
{$_('options.transformationThreshold')}
|
||||
<input type="number" min="0" max="1" step="0.05"
|
||||
bind:value={$options.compiler.aot.javascriptObfuscatorOptions.stringArrayThreshold}>
|
||||
</label>
|
||||
{/if}
|
||||
<label class="option">
|
||||
<input type="checkbox" bind:checked={$options.compiler.aot.javascriptObfuscatorOptions.controlFlowFlattening}>
|
||||
{$_('options.controlFlowFlattening')}
|
||||
</label>
|
||||
{#if $options.compiler.aot.javascriptObfuscatorOptions.controlFlowFlattening}
|
||||
<label class="option nested">
|
||||
{$_('options.transformationThreshold')}
|
||||
<input type="number" min="0" max="1" step="0.05"
|
||||
bind:value={$options.compiler.aot.javascriptObfuscatorOptions.controlFlowFlatteningThreshold}>
|
||||
</label>
|
||||
{/if}
|
||||
<label class="option">
|
||||
<input type="checkbox" bind:checked={$options.compiler.aot.javascriptObfuscatorOptions.deadCodeInjection}>
|
||||
{$_('options.deadCodeInjection')}
|
||||
</label>
|
||||
{#if $options.compiler.aot.javascriptObfuscatorOptions.deadCodeInjection}
|
||||
<label class="option nested">
|
||||
{$_('options.transformationThreshold')}
|
||||
<input type="number" min="0" max="1" step="0.05"
|
||||
bind:value={$options.compiler.aot.javascriptObfuscatorOptions.deadCodeInjectionThreshold}>
|
||||
</label>
|
||||
{/if}
|
||||
<label class="option">
|
||||
<input type="checkbox" bind:checked={$options.compiler.aot.javascriptObfuscatorOptions.splitStrings}>
|
||||
{$_('options.splitStrings')}
|
||||
</label>
|
||||
{#if $options.compiler.aot.javascriptObfuscatorOptions.splitStrings}
|
||||
<label class="option nested">
|
||||
{$_('options.splitStringsChunkLength')}
|
||||
<input type="number" min="1" step="1"
|
||||
bind:value={$options.compiler.aot.javascriptObfuscatorOptions.splitStringsChunkLength}>
|
||||
</label>
|
||||
{/if}
|
||||
<label class="option">
|
||||
<input type="checkbox" bind:checked={$options.compiler.aot.javascriptObfuscatorOptions.numbersToExpressions}>
|
||||
{$_('options.numbersToExpressions')}
|
||||
</label>
|
||||
<label class="option">
|
||||
<input type="checkbox" bind:checked={$options.compiler.aot.javascriptObfuscatorOptions.transformObjectKeys}>
|
||||
{$_('options.transformObjectKeys')}
|
||||
</label>
|
||||
<label class="option">
|
||||
<input type="checkbox" bind:checked={$options.compiler.aot.javascriptObfuscatorOptions.unicodeEscapeSequence}>
|
||||
{$_('options.unicodeEscapeSequence')}
|
||||
</label>
|
||||
<label class="option">
|
||||
<input type="checkbox" bind:checked={$options.compiler.aot.javascriptObfuscatorOptions.disableConsoleOutput}>
|
||||
{$_('options.disableConsoleOutput')}
|
||||
</label>
|
||||
<label class="option">
|
||||
<input type="checkbox" bind:checked={$options.compiler.aot.javascriptObfuscatorOptions.selfDefending}>
|
||||
{$_('options.selfDefending')}
|
||||
</label>
|
||||
{#if $options.compiler.aot.javascriptObfuscatorOptions.selfDefending}
|
||||
<p class="warning">{$_('options.selfDefendingWarning')}</p>
|
||||
{/if}
|
||||
<label class="option">
|
||||
<input type="checkbox" bind:checked={$options.compiler.aot.javascriptObfuscatorOptions.debugProtection}>
|
||||
{$_('options.debugProtection')}
|
||||
</label>
|
||||
{#if $options.compiler.aot.javascriptObfuscatorOptions.debugProtection}
|
||||
<label class="option nested">
|
||||
{$_('options.debugProtectionInterval')}
|
||||
<input type="number" min="0" step="1000"
|
||||
bind:value={$options.compiler.aot.javascriptObfuscatorOptions.debugProtectionInterval}>
|
||||
</label>
|
||||
<p>{$_('options.debugProtectionGlobal')}</p>
|
||||
<p class="warning">{$_('options.debugProtectionWarning')}</p>
|
||||
{/if}
|
||||
</details>
|
||||
<p>{$_('options.javascriptObfuscatorPerformance')}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="warning">{$_('options.aotCompileWarning')}</p>
|
||||
{/if}
|
||||
<h2>{$_('options.specializedProtection')}</h2>
|
||||
<p>{$_('options.specializedProtectionDescription')}</p>
|
||||
<label class="option">
|
||||
{$_('options.buildSeed')}
|
||||
<input type="text" bind:value={$options.compiler.seed} placeholder={$_('options.randomBuildSeed')}>
|
||||
</label>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
@@ -856,16 +712,6 @@
|
||||
<LearnMore slug="packager/special-cloud-behaviors" />
|
||||
</div>
|
||||
|
||||
<div class="option">
|
||||
<label>
|
||||
<input type="checkbox" bind:checked={$options.cloudVariables.unsafeCloudBehaviors}>
|
||||
{$_('options.unsafeCloudBehaviors')}
|
||||
</label>
|
||||
<LearnMore slug="packager/special-cloud-behaviors#eval" />
|
||||
</div>
|
||||
{#if $options.cloudVariables.unsafeCloudBehaviors}
|
||||
<p class="warning">{$_('options.unsafeCloudBehaviorsWarning')}</p>
|
||||
{/if}
|
||||
<p>{$_('options.implicitCloudHint').replace('{cloud}', '☁')}</p>
|
||||
{:else}
|
||||
<p>{$_('options.noCloudVariables')}</p>
|
||||
@@ -879,7 +725,6 @@
|
||||
resetOptions([
|
||||
'compiler',
|
||||
'extensions',
|
||||
'bakeExtensions',
|
||||
'custom',
|
||||
'projectId',
|
||||
'maxTextureDimension'
|
||||
@@ -891,13 +736,6 @@
|
||||
<details open={advancedOptionsInitiallyOpen}>
|
||||
<summary>{$_('options.advancedSummary')}</summary>
|
||||
|
||||
<div class="option">
|
||||
<label>
|
||||
<input type="checkbox" bind:checked={$options.compiler.enabled}>
|
||||
{$_('options.enableCompiler')}
|
||||
</label>
|
||||
<LearnMore slug="disable-compiler" />
|
||||
</div>
|
||||
<div class="option">
|
||||
<label>
|
||||
<input type="checkbox" bind:checked={$options.compiler.warpTimer}>
|
||||
@@ -916,20 +754,10 @@
|
||||
<p class="warning">{$_('options.customExtensionsSecurity')}</p>
|
||||
</label>
|
||||
|
||||
<label class="option">
|
||||
<input type="checkbox" bind:checked={$options.bakeExtensions}>
|
||||
{$_('options.bakeExtensions')}
|
||||
</label>
|
||||
|
||||
<label class="option">
|
||||
{$_('options.customCSS')}
|
||||
<textarea bind:value={$options.custom.css}></textarea>
|
||||
</label>
|
||||
<label class="option">
|
||||
{$_('options.customJS')}
|
||||
<textarea bind:value={$options.custom.js}></textarea>
|
||||
</label>
|
||||
|
||||
<label class="option">
|
||||
{$_('options.projectId')}
|
||||
<input type="text" bind:value={$options.projectId}>
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import writablePersistentStore from './persistent-store';
|
||||
import {error} from './stores';
|
||||
import {validatePackagerOptions} from '../packager/compile-project';
|
||||
|
||||
const writablePackagerOptionsStore = (key, defaultOptions) => writablePersistentStore(key, defaultOptions, savedOptions => {
|
||||
try {
|
||||
validatePackagerOptions(savedOptions);
|
||||
return savedOptions;
|
||||
} catch (migration) {
|
||||
error.set(migration);
|
||||
// Reset rejected compiler settings. The persistent store's merge removes
|
||||
// obsolete fields while preserving unrelated settings supported by defaults.
|
||||
return {...savedOptions, compiler: defaultOptions.compiler};
|
||||
}
|
||||
});
|
||||
|
||||
export default writablePackagerOptionsStore;
|
||||
@@ -4,11 +4,11 @@ import * as ExtendedJSON from '@turbowarp/json';
|
||||
import merge from './merge';
|
||||
import serialize from './serialize';
|
||||
|
||||
const writablePersistentStore = (key, defaultValue) => {
|
||||
const writablePersistentStore = (key, defaultValue, restore = value => value) => {
|
||||
let value = ExtendedJSON.parse(ExtendedJSON.stringify(defaultValue));
|
||||
const localValue = ExtendedJSON.parse(localStorage.getItem(key));
|
||||
if (localValue) {
|
||||
value = merge(localValue, value);
|
||||
value = merge(restore(localValue), value);
|
||||
}
|
||||
const store = writable(value, () => {
|
||||
const unsubscribe = store.subscribe(value => {
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
const SCAFFOLDING_BUILD_ID = process.env.SCAFFOLDING_BUILD_ID;
|
||||
|
||||
const verifyBuildId = (buildId, source) => {
|
||||
if (source.endsWith('=^..^=')) {
|
||||
return source.endsWith(`${buildId} =^..^=`);
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
export {
|
||||
SCAFFOLDING_BUILD_ID as buildId,
|
||||
verifyBuildId
|
||||
};
|
||||
+138
-165
@@ -1,173 +1,146 @@
|
||||
const loadDependencies = async () => {
|
||||
const [vmModule, storageModule, protection] = await Promise.all([
|
||||
import(/* webpackChunkName: "compiled-project-builder" */ 'scratch-vm'),
|
||||
import(/* webpackChunkName: "compiled-project-builder" */ '@turbowarp/scratch-storage'),
|
||||
import(/* webpackChunkName: "compiled-project-protection" */ 'sb3c-vm-obfuscator')
|
||||
import {Adapter} from './adapter';
|
||||
import sha256 from './sha256';
|
||||
import {facadeSource} from '../specialized/facade';
|
||||
|
||||
export const migrationError = () => new Error('旧编译包和保护选项已移除。请使用原始 SB3 重新构建,并重置旧保护设置。');
|
||||
export function validateBuildOptions(options = {}) {
|
||||
if (['aot', 'signManifest', 'deriveSeed', 'stages', 'signatureVerifierSource', 'wasm', 'protection'].some(key => key in options)) throw migrationError();
|
||||
if (options.enabled === false) throw new Error('专用播放器必须启用编译。');
|
||||
}
|
||||
export function validatePackagerOptions(options = {}) {
|
||||
validateBuildOptions(options);
|
||||
validateBuildOptions(options.compiler || {});
|
||||
if ('bakeExtensions' in options || 'compiledProjectHooks' in options || options.custom && options.custom.js ||
|
||||
options.cloudVariables && options.cloudVariables.unsafeCloudBehaviors) throw migrationError();
|
||||
}
|
||||
const randomSeed = () => {
|
||||
const values = new Uint32Array(4);
|
||||
globalThis.crypto.getRandomValues(values);
|
||||
return Array.from(values, x => x.toString(16).padStart(8, '0')).join('');
|
||||
};
|
||||
const seedNumber = seed => {
|
||||
let hash = 2166136261;
|
||||
for (const char of String(seed)) { hash ^= char.charCodeAt(0); hash = Math.imul(hash, 16777619); }
|
||||
return hash >>> 0;
|
||||
};
|
||||
export async function rejectLegacyProject(data) {
|
||||
const bytes = new Uint8Array(data.buffer || data, data.byteOffset || 0, data.byteLength);
|
||||
if (bytes[0] === 0x50 && bytes[1] === 0x4b) {
|
||||
const {default: JSZip} = await import(/* webpackChunkName: "jszip" */ '@turbowarp/jszip');
|
||||
const archive = await JSZip.loadAsync(bytes);
|
||||
if (Object.keys(archive.files).some(name => /(^|\/)(compiled-project(?:\.integrity)?\.json|program\.wasm|effects\.wasm)$/.test(name))) throw migrationError();
|
||||
} else if (/scratch-vm-compiled-project/.test(new TextDecoder().decode(bytes.subarray(0, 512)))) throw migrationError();
|
||||
}
|
||||
|
||||
// Internal build input never becomes a runtime-loadable compilation artifact.
|
||||
export async function prepareProject(projectData, options = {}) {
|
||||
validateBuildOptions(options);
|
||||
await rejectLegacyProject(projectData);
|
||||
const [vmModule, storageModule, lowering, zipModule] = await Promise.all([
|
||||
import(/* webpackChunkName: "project-frontend" */ 'scratch-vm'),
|
||||
import(/* webpackChunkName: "project-frontend" */ '@turbowarp/scratch-storage'),
|
||||
import(/* webpackChunkName: "project-frontend" */ 'scratch-vm/src/compiler/continuation'),
|
||||
import(/* webpackChunkName: "jszip" */ '@turbowarp/jszip')
|
||||
]);
|
||||
return {
|
||||
VM: vmModule.default || vmModule,
|
||||
Storage: storageModule.default || storageModule,
|
||||
protection
|
||||
};
|
||||
};
|
||||
|
||||
const configureExtensionSecurity = (vm) => {
|
||||
const securityManager = vm.extensionManager && vm.extensionManager.securityManager;
|
||||
if (!securityManager) return;
|
||||
securityManager.getSandboxMode = () => 'unsandboxed';
|
||||
securityManager.canLoadExtensionFromProject = () => true;
|
||||
};
|
||||
|
||||
const loadJavaScriptObfuscator = async supplied => {
|
||||
if (supplied) return supplied.default || supplied;
|
||||
const imported = await import(
|
||||
/* webpackChunkName: "compiled-project-debug-protection" */ 'javascript-obfuscator'
|
||||
);
|
||||
return imported.default || imported;
|
||||
};
|
||||
|
||||
/**
|
||||
* Create one player-level anti-debugging script. Applying debug protection to
|
||||
* each compiled factory would duplicate guards and timer loops in every
|
||||
* procedure, so it is intentionally separated from factory obfuscation.
|
||||
* @param {object} options debug protection options
|
||||
* @param {number} [options.interval=0] repeat interval in milliseconds; 0 disables repetition
|
||||
* @param {object} [implementation] injected javascript-obfuscator implementation
|
||||
* @returns {object} protection stage
|
||||
*/
|
||||
export const createGlobalDebugProtectionStage = (options = {}, implementation) => ({
|
||||
id: 'tw-packager-global-debug-protection',
|
||||
version: 1,
|
||||
kind: 'runtime-sidecar',
|
||||
mutatesFactories: false,
|
||||
async transform ({artifact, seed}) {
|
||||
const interval = Number(options.interval || 0);
|
||||
if (!Number.isFinite(interval) || interval < 0 || (interval > 0 && interval < 1000)) {
|
||||
throw new Error('debugProtectionInterval must be 0 or at least 1000 milliseconds');
|
||||
}
|
||||
const obfuscator = await loadJavaScriptObfuscator(implementation);
|
||||
if (!obfuscator || typeof obfuscator.obfuscate !== 'function') {
|
||||
throw new Error('Invalid javascript-obfuscator implementation');
|
||||
}
|
||||
const result = obfuscator.obfuscate('void 0;', {
|
||||
compact: true,
|
||||
debugProtection: true,
|
||||
debugProtectionInterval: interval,
|
||||
renameGlobals: false,
|
||||
renameProperties: false,
|
||||
seed,
|
||||
selfDefending: Boolean(options.selfDefending),
|
||||
sourceMap: false,
|
||||
target: 'browser'
|
||||
});
|
||||
return {
|
||||
artifact,
|
||||
sidecars: [{
|
||||
name: 'debug-protection.js',
|
||||
mediaType: 'text/javascript',
|
||||
content: result.getObfuscatedCode(),
|
||||
execution: 'global-script'
|
||||
}],
|
||||
diagnostics: {interval}
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
const getJavaScriptObfuscatorOptions = aot => {
|
||||
const configured = {...(aot.javascriptObfuscatorOptions || {})};
|
||||
const encoding = configured.stringArrayEncoding;
|
||||
if (typeof encoding === 'string') {
|
||||
configured.stringArrayEncoding = encoding === 'none' ? [] : [encoding];
|
||||
}
|
||||
delete configured.debugProtection;
|
||||
delete configured.debugProtectionInterval;
|
||||
return configured;
|
||||
};
|
||||
|
||||
/**
|
||||
* Compile and optionally protect a project before it is embedded in a packaged application.
|
||||
* @param {ArrayBuffer|Uint8Array} projectData original project bytes
|
||||
* @param {object} options compiler options
|
||||
* @param {object} [hooks] non-serializable build hooks for advanced callers
|
||||
* @param {Function} [hooks.seedMaterial] public seed material selector
|
||||
* @param {Function} [hooks.deriveSeed] local or remote seed provider
|
||||
* @param {Function} [hooks.signManifest] integrity manifest signer
|
||||
* @param {object} [hooks.javascriptObfuscatorOptions] javascript-obfuscator options
|
||||
* @param {Array<object>} [hooks.stages] custom protection stages
|
||||
* @param {object} [dependencies] injected dependencies for tests
|
||||
* @returns {Promise<object>} compiled package, accessor sidecar, and diagnostics
|
||||
*/
|
||||
export const compileProject = async (projectData, options, hooks = {}, dependencies) => {
|
||||
const {VM, Storage, protection} = dependencies || await loadDependencies();
|
||||
const JSZip = zipModule.default || zipModule;
|
||||
const bytes = new Uint8Array(projectData.buffer || projectData, projectData.byteOffset || 0, projectData.byteLength);
|
||||
const VM = vmModule.default || vmModule;
|
||||
const Storage = storageModule.default || storageModule;
|
||||
const vm = new VM();
|
||||
vm.attachStorage(new Storage());
|
||||
configureExtensionSecurity(vm);
|
||||
vm.setCompilerOptions({enabled: true, warpTimer: Boolean(options.warpTimer)});
|
||||
|
||||
try {
|
||||
await vm.loadProject(projectData);
|
||||
const aot = options.aot;
|
||||
let artifact = vm.exportCompiledProject();
|
||||
let sidecars = [];
|
||||
let diagnostics = {stages: []};
|
||||
|
||||
if (aot.obfuscate || aot.javascriptObfuscator || hooks.stages) {
|
||||
const configuredObfuscatorOptions = {
|
||||
...getJavaScriptObfuscatorOptions(aot),
|
||||
...(hooks.javascriptObfuscatorOptions || {})
|
||||
};
|
||||
const debugProtection = Boolean(
|
||||
(aot.javascriptObfuscatorOptions && aot.javascriptObfuscatorOptions.debugProtection) ||
|
||||
configuredObfuscatorOptions.debugProtection
|
||||
);
|
||||
const debugProtectionInterval = configuredObfuscatorOptions.debugProtectionInterval !== undefined ?
|
||||
configuredObfuscatorOptions.debugProtectionInterval :
|
||||
(aot.javascriptObfuscatorOptions && aot.javascriptObfuscatorOptions.debugProtectionInterval);
|
||||
delete configuredObfuscatorOptions.debugProtection;
|
||||
delete configuredObfuscatorOptions.debugProtectionInterval;
|
||||
const stages = hooks.stages || [
|
||||
...(aot.obfuscate ? [protection.createVmAccessStage({
|
||||
accessorName: aot.accessorName,
|
||||
shortIds: aot.shortIds
|
||||
})] : []),
|
||||
...(aot.javascriptObfuscator ? [protection.createJavaScriptObfuscatorStage({
|
||||
options: configuredObfuscatorOptions
|
||||
})] : []),
|
||||
...(aot.javascriptObfuscator && debugProtection ? [
|
||||
createGlobalDebugProtectionStage({
|
||||
interval: debugProtectionInterval,
|
||||
selfDefending: configuredObfuscatorOptions.selfDefending
|
||||
})
|
||||
] : [])
|
||||
];
|
||||
const protectOptions = {stages};
|
||||
if (hooks.seedMaterial) protectOptions.seedMaterial = hooks.seedMaterial;
|
||||
if (hooks.deriveSeed) {
|
||||
protectOptions.deriveSeed = hooks.deriveSeed;
|
||||
} else if (aot.seed) {
|
||||
protectOptions.deriveSeed = () => ({
|
||||
seed: aot.seed,
|
||||
derivationId: 'tw-packager-explicit-v1'
|
||||
});
|
||||
vm.extensionManager.securityManager.getSandboxMode = () => 'worker';
|
||||
vm.extensionManager.securityManager.canLoadExtensionFromProject = () => true;
|
||||
const seed = options.seed === undefined || options.seed === '' ? randomSeed() : String(options.seed);
|
||||
const fixedExtensions = [];
|
||||
const requests = new Map();
|
||||
const loadExtension = vm.extensionManager.loadExtensionURL.bind(vm.extensionManager);
|
||||
vm.extensionManager.loadExtensionURL = url => {
|
||||
if (vm.extensionManager.isBuiltinExtension(url)) return loadExtension(url);
|
||||
if (requests.has(url)) return requests.get(url);
|
||||
const request = (async () => {
|
||||
const supplied = (options.extensions || []).find(item => typeof item === 'object' && item.url === url);
|
||||
const sources = {...(supplied && supplied.dependencies || {})};
|
||||
sources[url] = supplied && supplied.source !== undefined ? supplied.source : await Adapter.fetchExtensionScript(url);
|
||||
let frozen;
|
||||
for (;;) {
|
||||
try { frozen = await Adapter.prepareExtension({url, sources}); break; }
|
||||
catch (error) {
|
||||
const missing = error.missingDependency;
|
||||
if (!missing || Object.hasOwn(sources, missing)) throw error;
|
||||
if (!/^(https?:|data:)/.test(missing)) throw new Error(`Unsupported extension dependency URL: ${missing}`);
|
||||
sources[missing] = await Adapter.fetchExtensionScript(missing);
|
||||
}
|
||||
}
|
||||
const result = await protection.protectCompiledProject(
|
||||
vm.exportCompiledProjectBuildInput(),
|
||||
protectOptions
|
||||
);
|
||||
artifact = result.artifact;
|
||||
sidecars = result.sidecars;
|
||||
diagnostics = result.diagnostics;
|
||||
}
|
||||
const infos = await Adapter.inspectExtension(frozen.code);
|
||||
const digest = await sha256(new TextEncoder().encode(frozen.code));
|
||||
const token = `fixed:${digest}`;
|
||||
for (const info of infos) {
|
||||
if (vm.extensionManager.isExtensionLoaded(info.id)) throw new Error(`Duplicate fixed extension ID: ${info.id}`);
|
||||
const proxy = {getInfo: () => JSON.parse(JSON.stringify(info))};
|
||||
for (const block of info.blocks || []) if (block && typeof block === 'object' && block.opcode) {
|
||||
proxy[block.func || block.opcode] = () => { throw new Error('Extension execution is unavailable during IR analysis'); };
|
||||
}
|
||||
const service = vm.extensionManager._registerInternalExtension(proxy);
|
||||
vm.extensionManager._loadedExtensions.set(info.id, service);
|
||||
}
|
||||
fixedExtensions.push({token, infos, code: frozen.code, dependencies: frozen.dependencies});
|
||||
})();
|
||||
requests.set(url, request);
|
||||
return request;
|
||||
};
|
||||
try {
|
||||
for (const extension of options.extensions || []) await vm.extensionManager.loadExtensionURL(typeof extension === 'string' ? extension : extension.url);
|
||||
await vm.loadProject(bytes);
|
||||
const input = vm.exportSpecializedProjectBuildInput();
|
||||
input.fixedExtensions = fixedExtensions.sort((a, b) => a.token.localeCompare(b.token));
|
||||
input.project.extensionURLs = {};
|
||||
for (const extension of fixedExtensions) for (const info of extension.infos) input.project.extensionURLs[info.id] = extension.token;
|
||||
input.project.extensions = [...new Set([...(input.project.extensions || []), ...Object.keys(input.project.extensionURLs)])].sort();
|
||||
const program = (lowering.lower || lowering.default.lower)(input, seedNumber(seed));
|
||||
const assets = vm.serializeAssets().map(asset => ({name: asset.fileName, data: asset.fileContent}));
|
||||
const archive = new JSZip();
|
||||
const date = new Date('2000-01-01T00:00:00Z');
|
||||
archive.file('project.json', JSON.stringify(input.project), {date});
|
||||
for (const asset of [...assets].sort((a, b) => a.name.localeCompare(b.name))) archive.file(asset.name, asset.data, {date});
|
||||
const resourceData = await archive.generateAsync({type: 'uint8array', compression: 'DEFLATE', compressionOptions: {level: 6}});
|
||||
return {input, program, seed, assets, resourceData};
|
||||
} finally { vm.quit(); }
|
||||
}
|
||||
|
||||
const packageData = await vm.saveCompiledProject('arraybuffer', {
|
||||
artifact,
|
||||
integrity: hooks.signManifest ? {signManifest: hooks.signManifest} : {}
|
||||
});
|
||||
const accessorSidecar = sidecars.find(sidecar => sidecar.bindingName) || null;
|
||||
return {packageData, artifact, accessorSidecar, sidecars, diagnostics};
|
||||
} finally {
|
||||
vm.quit();
|
||||
}
|
||||
};
|
||||
export async function finishProject(prepared, boot, options = {}) {
|
||||
if (!Adapter || !Adapter.linkSpecialized) throw new Error('Specialized build adapter is unavailable');
|
||||
const linked = await Adapter.linkSpecialized({input: prepared.input, program: prepared.program,
|
||||
seed: prepared.seed, boot, addons: options.addons || {}, minifyOutput: options.minify !== false, sourceMap: options.sourceMap === true});
|
||||
const codeHash = await sha256(new TextEncoder().encode(linked.code));
|
||||
const resourceHash = await sha256(prepared.resourceData);
|
||||
const buildId = await sha256(new TextEncoder().encode(JSON.stringify({codeHash, resourceHash})));
|
||||
return {code: linked.code, assets: [{name: 'project.zip', data: prepared.resourceData}], buildId,
|
||||
...(linked.sourceMap ? {sourceMap: linked.sourceMap} : {}),
|
||||
diagnostics: {...linked.diagnostics, seed: prepared.seed, codeHash, resourceHash}};
|
||||
}
|
||||
|
||||
/** Build one program/VM code unit and its source-free resources. */
|
||||
export async function compileProject(projectData, options = {}) {
|
||||
const prepared = await prepareProject(projectData, options);
|
||||
const boot = `const scaffolding = new Scaffolding.Scaffolding();
|
||||
scaffolding._root.style.height = '100vh';
|
||||
scaffolding.setup(); scaffolding.appendTo(document.body);
|
||||
const vm = scaffolding.vm;
|
||||
ScaffoldingAddons.run(scaffolding, {pause: true});
|
||||
${facadeSource}
|
||||
(async () => {
|
||||
const response = await fetch('./project.zip');
|
||||
if (!response.ok) throw new Error('Could not load player resources');
|
||||
const zip = await Scaffolding.JSZip.loadAsync(await response.arrayBuffer());
|
||||
scaffolding.storage.addHelper({load: async (type, id, format) => {
|
||||
const file = zip.file(id + '.' + format);
|
||||
return file ? scaffolding.storage.createAsset(type, format, await file.async('uint8array'), id) : null;
|
||||
}});
|
||||
await scaffolding.loadProject(await zip.file('project.json').async('string'));
|
||||
playerLoaded();
|
||||
${options.autoplay === false ? '' : 'window.player.start();'}
|
||||
})().catch(playerError);`;
|
||||
return finishProject(prepared, boot, {...options, addons: {pause: true}});
|
||||
}
|
||||
export default compileProject;
|
||||
@@ -1,5 +1,6 @@
|
||||
import optimizeSb3Json from './minify/sb3';
|
||||
import {downloadProjectFromBuffer} from '@turbowarp/sbdl';
|
||||
import {rejectLegacyProject} from './compile-project';
|
||||
|
||||
const unknownAnalysis = () => ({
|
||||
stageVariables: [],
|
||||
@@ -97,6 +98,7 @@ const mutateScratch3InPlace = (projectData) => {
|
||||
};
|
||||
|
||||
export const downloadProject = async (projectData, progressCallback = () => {}, signal) => {
|
||||
await rejectLegacyProject(projectData);
|
||||
let analysis = unknownAnalysis();
|
||||
|
||||
const options = {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {readAsArrayBuffer} from '../common/readers';
|
||||
import {Adapter} from './adapter';
|
||||
|
||||
const loadImage = (src) => new Promise((resolve, reject) => {
|
||||
const image = new Image();
|
||||
@@ -18,6 +19,7 @@ const canvasToBlob = (canvas) => new Promise((resolve, reject) => {
|
||||
});
|
||||
|
||||
const pngToAppleICNS = async (pngData) => {
|
||||
if (Adapter.pngToAppleICNS) return Adapter.pngToAppleICNS(pngData);
|
||||
const {
|
||||
Icns,
|
||||
Buffer
|
||||
|
||||
@@ -10,16 +10,12 @@
|
||||
// match; this is just for the progress bar. estimatedSize is optional and can be omitted.
|
||||
// Make sure to use size estimates from production builds, not development ones.
|
||||
|
||||
// useBuildId is used for various cache related things. It shouldn't be changed.
|
||||
|
||||
const externalFile = (name) => [
|
||||
// Hopefully one of these URLs will not be blocked.
|
||||
`https://packagerdata.turbowarp.org/${name}`,
|
||||
`https://blobs.turbowarp.xyz/${name}`
|
||||
];
|
||||
|
||||
const relativeScaffolding = (name) => `scaffolding/${name}`;
|
||||
|
||||
export default {
|
||||
'nwjs-win64': {
|
||||
src: externalFile('nwjs-v0.68.1-win-x64.zip'),
|
||||
@@ -85,20 +81,5 @@ export default {
|
||||
src: externalFile('steamworks.js-0.3.2.zip'),
|
||||
sha256: 'fd8bc80a97cd880d71113dfc5f81b124b6e212335393db73e3df168c5c546fbc',
|
||||
estimatedSize: 3279554,
|
||||
},
|
||||
scaffolding: {
|
||||
src: relativeScaffolding('scaffolding-full.js'),
|
||||
estimatedSize: 4564032,
|
||||
useBuildId: true
|
||||
},
|
||||
'scaffolding-min': {
|
||||
src: relativeScaffolding('scaffolding-min.js'),
|
||||
estimatedSize: 2530463,
|
||||
useBuildId: true
|
||||
},
|
||||
addons: {
|
||||
src: relativeScaffolding('addons.js'),
|
||||
estimatedSize: 19931,
|
||||
useBuildId: true
|
||||
}
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
import {readAsArrayBuffer} from '../common/readers';
|
||||
import request from '../common/request';
|
||||
import {AbortError} from '../common/errors';
|
||||
import {migrationError} from './compile-project';
|
||||
|
||||
const downloadProject = async (buffer, progressCallback) => {
|
||||
const controller = typeof AbortController === 'function' && new AbortController();
|
||||
@@ -42,6 +43,7 @@ const fromID = (id, token, progressCallback) => {
|
||||
};
|
||||
|
||||
const fromFile = async (file, progressCallback) => {
|
||||
if (/\.sb3c2?$/i.test(file.name)) throw migrationError();
|
||||
const buffer = await readAsArrayBuffer(file);
|
||||
return downloadProject(buffer, progressCallback);
|
||||
};
|
||||
|
||||
@@ -5,6 +5,10 @@ import fetch from 'cross-fetch';
|
||||
import {name} from '../../../package.json';
|
||||
import defaultIcon from '../images/default-icon.png';
|
||||
import Image from './image';
|
||||
import {linkSpecialized, prepareExtension, inspectExtension} from 'specialized-link';
|
||||
import {PNG} from 'pngjs';
|
||||
import {Icns, IcnsImage} from '@fiahfy/icns';
|
||||
import {DOMParser, DOMImplementation, XMLSerializer} from '@xmldom/xmldom';
|
||||
|
||||
const readFile = promisify(fs.readFile);
|
||||
const writeFile = promisify(fs.writeFile);
|
||||
@@ -36,17 +40,41 @@ const nodeBufferToArrayBuffer = (buffer) => buffer.buffer.slice(buffer.byteOffse
|
||||
const arrayBufferToNodeBuffer = (buffer) => Buffer.from(buffer);
|
||||
|
||||
class NodeAdapter {
|
||||
parseXML (text) { return new DOMParser().parseFromString(text, 'text/xml'); }
|
||||
createXMLDocument (name) { return new DOMImplementation().createDocument(null, name); }
|
||||
serializeXML (document) { return new XMLSerializer().serializeToString(document); }
|
||||
async pngToAppleICNS (bytes) {
|
||||
const source = PNG.sync.read(Buffer.from(bytes));
|
||||
const icon = new Icns();
|
||||
const formats = [['ic04', 16], ['ic07', 128], ['ic08', 256], ['ic09', 512], ['ic10', 1024],
|
||||
['ic11', 32], ['ic12', 64], ['ic13', 256], ['ic14', 512]];
|
||||
for (const [type, size] of formats) {
|
||||
if (size !== 16 && (source.width < size || source.height < size)) continue;
|
||||
const output = new PNG({width: size, height: size});
|
||||
for (let y = 0; y < size; y++) for (let x = 0; x < size; x++) {
|
||||
const x0 = Math.floor(x * source.width / size), x1 = Math.max(x0 + 1, Math.floor((x + 1) * source.width / size));
|
||||
const y0 = Math.floor(y * source.height / size), y1 = Math.max(y0 + 1, Math.floor((y + 1) * source.height / size));
|
||||
const sums = [0, 0, 0]; let alpha = 0, count = 0;
|
||||
for (let sy = y0; sy < y1; sy++) for (let sx = x0; sx < x1; sx++) {
|
||||
const offset = (sy * source.width + sx) * 4, a = source.data[offset + 3];
|
||||
for (let channel = 0; channel < 3; channel++) sums[channel] += source.data[offset + channel] * a;
|
||||
alpha += a; count++;
|
||||
}
|
||||
const offset = (y * size + x) * 4;
|
||||
for (let channel = 0; channel < 3; channel++) output.data[offset + channel] = alpha ? Math.round(sums[channel] / alpha) : 0;
|
||||
output.data[offset + 3] = Math.round(alpha / count);
|
||||
}
|
||||
icon.append(await IcnsImage.fromPNG(PNG.sync.write(output), type));
|
||||
}
|
||||
return icon.data;
|
||||
}
|
||||
linkSpecialized (options) { return linkSpecialized(options); }
|
||||
prepareExtension (options) { return prepareExtension(options); }
|
||||
inspectExtension (code) { return inspectExtension(code); }
|
||||
async getCachedAsset (asset) {
|
||||
const urls = asset.src;
|
||||
const src = Array.isArray(urls) ? urls[0] : urls;
|
||||
|
||||
if (src.startsWith('scaffolding/')) {
|
||||
const file = safeJoin(__dirname, src);
|
||||
if (file) {
|
||||
return readFile(file, 'utf-8');
|
||||
}
|
||||
}
|
||||
|
||||
if (src.startsWith('https:')) {
|
||||
const cachePath = await getCachePath(asset);
|
||||
if (cachePath) {
|
||||
|
||||
@@ -3,11 +3,13 @@ import {downloadProject} from '../download-project';
|
||||
import NodeAdapter from './adapter';
|
||||
import Image from './image';
|
||||
import {setAdapter} from '../adapter';
|
||||
import {compileProject} from '../compile-project';
|
||||
|
||||
setAdapter(new NodeAdapter());
|
||||
|
||||
export {
|
||||
Packager,
|
||||
Image,
|
||||
compileProject,
|
||||
downloadProject as loadProject
|
||||
};
|
||||
+72
-334
@@ -4,15 +4,13 @@ import escapeXML from '../common/escape-xml';
|
||||
import largeAssets from './large-assets';
|
||||
import request from '../common/request';
|
||||
import pngToAppleICNS from './icns';
|
||||
import {buildId, verifyBuildId} from './build-id';
|
||||
import {encode} from './base85';
|
||||
import {parsePlist, generatePlist} from './plist';
|
||||
import {APP_NAME, WEBSITE, COPYRIGHT_NOTICE, ACCENT_COLOR} from './brand';
|
||||
import {OutdatedPackagerError} from '../common/errors';
|
||||
import {darken} from './colors';
|
||||
import {Adapter} from './adapter';
|
||||
import encodeBigString from './encode-big-string';
|
||||
import compileProject from './compile-project';
|
||||
import {prepareProject, finishProject, migrationError, validatePackagerOptions} from './compile-project';
|
||||
import {facadeSource} from '../specialized/facade';
|
||||
|
||||
const PROGRESS_LOADED_SCRIPTS = 0.1;
|
||||
|
||||
@@ -188,8 +186,7 @@ class Packager extends EventTarget {
|
||||
this.aborted = false;
|
||||
this.used = false;
|
||||
// Functions and provider credentials belong here rather than in serializable UI options.
|
||||
this.compiledProjectHooks = {};
|
||||
this.compiledProjectRuntime = null;
|
||||
this.specializedBuild = null;
|
||||
}
|
||||
|
||||
abort () {
|
||||
@@ -234,12 +231,8 @@ class Packager extends EventTarget {
|
||||
console.warn(e);
|
||||
}
|
||||
if (!result) {
|
||||
let url = asset.src;
|
||||
if (asset.useBuildId) {
|
||||
url += `?${buildId}`;
|
||||
}
|
||||
result = await request({
|
||||
url,
|
||||
url: asset.src,
|
||||
type,
|
||||
estimatedSize: asset.estimatedSize,
|
||||
progressCallback: (progress) => {
|
||||
@@ -248,9 +241,6 @@ class Packager extends EventTarget {
|
||||
abortTarget: this
|
||||
});
|
||||
}
|
||||
if (asset.useBuildId && !verifyBuildId(buildId, result)) {
|
||||
throw new OutdatedPackagerError('Build ID does not match.');
|
||||
}
|
||||
if (asset.sha256) {
|
||||
const hash = await sha256(result);
|
||||
if (hash !== asset.sha256) {
|
||||
@@ -272,54 +262,20 @@ class Packager extends EventTarget {
|
||||
return {
|
||||
...this.options.chunks,
|
||||
specialCloudBehaviors: this.options.cloudVariables.specialCloudBehaviors,
|
||||
unsafeCloudBehaviors: this.options.cloudVariables.unsafeCloudBehaviors,
|
||||
pause: this.options.controls.pause.enabled
|
||||
pause: true
|
||||
};
|
||||
}
|
||||
|
||||
async loadResources () {
|
||||
const texts = [COPYRIGHT_HEADER];
|
||||
if (this.project.analysis.usesMusic) {
|
||||
texts.push(await this.fetchLargeAsset('scaffolding', 'text'));
|
||||
} else {
|
||||
texts.push(await this.fetchLargeAsset('scaffolding-min', 'text'));
|
||||
}
|
||||
if (Object.values(this.getAddonOptions()).some((i) => i)) {
|
||||
texts.push(await this.fetchLargeAsset('addons', 'text'));
|
||||
}
|
||||
this.script = texts.join('\n').replace(/<\/script>/g,"</scri'+'pt>");
|
||||
this.script = '';
|
||||
}
|
||||
|
||||
async prepareCompiledProject () {
|
||||
const aot = this.options.compiler.aot;
|
||||
if (!aot || !aot.enabled) return;
|
||||
if (this.compiledProjectHooks) throw migrationError();
|
||||
validatePackagerOptions(this.options);
|
||||
this.dispatchEvent(new CustomEvent('compile-progress', {detail: {progress: 0}}));
|
||||
const result = await compileProject(
|
||||
this.project.arrayBuffer,
|
||||
this.options.compiler,
|
||||
this.compiledProjectHooks
|
||||
);
|
||||
if (this.compiledProjectHooks.signManifest && !this.compiledProjectHooks.signatureVerifierSource) {
|
||||
throw new Error('Signed compiled projects require compiledProjectHooks.signatureVerifierSource');
|
||||
}
|
||||
this.ensureNotAborted();
|
||||
this.project = {
|
||||
...this.project,
|
||||
arrayBuffer: result.packageData,
|
||||
type: 'sb3c'
|
||||
};
|
||||
this.compiledProjectRuntime = {
|
||||
accessorSource: result.accessorSidecar ? result.accessorSidecar.content : null,
|
||||
bindingName: result.accessorSidecar ? result.accessorSidecar.bindingName : null,
|
||||
signed: Boolean(this.compiledProjectHooks.signManifest),
|
||||
signatureVerifierSource: this.compiledProjectHooks.signatureVerifierSource || null,
|
||||
runtimeScripts: result.sidecars
|
||||
.filter(sidecar => sidecar.execution === 'global-script')
|
||||
.map(sidecar => ({name: sidecar.name, content: sidecar.content}))
|
||||
};
|
||||
this.dispatchEvent(new CustomEvent('compile-progress', {
|
||||
detail: {progress: 1, diagnostics: result.diagnostics}
|
||||
}));
|
||||
this.specializedBuild = await prepareProject(this.project.arrayBuffer, {...this.options.compiler, extensions: this.options.extensions});
|
||||
this.project = {...this.project, arrayBuffer: this.specializedBuild.resourceData, type: 'specialized'};
|
||||
}
|
||||
|
||||
computeWindowSize () {
|
||||
@@ -1050,155 +1006,45 @@ cd "$(dirname "$0")"
|
||||
}
|
||||
|
||||
async generateGetProjectData () {
|
||||
const result = [];
|
||||
let getProjectDataFunction = '';
|
||||
let isZip = false;
|
||||
let storageProgressStart;
|
||||
let storageProgressEnd;
|
||||
|
||||
if (this.options.target === 'html') {
|
||||
isZip = this.project.type !== 'blob';
|
||||
storageProgressStart = PROGRESS_FETCHED_COMPRESSED;
|
||||
storageProgressEnd = PROGRESS_EXTRACTED_COMPRESSED;
|
||||
|
||||
const projectData = new Uint8Array(this.project.arrayBuffer);
|
||||
|
||||
// keep this up-to-date with base85.js
|
||||
result.push(`
|
||||
<script>
|
||||
const getBase85DecodeValue = (code) => {
|
||||
if (code === 0x28) code = 0x3c;
|
||||
if (code === 0x29) code = 0x3e;
|
||||
return code - 0x2a;
|
||||
};
|
||||
const base85decode = (str, outBuffer, outOffset) => {
|
||||
const view = new DataView(outBuffer, outOffset, Math.floor(str.length / 5 * 4));
|
||||
for (let i = 0, j = 0; i < str.length; i += 5, j += 4) {
|
||||
view.setUint32(j, (
|
||||
getBase85DecodeValue(str.charCodeAt(i + 4)) * 85 * 85 * 85 * 85 +
|
||||
getBase85DecodeValue(str.charCodeAt(i + 3)) * 85 * 85 * 85 +
|
||||
getBase85DecodeValue(str.charCodeAt(i + 2)) * 85 * 85 +
|
||||
getBase85DecodeValue(str.charCodeAt(i + 1)) * 85 +
|
||||
getBase85DecodeValue(str.charCodeAt(i))
|
||||
), true);
|
||||
}
|
||||
};
|
||||
let projectDecodeBuffer = new ArrayBuffer(${Math.ceil(projectData.length / 4) * 4});
|
||||
let projectDecodeIndex = 0;
|
||||
const decodeChunk = (size) => {
|
||||
try {
|
||||
if (document.currentScript.tagName.toUpperCase() !== 'SCRIPT') throw new Error('document.currentScript is not a script');
|
||||
base85decode(document.currentScript.getAttribute("data"), projectDecodeBuffer, projectDecodeIndex);
|
||||
document.currentScript.remove();
|
||||
projectDecodeIndex += size;
|
||||
setProgress(interpolate(${PROGRESS_LOADED_SCRIPTS}, ${PROGRESS_FETCHED_COMPRESSED}, projectDecodeIndex / ${projectData.length}));
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
};
|
||||
</script>`);
|
||||
|
||||
// To avoid unnecessary padding, this should be a multiple of 4.
|
||||
const CHUNK_SIZE = 1024 * 64;
|
||||
|
||||
for (let i = 0; i < projectData.length; i += CHUNK_SIZE) {
|
||||
const projectChunk = projectData.subarray(i, i + CHUNK_SIZE);
|
||||
const base85 = encode(projectChunk);
|
||||
result.push(`<script data="${base85}">decodeChunk(${projectChunk.length})</script>\n`);
|
||||
}
|
||||
|
||||
getProjectDataFunction = `() => {
|
||||
const buffer = projectDecodeBuffer;
|
||||
projectDecodeBuffer = null; // Allow GC
|
||||
return Promise.resolve(new Uint8Array(buffer, 0, ${projectData.length}));
|
||||
}`;
|
||||
} else {
|
||||
let src;
|
||||
if (this.project.type === 'blob' || this.project.type === 'sb3c' ||
|
||||
this.options.target === 'zip-one-asset') {
|
||||
isZip = this.project.type !== 'blob';
|
||||
src = './project.zip';
|
||||
storageProgressStart = PROGRESS_FETCHED_COMPRESSED;
|
||||
storageProgressEnd = PROGRESS_EXTRACTED_COMPRESSED;
|
||||
} else {
|
||||
src = './assets/project.json';
|
||||
storageProgressStart = PROGRESS_FETCHED_PROJECT_JSON;
|
||||
storageProgressEnd = PROGRESS_FETCHED_ASSETS;
|
||||
}
|
||||
|
||||
getProjectDataFunction = `() => new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.onload = () => {
|
||||
resolve(xhr.response);
|
||||
};
|
||||
xhr.onerror = () => {
|
||||
if (location.protocol === 'file:') {
|
||||
reject(new Error('Zip environment must be used on a website, not on a local file. To fix this error, use the "Plain HTML" environment instead.'));
|
||||
} else {
|
||||
reject(new Error('Request to load project data failed.'));
|
||||
}
|
||||
};
|
||||
xhr.onprogress = (e) => {
|
||||
if (e.lengthComputable) {
|
||||
setProgress(interpolate(${PROGRESS_LOADED_SCRIPTS}, ${storageProgressStart}, e.loaded / e.total));
|
||||
}
|
||||
};
|
||||
xhr.responseType = 'arraybuffer';
|
||||
xhr.open('GET', ${JSON.stringify(src)});
|
||||
xhr.send();
|
||||
})`;
|
||||
const embedded = this.options.target === 'html';
|
||||
let data = '';
|
||||
if (embedded) {
|
||||
const bytes = new Uint8Array(this.project.arrayBuffer);
|
||||
const chunks = [];
|
||||
for (let i = 0; i < bytes.length; i += 16384) chunks.push(String.fromCharCode(...bytes.subarray(i, i + 16384)));
|
||||
data = typeof btoa === 'function' ? btoa(chunks.join('')) : Buffer.from(bytes).toString('base64');
|
||||
}
|
||||
|
||||
result.push(`
|
||||
return `${embedded ? `<script type="application/octet-stream" id="player-resources">${data}</script>` : ''}
|
||||
<script>
|
||||
const getProjectData = (function() {
|
||||
const getProjectData = async () => {
|
||||
const element = document.getElementById('player-resources');
|
||||
let bytes;
|
||||
if (element) {
|
||||
bytes = Uint8Array.from(atob(element.textContent), char => char.charCodeAt(0));
|
||||
element.remove();
|
||||
} else {
|
||||
bytes = await new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open('GET', './project.zip'); xhr.responseType = 'arraybuffer';
|
||||
xhr.onload = () => {
|
||||
if ((xhr.status === 0 || xhr.status === 200) && xhr.response) resolve(xhr.response);
|
||||
else reject(new Error('Could not load player resources'));
|
||||
};
|
||||
xhr.onerror = () => reject(new Error('Could not load player resources. Web ZIP files require a web server.'));
|
||||
xhr.send();
|
||||
});
|
||||
}
|
||||
const zip = await Scaffolding.JSZip.loadAsync(bytes);
|
||||
const storage = scaffolding.storage;
|
||||
storage.onprogress = (total, loaded) => {
|
||||
setProgress(interpolate(${storageProgressStart}, ${storageProgressEnd}, loaded / total));
|
||||
};
|
||||
${isZip ? `
|
||||
let zip;
|
||||
// Allow zip to be GC'd after project loads
|
||||
vm.runtime.on('PROJECT_LOADED', () => (zip = null));
|
||||
const findFileInZip = (path) => zip.file(path) || zip.file(new RegExp("^([^/]*/)?" + path + "$"))[0];
|
||||
storage.addHelper({
|
||||
load: (assetType, assetId, dataFormat) => {
|
||||
if (!zip) {
|
||||
throw new Error('Zip is not loaded or has been closed');
|
||||
}
|
||||
const path = assetId + '.' + dataFormat;
|
||||
const file = findFileInZip(path);
|
||||
if (!file) {
|
||||
console.error('Asset is not in zip: ' + path);
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
return file
|
||||
.async('uint8array')
|
||||
.then((data) => storage.createAsset(assetType, dataFormat, data, assetId));
|
||||
}
|
||||
});
|
||||
return () => (${getProjectDataFunction})().then(async (data) => {
|
||||
zip = await Scaffolding.JSZip.loadAsync(data);
|
||||
const file = findFileInZip('project.json');
|
||||
if (!file) {
|
||||
throw new Error('project.json is not in zip');
|
||||
}
|
||||
${this.project.type === 'sb3c' ? 'return data;' : "return file.async('arraybuffer');"}
|
||||
});` : `
|
||||
storage.addWebStore(
|
||||
[
|
||||
storage.AssetType.ImageVector,
|
||||
storage.AssetType.ImageBitmap,
|
||||
storage.AssetType.Sound,
|
||||
storage.AssetType.Font
|
||||
].filter(i => i),
|
||||
(asset) => new URL('./assets/' + asset.assetId + '.' + asset.dataFormat, location).href
|
||||
);
|
||||
return ${getProjectDataFunction};`}
|
||||
})();
|
||||
</script>`);
|
||||
|
||||
return result;
|
||||
storage.addHelper({load: async (type, id, format) => {
|
||||
const file = zip.file(id + '.' + format);
|
||||
return file ? storage.createAsset(type, format, await file.async('uint8array'), id) : null;
|
||||
}});
|
||||
const project = zip.file('project.json');
|
||||
if (!project) throw new Error('Player resources are incomplete');
|
||||
return project.async('string');
|
||||
};
|
||||
</script>`;
|
||||
}
|
||||
|
||||
async generateFavicon () {
|
||||
@@ -1221,54 +1067,6 @@ cd "$(dirname "$0")"
|
||||
return `url(${data}) ${this.options.cursor.center.x} ${this.options.cursor.center.y}, auto`;
|
||||
}
|
||||
|
||||
async generateExtensionURLs () {
|
||||
const dispatchProgress = (progress) => this.dispatchEvent(new CustomEvent('fetch-extensions', {
|
||||
detail: {
|
||||
progress
|
||||
}
|
||||
}));
|
||||
|
||||
const shouldTryToFetch = (url) => {
|
||||
if (!this.options.bakeExtensions) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
return parsed.protocol === 'http:' || parsed.protocol === 'https:';
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/** @type {string[]} */
|
||||
const allURLs = this.options.extensions;
|
||||
const unfetchableURLs = allURLs.filter((url) => !shouldTryToFetch(url));
|
||||
const urlsToFetch = allURLs.filter((url) => shouldTryToFetch(url));
|
||||
const finalURLs = [...unfetchableURLs];
|
||||
|
||||
if (urlsToFetch.length !== 0) {
|
||||
for (let i = 0; i < urlsToFetch.length; i++) {
|
||||
dispatchProgress(i / urlsToFetch.length);
|
||||
const url = urlsToFetch[i];
|
||||
try {
|
||||
const source = await Adapter.fetchExtensionScript(url);
|
||||
// Wrap the extension in an IIFE so that extensions written for the sandbox are less
|
||||
// likely to cause issues in an unsandboxed environment due to global pollution or
|
||||
// overriding Scratch.*
|
||||
const wrappedSource = `(function(Scratch) { ${source} })(Scratch);`
|
||||
const dataURI = `data:text/javascript;,${encodeURIComponent(wrappedSource)}`;
|
||||
finalURLs.push(dataURI);
|
||||
} catch (e) {
|
||||
console.warn('Could not bake extension', url, e);
|
||||
finalURLs.push(url);
|
||||
}
|
||||
}
|
||||
dispatchProgress(1);
|
||||
}
|
||||
|
||||
return finalURLs;
|
||||
}
|
||||
|
||||
async package () {
|
||||
if (!Adapter) {
|
||||
throw new Error('Missing adapter');
|
||||
@@ -1282,14 +1080,13 @@ cd "$(dirname "$0")"
|
||||
this.ensureNotAborted();
|
||||
await this.loadResources();
|
||||
this.ensureNotAborted();
|
||||
const html = encodeBigString`<!DOCTYPE html>
|
||||
let html = encodeBigString`<!DOCTYPE html>
|
||||
<!-- Created with ${WEBSITE} -->
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||
<!-- We only include this to explicitly loosen the CSP of various packager environments. It does not provide any security. -->
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src * 'self' 'unsafe-inline' 'unsafe-eval' data: blob:">
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src * 'self' 'unsafe-inline' data: blob:; script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval' blob:; worker-src 'self' blob:">
|
||||
<title>${escapeXML(this.options.app.windowTitle)}</title>
|
||||
<style>
|
||||
body {
|
||||
@@ -1486,10 +1283,11 @@ cd "$(dirname "$0")"
|
||||
};
|
||||
const interpolate = (a, b, t) => a + t * (b - a);
|
||||
|
||||
let scaffolding, vm;
|
||||
try {
|
||||
setProgress(${PROGRESS_LOADED_SCRIPTS});
|
||||
|
||||
const scaffolding = new Scaffolding.Scaffolding();
|
||||
scaffolding = new Scaffolding.Scaffolding();
|
||||
scaffolding.width = ${this.options.stageWidth};
|
||||
scaffolding.height = ${this.options.stageHeight};
|
||||
scaffolding.resizeMode = ${JSON.stringify(this.options.resizeMode)};
|
||||
@@ -1498,16 +1296,7 @@ cd "$(dirname "$0")"
|
||||
scaffolding.setup();
|
||||
scaffolding.appendTo(appElement);
|
||||
|
||||
const vm = scaffolding.vm;
|
||||
window.scaffolding = scaffolding;
|
||||
window.vm = scaffolding.vm;
|
||||
window.Scratch = {
|
||||
vm,
|
||||
renderer: vm.renderer,
|
||||
audioEngine: vm.runtime.audioEngine,
|
||||
bitmapAdapter: vm.runtime.v2BitmapAdapter,
|
||||
videoProvider: vm.runtime.ioDevices.video.provider
|
||||
};
|
||||
vm = scaffolding.vm;
|
||||
|
||||
scaffolding.setUsername(${JSON.stringify(this.options.username)}.replace(/#/g, () => Math.floor(Math.random() * 10)));
|
||||
scaffolding.setAccentColor(${JSON.stringify(this.options.appearance.accent)});
|
||||
@@ -1655,14 +1444,6 @@ cd "$(dirname "$0")"
|
||||
ScaffoldingAddons.run(scaffolding, ${JSON.stringify(this.getAddonOptions())});
|
||||
}
|
||||
|
||||
scaffolding.setExtensionSecurityManager({
|
||||
getSandboxMode: () => 'unsandboxed',
|
||||
canLoadExtensionFromProject: () => true
|
||||
});
|
||||
for (const extension of ${JSON.stringify(await this.generateExtensionURLs())}) {
|
||||
vm.extensionManager.loadExtensionURL(extension);
|
||||
}
|
||||
|
||||
${this.options.closeWhenStopped ? `
|
||||
vm.runtime.on('PROJECT_RUN_STOP', () => {
|
||||
if (!vm.isPaused || !vm.isPaused()) {
|
||||
@@ -1670,7 +1451,6 @@ cd "$(dirname "$0")"
|
||||
}
|
||||
});` : ''}
|
||||
|
||||
${this.options.target.startsWith('nwjs-') ? `
|
||||
if (typeof nw !== 'undefined') {
|
||||
const win = nw.Window.get();
|
||||
win.on('new-win-policy', (frame, url, policy) => {
|
||||
@@ -1686,41 +1466,18 @@ cd "$(dirname "$0")"
|
||||
document.exitFullscreen();
|
||||
}
|
||||
});
|
||||
}` : ''}
|
||||
}
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
`)}</script>
|
||||
${this.options.custom.js ? `<script>
|
||||
try {
|
||||
${this.options.custom.js}
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
</script>` : ''}
|
||||
${await this.generateGetProjectData()}
|
||||
${this.project.type === 'sb3c' ? this.compiledProjectRuntime.runtimeScripts
|
||||
.map(script => `<script data-sb3c-runtime=${JSON.stringify(script.name)}>${
|
||||
script.content.replace(/<\/script>/gi, "</scri'+'pt>")
|
||||
}</script>`)
|
||||
.join('\n') : ''}
|
||||
<script>
|
||||
${facadeSource}
|
||||
const run = async () => {
|
||||
const projectData = await getProjectData();
|
||||
${this.project.type === 'sb3c' ? `
|
||||
let bindings = {};
|
||||
${this.compiledProjectRuntime.accessorSource ? `
|
||||
const accessorModule = await import(${JSON.stringify(
|
||||
`data:text/javascript;charset=utf-8,${encodeURIComponent(this.compiledProjectRuntime.accessorSource)}`
|
||||
)});
|
||||
bindings = {[accessorModule.bindingName]: accessorModule.accessor};` : ''}
|
||||
await scaffolding.loadCompiledProjectPackage(projectData, {
|
||||
bindings,
|
||||
requireIntegrity: true,
|
||||
requireSignature: ${this.compiledProjectRuntime.signed},
|
||||
${this.compiledProjectRuntime.signatureVerifierSource ?
|
||||
`verifyIntegritySignature: (${this.compiledProjectRuntime.signatureVerifierSource})` : ''}
|
||||
});` : 'await scaffolding.loadProject(projectData);'}
|
||||
await scaffolding.loadProject(projectData);
|
||||
playerLoaded();
|
||||
setProgress(1);
|
||||
loadingScreen.hidden = true;
|
||||
if (${this.options.autoplay}) {
|
||||
@@ -1734,12 +1491,25 @@ cd "$(dirname "$0")"
|
||||
launchScreen.focus();
|
||||
}
|
||||
};
|
||||
run().catch(handleError);
|
||||
run().catch(error => { playerError(error); handleError(error); });
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
this.ensureNotAborted();
|
||||
const boot = [];
|
||||
let page = new TextDecoder().decode(html).replace(/<script([^>]*)>([\s\S]*?)<\/script>/gi, (tag, attributes, source) => {
|
||||
if (/type="application\/octet-stream"/.test(attributes)) return tag;
|
||||
if (!/\bsrc=/.test(attributes)) boot.push(source);
|
||||
return '';
|
||||
});
|
||||
const result = await finishProject(this.specializedBuild, boot.join('\n'), {addons: this.getAddonOptions(), sourceMap: this.options.compiler.sourceMap === true});
|
||||
this.script = COPYRIGHT_HEADER + '\n' + result.code;
|
||||
this.buildResult = result;
|
||||
const script = this.options.target === 'html' ? `<script>${this.script.replace(/<\/script/gi, '<\\/script')}</script>` : '<script src="script.js"></script>';
|
||||
page = page.replace('</body>', () => script + '\n</body>');
|
||||
html = new TextEncoder().encode(page);
|
||||
this.dispatchEvent(new CustomEvent('compile-progress', {detail: {progress: 1, diagnostics: result.diagnostics}}));
|
||||
|
||||
if (this.options.target !== 'html') {
|
||||
let zip;
|
||||
@@ -1814,8 +1584,7 @@ Packager.usesUnsafeOptions = (options) => {
|
||||
const getUnsafeOptions = (options) => [
|
||||
options.custom,
|
||||
options.extensions,
|
||||
options.cloudVariables.unsafeCloudBehaviors,
|
||||
options.compiler.aot.javascriptObfuscatorOptions.debugProtection
|
||||
options.cloudVariables.unsafeCloudBehaviors
|
||||
];
|
||||
return JSON.stringify(getUnsafeOptions(defaultOptions)) !== JSON.stringify(getUnsafeOptions(options));
|
||||
};
|
||||
@@ -1836,8 +1605,7 @@ Packager.DEFAULT_OPTIONS = () => ({
|
||||
closeWhenStopped: false,
|
||||
projectId: '',
|
||||
custom: {
|
||||
css: '',
|
||||
js: ''
|
||||
css: ''
|
||||
},
|
||||
appearance: {
|
||||
background: '#000000',
|
||||
@@ -1872,35 +1640,7 @@ Packager.DEFAULT_OPTIONS = () => ({
|
||||
compiler: {
|
||||
enabled: true,
|
||||
warpTimer: false,
|
||||
aot: {
|
||||
enabled: true,
|
||||
obfuscate: true,
|
||||
javascriptObfuscator: false,
|
||||
shortIds: true,
|
||||
accessorName: '__vmAccess',
|
||||
seed: '',
|
||||
javascriptObfuscatorOptions: {
|
||||
compact: true,
|
||||
controlFlowFlattening: false,
|
||||
controlFlowFlatteningThreshold: 0.75,
|
||||
deadCodeInjection: false,
|
||||
deadCodeInjectionThreshold: 0.4,
|
||||
debugProtection: false,
|
||||
debugProtectionInterval: 0,
|
||||
disableConsoleOutput: false,
|
||||
identifierNamesGenerator: 'hexadecimal',
|
||||
numbersToExpressions: false,
|
||||
selfDefending: false,
|
||||
simplify: true,
|
||||
splitStrings: false,
|
||||
splitStringsChunkLength: 10,
|
||||
stringArray: true,
|
||||
stringArrayEncoding: 'none',
|
||||
stringArrayThreshold: 0.75,
|
||||
transformObjectKeys: false,
|
||||
unicodeEscapeSequence: false
|
||||
}
|
||||
}
|
||||
seed: ''
|
||||
},
|
||||
packagedRuntime: true,
|
||||
target: 'html',
|
||||
@@ -1923,7 +1663,6 @@ Packager.DEFAULT_OPTIONS = () => ({
|
||||
cloudHost: 'wss://clouddata.turbowarp.org',
|
||||
custom: {},
|
||||
specialCloudBehaviors: false,
|
||||
unsafeCloudBehaviors: false,
|
||||
},
|
||||
cursor: {
|
||||
type: 'auto',
|
||||
@@ -1940,7 +1679,6 @@ Packager.DEFAULT_OPTIONS = () => ({
|
||||
onError: 'warning'
|
||||
},
|
||||
extensions: [],
|
||||
bakeExtensions: true,
|
||||
maxTextureDimension: 2048
|
||||
});
|
||||
|
||||
|
||||
+10
-8
@@ -1,3 +1,4 @@
|
||||
import {Adapter} from './adapter';
|
||||
// Parses and generates Apple Info.plist files
|
||||
// Example file:
|
||||
/*
|
||||
@@ -66,14 +67,15 @@
|
||||
const xmlToValue = (node) => {
|
||||
if (node.tagName === 'dict') {
|
||||
const result = {};
|
||||
for (const child of node.children) {
|
||||
const children = Array.from(node.childNodes).filter(child => child.nodeType === 1);
|
||||
for (const [index, child] of children.entries()) {
|
||||
if (child.tagName === 'key') {
|
||||
result[child.textContent] = xmlToValue(child.nextElementSibling);
|
||||
Object.defineProperty(result, child.textContent, {value: xmlToValue(children[index + 1]), enumerable: true, writable: true, configurable: true});
|
||||
}
|
||||
}
|
||||
return result;
|
||||
} else if (node.tagName === 'array') {
|
||||
return Array.from(node.children).map(xmlToValue);
|
||||
return Array.from(node.childNodes).filter(child => child.nodeType === 1).map(xmlToValue);
|
||||
} else if (node.tagName === 'string') {
|
||||
return node.textContent;
|
||||
} else if (node.tagName === 'true') {
|
||||
@@ -115,18 +117,18 @@ const valueToXml = (doc, value) => {
|
||||
};
|
||||
|
||||
export const parsePlist = (string) => {
|
||||
const xml = new DOMParser().parseFromString(string, 'text/xml');
|
||||
const rootNode = xml.children[0];
|
||||
const rootDict = rootNode.children[0];
|
||||
const xml = Adapter && Adapter.parseXML ? Adapter.parseXML(string) : new DOMParser().parseFromString(string, 'text/xml');
|
||||
const rootNode = xml.documentElement;
|
||||
const rootDict = Array.from(rootNode.childNodes).find(child => child.nodeType === 1);
|
||||
return xmlToValue(rootDict);
|
||||
};
|
||||
|
||||
export const generatePlist = (values) => {
|
||||
const xml = document.implementation.createDocument(null, "plist");
|
||||
const xml = Adapter && Adapter.createXMLDocument ? Adapter.createXMLDocument('plist') : document.implementation.createDocument(null, "plist");
|
||||
const rootNode = xml.documentElement;
|
||||
rootNode.setAttribute('version', '1.0');
|
||||
rootNode.appendChild(valueToXml(xml, values));
|
||||
const serialized = new XMLSerializer().serializeToString(xml);
|
||||
const serialized = Adapter && Adapter.serializeXML ? Adapter.serializeXML(xml) : new XMLSerializer().serializeToString(xml);
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
${serialized}`;
|
||||
|
||||
@@ -4,6 +4,43 @@ import {readAsURL, readAsArrayBuffer} from '../../common/readers';
|
||||
import defaultIcon from '../images/default-icon.png';
|
||||
|
||||
class WebAdapter {
|
||||
linkSpecialized (options) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const urls = [];
|
||||
let workerURL = new URL('specialized-worker.js', document.baseURI);
|
||||
if (window.__ASSETS__ && window.__ASSETS__['specialized-worker.js']) {
|
||||
const assets = window.__ASSETS__;
|
||||
workerURL = URL.createObjectURL(new Blob([
|
||||
`self.__specializedWasmBytes = Uint8Array.from(atob(${JSON.stringify(assets['bindings_wasm_bg.wasm'])}), c => c.charCodeAt(0));\n`,
|
||||
assets['rollup.browser.js'], '\n', assets['specialized-worker.js']
|
||||
], {type: 'text/javascript'}));
|
||||
urls.push(workerURL);
|
||||
}
|
||||
const worker = new Worker(workerURL);
|
||||
const cleanup = () => { worker.terminate(); urls.forEach(url => URL.revokeObjectURL(url)); };
|
||||
worker.onmessage = ({data}) => {
|
||||
cleanup();
|
||||
if (data.error) reject(Object.assign(new Error(data.error), {missingDependency: data.missingDependency})); else resolve(data.result);
|
||||
};
|
||||
worker.onerror = event => { cleanup(); reject(new Error(event.message)); };
|
||||
worker.postMessage(options);
|
||||
});
|
||||
}
|
||||
prepareExtension (options) { return this.linkSpecialized({operation: 'extension', options}); }
|
||||
inspectExtension (code) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const url = URL.createObjectURL(new Blob([code], {type: 'text/javascript'}));
|
||||
const worker = new Worker(url);
|
||||
const cleanup = () => { clearTimeout(timer); worker.terminate(); URL.revokeObjectURL(url); };
|
||||
const timer = setTimeout(() => { cleanup(); reject(new Error('Extension metadata collection timed out')); }, 5000);
|
||||
worker.onmessage = ({data}) => {
|
||||
if (data.type !== 'ready') return;
|
||||
cleanup();
|
||||
if (!data.infos.length) reject(new Error('Extension must register during initialization')); else resolve(data.infos);
|
||||
};
|
||||
worker.onerror = event => { cleanup(); reject(new Error(event.message)); };
|
||||
});
|
||||
}
|
||||
getCachedAsset (asset) {
|
||||
return assetCache.get(asset)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import largeAssets from '../large-assets';
|
||||
import {buildId} from '../build-id';
|
||||
import Database from '../../common/idb';
|
||||
|
||||
const DATABASE_NAME = 'p4-large-assets';
|
||||
@@ -12,7 +11,7 @@ const getAssetId = (asset) => {
|
||||
if (asset.sha256) {
|
||||
return asset.sha256;
|
||||
}
|
||||
return `${buildId}-${JSON.stringify(asset.src)}`;
|
||||
throw new Error('Cached desktop resources require a content hash');
|
||||
};
|
||||
|
||||
const removeExtraneous = async () => {
|
||||
|
||||
@@ -5,3 +5,4 @@ import WebAdapter from './adapter';
|
||||
setAdapter(new WebAdapter());
|
||||
|
||||
export default Packager;
|
||||
export {compileProject} from '../compile-project';
|
||||
@@ -99,6 +99,14 @@ class WebSocketProvider {
|
||||
this.openConnection();
|
||||
}
|
||||
|
||||
dispose () {
|
||||
clearTimeout(this.reconnectTimeout);
|
||||
if (this.ws) {
|
||||
this.ws.onclose = this.ws.onmessage = this.ws.onopen = this.ws.onerror = null;
|
||||
this.ws.close();
|
||||
}
|
||||
}
|
||||
|
||||
setProjectId (id) {
|
||||
this.projectId = id;
|
||||
this.closeAndReconnect();
|
||||
@@ -282,6 +290,10 @@ class LocalStorageProvider {
|
||||
this.variables[name] = value;
|
||||
this.storeToLocalStorage();
|
||||
}
|
||||
|
||||
dispose () {
|
||||
window.removeEventListener('storage', this.handleStorageEvent);
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
|
||||
@@ -79,17 +79,33 @@ class Scaffolding extends EventTarget {
|
||||
this._topControls = new ControlBar();
|
||||
this._layers.appendChild(this._topControls.root);
|
||||
|
||||
document.addEventListener('mousemove', this._onmousemove.bind(this));
|
||||
this._canvas.addEventListener('mousedown', this._onmousedown.bind(this));
|
||||
document.addEventListener('mouseup', this._onmouseup.bind(this));
|
||||
this._canvas.addEventListener('touchstart', this._ontouchstart.bind(this));
|
||||
document.addEventListener('touchmove', this._ontouchmove.bind(this));
|
||||
document.addEventListener('touchend', this._ontouchend.bind(this));
|
||||
this._canvas.addEventListener('contextmenu', this._oncontextmenu.bind(this));
|
||||
this._canvas.addEventListener('wheel', this._onwheel.bind(this));
|
||||
document.addEventListener('keydown', this._onkeydown.bind(this));
|
||||
document.addEventListener('keyup', this._onkeyup.bind(this));
|
||||
window.addEventListener('resize', this._onresize.bind(this));
|
||||
this._listeners = [];
|
||||
this._listen(document, 'mousemove', this._onmousemove.bind(this));
|
||||
this._listen(this._canvas, 'mousedown', this._onmousedown.bind(this));
|
||||
this._listen(document, 'mouseup', this._onmouseup.bind(this));
|
||||
this._listen(this._canvas, 'touchstart', this._ontouchstart.bind(this));
|
||||
this._listen(document, 'touchmove', this._ontouchmove.bind(this));
|
||||
this._listen(document, 'touchend', this._ontouchend.bind(this));
|
||||
this._listen(this._canvas, 'contextmenu', this._oncontextmenu.bind(this));
|
||||
this._listen(this._canvas, 'wheel', this._onwheel.bind(this));
|
||||
this._listen(document, 'keydown', this._onkeydown.bind(this));
|
||||
this._listen(document, 'keyup', this._onkeyup.bind(this));
|
||||
this._listen(window, 'resize', this._onresize.bind(this));
|
||||
}
|
||||
|
||||
_listen (element, type, listener, options) {
|
||||
element.addEventListener(type, listener, options);
|
||||
this._listeners.push(() => element.removeEventListener(type, listener, options));
|
||||
}
|
||||
|
||||
_specializedDispose () {
|
||||
this._disposed = true;
|
||||
this._listeners.splice(0).forEach(remove => remove());
|
||||
for (const provider of this.cloudManager ? this.cloudManager.providers : []) if (provider.dispose) provider.dispose();
|
||||
if (this.videoProvider) this.videoProvider.disableVideo();
|
||||
this._cancelDragTimeout();
|
||||
if (this._question) this._question.destroy();
|
||||
this._root.remove();
|
||||
}
|
||||
|
||||
_addLayer (el) {
|
||||
@@ -452,14 +468,16 @@ class Scaffolding extends EventTarget {
|
||||
}
|
||||
|
||||
loadProject (data) {
|
||||
if (this._disposed) return Promise.reject(new Error('Player disposed'));
|
||||
return this.vm.loadProject(data)
|
||||
.then(() => {
|
||||
if (this._disposed) { this.vm.clear(); return; }
|
||||
this.vm.setCloudProvider(this.cloudManager);
|
||||
this.cloudManager.projectReady();
|
||||
this.renderer.draw();
|
||||
// Render again after a short delay because some costumes are loaded async
|
||||
setTimeout(() => {
|
||||
this.renderer.draw();
|
||||
if (!this._disposed) this.renderer.draw();
|
||||
});
|
||||
|
||||
if (this.shouldConnectPeripherals) {
|
||||
@@ -468,20 +486,6 @@ class Scaffolding extends EventTarget {
|
||||
});
|
||||
}
|
||||
|
||||
loadCompiledProjectPackage (data, options) {
|
||||
if (typeof this.vm.loadCompiledProjectPackage !== 'function') {
|
||||
return Promise.reject(new Error('This Scratch VM does not support compiled project packages'));
|
||||
}
|
||||
return this.vm.loadCompiledProjectPackage(data, options)
|
||||
.then(() => {
|
||||
this.vm.setCloudProvider(this.cloudManager);
|
||||
this.cloudManager.projectReady();
|
||||
this.renderer.draw();
|
||||
setTimeout(() => this.renderer.draw());
|
||||
if (this.shouldConnectPeripherals) this._connectPeripherals();
|
||||
});
|
||||
}
|
||||
|
||||
setUsername (username) {
|
||||
this._username = username;
|
||||
this.vm.postIOData('userData', {
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
export const extensionLoaderBody = extensions => `{
|
||||
if (this.runtime.__spDisposed) throw new Error('Player disposed');
|
||||
if (this.isBuiltinExtension(url)) { this.loadExtensionIdSync(url); return; }
|
||||
const fixed = ${JSON.stringify(extensions.map(({token, infos, code}) => ({token, infos, code})))}.find(entry => entry.token === url);
|
||||
if (!fixed) throw new Error('Only extensions frozen into this player can be loaded');
|
||||
if (fixed.infos.every(info => this.isExtensionLoaded(info.id))) return;
|
||||
const objectURL = URL.createObjectURL(new Blob([fixed.code], {type: 'text/javascript'}));
|
||||
const worker = new Worker(objectURL);
|
||||
const pending = new Map();
|
||||
let sequence = 0;
|
||||
const lifetime = {close: () => {
|
||||
worker.terminate(); URL.revokeObjectURL(objectURL);
|
||||
for (const promise of pending.values()) promise.reject(new Error('Extension worker disposed'));
|
||||
pending.clear();
|
||||
}};
|
||||
(this.runtime.__spWorkers || (this.runtime.__spWorkers = [])).push(lifetime);
|
||||
await new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => { lifetime.close(); reject(new Error('Fixed extension startup timed out')); }, 10000);
|
||||
worker.onerror = event => { clearTimeout(timeout); lifetime.close(); reject(new Error(event.message)); };
|
||||
worker.onmessage = ({data}) => {
|
||||
if (data.type === 'result') {
|
||||
const promise = pending.get(data.id);
|
||||
if (promise) { pending.delete(data.id); if (data.error) promise.reject(new Error(data.error)); else promise.resolve(data.value); }
|
||||
return;
|
||||
}
|
||||
if (data.type !== 'ready') return;
|
||||
clearTimeout(timeout); URL.revokeObjectURL(objectURL);
|
||||
if (JSON.stringify(data.infos) !== JSON.stringify(fixed.infos)) {
|
||||
lifetime.close(); reject(new Error('Fixed extension metadata changed since build')); return;
|
||||
}
|
||||
try {
|
||||
fixed.infos.forEach((info, index) => {
|
||||
const proxy = {getInfo: () => JSON.parse(JSON.stringify(info))};
|
||||
for (const block of info.blocks || []) if (block && typeof block === 'object' && block.opcode) {
|
||||
const method = block.func || block.opcode;
|
||||
proxy[method] = args => new Promise((resolve, reject) => {
|
||||
const id = sequence++;
|
||||
pending.set(id, {resolve, reject});
|
||||
worker.postMessage({type: 'call', id, extension: index, method, args});
|
||||
});
|
||||
}
|
||||
const service = this._registerInternalExtension(proxy);
|
||||
this._loadedExtensions.set(info.id, service);
|
||||
});
|
||||
resolve();
|
||||
} catch (error) { lifetime.close(); reject(error); }
|
||||
};
|
||||
});
|
||||
}`;
|
||||
@@ -0,0 +1,114 @@
|
||||
import {parse} from '@babel/parser';
|
||||
import traverseModule from '@babel/traverse';
|
||||
import generateModule from '@babel/generator';
|
||||
import * as t from '@babel/types';
|
||||
import {auditArtifact} from './link.mjs';
|
||||
const traverse = traverseModule.default || traverseModule;
|
||||
const generate = generateModule.default || generateModule;
|
||||
|
||||
// All code dependencies must be provided to this memory linker. URL fetching is
|
||||
// a build-host responsibility; the resulting worker cannot load more scripts.
|
||||
export async function prepareExtension({url, sources}, sourcePack, rollup) {
|
||||
const missing = name => {
|
||||
const error = new Error(`Missing frozen extension dependency: ${name}`);
|
||||
error.missingDependency = name;
|
||||
throw error;
|
||||
};
|
||||
const visiting = new Set();
|
||||
const included = new Set();
|
||||
const flatten = name => {
|
||||
if (!Object.hasOwn(sources, name)) missing(name);
|
||||
if (visiting.has(name)) throw new Error(`Cyclic importScripts dependency: ${name}`);
|
||||
visiting.add(name); included.add(name);
|
||||
const tree = parse(sources[name], {sourceType: 'module'});
|
||||
traverse(tree, {
|
||||
VariableDeclarator(p) {
|
||||
if (t.isIdentifier(p.node.init, {name: 'Scratch'}) && t.isObjectPattern(p.node.id) && p.node.id.properties.some(property =>
|
||||
t.isObjectProperty(property) && ['vm', 'runtime', 'compiler'].includes(property.key.name || property.key.value))) {
|
||||
throw new Error('Fixed extensions cannot access the VM or modify the compiler');
|
||||
}
|
||||
},
|
||||
ReferencedIdentifier(p) {
|
||||
if (p.node.name === 'vm' && !p.scope.hasBinding('vm')) throw new Error('Fixed extensions cannot access a global VM');
|
||||
},
|
||||
MemberExpression(p) {
|
||||
const node = p.node;
|
||||
const key = node.computed ? t.isStringLiteral(node.property) && node.property.value : node.property.name;
|
||||
if (t.isIdentifier(node.object, {name: 'Scratch'}) &&
|
||||
(!key || ['vm', 'runtime', 'compiler'].includes(key))) throw new Error('Fixed extensions cannot access the VM or modify the compiler');
|
||||
if (t.isMemberExpression(node.object) && t.isIdentifier(node.object.object, {name: 'Scratch'}) &&
|
||||
node.object.property.name === 'extensions' && key && !['register', 'unsandboxed'].includes(key)) {
|
||||
throw new Error('Extensions cannot load or register new extension code at runtime');
|
||||
}
|
||||
},
|
||||
CallExpression(p) {
|
||||
if (!t.isIdentifier(p.node.callee, {name: 'importScripts'})) return;
|
||||
if (!p.parentPath.isExpressionStatement() || !p.node.arguments.every(t.isStringLiteral)) {
|
||||
throw new Error('Extension code dependencies must use literal importScripts URLs');
|
||||
}
|
||||
const statements = p.node.arguments.flatMap(arg => flatten(new URL(arg.value, name).href));
|
||||
p.parentPath.replaceWithMultiple(statements); p.skip();
|
||||
},
|
||||
ImportDeclaration(p) {
|
||||
p.node.source.value = new URL(p.node.source.value.value, name).href;
|
||||
},
|
||||
'ExportNamedDeclaration|ExportAllDeclaration'(p) {
|
||||
if (p.node.source) p.node.source.value = new URL(p.node.source.value, name).href;
|
||||
}
|
||||
});
|
||||
visiting.delete(name);
|
||||
return tree.program.body;
|
||||
};
|
||||
const body = generate(t.file(t.program(flatten(url))), {comments: false}).code;
|
||||
const bootstrap = `import common from ${JSON.stringify(sourcePack.extensionApi)};
|
||||
const objects = [];
|
||||
const deny = () => { throw new Error('This operation is unavailable in a fixed sandboxed extension'); };
|
||||
const Scratch = Object.freeze({...common, external: deny,
|
||||
extensions: Object.freeze({unsandboxed: false, register: object => objects.push(object)}),
|
||||
translate: message => typeof message === 'string' ? message : message.default,
|
||||
canFetch: () => Promise.resolve(true), fetch: (...args) => fetch(...args),
|
||||
canOpenWindow: () => Promise.resolve(false), openWindow: deny,
|
||||
canRedirect: () => Promise.resolve(false), redirect: deny,
|
||||
canRecordAudio: () => Promise.resolve(false), canRecordVideo: () => Promise.resolve(false)});
|
||||
self.Scratch = Scratch;
|
||||
${body}
|
||||
const infos = objects.map(object => object.getInfo());
|
||||
self.postMessage({type: 'ready', infos});
|
||||
self.onmessage = async ({data}) => {
|
||||
if (data.type !== 'call') return;
|
||||
try {
|
||||
const object = objects[data.extension];
|
||||
const info = infos[data.extension];
|
||||
const block = info.blocks.find(block => block && typeof block === 'object' && (block.func || block.opcode) === data.method);
|
||||
if (!block) throw new Error('Unregistered extension operation');
|
||||
let value = await object[data.method](data.args);
|
||||
if (!['number', 'string', 'boolean'].includes(typeof value)) value = String(value);
|
||||
self.postMessage({type: 'result', id: data.id, value});
|
||||
} catch (error) { self.postMessage({type: 'result', id: data.id, error: String(error)}); }
|
||||
};`;
|
||||
const modules = {...sourcePack.sources, ...sources, 'fixed-extension.js': bootstrap};
|
||||
const bundle = await rollup({input: 'fixed-extension.js', plugins: [{
|
||||
name: 'frozen-extension', resolveId(source, importer) {
|
||||
let id = source;
|
||||
if (source.startsWith('.')) {
|
||||
if (/^https?:/.test(importer)) id = new URL(source, importer).href;
|
||||
else {
|
||||
const parts = (importer || '').split('/'); parts.pop();
|
||||
for (const part of source.split('/')) { if (part === '..') parts.pop(); else if (part !== '.') parts.push(part); }
|
||||
id = parts.join('/');
|
||||
}
|
||||
}
|
||||
if (!Object.hasOwn(modules, id)) missing(id);
|
||||
return id;
|
||||
}, load(id) {
|
||||
if (Object.hasOwn(sources, id)) return generate(t.file(t.program(flatten(id))), {comments: false}).code;
|
||||
return modules[id];
|
||||
}
|
||||
}], onwarn(warning) { if (warning.code !== 'CIRCULAR_DEPENDENCY') throw new Error(warning.message); }});
|
||||
try {
|
||||
const {output} = await bundle.generate({format: 'iife', inlineDynamicImports: true});
|
||||
const code = output[0].code;
|
||||
auditArtifact(code, {allowGenerators: true});
|
||||
return {code, dependencies: [...included].sort()};
|
||||
} finally { await bundle.close(); }
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
// Parsed into the joint link input at build time, never evaluated as runtime text.
|
||||
export const facadeSource = String.raw`let playerLoaded, playerError; (() => {
|
||||
const listeners = new Map();
|
||||
const types = new Set(['loaded', 'state', 'problem', 'error']);
|
||||
let disposed = false;
|
||||
let loaded = false;
|
||||
const emit = (type, data) => {
|
||||
const event = Object.freeze({type, ...data});
|
||||
for (const listener of [...(listeners.get(type) || [])]) {
|
||||
try { listener(event); } catch (error) { console.error(error); }
|
||||
}
|
||||
};
|
||||
const guard = () => { if (disposed) throw new Error('Player disposed'); };
|
||||
const start = () => { guard(); if (!loaded) throw new Error('Player is loading'); scaffolding.start(); };
|
||||
const stop = () => { guard(); vm.stopAll(); };
|
||||
const pause = () => { guard(); vm.setPaused(true); emit('state', {state: 'paused'}); };
|
||||
const resume = () => { guard(); vm.setPaused(false); emit('state', {state: 'running'}); };
|
||||
const on = (type, listener) => {
|
||||
guard();
|
||||
if (!types.has(type) || typeof listener !== 'function') throw new TypeError('Invalid player listener');
|
||||
if (!listeners.has(type)) listeners.set(type, new Set());
|
||||
listeners.get(type).add(listener);
|
||||
};
|
||||
const off = (type, listener) => { if (listeners.has(type)) listeners.get(type).delete(listener); };
|
||||
const postInput = (device, data) => {
|
||||
guard();
|
||||
const fields = {keyboard: ['key', 'keyCode', 'isDown'], mouse: ['x', 'y', 'canvasWidth', 'canvasHeight', 'isDown', 'button'],
|
||||
mouseWheel: ['deltaX', 'deltaY'], userData: ['username']};
|
||||
if (!Object.prototype.hasOwnProperty.call(fields, device) || !data || typeof data !== 'object') throw new TypeError('Invalid input device');
|
||||
const copy = {};
|
||||
for (const key of fields[device]) {
|
||||
const value = data[key];
|
||||
if (['string', 'number', 'boolean'].includes(typeof value)) copy[key] = value;
|
||||
}
|
||||
vm.postIOData(device, copy);
|
||||
};
|
||||
const dispose = () => {
|
||||
if (disposed) return;
|
||||
disposed = true; vm.runtime.__spDisposed = true; vm.stopAll(); vm.quit(); vm.clear();
|
||||
for (const worker of vm.runtime.__spWorkers || []) worker.close();
|
||||
if (vm.runtime.audioEngine && vm.runtime.audioEngine.audioContext) vm.runtime.audioEngine.audioContext.close();
|
||||
scaffolding._specializedDispose();
|
||||
emit('state', {state: 'disposed'}); listeners.clear();
|
||||
};
|
||||
vm.runtime.on('PROJECT_RUN_START', () => emit('state', {state: 'running'}));
|
||||
vm.runtime.on('PROJECT_RUN_STOP', () => emit('state', {state: vm.isPaused() ? 'paused' : 'stopped'}));
|
||||
window.player = Object.freeze({start, stop, pause, resume, postInput, on, off, dispose});
|
||||
playerLoaded = () => { if (disposed) return; loaded = true; emit('loaded', {}); };
|
||||
playerError = error => { if (disposed) return; console.error(error); emit('error', {message: String(error && error.message || error)}); };
|
||||
})();`;
|
||||
@@ -0,0 +1,42 @@
|
||||
import {spawn} from 'node:child_process';
|
||||
|
||||
// Metadata collection runs in a short-lived, permission-restricted process. The
|
||||
// context receives no host objects, filesystem, environment, VM, or compiler.
|
||||
const inspector = String.raw`
|
||||
const vm = require('node:vm');
|
||||
let input = '';
|
||||
process.stdin.setEncoding('utf8');
|
||||
process.stdin.on('data', chunk => { input += chunk; if (input.length > 16000000) process.exit(2); });
|
||||
process.stdin.on('end', () => {
|
||||
try {
|
||||
const context = vm.createContext(Object.create(null), {codeGeneration: {strings: false, wasm: false}, microtaskMode: 'afterEvaluate'});
|
||||
vm.runInContext('var __messages = []; var self = {postMessage: value => __messages.push(value)}; var console = {log(){},warn(){},error(){}};', context);
|
||||
vm.runInContext(JSON.parse(input).code, context, {timeout: 3000});
|
||||
const messages = JSON.parse(vm.runInContext('JSON.stringify(__messages)', context, {timeout: 1000}));
|
||||
const ready = messages.find(message => message.type === 'ready');
|
||||
if (!ready || !ready.infos.length) throw new Error('Extension must register during initialization');
|
||||
process.stdout.write(JSON.stringify({infos: ready.infos}));
|
||||
} catch (error) { process.stdout.write(JSON.stringify({error: String(error)})); }
|
||||
});`;
|
||||
|
||||
export function inspectExtension(code) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(process.execPath, ['--permission', '--disable-proto=throw', '--eval', inspector],
|
||||
{env: {}, stdio: ['pipe', 'pipe', 'pipe']});
|
||||
let output = '', errors = '';
|
||||
const timeout = setTimeout(() => { child.kill(); reject(new Error('Extension metadata collection timed out')); }, 5000);
|
||||
child.stdout.on('data', value => { output += value; if (output.length > 1000000) child.kill(); });
|
||||
child.stderr.on('data', value => { if (errors.length < 10000) errors += value; });
|
||||
child.on('error', error => { clearTimeout(timeout); reject(error); });
|
||||
child.on('close', status => {
|
||||
clearTimeout(timeout);
|
||||
try {
|
||||
if (status !== 0) throw new Error(`Extension inspector failed (${status}): ${errors}`);
|
||||
const result = JSON.parse(output);
|
||||
if (result.error) throw new Error(result.error);
|
||||
resolve(result.infos);
|
||||
} catch (error) { reject(error); }
|
||||
});
|
||||
child.stdin.end(JSON.stringify({code}));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,870 @@
|
||||
import {parse} from '@babel/parser';
|
||||
import traverseModule from '@babel/traverse';
|
||||
import generateModule from '@babel/generator';
|
||||
import * as t from '@babel/types';
|
||||
import {minify} from 'terser';
|
||||
import {extensionLoaderBody} from './extension-runtime.mjs';
|
||||
|
||||
const traverse = traverseModule.default || traverseModule;
|
||||
const generate = generateModule.default || generateModule;
|
||||
const parseCode = code => parse(code, {sourceType: 'module'});
|
||||
const print = tree => generate(tree, {comments: false}).code;
|
||||
const parseMethod = source => parseCode(`class A { ${source} }`).program.body[0].body.body[0];
|
||||
const keyName = node => node.computed ? t.isStringLiteral(node.key) && node.key.value : node.key.name || node.key.value;
|
||||
const q = JSON.stringify;
|
||||
|
||||
function cachePureComputation(tree, blocks) {
|
||||
const provenReferences = new Map(blocks.map(block => [block.id, new Set(block.cacheableReferences || [])]));
|
||||
const helpers = new Set(['toNotNaN', 'toBoolean', 'compareEqual', 'compareLessThan', 'compareGreaterThan',
|
||||
'mod', 'randomInt', 'randomFloat', 'random', 'listGet', 'listReplace', 'listInsert', 'listDelete',
|
||||
'listContains', 'listIndexOf', 'listContents', 'letterOf', 'stringContains', 'tan']);
|
||||
let count = 0;
|
||||
traverse(tree, {LabeledStatement: {exit(region) {
|
||||
if (region.node.label.name !== '__spCompute') return;
|
||||
const casePath = region.findParent(p => p.isSwitchCase());
|
||||
const proven = casePath && provenReferences.get(casePath.node.test?.value);
|
||||
if (!proven) return;
|
||||
const candidates = new Map();
|
||||
const exits = [];
|
||||
let pure = true;
|
||||
region.traverse({
|
||||
LabeledStatement(p) {
|
||||
if (p.node.label.name === '__spLeave') { exits.push(p); p.skip(); }
|
||||
},
|
||||
Function() { pure = false; },
|
||||
CallExpression(p) {
|
||||
const callee = p.node.callee;
|
||||
if (t.isIdentifier(callee) && helpers.has(callee.name)) {
|
||||
const binding = p.scope.getBinding(callee.name);
|
||||
if (binding?.path.isImportSpecifier() && binding.path.parent.source.value === 'specialized-helpers.js') return;
|
||||
}
|
||||
if (t.isMemberExpression(callee) && !callee.computed &&
|
||||
t.isIdentifier(callee.object, {name: 'Math'}) && !p.scope.getBinding('Math')) return;
|
||||
// Native array operations on owned Scratch list values have no VM callbacks.
|
||||
if (t.isMemberExpression(callee) && !callee.computed && ['push', 'pop', 'splice'].includes(callee.property.name) &&
|
||||
t.isMemberExpression(callee.object) && !callee.object.computed &&
|
||||
callee.object.property.name === 'value' && t.isIdentifier(callee.object.object) &&
|
||||
callee.object.object.name.startsWith('__spV')) return;
|
||||
pure = false;
|
||||
},
|
||||
MemberExpression(p) {
|
||||
const node = p.node;
|
||||
if (!t.isIdentifier(node.object) || !proven.has(node.object.name) || node.computed || node.property.name !== 'value') return;
|
||||
const binding = p.scope.getBinding(node.object.name);
|
||||
if (!binding?.path.isVariableDeclarator() || !t.isLogicalExpression(binding.path.node.init)) return;
|
||||
const assignment = binding.path.node.init.right;
|
||||
if (!t.isAssignmentExpression(assignment) || !t.isMemberExpression(assignment.right) ||
|
||||
!t.isStringLiteral(assignment.right.property) || !t.isMemberExpression(assignment.right.object) ||
|
||||
assignment.right.object.computed || assignment.right.object.property.name !== 'variables' ||
|
||||
!t.isIdentifier(assignment.right.object.object) ||
|
||||
!['target', 'stage'].includes(assignment.right.object.object.name)) return;
|
||||
if (!candidates.has(binding)) candidates.set(binding, []);
|
||||
candidates.get(binding).push(p);
|
||||
}
|
||||
});
|
||||
if (!pure) return;
|
||||
const declarations = [], commits = [];
|
||||
const ids = new Map();
|
||||
const aliases = new Set([...candidates.keys()].flatMap(binding => Object.values(binding.scope.bindings)));
|
||||
for (const binding of aliases) {
|
||||
const init = binding.path.node.init;
|
||||
if (!t.isLogicalExpression(init) || !t.isAssignmentExpression(init.right) ||
|
||||
!t.isMemberExpression(init.right.right) || !t.isStringLiteral(init.right.right.property)) continue;
|
||||
const id = init.right.right.property.value;
|
||||
ids.set(id, (ids.get(id) || 0) + 1);
|
||||
}
|
||||
for (const [binding, accesses] of candidates) {
|
||||
// Stage scripts can reference the same object through target and stage.
|
||||
if (ids.get(binding.path.node.init.right.right.property.value) > 1) continue;
|
||||
// A helper receiving the variable object might observe or change .value.
|
||||
// Keep those objects live; cache only scalars/array references used directly.
|
||||
const scopedReferences = binding.referencePaths.filter(p => p.findParent(parent => parent === region));
|
||||
if (scopedReferences.some(p => !p.parentPath.isMemberExpression() || p.parentPath.node.object !== p.node ||
|
||||
p.parentPath.node.computed || p.parentPath.node.property.name !== 'value')) continue;
|
||||
if (accesses.some(p => p.parentPath.isCallExpression() && p.parentPath.node.callee === p.node)) continue;
|
||||
const temporary = region.scope.generateUidIdentifier('scalar');
|
||||
declarations.push(t.variableDeclarator(t.cloneNode(temporary), t.memberExpression(t.identifier(binding.identifier.name), t.identifier('value'))));
|
||||
let written = false;
|
||||
for (const access of accesses) {
|
||||
if (access.parentPath.isAssignmentExpression() && access.parentPath.node.left === access.node || access.parentPath.isUpdateExpression()) written = true;
|
||||
access.replaceWith(t.cloneNode(temporary));
|
||||
}
|
||||
if (written) commits.push(t.expressionStatement(t.assignmentExpression('=',
|
||||
t.memberExpression(t.identifier(binding.identifier.name), t.identifier('value')), t.cloneNode(temporary))));
|
||||
count++;
|
||||
}
|
||||
if (declarations.length) {
|
||||
// Commit before the fused VM return/retirement path can emit an event or
|
||||
// enter another continuation. The finally also commits on an exception.
|
||||
for (const exit of exits) exit.node.body.body.unshift(...commits.map(node => t.cloneNode(node, true)));
|
||||
const body = region.node.body;
|
||||
region.node.body = t.blockStatement([t.variableDeclaration('let', declarations),
|
||||
...(commits.length ? [t.tryStatement(body, null, t.blockStatement(commits))] : body.body)]);
|
||||
}
|
||||
}}});
|
||||
return count;
|
||||
}
|
||||
|
||||
function removeUnusedImports(tree) {
|
||||
traverse(tree, {Program(p) {
|
||||
p.scope.crawl();
|
||||
for (const binding of Object.values(p.scope.bindings)) {
|
||||
if (!binding.referenced && binding.path.isVariableDeclarator() && t.isIdentifier(binding.path.node.init)) binding.path.remove();
|
||||
}
|
||||
p.scope.crawl();
|
||||
for (const statement of p.get('body')) {
|
||||
if (!statement.isImportDeclaration() || statement.node.specifiers.length === 0) continue;
|
||||
statement.node.specifiers = statement.node.specifiers.filter(specifier => p.scope.getBinding(specifier.local.name)?.referenced);
|
||||
if (statement.node.specifiers.length === 0) statement.remove();
|
||||
}
|
||||
}});
|
||||
}
|
||||
|
||||
export const seedNumber = seed => {
|
||||
let hash = 2166136261;
|
||||
for (const char of String(seed)) { hash ^= char.charCodeAt(0); hash = Math.imul(hash, 16777619); }
|
||||
return hash >>> 0;
|
||||
};
|
||||
|
||||
export function dispatchGroups(blocks) {
|
||||
if (blocks.length && blocks.every(block => Number.isInteger(block.dispatchGroup))) {
|
||||
const grouped = new Map();
|
||||
for (const block of [...blocks].sort((a, b) => a.id - b.id)) {
|
||||
if (!grouped.has(block.dispatchGroup)) grouped.set(block.dispatchGroup, []);
|
||||
grouped.get(block.dispatchGroup).push(block);
|
||||
}
|
||||
return [...grouped.values()];
|
||||
}
|
||||
const groups = [];
|
||||
let group = [], length = 0;
|
||||
for (const block of [...blocks].sort((a, b) => a.id - b.id)) {
|
||||
const size = block.code.join('\n').length;
|
||||
// Limit actual functions, not just switches in one enormous function. Keep
|
||||
// native hot loops intact even when a single region exceeds this budget.
|
||||
if (group.length && (group.length >= 256 || length + size > 12000)) {
|
||||
groups.push(group); group = []; length = 0;
|
||||
}
|
||||
group.push(block); length += size;
|
||||
}
|
||||
if (group.length) groups.push(group);
|
||||
return groups;
|
||||
}
|
||||
|
||||
export function stepMethods(blocks) {
|
||||
const groups = dispatchGroups(blocks);
|
||||
if (groups.length <= 1) return {stepThread: stepBody(blocks)};
|
||||
const methods = {};
|
||||
const encoded = groups.every((group, index) => group.every(block => (block.id >>> 8) === index + 1));
|
||||
const dispatch = (from, to) => {
|
||||
if (encoded && to - from <= 256) return `switch (pc >>> 8) {
|
||||
${groups.slice(from, to).map((_, offset) => `case ${from + offset + 1}:
|
||||
if (!this.__spDispatch${from + offset}(thread)) return; break;`).join('\n')}
|
||||
default: throw new Error('Invalid continuation');
|
||||
}`;
|
||||
if (to - from === 1) return `if (!this.__spDispatch${from}(thread)) return;`;
|
||||
const middle = (from + to) >>> 1;
|
||||
return `if (pc <= ${groups[middle - 1].at(-1).id}) { ${dispatch(from, middle)} }
|
||||
else { ${dispatch(middle, to)} }`;
|
||||
};
|
||||
methods.stepThread = `{
|
||||
if (thread.updateMonitor) { execute(this, thread); return; }
|
||||
__spRun: while (!thread.isKilled && thread.status !== 4 && thread.__spFrames.length) {
|
||||
const pc = thread.__spFrames[thread.__spFrames.length - 1].__spPC;
|
||||
if (pc < ${groups[0][0].id} || pc > ${groups.at(-1).at(-1).id}) throw new Error('Invalid continuation');
|
||||
${dispatch(0, groups.length)}
|
||||
}
|
||||
}`;
|
||||
groups.forEach((group, index) => { methods[`__spDispatch${index}`] = stepBody(group, true); });
|
||||
return methods;
|
||||
}
|
||||
|
||||
export function stepBody(blocks, transfer = false) {
|
||||
const ordered = [...blocks].sort((a, b) => a.id - b.id);
|
||||
const dispatch = regions => {
|
||||
if (regions.length <= 256) return `switch (frame.__spPC) {
|
||||
${regions.map(block => `case ${block.id}: { ${block.code.join('\n')} }`).join('\n')}
|
||||
default:
|
||||
${transfer ? `if (frame.__spPC < ${ordered[0].id} || frame.__spPC > ${ordered.at(-1).id}) return true;` : ''}
|
||||
throw new Error('Invalid continuation');
|
||||
}`;
|
||||
const middle = Math.ceil(regions.length / 512) * 256;
|
||||
return `if (frame.__spPC <= ${regions[middle - 1].id}) {
|
||||
${dispatch(regions.slice(0, middle))}
|
||||
} else { ${dispatch(regions.slice(middle))} }`;
|
||||
};
|
||||
return `{
|
||||
if (thread.updateMonitor) { execute(this, thread); return; }
|
||||
const target = thread.target, runtime = this.runtime;
|
||||
const variables = thread.__spVariables || (thread.__spVariables = []);
|
||||
${blocks.some(block => block.code.some(code => /\bstage\b/.test(code))) ? 'const stage = runtime.getTargetForStage();' : ''}
|
||||
const isStuck = () => {
|
||||
thread.__spStuck = (thread.__spStuck || 0) + 1;
|
||||
if (thread.__spStuck < 100) return false;
|
||||
thread.__spStuck = 0; return this.timer.timeElapsed() > 500;
|
||||
};
|
||||
const timer = () => { const value = new Timer({now: () => runtime.currentMSecs}); value.start(); return value; };
|
||||
const colorToList = color => Cast.toRgbColorList(color);
|
||||
const distance = menu => {
|
||||
if (target.isStage) return 10000;
|
||||
const other = menu === '_mouse_' ? {x: runtime.ioDevices.mouse.getScratchX(), y: runtime.ioDevices.mouse.getScratchY()} : runtime.getSpriteTargetByName(menu);
|
||||
return other ? Math.hypot(target.x - other.x, target.y - other.y) : 10000;
|
||||
};
|
||||
__spRun: while (!thread.isKilled && thread.status !== 4 && thread.__spFrames.length) {
|
||||
const frame = thread.__spFrames[thread.__spFrames.length - 1];
|
||||
${dispatch(ordered)}
|
||||
}
|
||||
}`;
|
||||
}
|
||||
|
||||
export function hatsBody(hats) {
|
||||
return `{
|
||||
const specs = ${q(hats)};
|
||||
const originalLength = this.threads.length;
|
||||
const started = [];
|
||||
// executableTargets order, including clones, is part of Scratch broadcast semantics.
|
||||
const candidates = target ? [target] : this.executableTargets;
|
||||
for (let i = candidates.length - 1; i >= 0; i--) {
|
||||
const candidate = candidates[i];
|
||||
for (const spec of specs) {
|
||||
if (spec.opcode !== opcode || spec.target !== candidate.sprite.__spTarget) continue;
|
||||
if (fields && Object.keys(fields).some(key => spec.fields[key] !==
|
||||
(typeof fields[key] === 'string' ? fields[key].toUpperCase() : fields[key]))) continue;
|
||||
const id = String(spec.pc);
|
||||
if (spec.metadata.restartExistingThreads) {
|
||||
const existing = this.threadMap.get(Thread.getIdFromTargetAndBlock(candidate, id));
|
||||
if (existing) { started.push(this._restartThread(existing)); continue; }
|
||||
} else if (this.threads.slice(0, originalLength).some(thread =>
|
||||
thread.target === candidate && thread.topBlock === id && !thread.stackClick && thread.status !== 4)) continue;
|
||||
const thread = this._pushThread(id, candidate);
|
||||
thread.executableHat = spec.executable;
|
||||
started.push(thread);
|
||||
}
|
||||
}
|
||||
for (const thread of started) if (thread.executableHat) this.sequencer.stepThread(thread);
|
||||
return started;
|
||||
}`;
|
||||
}
|
||||
|
||||
function resolvePath(source, importer) {
|
||||
if (!source.startsWith('.')) return source;
|
||||
const parts = importer.split('/'); parts.pop();
|
||||
for (const part of source.split('/')) {
|
||||
if (part === '..') parts.pop(); else if (part !== '.') parts.push(part);
|
||||
}
|
||||
return parts.join('/');
|
||||
}
|
||||
|
||||
function referencedMembers(code) {
|
||||
const result = new Map();
|
||||
traverse(parseCode(code), {MemberExpression(p) {
|
||||
const node = p.node;
|
||||
if (!t.isMemberExpression(node.object) || node.computed || node.object.computed) return;
|
||||
if (!t.isIdentifier(node.object.object, {name: 'runtime'})) return;
|
||||
const name = node.object.property.name;
|
||||
if (!name.startsWith('ext_')) return;
|
||||
if (!result.has(name)) result.set(name, new Set());
|
||||
result.get(name).add(node.property.name);
|
||||
}});
|
||||
return result;
|
||||
}
|
||||
|
||||
function pruneClass(tree, roots, diagnostics, id) {
|
||||
const cls = tree.program.body.find(n => t.isClassDeclaration(n));
|
||||
if (!cls) return;
|
||||
const keep = new Set(['constructor', ...roots]);
|
||||
let changed = true;
|
||||
while (changed) {
|
||||
changed = false;
|
||||
for (const member of cls.body.body) if (keep.has(keyName(member))) {
|
||||
t.traverseFast(member, node => {
|
||||
if (!t.isMemberExpression(node) || !(t.isThisExpression(node.object) ||
|
||||
t.isIdentifier(node.object, {name: cls.id.name}))) return;
|
||||
const name = node.computed ? t.isStringLiteral(node.property) && node.property.value : node.property.name;
|
||||
if (!name) {
|
||||
// Reflection inside the retained implementation prevents safe member elimination.
|
||||
for (const other of cls.body.body) if (!keep.has(keyName(other))) { keep.add(keyName(other)); changed = true; }
|
||||
} else if (!keep.has(name)) { keep.add(name); changed = true; }
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const member of cls.body.body) {
|
||||
(keep.has(keyName(member)) ? diagnostics.retainedMembers : diagnostics.removedMembers).push(`${id}#${keyName(member)}`);
|
||||
}
|
||||
cls.body.body = cls.body.body.filter(member => keep.has(keyName(member)));
|
||||
}
|
||||
|
||||
const internalProperties = [
|
||||
'__spPC', '__spSlots', '__spArgs', '__spReturn', '__spFrames', '__spLifetime', '__spUtility',
|
||||
'__spTarget', '__spStuck', '__spInitialize', '__spPrimitive', '__spResumedPromise',
|
||||
'__spWorkers', '__spDisposed', '__spVariables'
|
||||
];
|
||||
|
||||
// Reviewed against the production Thread/Sequencer definitions and their callers
|
||||
// in Runtime, execute, block utilities and the pause addon. These objects never
|
||||
// cross the player/extension RPC boundary. Ambiguous fields such as status,
|
||||
// target, runtime and value intentionally remain outside this manifest.
|
||||
const schedulingProperties = {
|
||||
Sequencer: ['stepThread', 'stepThreads', 'retireThread'],
|
||||
Thread: ['getIdFromTargetAndBlock', 'pushStack', 'popStack', 'reuseStackForNextBlock',
|
||||
'peekStack', 'peekStackFrame', 'peekParentStackFrame', 'pushReportedValue',
|
||||
'atStackTop', 'goToNextBlock', 'isRecursiveCall']
|
||||
};
|
||||
const schedulingArity = {stepThread: 1, retireThread: 1, getIdFromTargetAndBlock: 2,
|
||||
pushStack: 1, reuseStackForNextBlock: 1, pushReportedValue: 1, isRecursiveCall: 1};
|
||||
|
||||
export function renameProperties(tree, seed, diagnostics, generatedProperties = []) {
|
||||
const declaredProperties = [...internalProperties, ...Object.values(schedulingProperties).flat(), ...generatedProperties];
|
||||
const schedulingOwner = new Map(Object.entries(schedulingProperties).flatMap(([owner, names]) => names.map(name => [name, owner])));
|
||||
const candidates = new Set(declaredProperties);
|
||||
const occupied = new Set();
|
||||
const reasons = new Map();
|
||||
const classes = new Map(), parameters = new Map();
|
||||
// Markers are inserted into the known source modules before Rollup. Resolve
|
||||
// bindings after linking instead of trusting a class/parameter's spelling.
|
||||
traverse(tree, {
|
||||
ClassMethod(p) {
|
||||
const family = Object.keys(schedulingProperties).find(name => keyName(p.node) === `__spOwner${name}`);
|
||||
if (!family) return;
|
||||
classes.set(p.findParent(parent => parent.isClass()).node, family); p.remove();
|
||||
},
|
||||
ExpressionStatement(p) {
|
||||
const value = p.node.expression;
|
||||
if (!t.isAssignmentExpression(value) || !t.isMemberExpression(value.left) ||
|
||||
value.left.property.name !== '__spOwnerExecute' || !t.isIdentifier(value.left.object)) return;
|
||||
const binding = p.scope.getBinding(value.left.object.name);
|
||||
if (!binding?.path.isFunctionDeclaration()) throw new Error('Monitor execution ownership marker lost its binding');
|
||||
const params = binding.path.node.params;
|
||||
if (params.length !== 2 || !params.every(param => t.isIdentifier(param))) {
|
||||
throw new Error('Monitor execution parameters changed; review scheduling ownership');
|
||||
}
|
||||
parameters.set(params[0], 'Sequencer'); parameters.set(params[1], 'Thread'); p.remove();
|
||||
}
|
||||
});
|
||||
for (const [cls, family] of classes) {
|
||||
if (family !== 'Sequencer') continue;
|
||||
for (const method of cls.body.body) {
|
||||
if (['stepThread', 'retireThread', ...generatedProperties].includes(keyName(method)) && method.params?.length) {
|
||||
parameters.set(method.params[0], 'Thread');
|
||||
}
|
||||
}
|
||||
}
|
||||
const reserve = (name, reason) => {
|
||||
if (!declaredProperties.includes(name)) return;
|
||||
candidates.delete(name);
|
||||
if (!reasons.has(name)) reasons.set(name, new Set());
|
||||
reasons.get(name).add(reason);
|
||||
};
|
||||
const isReflector = (node, p, seen = new Set()) => {
|
||||
if (!t.isIdentifier(node)) return false;
|
||||
const binding = p.scope.getBinding(node.name);
|
||||
if (!binding) return node.name === 'Object' || node.name === 'Reflect';
|
||||
if (!binding.constant || seen.has(binding) || !binding.path.isVariableDeclarator()) return false;
|
||||
seen.add(binding);
|
||||
return isReflector(binding.path.node.init, binding.path, seen);
|
||||
};
|
||||
// This is a conservative escape check, not inference of rename ownership from
|
||||
// a variable name. Ownership comes from the explicit manifest above.
|
||||
const familyOf = (node, p, seen = new Set()) => {
|
||||
if (t.isClassExpression(node)) return classes.get(node);
|
||||
if (t.isThisExpression(node)) {
|
||||
const fn = p.isClassMethod() ? p : p.findParent(parent => parent.isFunction() && !parent.isArrowFunctionExpression());
|
||||
const cls = fn?.isClassMethod() && fn.findParent(parent => parent.isClass());
|
||||
return cls && classes.get(cls.node);
|
||||
}
|
||||
if (t.isIdentifier(node)) {
|
||||
const binding = p.scope.getBinding(node.name);
|
||||
if (binding && !binding.constant) return;
|
||||
if (binding && parameters.has(binding.identifier)) return parameters.get(binding.identifier);
|
||||
if (binding?.path.isClass()) return classes.get(binding.path.node);
|
||||
if (binding && !seen.has(binding) && binding.path.isVariableDeclarator()) {
|
||||
seen.add(binding);
|
||||
const family = familyOf(binding.path.node.init, binding.path, seen);
|
||||
if (family) return family;
|
||||
const loop = binding.path.parentPath.parentPath;
|
||||
if (loop.isForOfStatement() && t.isMemberExpression(loop.node.right) &&
|
||||
!loop.node.right.computed && loop.node.right.property.name === 'threads') return 'Thread';
|
||||
}
|
||||
}
|
||||
if (t.isNewExpression(node) && t.isIdentifier(node.callee)) {
|
||||
return familyOf(node.callee, p, seen);
|
||||
}
|
||||
if ((t.isMemberExpression(node) || t.isOptionalMemberExpression(node)) && !node.computed) {
|
||||
if (node.property.name === 'sequencer') return 'Sequencer';
|
||||
if (node.property.name === 'thread' || node.property.name === 'activeThread') return 'Thread';
|
||||
}
|
||||
if ((t.isCallExpression(node) || t.isOptionalCallExpression(node)) &&
|
||||
(t.isMemberExpression(node.callee) || t.isOptionalMemberExpression(node.callee)) &&
|
||||
node.callee.property.name === 'getPrototypeOf' && isReflector(node.callee.object, p)) return familyOf(node.arguments[0], p, seen);
|
||||
};
|
||||
const reserveFamily = (node, p, reason) => {
|
||||
const possible = new Set(), seen = new Set();
|
||||
const collect = (value, path) => {
|
||||
const family = familyOf(value, path);
|
||||
if (family) possible.add(family);
|
||||
if (!t.isIdentifier(value)) return;
|
||||
const binding = path.scope.getBinding(value.name);
|
||||
if (!binding || seen.has(binding)) return;
|
||||
seen.add(binding);
|
||||
if (parameters.has(binding.identifier)) possible.add(parameters.get(binding.identifier));
|
||||
if (binding.path.isVariableDeclarator()) collect(binding.path.node.init, binding.path);
|
||||
for (const violation of binding.constantViolations) {
|
||||
if (violation.isAssignmentExpression()) collect(violation.node.right, violation);
|
||||
}
|
||||
};
|
||||
collect(node, p);
|
||||
for (const family of possible) for (const name of schedulingProperties[family]) reserve(name, reason);
|
||||
};
|
||||
// Any string-based reflection of an owned name makes it ineligible for this build.
|
||||
traverse(tree, {
|
||||
StringLiteral(p) {
|
||||
occupied.add(p.node.value);
|
||||
reserve(p.node.value, 'string access, reflection or externally observable literal');
|
||||
},
|
||||
Identifier(p) { occupied.add(p.node.name); },
|
||||
'CallExpression|OptionalCallExpression'(p) {
|
||||
if (!t.isMemberExpression(p.node.callee) && !t.isOptionalMemberExpression(p.node.callee)) return;
|
||||
const method = p.node.callee.property.name;
|
||||
if (['hasOwnProperty', 'propertyIsEnumerable'].includes(method)) {
|
||||
reserveFamily(p.node.callee.object, p, 'reflected scheduling receiver');
|
||||
}
|
||||
if (method === 'call' && t.isMemberExpression(p.node.callee.object) &&
|
||||
['hasOwnProperty', 'propertyIsEnumerable'].includes(p.node.callee.object.property.name)) {
|
||||
reserveFamily(p.node.arguments[0], p, 'reflected scheduling receiver');
|
||||
}
|
||||
if (!isReflector(p.node.callee.object, p)) return;
|
||||
if (['keys', 'values', 'entries', 'ownKeys', 'getOwnPropertyNames', 'getOwnPropertyDescriptors'].includes(method)) {
|
||||
reserveFamily(p.node.arguments[0], p, 'enumeration of an internal scheduling object');
|
||||
}
|
||||
if (!['defineProperty', 'defineProperties', 'getOwnPropertyDescriptor', 'hasOwnProperty', 'hasOwn', 'has', 'deleteProperty', 'get', 'set'].includes(method)) return;
|
||||
for (const argument of p.node.arguments) if (t.isStringLiteral(argument)) reserve(argument.value, 'reflected property name');
|
||||
if (!t.isStringLiteral(p.node.arguments[1])) reserveFamily(p.node.arguments[0], p, 'dynamic reflection on an internal scheduling object');
|
||||
},
|
||||
BinaryExpression(p) {
|
||||
if (p.node.operator === 'in') reserveFamily(p.node.right, p, 'dynamic property membership on an internal scheduling object');
|
||||
},
|
||||
ForInStatement(p) { reserveFamily(p.node.right, p, 'enumeration of an internal scheduling object'); },
|
||||
'MemberExpression|OptionalMemberExpression'(p) {
|
||||
if (!p.node.computed) {
|
||||
const name = p.node.property.name;
|
||||
if (schedulingOwner.has(name) && familyOf(p.node.object, p) !== schedulingOwner.get(name)) {
|
||||
reserve(name, 'receiver outside the audited scheduling object families');
|
||||
diagnostics.unprovenSchedulingReceivers ||= {};
|
||||
const receivers = diagnostics.unprovenSchedulingReceivers[name] ||= [];
|
||||
const receiver = generate(p.node.object).code;
|
||||
if (!receivers.includes(receiver)) receivers.push(receiver);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (t.isStringLiteral(p.node.property)) reserve(p.node.property.value, 'computed string access');
|
||||
else reserveFamily(p.node.object, p, 'dynamic access to an internal scheduling object');
|
||||
},
|
||||
'ObjectProperty|ObjectMethod|ClassMethod'(p) {
|
||||
const name = keyName(p.node), owner = schedulingOwner.get(name);
|
||||
if (!owner) return;
|
||||
if (p.isClassMethod() && familyOf(t.thisExpression(), p) === owner) return;
|
||||
if (p.parentPath.isObjectPattern() && p.parentPath.parentPath.isVariableDeclarator() &&
|
||||
familyOf(p.parentPath.parentPath.node.init, p) === owner) return;
|
||||
reserve(name, 'declaration or destructuring outside the audited scheduling object families');
|
||||
}
|
||||
});
|
||||
const map = Object.create(null);
|
||||
let index = 0;
|
||||
for (const key of candidates) {
|
||||
let name;
|
||||
do { name = `_p${seedNumber(`${seed}:${index++}`).toString(36)}`; } while (occupied.has(name));
|
||||
map[key] = name; occupied.add(name);
|
||||
}
|
||||
traverse(tree, {
|
||||
'MemberExpression|OptionalMemberExpression'(p) {
|
||||
if (!p.node.computed && map[p.node.property.name]) p.node.property.name = map[p.node.property.name];
|
||||
},
|
||||
'ObjectProperty|ObjectMethod|ClassMethod'(p) {
|
||||
const name = keyName(p.node);
|
||||
if (!p.node.computed && map[name]) { p.node.key = t.identifier(map[name]); p.node.shorthand = false; }
|
||||
}
|
||||
});
|
||||
diagnostics.propertyMap = map;
|
||||
diagnostics.reservedProperties = declaredProperties.filter(key => !candidates.has(key));
|
||||
diagnostics.reservedPropertyReasons = Object.fromEntries([...reasons].map(([name, entries]) => [name, [...entries].sort()]));
|
||||
diagnostics.schedulingProperties = Object.fromEntries(Object.entries(schedulingProperties).map(([owner, names]) =>
|
||||
[owner, names.map(name => ({name, renamed: map[name] || null, reasons: diagnostics.reservedPropertyReasons[name] || []}))]));
|
||||
diagnostics.propertyBoundary = 'Declared continuation, lifecycle, target-identity and audited Thread/Sequencer methods share one mapping. Ambiguous legacy VM, host API and RPC shapes retain stable keys.';
|
||||
}
|
||||
|
||||
export function auditArtifact(code, {allowGenerators = false} = {}) {
|
||||
const violations = [];
|
||||
const dynamicCallee = (node, scope, seen = new Set()) => {
|
||||
if (t.isSequenceExpression(node)) return dynamicCallee(node.expressions.at(-1), scope, seen);
|
||||
if (t.isIdentifier(node)) {
|
||||
const binding = scope.getBinding(node.name);
|
||||
if (!binding) return ['eval', 'Function', 'scopedEval', 'importScripts'].includes(node.name) ? node.name : null;
|
||||
if (seen.has(binding) || !binding.constant || !binding.path.isVariableDeclarator()) return null;
|
||||
seen.add(binding);
|
||||
return dynamicCallee(binding.path.node.init, binding.path.scope, seen);
|
||||
}
|
||||
if (t.isMemberExpression(node)) {
|
||||
const name = node.computed ? t.isStringLiteral(node.property) && node.property.value : node.property.name;
|
||||
if (['eval', 'Function', 'importScripts'].includes(name)) return name;
|
||||
if (name === 'call' || name === 'apply' || name === 'bind') return dynamicCallee(node.object, scope, seen);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
traverse(parseCode(code), {
|
||||
'CallExpression|NewExpression'(p) {
|
||||
const callee = p.node.callee;
|
||||
const dynamic = dynamicCallee(callee, p.scope);
|
||||
if (dynamic) violations.push(dynamic);
|
||||
if (t.isImport(callee)) violations.push('runtime import');
|
||||
if (t.isIdentifier(callee) && ['setTimeout', 'setInterval'].includes(callee.name) && t.isStringLiteral(p.node.arguments[0])) violations.push('timer code string');
|
||||
},
|
||||
Function(p) { if (p.node.generator && !allowGenerators) violations.push('generator'); }
|
||||
});
|
||||
if (violations.length) throw new Error(`Specialized artifact contains forbidden execution paths: ${[...new Set(violations)].join(', ')}`);
|
||||
}
|
||||
|
||||
export async function linkPlayer({sourcePack, program, input, boot, seed, minifyOutput = true, sourceMap: wantSourceMap = false, addons = {}}, rollup) {
|
||||
if (!sourcePack || sourcePack.version !== 1) throw new Error('Runtime source pack is missing or incompatible');
|
||||
const sources = {...sourcePack.sources};
|
||||
// Seed-permuted regions get dense local PCs and an encoded group number.
|
||||
// Both dispatch levels can use jump tables instead of comparing every range.
|
||||
const layout = dispatchGroups(program.blocks);
|
||||
const pcMap = new Map(layout.flatMap((group, index) => group.map((block, offset) =>
|
||||
[block.id, (index + 1) * 256 + offset])));
|
||||
const rewritePC = node => {
|
||||
if (t.isNumericLiteral(node) && pcMap.has(node.value)) node.value = pcMap.get(node.value);
|
||||
else if (t.isConditionalExpression(node)) { rewritePC(node.consequent); rewritePC(node.alternate); }
|
||||
};
|
||||
program = {...program, hats: program.hats.map(hat => ({...hat, pc: pcMap.get(hat.pc)})), blocks: program.blocks.map(block => {
|
||||
const tree = parseCode(`function region() { __spRun: while (true) { ${block.code.join('\n')} } }`);
|
||||
traverse(tree, {
|
||||
AssignmentExpression(p) {
|
||||
if (t.isMemberExpression(p.node.left) && p.node.left.property.name === '__spPC') rewritePC(p.node.right);
|
||||
},
|
||||
ObjectProperty(p) { if (keyName(p.node) === '__spPC') rewritePC(p.node.value); }
|
||||
});
|
||||
return {...block, id: pcMap.get(block.id), dispatchGroup: pcMap.get(block.id) >>> 8,
|
||||
code: tree.program.body[0].body.body[0].body.body.body.map(node => print(t.file(t.program([node]))))};
|
||||
})};
|
||||
const diagnostics = {retainedMembers: [], removedMembers: [], retainedModules: [], removedModules: [],
|
||||
continuationCount: program.blocks.length, scriptCount: program.hats.length, runtimeFingerprint: sourcePack.fingerprint};
|
||||
if (input.project.targets.some(target => Object.keys(target.blocks).length || Object.keys(target.comments).length)) {
|
||||
throw new Error('Player initial data must not contain source blocks or comments');
|
||||
}
|
||||
diagnostics.slotMaps = program.slotMaps;
|
||||
diagnostics.dispatchGroups = [];
|
||||
for (const regions of dispatchGroups(program.blocks)) {
|
||||
diagnostics.dispatchGroups.push({regionCount: regions.length, entries: [...new Set(regions.map(region => region.owner))],
|
||||
sourceLength: regions.reduce((sum, region) => sum + region.code.join('\n').length, 0),
|
||||
includesLifecycle: regions.some(region => region.code.some(source => /retireThread|__spLifetime|__spFrames\.pop/.test(source)))});
|
||||
}
|
||||
const find = suffix => Object.keys(sources).find(id => (sourcePack.provenance[id] || []).some(origin => origin.endsWith(suffix)));
|
||||
const runtimeId = find('scratch-vm/src/engine/runtime.js');
|
||||
const sequencerId = find('scratch-vm/src/engine/sequencer.js');
|
||||
if (!runtimeId || !sequencerId) throw new Error('Source pack does not contain the production runtime');
|
||||
for (const family of Object.keys(schedulingProperties)) {
|
||||
const id = find(`scratch-vm/src/engine/${family.toLowerCase()}.js`);
|
||||
if (!id) throw new Error(`Source pack is missing audited ${family}`);
|
||||
const tree = parseCode(sources[id]);
|
||||
const cls = tree.program.body.find(node => t.isClassDeclaration(node) && node.id.name === family);
|
||||
if (!cls || schedulingProperties[family].some(name => {
|
||||
const method = cls.body.body.find(member => keyName(member) === name);
|
||||
return !method || method.params.length !== (schedulingArity[name] || 0) ||
|
||||
!method.params.every(param => t.isIdentifier(param)) || method.async || method.generator ||
|
||||
method.static !== (name === 'getIdFromTargetAndBlock');
|
||||
})) {
|
||||
throw new Error(`${family} methods changed; review the scheduling property manifest`);
|
||||
}
|
||||
const marker = t.classMethod('method', t.identifier(`__spOwner${family}`), [], t.blockStatement([]));
|
||||
marker.static = true; cls.body.body.push(marker); sources[id] = print(tree);
|
||||
}
|
||||
const executeId = find('scratch-vm/src/engine/execute.js');
|
||||
if (!executeId) throw new Error('Source pack is missing monitor execution');
|
||||
sources[executeId] += '\nexecute.__spOwnerExecute = true;';
|
||||
const helpers = parseCode(sourcePack.helpers);
|
||||
const helperNames = helpers.program.body.flatMap(n => t.isExportNamedDeclaration(n) && t.isVariableDeclaration(n.declaration) ?
|
||||
n.declaration.declarations.map(d => d.id.name) : []);
|
||||
sources['specialized-helpers.js'] = sourcePack.helpers;
|
||||
const methods = stepMethods(program.blocks);
|
||||
const step = Object.entries(methods).map(([name, body]) => `function ${name}(thread) ${body}`).join('\n');
|
||||
const roots = referencedMembers(step);
|
||||
const extensionIDs = input.extensions || [];
|
||||
const audioReasons = [];
|
||||
if (roots.has('ext_scratch3_sound')) audioReasons.push('sound operations');
|
||||
if (input.project.targets.some(target => target.sounds.length)) audioReasons.push('sound assets');
|
||||
if (extensionIDs.some(id => ['music', 'text2speech'].includes(id))) audioReasons.push('audio extension');
|
||||
if (/getLoudness/.test(step) || (input.project.monitors || []).some(m => m.opcode === 'sensing_loudness')) audioReasons.push('microphone input');
|
||||
const videoReasons = extensionIDs.includes('videoSensing') ? ['video sensing extension'] : [];
|
||||
const audio = audioReasons.length > 0;
|
||||
const video = videoReasons.length > 0;
|
||||
diagnostics.capabilities = {audio: audioReasons, video: videoReasons,
|
||||
pen: extensionIDs.includes('pen') ? ['pen extension'] : []};
|
||||
const rendererId = find('node_modules/scratch-render/src/RenderWebGL.js');
|
||||
if (rendererId) {
|
||||
const tree = parseCode(sources[rendererId]);
|
||||
traverse(tree, {
|
||||
ExpressionStatement(p) {
|
||||
const expression = p.node.expression;
|
||||
if (t.isAssignmentExpression(expression) && t.isMemberExpression(expression.left) &&
|
||||
t.isThisExpression(expression.left.object) && expression.left.property.name === 'exports') p.remove();
|
||||
},
|
||||
ClassMethod(p) {
|
||||
if (keyName(p.node) === 'exports' || (!extensionIDs.includes('pen') &&
|
||||
['createPenSkin', 'penClear', 'penPoint', 'penLine', 'penStamp'].includes(keyName(p.node)))) p.remove();
|
||||
},
|
||||
IfStatement(p) {
|
||||
if (!extensionIDs.includes('pen') && t.isBinaryExpression(p.node.test) &&
|
||||
t.isMemberExpression(p.node.test.left) && p.node.test.left.property.name === '_penSkinId') p.remove();
|
||||
}
|
||||
});
|
||||
removeUnusedImports(tree); sources[rendererId] = print(tree);
|
||||
}
|
||||
const librariesId = find('tw-packager/src/scaffolding/scratch-libraries.js');
|
||||
if (librariesId && !audio) {
|
||||
const tree = parseCode(sources[librariesId]);
|
||||
tree.program.body = tree.program.body.filter(node => !t.isExportNamedDeclaration(node) ||
|
||||
!node.specifiers.some(specifier => specifier.exported.name === 'AudioEngine'));
|
||||
sources[librariesId] = print(tree);
|
||||
}
|
||||
const scaffoldingId = find('tw-packager/src/scaffolding/scaffolding.js');
|
||||
if (scaffoldingId) {
|
||||
const tree = parseCode(sources[scaffoldingId]);
|
||||
if (!audio) for (const node of tree.program.body) {
|
||||
if (t.isExportNamedDeclaration(node)) node.specifiers = node.specifiers.filter(specifier => specifier.exported.name !== 'AudioEngine');
|
||||
}
|
||||
traverse(tree, {
|
||||
IfStatement(p) {
|
||||
if (!audio && print(t.file(t.program([p.node]))).includes('new AudioEngine(')) p.remove();
|
||||
},
|
||||
ExpressionStatement(p) {
|
||||
const value = p.node.expression;
|
||||
if (!video && t.isAssignmentExpression(value) && t.isMemberExpression(value.left) &&
|
||||
value.left.property.name === 'videoProvider') p.remove();
|
||||
if (!video && t.isCallExpression(value) && t.isMemberExpression(value.callee) && value.callee.property.name === 'setVideoProvider') p.remove();
|
||||
}
|
||||
});
|
||||
removeUnusedImports(tree); sources[scaffoldingId] = print(tree);
|
||||
}
|
||||
const requiredPrimitives = new Set(program.primitives);
|
||||
for (const hat of program.hats) requiredPrimitives.add(hat.opcode);
|
||||
for (const monitor of input.project.monitors || []) requiredPrimitives.add(monitor.opcode);
|
||||
// show/hide can reveal initially hidden variable/list monitors after startup.
|
||||
requiredPrimitives.add('data_variable'); requiredPrimitives.add('data_listcontents');
|
||||
const primitiveBindings = new Map();
|
||||
const blockClasses = new Map();
|
||||
for (const id of Object.keys(sources)) {
|
||||
const origins = sourcePack.provenance[id] || [];
|
||||
const origin = origins.find(name => /scratch-vm\/src\/(blocks\/scratch3_|extensions\/)/.test(name));
|
||||
if (!origin || !sources[id].includes('class ')) continue;
|
||||
const tree = parseCode(sources[id]);
|
||||
const cls = tree.program.body.find(n => t.isClassDeclaration(n));
|
||||
if (!cls) continue;
|
||||
const builtin = origin.match(/blocks\/(scratch3_\w+)\.js$/);
|
||||
const extension = origin.match(/extensions\/scratch3_([^/]+)\/index.js$/);
|
||||
const name = builtin ? builtin[1] : extension && extension[1];
|
||||
if (!name) continue;
|
||||
const field = `ext_${name}`;
|
||||
const method = cls.body.body.find(n => keyName(n) === 'getPrimitives');
|
||||
if (method) t.traverseFast(method, node => {
|
||||
if (!t.isObjectProperty(node)) return;
|
||||
const opcode = keyName(node);
|
||||
if (requiredPrimitives.has(opcode)) {
|
||||
let implementation;
|
||||
if (t.isMemberExpression(node.value) && t.isThisExpression(node.value.object) && !node.value.computed) {
|
||||
implementation = node.value.property.name;
|
||||
} else if ((t.isArrowFunctionExpression(node.value) || t.isFunctionExpression(node.value) && !node.value.id) &&
|
||||
method.body.body.length === 1 && t.isReturnStatement(method.body.body[0])) {
|
||||
// Legacy no-op primitives can be inline functions instead of methods.
|
||||
// Move them into the same class so lexical `this` and direct calls agree.
|
||||
implementation = `_inline${seedNumber(`${seed}:${opcode}`).toString(36)}`;
|
||||
const value = node.value;
|
||||
const member = t.classMethod('method', t.identifier(implementation), value.params,
|
||||
t.isBlockStatement(value.body) ? value.body : t.blockStatement([t.returnStatement(value.body)]));
|
||||
member.async = value.async; member.generator = value.generator || false;
|
||||
cls.body.body.push(member);
|
||||
} else {
|
||||
throw new Error(`Cannot statically bind builtin primitive ${opcode}`);
|
||||
}
|
||||
primitiveBindings.set(opcode, {field, method: implementation});
|
||||
if (!roots.has(field)) roots.set(field, new Set());
|
||||
roots.get(field).add(implementation);
|
||||
}
|
||||
});
|
||||
blockClasses.set(field, {id, tree, builtin: Boolean(builtin)});
|
||||
}
|
||||
// Only selected builtin extensions are initialized. Their getInfo/registration hooks remain roots.
|
||||
for (const extension of input.extensions || []) {
|
||||
const field = `ext_${extension}`;
|
||||
if (!roots.has(field)) roots.set(field, new Set());
|
||||
roots.get(field).add('getInfo');
|
||||
}
|
||||
for (const [field, item] of blockClasses) {
|
||||
if (!roots.has(field)) continue;
|
||||
const selected = roots.get(field);
|
||||
if (!item.builtin) {
|
||||
const cls = item.tree.program.body.find(node => t.isClassDeclaration(node));
|
||||
const info = cls.body.body.find(node => keyName(node) === 'getInfo');
|
||||
let blockArray;
|
||||
if (info) t.traverseFast(info, node => {
|
||||
if (t.isObjectProperty(node) && keyName(node) === 'blocks' && t.isArrayExpression(node.value)) blockArray = node.value;
|
||||
});
|
||||
const staticBlocks = blockArray && blockArray.elements.every(node => t.isStringLiteral(node) ||
|
||||
t.isObjectExpression(node) && node.properties.every(t.isObjectProperty) && node.properties.every(property =>
|
||||
!['opcode', 'func'].includes(keyName(property)) || t.isStringLiteral(property.value)));
|
||||
if (!staticBlocks) {
|
||||
diagnostics.retainedMembers.push(`${item.id}#dynamic-extension-registration`);
|
||||
continue;
|
||||
}
|
||||
const extension = field.slice(4);
|
||||
blockArray.elements = blockArray.elements.filter(node => {
|
||||
if (!t.isObjectExpression(node)) return false;
|
||||
const opcode = node.properties.find(property => keyName(property) === 'opcode')?.value.value;
|
||||
if (!opcode || !requiredPrimitives.has(`${extension}_${opcode}`)) return false;
|
||||
selected.add(node.properties.find(property => keyName(property) === 'func')?.value.value || opcode);
|
||||
return true;
|
||||
});
|
||||
// Dynamic menu providers and callback names are protocol fields. Retain
|
||||
// their methods when registration metadata mentions them as strings.
|
||||
const methods = new Set(cls.body.body.map(keyName));
|
||||
t.traverseFast(info, node => { if (t.isStringLiteral(node) && methods.has(node.value)) selected.add(node.value); });
|
||||
pruneClass(item.tree, selected, diagnostics, item.id);
|
||||
removeUnusedImports(item.tree);
|
||||
sources[item.id] = print(item.tree);
|
||||
continue;
|
||||
}
|
||||
if (requiredPrimitives.size) selected.add('getMonitored');
|
||||
pruneClass(item.tree, selected, diagnostics, item.id);
|
||||
removeUnusedImports(item.tree);
|
||||
sources[item.id] = print(item.tree);
|
||||
}
|
||||
const runtimeTree = parseCode(sources[runtimeId]);
|
||||
if (!video) traverse(runtimeTree, {NewExpression(p) {
|
||||
if (t.isIdentifier(p.node.callee, {name: 'Video'})) p.replaceWith(t.nullLiteral());
|
||||
}});
|
||||
const runtimeClass = runtimeTree.program.body.find(n => t.isClassDeclaration(n));
|
||||
const packages = runtimeTree.program.body.filter(t.isVariableDeclaration).flatMap(n => n.declarations)
|
||||
.find(n => t.isIdentifier(n.id, {name: 'defaultBlockPackages'}));
|
||||
if (!packages) throw new Error('Source pack block registry changed');
|
||||
const registrations = [];
|
||||
packages.init.properties = packages.init.properties.filter(property => {
|
||||
const name = keyName(property); const field = `ext_${name}`;
|
||||
if (!roots.has(field)) return false;
|
||||
registrations.push(`this.${field} = new ${print(t.file(t.program([t.expressionStatement(property.value)]))).replace(/;$/, '')}(this);`);
|
||||
return true;
|
||||
});
|
||||
const hatsMetadata = Object.fromEntries(program.hats.map(hat => [hat.opcode, hat.metadata]));
|
||||
for (const [index, member] of runtimeClass.body.body.entries()) {
|
||||
const name = keyName(member);
|
||||
if (name === '_registerBlockPackages') runtimeClass.body.body[index] = parseMethod(`_registerBlockPackages() {
|
||||
${registrations.join('\n')}
|
||||
this._hats = ${q(hatsMetadata)};
|
||||
${[...roots.keys()].filter(name => blockClasses.get(name)?.builtin).map(name =>
|
||||
`if (this.${name}.getMonitored) Object.assign(this.monitorBlockInfo, this.${name}.getMonitored());`).join('\n')}
|
||||
}`);
|
||||
if (name === 'startHats') runtimeClass.body.body[index] = parseMethod(`startHats(opcode, fields, target) ${hatsBody(program.hats)}`);
|
||||
if (name === '__spPrimitive') runtimeClass.body.body[index] = parseMethod(`__spPrimitive(opcode, args, utility) {
|
||||
switch (opcode) {${[...primitiveBindings].map(([opcode, binding]) => `case ${q(opcode)}: return this.${binding.field}.${binding.method}(args, utility);`).join('\n')}
|
||||
default: {
|
||||
const primitive = this._primitives[opcode];
|
||||
if (!primitive) throw new Error('Unlinked primitive: ' + opcode);
|
||||
return primitive(args, utility);
|
||||
}}
|
||||
}`);
|
||||
}
|
||||
removeUnusedImports(runtimeTree);
|
||||
sources[runtimeId] = print(runtimeTree);
|
||||
const sequenceTree = parseCode(sources[sequencerId]);
|
||||
sequenceTree.program.body.unshift(t.importDeclaration(helperNames.map(name => t.importSpecifier(t.identifier(name), t.identifier(name))), t.stringLiteral('specialized-helpers.js')));
|
||||
const sequenceClass = sequenceTree.program.body.find(n => t.isClassDeclaration(n));
|
||||
const stepIndex = sequenceClass.body.body.findIndex(n => keyName(n) === 'stepThread');
|
||||
sequenceClass.body.body.splice(stepIndex, 1, ...Object.entries(methods).map(([name, body]) => parseMethod(`${name}(thread) ${body}`)));
|
||||
diagnostics.cachedScalars = cachePureComputation(sequenceTree, program.blocks);
|
||||
sources[sequencerId] = print(sequenceTree);
|
||||
const extensionId = find('scratch-vm/src/extension-support/extension-manager.js');
|
||||
if (extensionId) {
|
||||
const tree = parseCode(sources[extensionId]);
|
||||
traverse(tree, {VariableDeclarator(p) {
|
||||
if (p.node.id.name !== 'defaultBuiltinExtensions') return;
|
||||
p.node.init.properties = p.node.init.properties.filter(prop => input.extensions.includes(keyName(prop)));
|
||||
}});
|
||||
const cls = tree.program.body.find(node => t.isClassDeclaration(node));
|
||||
const index = cls.body.body.findIndex(node => keyName(node) === 'loadExtensionURL');
|
||||
cls.body.body[index] = parseMethod(`async loadExtensionURL(url) ${extensionLoaderBody(input.fixedExtensions || [])}`);
|
||||
removeUnusedImports(tree);
|
||||
sources[extensionId] = print(tree);
|
||||
}
|
||||
diagnostics.fixedExtensions = (input.fixedExtensions || []).map(extension => ({
|
||||
buildId: extension.token, ids: extension.infos.map(info => info.id), dependencies: extension.dependencies,
|
||||
boundary: 'isolated worker; no VM or compiler access'}));
|
||||
const addonId = find('tw-packager/src/addons/index.js');
|
||||
if (addonId) {
|
||||
const tree = parseCode(sources[addonId]);
|
||||
traverse(tree, {IfStatement(p) {
|
||||
if (t.isMemberExpression(p.node.test) && t.isIdentifier(p.node.test.object, {name: 'options'})) {
|
||||
if (addons[p.node.test.property.name]) p.replaceWith(p.node.consequent); else p.remove();
|
||||
}
|
||||
}});
|
||||
removeUnusedImports(tree);
|
||||
sources[addonId] = print(tree);
|
||||
}
|
||||
const gamepadId = find('tw-packager/src/addons/gamepad/userscript.js');
|
||||
if (gamepadId && addons.gamepad) {
|
||||
const tree = parseCode(sources[gamepadId]);
|
||||
traverse(tree, {VariableDeclarator(p) {
|
||||
if (p.node.id.name === 'getKeysUsedByProject') p.node.init = parseCode(
|
||||
`const f = () => new Set(${q(input.addons.usedKeys)}.map(scratchKeyToKey));`).program.body[0].declarations[0].init;
|
||||
if (p.node.id.name === 'parseOptionsComment') p.node.init = parseCode(
|
||||
`const f = () => (${q(input.addons.gamepadSettings)});`).program.body[0].declarations[0].init;
|
||||
}});
|
||||
sources[gamepadId] = print(tree);
|
||||
}
|
||||
const entry = 'specialized-boot.js';
|
||||
sources[entry] = `import {Scaffolding, ScaffoldingAddons} from ${q(sourcePack.entry)};\n${boot}`;
|
||||
const importedBy = new Map();
|
||||
const bundle = await rollup({input: entry, treeshake: {moduleSideEffects: true},
|
||||
onwarn(warning) { if (!['CIRCULAR_DEPENDENCY', 'THIS_IS_UNDEFINED'].includes(warning.code)) throw new Error(warning.message); },
|
||||
plugins: [{name: 'specialized-memory', resolveId(source, importer) {
|
||||
const id = resolvePath(source, importer || entry);
|
||||
if (!Object.hasOwn(sources, id)) throw new Error(`Unresolved runtime module ${source} from ${importer}`);
|
||||
return id;
|
||||
}, load(id) { return sources[id]; }, moduleParsed(info) {
|
||||
for (const imported of info.importedIds) {
|
||||
if (!importedBy.has(imported)) importedBy.set(imported, []);
|
||||
importedBy.get(imported).push(info.id);
|
||||
}
|
||||
}}]});
|
||||
try {
|
||||
const generated = await bundle.generate({format: 'iife', inlineDynamicImports: true, generatedCode: 'es2015', sourcemap: wantSourceMap ? 'hidden' : false});
|
||||
const chunk = generated.output[0];
|
||||
for (const id of Object.keys(sourcePack.sources)) {
|
||||
(chunk.modules[id]?.renderedLength > 0 ? diagnostics.retainedModules : diagnostics.removedModules).push(id);
|
||||
}
|
||||
diagnostics.retentionReasons = Object.fromEntries(diagnostics.retainedModules.map(id => [id,
|
||||
{importedBy: importedBy.get(id) || [], sideEffects: 'preserved'}]));
|
||||
diagnostics.commonJSWrappers = diagnostics.retainedModules.filter(id =>
|
||||
/__require/.test(sourcePack.sources[id]) && (chunk.modules[id].renderedExports || []).some(name => name === '__require'))
|
||||
.map(id => {
|
||||
const audit = sourcePack.wrapperAudit?.[id];
|
||||
if (!audit) throw new Error(`Unaudited CommonJS wrapper: ${id}`);
|
||||
return {module: id, origins: sourcePack.provenance[id], ...audit};
|
||||
});
|
||||
for (const item of diagnostics.commonJSWrappers) {
|
||||
if (item.origins.some(origin => /^(scratch-vm\/src|tw-packager\/src)\//.test(origin))) {
|
||||
throw new Error(`First-party CommonJS factory survived: ${item.module}`);
|
||||
}
|
||||
}
|
||||
const tree = parseCode(chunk.code);
|
||||
renameProperties(tree, seed, diagnostics, Object.keys(methods).filter(name => name !== 'stepThread'));
|
||||
const transformed = generate(tree, {comments: false, sourceMaps: wantSourceMap,
|
||||
inputSourceMap: wantSourceMap ? chunk.map : undefined, sourceFileName: 'linked-player.js'}, chunk.code);
|
||||
let code = transformed.code;
|
||||
let sourceMap = transformed.map;
|
||||
if (minifyOutput) {
|
||||
const result = await minify(code, {compress: {passes: 2}, mangle: {toplevel: true},
|
||||
format: {comments: false}, sourceMap: wantSourceMap ? {content: sourceMap, asObject: true, includeSources: true} : false});
|
||||
code = result.code;
|
||||
if (wantSourceMap) sourceMap = result.map;
|
||||
}
|
||||
auditArtifact(code);
|
||||
return {code, diagnostics, ...(wantSourceMap ? {sourceMap} : {})};
|
||||
} finally { await bundle.close(); }
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import {rollup} from 'rollup';
|
||||
import sourcePack from '../../generated/runtime-sources.json';
|
||||
import {linkPlayer} from './link.mjs';
|
||||
import {prepareExtension as prepare} from './extensions.mjs';
|
||||
export {inspectExtension} from './inspect-node.mjs';
|
||||
export const linkSpecialized = options => linkPlayer({...options, sourcePack}, rollup);
|
||||
export const prepareExtension = options => prepare(options, sourcePack, rollup);
|
||||
@@ -0,0 +1,11 @@
|
||||
import sourcePack from '../../generated/runtime-sources.json';
|
||||
import {linkPlayer} from './link.mjs';
|
||||
import {prepareExtension} from './extensions.mjs';
|
||||
// Shipped beside the worker, including its pinned WASM parser. No CDN resolution.
|
||||
if (!self.rollup) importScripts('./rollup.browser.js');
|
||||
self.onmessage = async ({data}) => {
|
||||
try { self.postMessage({result: data.operation === 'extension' ?
|
||||
await prepareExtension(data.options, sourcePack, self.rollup.rollup) :
|
||||
await linkPlayer({...data, sourcePack}, self.rollup.rollup)}); }
|
||||
catch (error) { self.postMessage({error: String(error && error.stack || error), missingDependency: error.missingDependency}); }
|
||||
};
|
||||
@@ -0,0 +1,75 @@
|
||||
import {get} from 'svelte/store';
|
||||
import {Blob} from 'buffer';
|
||||
import writablePersistentStore from '../../src/p4/persistent-store';
|
||||
import writablePackagerOptionsStore from '../../src/p4/packager-options-store';
|
||||
import {error} from '../../src/p4/stores';
|
||||
|
||||
beforeEach(() => {
|
||||
global.Blob = Blob;
|
||||
const values = new Map();
|
||||
global.localStorage = {
|
||||
getItem: key => values.get(key) ?? null,
|
||||
setItem: (key, value) => values.set(key, String(value)),
|
||||
removeItem: key => values.delete(key)
|
||||
};
|
||||
error.set(null);
|
||||
});
|
||||
|
||||
test.each([
|
||||
['P4.locale', 'en', 'zh-cn'],
|
||||
['SelectProject.type', 'id', 'file'],
|
||||
['SelectProject.id', '', '123456'],
|
||||
['SelectProject.url', '', 'https://example.com/project.sb3']
|
||||
])('restores %s without treating it as compiler options', (key, defaultValue, savedValue) => {
|
||||
localStorage.setItem(key, JSON.stringify(savedValue));
|
||||
expect(get(writablePersistentStore(key, defaultValue))).toBe(savedValue);
|
||||
expect(get(error)).toBeNull();
|
||||
expect(JSON.parse(localStorage.getItem(key))).toBe(savedValue);
|
||||
});
|
||||
|
||||
test('language changes survive recreating the store', () => {
|
||||
const store = writablePersistentStore('P4.locale', 'en');
|
||||
const unsubscribe = store.subscribe(() => {});
|
||||
store.set('zh-cn');
|
||||
unsubscribe();
|
||||
expect(get(writablePersistentStore('P4.locale', 'en'))).toBe('zh-cn');
|
||||
expect(get(error)).toBeNull();
|
||||
});
|
||||
|
||||
const defaultOptions = () => ({
|
||||
compiler: {enabled: true, warpTimer: false, seed: ''},
|
||||
custom: {css: ''},
|
||||
cloudVariables: {mode: 'ws'},
|
||||
framerate: 30
|
||||
});
|
||||
|
||||
test('legacy packager settings still report migration and preserve unrelated preferences', () => {
|
||||
const key = 'PackagerOptions.project';
|
||||
localStorage.setItem(key, JSON.stringify({
|
||||
compiler: {aot: true, protection: {online: true}},
|
||||
bakeExtensions: true,
|
||||
compiledProjectHooks: {},
|
||||
custom: {js: 'legacy()', css: 'body { color: red; }'},
|
||||
cloudVariables: {unsafeCloudBehaviors: true, mode: 'local'},
|
||||
framerate: 60
|
||||
}));
|
||||
const restored = get(writablePackagerOptionsStore(key, defaultOptions()));
|
||||
expect(get(error).message).toContain('原始 SB3');
|
||||
expect(restored).toEqual({
|
||||
compiler: defaultOptions().compiler,
|
||||
custom: {css: 'body { color: red; }'},
|
||||
cloudVariables: {mode: 'local'},
|
||||
framerate: 60
|
||||
});
|
||||
error.set(null);
|
||||
expect(get(writablePackagerOptionsStore(key, defaultOptions()))).toEqual(restored);
|
||||
expect(get(error)).toBeNull();
|
||||
});
|
||||
|
||||
test('current packager settings retain seed and warp timing', () => {
|
||||
const key = 'PackagerOptions.project';
|
||||
const compiler = {enabled: true, seed: 'test-seed', warpTimer: true};
|
||||
localStorage.setItem(key, JSON.stringify({compiler}));
|
||||
expect(get(writablePackagerOptionsStore(key, defaultOptions())).compiler).toEqual(compiler);
|
||||
expect(get(error)).toBeNull();
|
||||
});
|
||||
@@ -1,8 +0,0 @@
|
||||
import {verifyBuildId} from "../../src/packager/build-id";
|
||||
|
||||
test('verifyBuildId', () => {
|
||||
expect(verifyBuildId('81868e39f131a7c61c505fdeb5ed4731c3fd2420ab31fb9210175b4bb705d9a4', 'abc 123 // 81868e39f131a7c61c505fdeb5ed4731c3fd2420ab31fb9210175b4bb705d9a4 =^..^=')).toBe(true);
|
||||
expect(verifyBuildId('redgtoikjergorljikef', 'abc 123 //redgtoikjergorljikef =^..^=')).toBe(true);
|
||||
expect(verifyBuildId('redgtoikjergorljikef', 'abc 123 // gfrdvetgfroi =^..^=')).toBe(false);
|
||||
expect(verifyBuildId('redgtoikjergorljikef', 'abc 123')).toBe(true);
|
||||
});
|
||||
@@ -1,192 +1,13 @@
|
||||
import {
|
||||
compileProject,
|
||||
createGlobalDebugProtectionStage
|
||||
} from '../../src/packager/compile-project';
|
||||
import {compileProject, validateBuildOptions} from '../../src/packager/compile-project';
|
||||
|
||||
const makeDependencies = () => {
|
||||
const calls = {};
|
||||
class VM {
|
||||
constructor () {
|
||||
calls.vm = this;
|
||||
}
|
||||
|
||||
attachStorage (storage) {
|
||||
calls.storage = storage;
|
||||
}
|
||||
|
||||
setCompilerOptions (options) {
|
||||
calls.compilerOptions = options;
|
||||
}
|
||||
|
||||
loadProject (data) {
|
||||
calls.projectData = data;
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
exportCompiledProject () {
|
||||
return {format: 'scratch-vm-compiled-project', requiredBindings: []};
|
||||
}
|
||||
|
||||
exportCompiledProjectBuildInput () {
|
||||
return {artifact: this.exportCompiledProject(), project: {targets: []}};
|
||||
}
|
||||
|
||||
saveCompiledProject (type, options) {
|
||||
calls.save = {type, options};
|
||||
return Promise.resolve(new ArrayBuffer(4));
|
||||
}
|
||||
|
||||
quit () {
|
||||
calls.quit = true;
|
||||
}
|
||||
test.each(['aot', 'signManifest', 'deriveSeed', 'stages', 'signatureVerifierSource', 'wasm', 'protection'])(
|
||||
'old %s options report migration instead of being ignored', async key => {
|
||||
await expect(compileProject(new Uint8Array(), {[key]: false})).rejects.toThrow('原始 SB3');
|
||||
}
|
||||
class Storage {}
|
||||
const protection = {
|
||||
createVmAccessStage: jest.fn(options => ({id: 'vm-access', options})),
|
||||
createJavaScriptObfuscatorStage: jest.fn(options => ({id: 'javascript', options})),
|
||||
protectCompiledProject: jest.fn(async (input, options) => {
|
||||
calls.protect = {input, options};
|
||||
return {
|
||||
artifact: {...input.artifact, requiredBindings: ['__vmAccess']},
|
||||
sidecars: [{
|
||||
name: 'vm-accessor.mjs',
|
||||
bindingName: '__vmAccess',
|
||||
mediaType: 'text/javascript',
|
||||
content: 'export const accessor = {};'
|
||||
}],
|
||||
diagnostics: {stages: []}
|
||||
};
|
||||
})
|
||||
};
|
||||
return {calls, dependencies: {VM, Storage, protection}};
|
||||
};
|
||||
|
||||
test('compileProject applies ordered protection stages and packages the external artifact', async () => {
|
||||
const {calls, dependencies} = makeDependencies();
|
||||
const signManifest = jest.fn();
|
||||
const result = await compileProject(new Uint8Array([1, 2, 3]), {
|
||||
warpTimer: true,
|
||||
aot: {
|
||||
obfuscate: true,
|
||||
javascriptObfuscator: true,
|
||||
shortIds: true,
|
||||
accessorName: '__vmAccess',
|
||||
seed: 'release-seed'
|
||||
}
|
||||
}, {
|
||||
signManifest,
|
||||
javascriptObfuscatorOptions: {compact: true}
|
||||
}, dependencies);
|
||||
|
||||
expect(calls.compilerOptions).toEqual({enabled: true, warpTimer: true});
|
||||
expect(calls.protect.options.stages.map(stage => stage.id)).toEqual(['vm-access', 'javascript']);
|
||||
expect(calls.protect.options.deriveSeed()).toEqual({
|
||||
seed: 'release-seed',
|
||||
derivationId: 'tw-packager-explicit-v1'
|
||||
});
|
||||
expect(calls.save.type).toBe('arraybuffer');
|
||||
expect(calls.save.options.artifact.requiredBindings).toEqual(['__vmAccess']);
|
||||
expect(calls.save.options.integrity.signManifest).toBe(signManifest);
|
||||
expect(result.accessorSidecar.bindingName).toBe('__vmAccess');
|
||||
expect(calls.quit).toBe(true);
|
||||
);
|
||||
test('the production compiler cannot be disabled', () => {
|
||||
expect(() => validateBuildOptions({enabled: false})).toThrow('必须启用编译');
|
||||
});
|
||||
|
||||
test('compileProject can create an unobfuscated compiled package', async () => {
|
||||
const {calls, dependencies} = makeDependencies();
|
||||
const result = await compileProject(new ArrayBuffer(0), {
|
||||
warpTimer: false,
|
||||
aot: {
|
||||
obfuscate: false,
|
||||
javascriptObfuscator: false,
|
||||
shortIds: true,
|
||||
accessorName: '__vmAccess',
|
||||
seed: ''
|
||||
}
|
||||
}, {}, dependencies);
|
||||
|
||||
expect(dependencies.protection.protectCompiledProject).not.toHaveBeenCalled();
|
||||
expect(calls.save.options.artifact.requiredBindings).toEqual([]);
|
||||
expect(result.accessorSidecar).toBe(null);
|
||||
expect(calls.quit).toBe(true);
|
||||
});
|
||||
|
||||
test('compileProject can apply JavaScript obfuscation without the VM accessor stage', async () => {
|
||||
const {calls, dependencies} = makeDependencies();
|
||||
await compileProject(new ArrayBuffer(0), {
|
||||
warpTimer: false,
|
||||
aot: {
|
||||
obfuscate: false,
|
||||
javascriptObfuscator: true,
|
||||
shortIds: true,
|
||||
accessorName: '__vmAccess',
|
||||
seed: ''
|
||||
}
|
||||
}, {}, dependencies);
|
||||
|
||||
expect(calls.protect.options.stages.map(stage => stage.id)).toEqual(['javascript']);
|
||||
expect(dependencies.protection.createVmAccessStage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('debug protection is a single global stage and is not duplicated in factory options', async () => {
|
||||
const {calls, dependencies} = makeDependencies();
|
||||
await compileProject(new ArrayBuffer(0), {
|
||||
warpTimer: false,
|
||||
aot: {
|
||||
obfuscate: true,
|
||||
javascriptObfuscator: true,
|
||||
shortIds: true,
|
||||
accessorName: '__vmAccess',
|
||||
seed: '',
|
||||
javascriptObfuscatorOptions: {
|
||||
debugProtection: true,
|
||||
debugProtectionInterval: 3000,
|
||||
selfDefending: true
|
||||
}
|
||||
}
|
||||
}, {}, dependencies);
|
||||
|
||||
expect(calls.protect.options.stages.map(stage => stage.id)).toEqual([
|
||||
'vm-access',
|
||||
'javascript',
|
||||
'tw-packager-global-debug-protection'
|
||||
]);
|
||||
const factoryOptions = dependencies.protection.createJavaScriptObfuscatorStage.mock.calls[0][0].options;
|
||||
expect(factoryOptions.debugProtection).toBeUndefined();
|
||||
expect(factoryOptions.debugProtectionInterval).toBeUndefined();
|
||||
});
|
||||
|
||||
test('global debug protection stage emits one executable sidecar', async () => {
|
||||
const obfuscate = jest.fn(() => ({getObfuscatedCode: () => 'protected();'}));
|
||||
const stage = createGlobalDebugProtectionStage({interval: 2500, selfDefending: true}, {obfuscate});
|
||||
const artifact = {format: 'scratch-vm-compiled-project'};
|
||||
const result = await stage.transform({artifact, seed: 'derived-seed'});
|
||||
|
||||
expect(obfuscate).toHaveBeenCalledWith('void 0;', expect.objectContaining({
|
||||
debugProtection: true,
|
||||
debugProtectionInterval: 2500,
|
||||
selfDefending: true,
|
||||
seed: 'derived-seed'
|
||||
}));
|
||||
expect(result.artifact).toBe(artifact);
|
||||
expect(result.sidecars).toEqual([expect.objectContaining({
|
||||
name: 'debug-protection.js',
|
||||
execution: 'global-script',
|
||||
content: 'protected();'
|
||||
})]);
|
||||
});
|
||||
|
||||
test('compileProject always disposes the compiler VM after failure', async () => {
|
||||
const {calls, dependencies} = makeDependencies();
|
||||
dependencies.protection.protectCompiledProject.mockRejectedValueOnce(new Error('stage failed'));
|
||||
await expect(compileProject(new ArrayBuffer(0), {
|
||||
warpTimer: false,
|
||||
aot: {
|
||||
obfuscate: true,
|
||||
javascriptObfuscator: false,
|
||||
shortIds: true,
|
||||
accessorName: '__vmAccess',
|
||||
seed: ''
|
||||
}
|
||||
}, {}, dependencies)).rejects.toThrow('stage failed');
|
||||
expect(calls.quit).toBe(true);
|
||||
test('seed and warp timing remain explicit build options', () => {
|
||||
expect(() => validateBuildOptions({seed: 'reproducible', warpTimer: true})).not.toThrow();
|
||||
});
|
||||
@@ -27,9 +27,3 @@ test('unsafe cloud behaviors', () => {
|
||||
options.cloudVariables.unsafeCloudBehaviors = true;
|
||||
expect(Packager.usesUnsafeOptions(options)).toBe(true);
|
||||
});
|
||||
|
||||
test('debug protection', () => {
|
||||
const options = Packager.DEFAULT_OPTIONS();
|
||||
options.compiler.aot.javascriptObfuscatorOptions.debugProtection = true;
|
||||
expect(Packager.usesUnsafeOptions(options)).toBe(true);
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs/promises';
|
||||
import http from 'node:http';
|
||||
import path from 'node:path';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
import {chromium, firefox, webkit} from '@playwright/test';
|
||||
import {PNG} from 'pngjs';
|
||||
|
||||
const root = path.resolve(fileURLToPath(new URL('../../work/acceptance/', import.meta.url)));
|
||||
const server = http.createServer(async (request, response) => {
|
||||
try {
|
||||
const name = decodeURIComponent(new URL(request.url, 'http://localhost').pathname);
|
||||
const file = path.resolve(root, `.${name.endsWith('/') ? `${name}index.html` : name}`);
|
||||
if (!file.startsWith(`${root}${path.sep}`)) throw new Error('Invalid path');
|
||||
const bytes = await fs.readFile(file);
|
||||
response.setHeader('Content-Type', ({'.js': 'text/javascript', '.html': 'text/html', '.wasm': 'application/wasm', '.json': 'application/json'})[path.extname(file)] || 'application/octet-stream');
|
||||
response.setHeader('Content-Security-Policy', "default-src 'self' data: blob:; script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval' blob:; worker-src 'self' blob:; style-src 'self' 'unsafe-inline'; connect-src 'self' blob: data:");
|
||||
response.end(bytes);
|
||||
} catch { response.writeHead(404); response.end(); }
|
||||
});
|
||||
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
|
||||
const origin = `http://127.0.0.1:${server.address().port}`;
|
||||
const results = [];
|
||||
try {
|
||||
for (const [name, engine] of Object.entries({chromium, firefox, webkit})) {
|
||||
const browser = await engine.launch({headless: true});
|
||||
try {
|
||||
for (const [file, expected] of [['index.html', '53'], ['embedded.html', '53'], ['zip/index.html', '53'], ['extension/index.html', '43'], ['rendering/index.html', '1']]) {
|
||||
const page = await browser.newPage();
|
||||
const errors = [];
|
||||
page.on('pageerror', error => errors.push(error.message));
|
||||
await page.addInitScript(() => {
|
||||
window.testEvents = []; window.testCSP = [];
|
||||
document.addEventListener('securitypolicyviolation', event => window.testCSP.push(event.violatedDirective));
|
||||
Object.defineProperty(window, 'player', {configurable: true, set(value) {
|
||||
Object.defineProperty(window, 'player', {value, configurable: true});
|
||||
for (const event of ['loaded', 'error', 'state']) value.on(event, data => window.testEvents.push(data));
|
||||
}});
|
||||
});
|
||||
await page.goto(`${origin}/${file}`);
|
||||
await page.waitForFunction(() => window.testEvents.some(event => event.type === 'loaded' || event.type === 'error')).catch(async error => {
|
||||
console.error(name, file, errors, await page.evaluate(() => ({events: window.testEvents, csp: window.testCSP, body: document.body.innerText.slice(0, 600)})));
|
||||
throw error;
|
||||
});
|
||||
assert.deepEqual(await page.evaluate(() => window.testEvents.filter(event => event.type === 'error')), [], `${name}/${file}: loading`);
|
||||
assert.deepEqual(await page.evaluate(() => Object.keys(window.player).sort()), ['dispose', 'off', 'on', 'pause', 'postInput', 'resume', 'start', 'stop']);
|
||||
assert(await page.evaluate(() => !window.vm && !window.scaffolding && Object.isFrozen(window.player)));
|
||||
await page.evaluate(() => { window.player.start(); window.player.pause(); });
|
||||
await page.evaluate(() => { window.player.postInput('keyboard', {key: 'a', isDown: true}); window.player.resume(); });
|
||||
await page.waitForFunction(value => [...document.querySelectorAll('.sc-monitor-value')].some(node => node.textContent === value), expected);
|
||||
if (file.startsWith('rendering/')) {
|
||||
const pixels = PNG.sync.read(await page.locator('canvas').first().screenshot());
|
||||
let red = 0;
|
||||
for (let index = 0; index < pixels.data.length; index += 4) {
|
||||
if (pixels.data[index] > 200 && pixels.data[index + 1] < 50 && pixels.data[index + 2] < 50) red++;
|
||||
}
|
||||
assert(red > 100, `${name}: actual WebGL pen output (${red} red pixels)`);
|
||||
}
|
||||
await page.evaluate(() => { window.player.stop(); window.player.dispose(); window.player.dispose(); });
|
||||
assert.equal(await page.locator('canvas').count(), 0, 'dispose removes the player');
|
||||
assert.deepEqual(await page.evaluate(() => window.testCSP), [], `${name}/${file}: CSP`);
|
||||
assert.deepEqual(errors, [], `${name}/${file}: runtime errors`);
|
||||
results.push({browser: name, format: file, result: 'passed'});
|
||||
console.log(`${name}: ${file} passed`);
|
||||
await page.close();
|
||||
}
|
||||
const page = await browser.newPage();
|
||||
await page.goto(`${origin}/worker.html`);
|
||||
const parity = await page.evaluate(async () => {
|
||||
const {data, expected} = await (await fetch('worker-input.json')).json();
|
||||
const worker = new Worker('specialized-worker.js');
|
||||
const result = await new Promise((resolve, reject) => {
|
||||
worker.onmessage = ({data}) => data.error ? reject(new Error(data.error)) : resolve(data.result);
|
||||
worker.onerror = event => reject(new Error(event.message));
|
||||
worker.postMessage(data);
|
||||
}).finally(() => worker.terminate());
|
||||
const hash = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(result.code));
|
||||
return {expected, actual: [...new Uint8Array(hash)].map(byte => byte.toString(16).padStart(2, '0')).join('')};
|
||||
});
|
||||
assert.equal(parity.actual, parity.expected, `${name}: Worker/Node same seed`);
|
||||
console.log(`${name}: Worker/Node parity passed`);
|
||||
results.push({browser: name, format: 'build-worker', result: 'passed'});
|
||||
await page.close();
|
||||
} finally { await browser.close(); }
|
||||
}
|
||||
await fs.writeFile(path.join(root, 'browser-report.json'), JSON.stringify(results, null, 2));
|
||||
} finally { await new Promise(resolve => server.close(resolve)); }
|
||||
@@ -0,0 +1,93 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs/promises';
|
||||
import {createRequire} from 'node:module';
|
||||
import {fixtureProject, fixedExtension, costumeId, costumeSVG} from './specialized-fixtures.mjs';
|
||||
import {auditArtifact} from '../src/specialized/link.mjs';
|
||||
const require = createRequire(import.meta.url);
|
||||
const {compileProject, Packager, loadProject} = require('../dist/packager.js');
|
||||
const JSZip = require('@turbowarp/jszip');
|
||||
const out = new URL('../../work/acceptance/', import.meta.url);
|
||||
await fs.mkdir(out, {recursive: true});
|
||||
const bytes = async project => new JSZip().file('project.json', JSON.stringify(project)).file(`${costumeId}.svg`, costumeSVG)
|
||||
.generateAsync({type: 'uint8array'});
|
||||
const data = await bytes(fixtureProject());
|
||||
const first = await compileProject(data, {seed: 'acceptance', autoplay: false});
|
||||
const second = await compileProject(data, {seed: 'acceptance', autoplay: false, sourceMap: true});
|
||||
assert.equal(first.code, second.code, 'explicit seed reproduces the player');
|
||||
assert.deepEqual(first.assets, second.assets, 'explicit seed reproduces resources');
|
||||
assert.equal(first.buildId, second.buildId);
|
||||
assert(second.sourceMap.sources.some(source => source.includes('sequencer')));
|
||||
assert(!first.code.includes('sourceMappingURL=') && !first.sourceMap);
|
||||
auditArtifact(first.code);
|
||||
for (const name of ['stepThread', 'stepThreads', 'retireThread', 'getIdFromTargetAndBlock', 'peekStack']) {
|
||||
assert(first.diagnostics.propertyMap[name], `${name} must participate in joint scheduling renaming`);
|
||||
assert(!first.code.includes(`.${name}(`), `${name} must not survive as an unchanged call`);
|
||||
}
|
||||
assert(!first.code.includes('__spOwner'), 'build-only ownership markers must not enter the player');
|
||||
assert(!first.diagnostics.propertyMap.status && !first.diagnostics.propertyMap.value);
|
||||
assert(first.diagnostics.removedModules.some(id => id.includes('/PenSkin')));
|
||||
assert(!first.diagnostics.retainedModules.some(id => id.includes('/scratch-audio/')));
|
||||
assert(!first.diagnostics.retainedModules.some(id => /\/io\/video\.js/.test(id)));
|
||||
assert.equal(first.diagnostics.commonJSWrappers.length, 2);
|
||||
assert(first.diagnostics.commonJSWrappers.every(item => item.sha256.length === 64 && item.cycle.length === 3));
|
||||
assert(!first.code.includes('__webpack_require__'));
|
||||
const zip = await JSZip.loadAsync(first.assets[0].data);
|
||||
const initial = JSON.parse(await zip.file('project.json').async('string'));
|
||||
assert(initial.targets.every(target => Object.keys(target.blocks).length === 0 && Object.keys(target.comments).length === 0));
|
||||
assert.deepEqual(Object.keys(zip.files).sort(), [`${costumeId}.svg`, 'project.json'].sort());
|
||||
await fs.writeFile(new URL('player.js', out), first.code);
|
||||
await fs.writeFile(new URL('project.zip', out), first.assets[0].data);
|
||||
await fs.writeFile(new URL('index.html', out), '<!DOCTYPE html><html><body><script src="player.js"></script></body></html>');
|
||||
const packager = new Packager();
|
||||
packager.project = await loadProject(data);
|
||||
packager.options.compiler.seed = 'acceptance';
|
||||
packager.options.autoplay = false;
|
||||
packager.options.cloudVariables.mode = 'none';
|
||||
const html = await packager.package();
|
||||
await fs.writeFile(new URL('embedded.html', out), new Uint8Array(html.data));
|
||||
const zipped = new Packager();
|
||||
zipped.project = await loadProject(data);
|
||||
zipped.options.compiler.seed = 'acceptance';
|
||||
zipped.options.autoplay = false;
|
||||
zipped.options.cloudVariables.mode = 'none';
|
||||
zipped.options.target = 'zip';
|
||||
const webZip = await JSZip.loadAsync((await zipped.package()).data);
|
||||
assert.equal(zipped.buildResult.code, packager.buildResult.code, 'HTML and ZIP share the exact linked code');
|
||||
await fs.mkdir(new URL('zip/', out), {recursive: true});
|
||||
for (const [name, file] of Object.entries(webZip.files)) {
|
||||
assert(!name.includes('..') && !name.startsWith('/'));
|
||||
if (!file.dir) await fs.writeFile(new URL(`zip/${name}`, out), await file.async('uint8array'));
|
||||
}
|
||||
await fs.writeFile(new URL('player-web.zip', out), (await webZip.generateAsync({type: 'uint8array'})));
|
||||
const fixed = await compileProject(await bytes(fixtureProject({extension: true, branch: true})),
|
||||
{seed: 'extension', autoplay: false, extensions: [fixedExtension]});
|
||||
await fs.mkdir(new URL('extension/', out), {recursive: true});
|
||||
await fs.writeFile(new URL('extension/player.js', out), fixed.code);
|
||||
await fs.writeFile(new URL('extension/project.zip', out), fixed.assets[0].data);
|
||||
await fs.writeFile(new URL('extension/index.html', out), '<!DOCTYPE html><html><body><script src="player.js"></script></body></html>');
|
||||
const drawing = fixtureProject();
|
||||
const stage = drawing.targets[0];
|
||||
const sprite = {...structuredClone(stage), isStage: false, name: 'Pen', variables: {},
|
||||
visible: false, x: 0, y: 0, direction: 90, size: 100, rotationStyle: 'all around', layerOrder: 1};
|
||||
stage.blocks = {};
|
||||
sprite.blocks = {};
|
||||
const commands = [
|
||||
['event_whenflagclicked', {}], ['pen_clear', {}],
|
||||
['pen_setPenSizeTo', {SIZE: [1, [4, '10']]}],
|
||||
['pen_setPenColorToColor', {COLOR: [1, [9, '#ff0000']]}],
|
||||
['pen_penDown', {}], ['motion_gotoxy', {X: [1, [4, '80']], Y: [1, [4, '0']]}], ['pen_penUp', {}],
|
||||
['data_setvariableto', {VALUE: [1, [4, '1']]}, {VARIABLE: ['answer', 'answer']}]
|
||||
];
|
||||
commands.forEach(([opcode, inputs, fields = {}], index) => {
|
||||
sprite.blocks[`b${index}`] = {opcode, inputs, fields, topLevel: index === 0,
|
||||
parent: index ? `b${index - 1}` : null, next: index + 1 < commands.length ? `b${index + 1}` : null};
|
||||
});
|
||||
drawing.targets.push(sprite); drawing.extensions = ['pen'];
|
||||
const rendered = await compileProject(await bytes(drawing), {seed: 'rendering', autoplay: false});
|
||||
assert(rendered.diagnostics.retainedModules.some(id => id.includes('/PenSkin')));
|
||||
await fs.mkdir(new URL('rendering/', out), {recursive: true});
|
||||
await fs.writeFile(new URL('rendering/player.js', out), rendered.code);
|
||||
await fs.writeFile(new URL('rendering/project.zip', out), rendered.assets[0].data);
|
||||
await fs.writeFile(new URL('rendering/index.html', out), '<!DOCTYPE html><html><body><script src="player.js"></script></body></html>');
|
||||
await fs.writeFile(new URL('build-report.json', out), JSON.stringify({first: first.diagnostics, fixed: fixed.diagnostics}, null, 2));
|
||||
console.log('Build, reproducibility, capability removal, source stripping, HTML, and fixed extension checks passed.');
|
||||
@@ -0,0 +1,37 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import {createRequire} from 'node:module';
|
||||
import assert from 'node:assert/strict';
|
||||
import {fixtureProject, costumeSVG, costumeId} from './specialized-fixtures.mjs';
|
||||
const require = createRequire(import.meta.url);
|
||||
const {Packager, loadProject} = require('../dist/packager.js');
|
||||
const JSZip = require('@turbowarp/jszip');
|
||||
const data = await new JSZip().file('project.json', JSON.stringify(fixtureProject()))
|
||||
.file(`${costumeId}.svg`, costumeSVG).generateAsync({type: 'uint8array'});
|
||||
const packager = new Packager();
|
||||
packager.project = await loadProject(data);
|
||||
packager.options.target = 'webview-mac';
|
||||
packager.options.compiler.seed = 'acceptance';
|
||||
packager.options.autoplay = false;
|
||||
packager.options.cloudVariables.mode = 'none';
|
||||
const result = await packager.package();
|
||||
const zip = await JSZip.loadAsync(result.data);
|
||||
const directory = new URL('../../work/acceptance/desktop/', import.meta.url);
|
||||
await fs.mkdir(directory, {recursive: true});
|
||||
for (const [name, file] of Object.entries(zip.files)) {
|
||||
assert(!name.startsWith('/') && !name.split('/').includes('..'));
|
||||
const destination = new URL(name, directory);
|
||||
if (file.dir) await fs.mkdir(destination, {recursive: true});
|
||||
else {
|
||||
await fs.mkdir(path.dirname(destination.pathname), {recursive: true});
|
||||
await fs.writeFile(destination, await file.async('uint8array'));
|
||||
if (file.unixPermissions) await fs.chmod(destination, Number(file.unixPermissions) & 0o777);
|
||||
}
|
||||
}
|
||||
const scripts = Object.keys(zip.files).filter(name => name.endsWith('/Resources/script.js'));
|
||||
assert.equal(scripts.length, 1);
|
||||
const sameCode = await fs.readFile(new URL('../../work/acceptance/zip/script.js', import.meta.url), 'utf8');
|
||||
assert.equal(await zip.file(scripts[0]).async('string'), sameCode, 'desktop and web ZIP share final code');
|
||||
assert(zip.file(scripts[0].replace('script.js', 'project.zip')));
|
||||
assert(zip.file(scripts[0].replace('script.js', 'index.html')));
|
||||
console.log('Actual macOS WebView package built; executable, page, resources and identical player code verified.');
|
||||
@@ -0,0 +1,128 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
import vmModule from 'node:vm';
|
||||
import {createRequire} from 'node:module';
|
||||
import assert from 'node:assert/strict';
|
||||
import {stepMethods, hatsBody, linkPlayer} from '../src/specialized/link.mjs';
|
||||
import {rollup} from 'rollup';
|
||||
const require = createRequire(new URL('../../scratch-vm/package.json', import.meta.url));
|
||||
const VM = require('./src/virtual-machine');
|
||||
const Thread = require('./src/engine/thread');
|
||||
const {lower} = require('./src/compiler/continuation');
|
||||
const Storage = require('scratch-storage');
|
||||
const sourcePack = JSON.parse(await fs.readFile(new URL('../generated/runtime-sources.json', import.meta.url)));
|
||||
const helpers = await import(`data:text/javascript;base64,${Buffer.from(sourcePack.helpers).toString('base64')}`);
|
||||
|
||||
// This harness evaluates generated methods only in the test process. Production
|
||||
// inserts their AST into Sequencer before linking and never loads generated code.
|
||||
export function installForTest(vm, program) {
|
||||
vm.runtime.targets.forEach((target, index) => { target.sprite.__spTarget = index; });
|
||||
const context = vmModule.createContext({...helpers, Thread, console, Math, Date,
|
||||
Timer: require('./src/util/timer'), Cast: require('./src/util/cast'),
|
||||
log: require('./src/util/log'), execute: require('./src/engine/execute'),
|
||||
SpecializedBlockUtility: require('./src/compiler/specialized-block-utility')});
|
||||
for (const [name, body] of Object.entries(stepMethods(program.blocks))) {
|
||||
vm.runtime.sequencer[name] = new vmModule.Script(`(function(thread) ${body})`).runInContext(context);
|
||||
}
|
||||
vm.runtime.startHats = new vmModule.Script(`(function(opcode,fields,target) ${hatsBody(program.hats)})`).runInContext(context);
|
||||
vm.runtime.__spPrimitive = (opcode, args, util) => vm.runtime.getOpcodeFunction(opcode)(args, util);
|
||||
const initialize = thread => {
|
||||
thread.isCompiled = true; thread.triedToCompile = true; thread.__spLifetime = {};
|
||||
thread.__spFrames = [{__spPC: Number(thread.topBlock), __spSlots: [], __spArgs: [], __spReturn: -1}];
|
||||
return thread;
|
||||
};
|
||||
for (const name of ['_pushThread', '_restartThread']) {
|
||||
const previous = vm.runtime[name];
|
||||
vm.runtime[name] = function (...args) {
|
||||
this.compilerOptions.enabled = false;
|
||||
if (name === '_restartThread') args[0].__spLifetime = null;
|
||||
const thread = previous.apply(this, args);
|
||||
this.compilerOptions.enabled = true;
|
||||
if (!thread.updateMonitor) initialize(thread);
|
||||
if (name === '_restartThread') thread.executableHat = args[0].executableHat;
|
||||
return thread;
|
||||
};
|
||||
}
|
||||
const retire = vm.runtime.sequencer.retireThread;
|
||||
vm.runtime.sequencer.retireThread = function (thread) {
|
||||
thread.__spLifetime = null; thread.__spFrames = []; return retire.call(this, thread);
|
||||
};
|
||||
}
|
||||
|
||||
const directory = new URL('../../scratch-vm/test/fixtures/execute/', import.meta.url);
|
||||
const linked = process.argv.includes('--linked');
|
||||
const filter = process.argv.slice(2).find(value => !value.startsWith('--'));
|
||||
const shard = process.argv.find(value => value.startsWith('--shard='))?.slice(8) || '0/1';
|
||||
const [shardIndex, shardCount] = shard.split('/').map(Number);
|
||||
assert(Number.isInteger(shardIndex) && Number.isInteger(shardCount) && shardCount > 0 &&
|
||||
shardIndex >= 0 && shardIndex < shardCount, 'Expected --shard=index/count with a zero-based index');
|
||||
const names = process.argv[1] === fileURLToPath(import.meta.url) ? (await fs.readdir(directory))
|
||||
.filter(name => /\.sb[23]?$/.test(name) && (!filter || name.includes(filter))).sort()
|
||||
.filter((name, index) => index % shardCount === shardIndex) : [];
|
||||
let failures = 0;
|
||||
for (const name of names) {
|
||||
const data = await fs.readFile(new URL(name, directory));
|
||||
const run = async specialized => {
|
||||
let vm = new VM(); vm.attachStorage(new Storage()); vm.setCompilerOptions({enabled: true});
|
||||
const originalRandom = Math.random;
|
||||
const RealDate = Date;
|
||||
const events = [];
|
||||
const descriptors = {};
|
||||
try {
|
||||
await vm.loadProject(data);
|
||||
if (specialized && linked) {
|
||||
const input = vm.exportSpecializedProjectBuildInput();
|
||||
const built = await linkPlayer({sourcePack, input, program: lower(input, 42), seed: 'linked-differential',
|
||||
boot: `const vm = new Scaffolding.VM(); globalThis.linkedDifferentialVM = vm;
|
||||
vm.setCompilerOptions({enabled: true});
|
||||
globalThis.linkedDifferentialReady = vm.loadProject(${JSON.stringify(JSON.stringify(input.project))});`}, rollup);
|
||||
const element = () => ({style: {}, appendChild() {}, insertBefore() {}, setAttribute() {}, getContext() { return null; }});
|
||||
const globals = {window: globalThis, self: globalThis,
|
||||
document: {hidden: false, createElement: element, head: element(), body: element()}};
|
||||
for (const key of Object.keys(globals)) descriptors[key] = Object.getOwnPropertyDescriptor(globalThis, key);
|
||||
Object.assign(globalThis, globals);
|
||||
vm.quit();
|
||||
new vmModule.Script(built.code).runInThisContext();
|
||||
vm = globalThis.linkedDifferentialVM;
|
||||
await globalThis.linkedDifferentialReady;
|
||||
}
|
||||
vm.runtime.on('SAY', (target, kind, value) => events.push([target.getName(), kind, String(value)]));
|
||||
let logicalTime = RealDate.UTC(2024, 0, 1);
|
||||
global.Date = class extends RealDate {
|
||||
constructor(...args) { super(...(args.length ? args : [logicalTime])); }
|
||||
static now() { return Math.floor(logicalTime); }
|
||||
};
|
||||
if (specialized && !linked) installForTest(vm, lower(vm.exportSpecializedProjectBuildInput(), 42));
|
||||
let random = 1234;
|
||||
Math.random = () => { random = (Math.imul(random, 1664525) + 1013904223) >>> 0; return random / 4294967296; };
|
||||
vm.greenFlag();
|
||||
const deadline = RealDate.now() + 10000;
|
||||
for (let tick = 0; tick < 20000; tick++) {
|
||||
vm.runtime._step();
|
||||
logicalTime += 1000 / 30;
|
||||
if (!vm.runtime.threads.some(t => !t.updateMonitor) || events.some(e => e[2] === 'end')) break;
|
||||
if (RealDate.now() > deadline) throw new Error('Fixture timed out');
|
||||
await new Promise(resolve => setTimeout(resolve, 1));
|
||||
}
|
||||
return {events, values: vm.runtime.targets.map(target => [target.getName(),
|
||||
Object.values(target.variables).map(variable => [variable.name, variable.type, variable.value])])};
|
||||
} finally {
|
||||
vm.quit(); Math.random = originalRandom; global.Date = RealDate;
|
||||
delete globalThis.linkedDifferentialVM; delete globalThis.linkedDifferentialReady;
|
||||
for (const [key, descriptor] of Object.entries(descriptors)) {
|
||||
if (descriptor) Object.defineProperty(globalThis, key, descriptor); else delete globalThis[key];
|
||||
}
|
||||
}
|
||||
};
|
||||
try {
|
||||
const baseline = await run(false);
|
||||
const specialized = await run(true);
|
||||
// Preserve NaN, infinities and negative zero; JSON comparison conflates them.
|
||||
assert.deepEqual(structuredClone(specialized), structuredClone(baseline), 'Semantic mismatch');
|
||||
console.log(`PASS ${name}`);
|
||||
} catch (error) { failures++; console.error(`FAIL ${name}: ${error.stack}`); }
|
||||
}
|
||||
if (names.length) {
|
||||
console.log(`${names.length - failures}/${names.length} fixtures passed`);
|
||||
process.exitCode = failures ? 1 : 0;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import {createHash} from 'node:crypto';
|
||||
export const costumeSVG = '<svg xmlns="http://www.w3.org/2000/svg" width="480" height="360"><path fill="white" d="M0 0h480v360H0z"/></svg>';
|
||||
export const costumeId = createHash('md5').update(costumeSVG).digest('hex');
|
||||
export const fixtureProject = ({extension = false, branch = false, iterations = 50} = {}) => {
|
||||
const target = {isStage: true, name: 'Stage', variables: {answer: ['answer', 0]}, lists: {}, broadcasts: {},
|
||||
blocks: {}, comments: {}, currentCostume: 0, costumes: [{name: 'backdrop', assetId: costumeId, md5ext: `${costumeId}.svg`,
|
||||
dataFormat: 'svg', rotationCenterX: 240, rotationCenterY: 180, bitmapResolution: 1}], sounds: [], volume: 100, layerOrder: 0,
|
||||
tempo: 60, videoTransparency: 50, videoState: 'off', textToSpeechLanguage: null};
|
||||
const blocks = target.blocks;
|
||||
const block = (opcode, parent, next, inputs = {}, fields = {}) => ({opcode, parent, next, inputs, fields, shadow: false, topLevel: !parent});
|
||||
blocks.hat = block('event_whenflagclicked', null, 'set');
|
||||
blocks.set = block('data_setvariableto', 'hat', extension ? 'say' : 'repeat',
|
||||
{VALUE: extension ? [3, 'reporter', [4, '0']] : [1, [4, '3']]}, {VARIABLE: ['answer', 'answer']});
|
||||
if (extension) blocks.reporter = block('fixture_value', 'set', null);
|
||||
else {
|
||||
blocks.repeat = block('control_repeat', 'set', 'say', {TIMES: [1, [4, String(iterations)]], SUBSTACK: [2, 'change']});
|
||||
blocks.change = block('data_changevariableby', 'repeat', null, {VALUE: [1, [4, '1']]}, {VARIABLE: ['answer', 'answer']});
|
||||
}
|
||||
blocks.say = block('looks_say', extension ? 'set' : 'repeat', null, {MESSAGE: [1, [10, 'done']]});
|
||||
if (extension && branch) {
|
||||
blocks.set.next = 'choose';
|
||||
blocks.choose = block('fixture_choose', 'set', 'say', {SUBSTACK: [2, 'increment']});
|
||||
blocks.increment = block('data_changevariableby', 'choose', null, {VALUE: [1, [4, '1']]}, {VARIABLE: ['answer', 'answer']});
|
||||
blocks.say.parent = 'choose';
|
||||
}
|
||||
return {targets: [target], monitors: [{id: 'answer', mode: 'default', opcode: 'data_variable',
|
||||
params: {VARIABLE: 'answer'}, spriteName: null, value: 0, width: 0, height: 0, x: 5, y: 5, visible: true,
|
||||
sliderMin: 0, sliderMax: 100, isDiscrete: true}], extensions: extension ? ['fixture'] : [],
|
||||
extensionURLs: extension ? {fixture: 'https://test.invalid/fixture.js'} : {},
|
||||
meta: {semver: '3.0.0', vm: '1.0.0', agent: 'specialized-tests'}};
|
||||
};
|
||||
|
||||
export const fixedExtension = {
|
||||
url: 'https://test.invalid/fixture.js',
|
||||
source: `Scratch.extensions.register({
|
||||
getInfo() { return {id: 'fixture', name: 'Fixture', blocks: [
|
||||
{opcode: 'value', blockType: Scratch.BlockType.REPORTER, text: 'async value'},
|
||||
{opcode: 'choose', blockType: Scratch.BlockType.CONDITIONAL, branchCount: 1, text: 'async branch'}]}; },
|
||||
async value() { await new Promise(resolve => setTimeout(resolve, 20)); return 42; },
|
||||
async choose() { await new Promise(resolve => setTimeout(resolve, 10)); return 1; }
|
||||
});`
|
||||
};
|
||||
@@ -0,0 +1,67 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {createRequire} from 'node:module';
|
||||
import {installForTest} from './specialized-differential.mjs';
|
||||
import {fixtureProject} from './specialized-fixtures.mjs';
|
||||
import pause from '../src/addons/pause.js';
|
||||
const require = createRequire(new URL('../../scratch-vm/package.json', import.meta.url));
|
||||
const VM = require('./src/virtual-machine');
|
||||
const {lower} = require('./src/compiler/continuation');
|
||||
const pending = [];
|
||||
class Extension {
|
||||
getInfo() { return {id: 'fixture', name: 'Fixture', blocks: [{opcode: 'value', blockType: 'reporter', text: 'value'}]}; }
|
||||
value() { return new Promise(resolve => pending.push(resolve)); }
|
||||
}
|
||||
async function setup() {
|
||||
pending.length = 0;
|
||||
const vm = new VM();
|
||||
vm.extensionManager.addBuiltinExtension('fixture', Extension);
|
||||
await vm.loadProject(JSON.stringify(fixtureProject({extension: true})));
|
||||
installForTest(vm, lower(vm.exportSpecializedProjectBuildInput(), 45));
|
||||
return vm;
|
||||
}
|
||||
const settle = async () => { await Promise.resolve(); await Promise.resolve(); };
|
||||
const value = vm => vm.runtime.getTargetForStage().variables.answer.value;
|
||||
|
||||
test('an old Promise cannot revive a stopped/restarted program', async () => {
|
||||
const vm = await setup();
|
||||
try {
|
||||
vm.greenFlag(); vm.runtime._step();
|
||||
assert.equal(pending.length, 1);
|
||||
const old = vm.runtime.threads.find(thread => !thread.updateMonitor);
|
||||
vm.stopAll(); vm.greenFlag(); vm.runtime._step();
|
||||
assert.equal(pending.length, 2);
|
||||
pending[0](123); await settle(); vm.runtime._step();
|
||||
assert.equal(value(vm), 0);
|
||||
assert.equal(vm.runtime.threads.includes(old), false);
|
||||
pending[1](42); await settle(); vm.runtime._step();
|
||||
assert.equal(value(vm), 42);
|
||||
} finally { vm.quit(); }
|
||||
});
|
||||
|
||||
test('pausing preserves a completed Promise until resume, without an audio engine', async () => {
|
||||
const vm = await setup();
|
||||
try {
|
||||
pause({scaffolding: {vm}});
|
||||
vm.greenFlag(); vm.runtime._step(); vm.setPaused(true);
|
||||
pending[0](42); await settle(); vm.runtime._step();
|
||||
assert.equal(value(vm), 0);
|
||||
vm.setPaused(false); vm.runtime._step();
|
||||
assert.equal(value(vm), 42);
|
||||
} finally { vm.quit(); }
|
||||
});
|
||||
|
||||
test('restarting a waiting hat invalidates only the old invocation', async () => {
|
||||
const vm = await setup();
|
||||
try {
|
||||
vm.greenFlag(); vm.runtime._step();
|
||||
const old = vm.runtime.threads.find(thread => !thread.updateMonitor);
|
||||
const replacement = vm.runtime._restartThread(old);
|
||||
assert.notEqual(replacement, old);
|
||||
vm.runtime._step();
|
||||
pending[0](123); await settle(); vm.runtime._step();
|
||||
assert.equal(value(vm), 0);
|
||||
pending[1](42); await settle(); vm.runtime._step();
|
||||
assert.equal(value(vm), 42);
|
||||
} finally { vm.quit(); }
|
||||
});
|
||||
@@ -0,0 +1,144 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import vmModule from 'node:vm';
|
||||
import assert from 'node:assert/strict';
|
||||
import {createRequire} from 'node:module';
|
||||
import {performance} from 'node:perf_hooks';
|
||||
import {linkPlayer} from '../src/specialized/link.mjs';
|
||||
import {rollup} from 'rollup';
|
||||
import {fixtureProject} from './specialized-fixtures.mjs';
|
||||
const require = createRequire(new URL('../../scratch-vm/package.json', import.meta.url));
|
||||
const VM = require('./src/virtual-machine');
|
||||
const {lower} = require('./src/compiler/continuation');
|
||||
const sourcePack = JSON.parse(await fs.readFile(new URL('../generated/runtime-sources.json', import.meta.url)));
|
||||
const quiet = {log() {}, warn() {}, error() {}, debug() {}, info() {}};
|
||||
const element = () => ({style: {}, appendChild() {}, insertBefore() {}, setAttribute() {}, getContext() { return null; }});
|
||||
const report = [];
|
||||
|
||||
for (const category of ['compute', 'warp-calls', 'list', 'broadcast', 'clone', 'pen']) {
|
||||
const project = fixtureProject({iterations: category === 'compute' || category === 'list' ? 200000 : 10000});
|
||||
project.monitors = [];
|
||||
project.targets[0].costumes = [];
|
||||
delete project.targets[0].blocks.say;
|
||||
project.targets[0].blocks.repeat.next = null;
|
||||
const target = project.targets[0];
|
||||
if (category === 'warp-calls') {
|
||||
const blocks = target.blocks;
|
||||
blocks.set.next = 'call';
|
||||
blocks.repeat.parent = 'definition';
|
||||
blocks.repeat.inputs.TIMES = [1, [4, '1000000']];
|
||||
blocks.repeat.inputs.SUBSTACK = [2, 'innerCall'];
|
||||
blocks.change.parent = 'innerDefinition';
|
||||
const mutation = code => ({tagName: 'mutation', children: [], proccode: code,
|
||||
argumentids: '[]', argumentnames: '[]', argumentdefaults: '[]', warp: 'true'});
|
||||
const block = (opcode, parent, next, inputs = {}) => ({opcode, parent, next, inputs, fields: {}, topLevel: !parent});
|
||||
blocks.call = {...block('procedures_call', 'set', null), mutation: mutation('work')};
|
||||
blocks.definition = block('procedures_definition', null, 'repeat', {custom_block: [1, 'prototype']});
|
||||
blocks.prototype = {...block('procedures_prototype', 'definition', null), shadow: true, mutation: mutation('work')};
|
||||
blocks.innerCall = {...block('procedures_call', 'repeat', null), mutation: mutation('step')};
|
||||
blocks.innerDefinition = block('procedures_definition', null, 'change', {custom_block: [1, 'innerPrototype']});
|
||||
blocks.innerPrototype = {...block('procedures_prototype', 'innerDefinition', null), shadow: true, mutation: mutation('step')};
|
||||
// Cold scripts expose oversized-dispatcher regressions that a lone loop misses.
|
||||
for (let i = 0; i < 80; i++) blocks[`cold${i}`] = block('event_whenkeypressed', null, null);
|
||||
for (let i = 0; i < 80; i++) blocks[`cold${i}`].fields.KEY_OPTION = ['space', null];
|
||||
}
|
||||
if (category === 'list') {
|
||||
target.lists.items = ['items', []];
|
||||
target.blocks.change.opcode = 'data_addtolist';
|
||||
target.blocks.change.fields = {LIST: ['items', 'items']};
|
||||
target.blocks.change.inputs = {ITEM: [1, [4, '1']]};
|
||||
}
|
||||
if (category === 'broadcast') {
|
||||
target.broadcasts.message = 'message';
|
||||
target.blocks.change.opcode = 'event_broadcastandwait';
|
||||
target.blocks.change.inputs = {BROADCAST_INPUT: [1, [11, 'message', 'message']]};
|
||||
target.blocks.change.fields = {};
|
||||
target.blocks.receiver = {opcode: 'event_whenbroadcastreceived', parent: null, next: 'add', fields: {BROADCAST_OPTION: ['message', 'message']}, inputs: {}, topLevel: true};
|
||||
target.blocks.add = {opcode: 'data_changevariableby', parent: 'receiver', next: null, fields: {VARIABLE: ['answer', 'answer']}, inputs: {VALUE: [1, [4, '1']]}};
|
||||
}
|
||||
if (category === 'clone' || category === 'pen') {
|
||||
const sprite = structuredClone(target);
|
||||
Object.assign(sprite, {isStage: false, name: 'Sprite', x: 0, y: 0, size: 100, direction: 90, draggable: false, rotationStyle: 'all around', visible: true, layerOrder: 1});
|
||||
sprite.variables = {}; target.blocks = {};
|
||||
project.targets.push(sprite);
|
||||
if (category === 'clone') {
|
||||
sprite.blocks.change.opcode = 'control_create_clone_of';
|
||||
sprite.blocks.change.inputs = {CLONE_OPTION: [1, 'menu']}; sprite.blocks.change.fields = {};
|
||||
sprite.blocks.menu = {opcode: 'control_create_clone_of_menu', parent: 'change', next: null, fields: {CLONE_OPTION: ['_myself_', null]}, inputs: {}, shadow: true};
|
||||
sprite.blocks.receiver = {opcode: 'control_start_as_clone', parent: null, next: 'delete', inputs: {}, fields: {}, topLevel: true};
|
||||
sprite.blocks.delete = {opcode: 'control_delete_this_clone', parent: 'receiver', next: null, inputs: {}, fields: {}};
|
||||
} else {
|
||||
project.extensions = ['pen'];
|
||||
sprite.blocks.change.opcode = 'pen_setPenColorToColor'; sprite.blocks.change.inputs = {COLOR: [1, [9, '#336699']]}; sprite.blocks.change.fields = {};
|
||||
}
|
||||
}
|
||||
const baseline = new VM();
|
||||
baseline.setCompilerOptions({enabled: true}); baseline.setTurboMode(true);
|
||||
await baseline.loadProject(JSON.stringify(project));
|
||||
const input = baseline.exportSpecializedProjectBuildInput();
|
||||
const buildStart = performance.now();
|
||||
const program = lower(input, 42);
|
||||
const boot = `const vm = new Scaffolding.VM(); globalThis.testVM = vm;
|
||||
vm.setCompilerOptions({enabled: true}); vm.setTurboMode(true);
|
||||
globalThis.testReady = vm.loadProject(${JSON.stringify(JSON.stringify(input.project))});`;
|
||||
const built = await linkPlayer({sourcePack, input, program, boot, seed: 'performance'}, rollup);
|
||||
const buildMs = performance.now() - buildStart;
|
||||
// Run both VMs in the same JS realm. A vm.createContext proxy adds a global
|
||||
// lookup tax to every generated Math/Number operation and is not a browser.
|
||||
const context = globalThis;
|
||||
const globals = {window: context, self: context, document: {createElement: element, head: element(), body: element()}};
|
||||
const previousGlobals = Object.fromEntries(Object.keys(globals).map(key => [key, Object.getOwnPropertyDescriptor(context, key)]));
|
||||
Object.assign(context, globals);
|
||||
await fs.writeFile(new URL('../../work/performance-player.js', import.meta.url), built.code);
|
||||
const start = performance.now();
|
||||
try { new vmModule.Script(built.code).runInThisContext(); }
|
||||
catch (error) { console.error(error.message, error.stack.split('\n').slice(-5)); throw new Error('Performance player initialization failed'); }
|
||||
await context.testReady;
|
||||
const startupMs = performance.now() - start;
|
||||
const specialized = context.testVM;
|
||||
const measure = vm => {
|
||||
for (const t of vm.runtime.targets) for (const variable of Object.values(t.variables)) if (variable.type === 'list') variable.value.length = 0;
|
||||
const started = performance.now();
|
||||
vm.greenFlag();
|
||||
while (vm.runtime.threads.some(thread => !thread.updateMonitor)) vm.runtime._step();
|
||||
return performance.now() - started;
|
||||
};
|
||||
const samples = {aot: [], specialized: []};
|
||||
for (let round = 0; round < 8; round++) {
|
||||
const aot = measure(baseline), fused = measure(specialized);
|
||||
if (round >= 3) { samples.aot.push(aot); samples.specialized.push(fused); }
|
||||
}
|
||||
const median = array => [...array].sort((a, b) => a - b)[2];
|
||||
const slowdown = median(samples.specialized) / median(samples.aot) - 1;
|
||||
assert.deepEqual(JSON.parse(JSON.stringify(specialized.runtime.targets.map(t => Object.values(t.variables).map(v => v.value)))),
|
||||
JSON.parse(JSON.stringify(baseline.runtime.targets.map(t => Object.values(t.variables).map(v => v.value)))));
|
||||
report.push({category, samples, medianAOT: median(samples.aot), medianSpecialized: median(samples.specialized),
|
||||
p95AOT: Math.max(...samples.aot), p95Specialized: Math.max(...samples.specialized), slowdown, buildMs, startupMs,
|
||||
heapUsed: process.memoryUsage().heapUsed, scope: 'CPU execution; pen state update; excludes GPU rasterization'});
|
||||
console.log(category, `${(slowdown * 100).toFixed(1)}%`, JSON.stringify(samples));
|
||||
if (category === 'compute') {
|
||||
const otherBuild = await linkPlayer({sourcePack, input, program: lower(input, 43), boot, seed: 'other-build'}, rollup);
|
||||
new vmModule.Script(otherBuild.code).runInThisContext();
|
||||
await context.testReady;
|
||||
const other = context.testVM;
|
||||
measure(other);
|
||||
assert.equal(other.runtime.targets[0].variables.answer.value, specialized.runtime.targets[0].variables.answer.value,
|
||||
'different seeds preserve execution');
|
||||
const sequencer = specialized.runtime.sequencer;
|
||||
const stepName = built.diagnostics.propertyMap.stepThread || 'stepThread';
|
||||
const otherStepName = otherBuild.diagnostics.propertyMap.stepThread || 'stepThread';
|
||||
const original = sequencer[stepName];
|
||||
try {
|
||||
sequencer[stepName] = other.runtime.sequencer[otherStepName];
|
||||
specialized.greenFlag();
|
||||
assert.throws(() => specialized.runtime._step(), 'a dispatcher from another build cannot run against this build\'s thread layout');
|
||||
} finally { sequencer[stepName] = original; specialized.stopAll(); other.quit(); }
|
||||
report.at(-1).crossBuildDispatcherSubstitution = 'rejected';
|
||||
}
|
||||
baseline.quit(); specialized.quit();
|
||||
delete context.testVM; delete context.testReady;
|
||||
for (const [key, descriptor] of Object.entries(previousGlobals)) {
|
||||
if (descriptor) Object.defineProperty(context, key, descriptor); else delete context[key];
|
||||
}
|
||||
}
|
||||
await fs.writeFile(new URL('../../work/acceptance/performance-report.json', import.meta.url), JSON.stringify(report, null, 2));
|
||||
assert(report.every(item => item.slowdown <= 0.1), '10% median CPU regression gate');
|
||||
@@ -0,0 +1,224 @@
|
||||
import {test} from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import vm from 'node:vm';
|
||||
import fs from 'node:fs/promises';
|
||||
import {createRequire} from 'node:module';
|
||||
import {rollup} from 'rollup';
|
||||
import {parse} from '@babel/parser';
|
||||
import * as t from '@babel/types';
|
||||
import generateModule from '@babel/generator';
|
||||
import {renameProperties, linkPlayer} from '../src/specialized/link.mjs';
|
||||
import {fixtureProject} from './specialized-fixtures.mjs';
|
||||
|
||||
const generate = generateModule.default || generateModule;
|
||||
const definitions = `
|
||||
class A {
|
||||
static __spOwnerThread() {}
|
||||
static getIdFromTargetAndBlock() { return 13; }
|
||||
constructor() { this.value = 7; this.status = 0; }
|
||||
peekStack() { return this.value; }
|
||||
}
|
||||
class B {
|
||||
static __spOwnerSequencer() {}
|
||||
stepThread(t) { const {peekStack: read} = t; return read.call(t); }
|
||||
stepThreads(t) { return this.stepThread(t); }
|
||||
retireThread(t) { t.status = 4; }
|
||||
}
|
||||
const runtime = {sequencer: new B()};
|
||||
const task = new A();
|
||||
`;
|
||||
const transform = (source, seed = 'properties') => {
|
||||
const tree = parse(source), diagnostics = {};
|
||||
renameProperties(tree, seed, diagnostics);
|
||||
return {code: generate(tree).code, diagnostics};
|
||||
};
|
||||
const execute = code => new vm.Script(code).runInNewContext();
|
||||
|
||||
test('VM declarations, aliases, destructuring and optional calls share one property map', () => {
|
||||
const source = definitions + `
|
||||
const scheduler = runtime.sequencer;
|
||||
const answer = scheduler?.stepThread(task);
|
||||
scheduler.retireThread(task);
|
||||
[answer, task.status, task.value].join(',');
|
||||
`;
|
||||
const {code, diagnostics} = transform(source);
|
||||
assert.equal(execute(code), '7,4,7');
|
||||
for (const name of ['stepThread', 'stepThreads', 'retireThread', 'peekStack']) {
|
||||
assert(diagnostics.propertyMap[name], `${name} must be renamed`);
|
||||
}
|
||||
assert(!code.includes('__spOwner'));
|
||||
assert(code.includes('.status') && code.includes('.value'), 'protocol-shaped fields stay stable');
|
||||
assert.equal(code, transform(source).code);
|
||||
const other = transform(source, 'another-seed');
|
||||
assert.notEqual(code, other.code);
|
||||
assert.equal(execute(other.code), '7,4,7');
|
||||
});
|
||||
|
||||
test('literal computed accesses reserve their property and explain why', () => {
|
||||
const {code, diagnostics} = transform(definitions + `task?.['peekStack']();`);
|
||||
assert.equal(execute(code), 7);
|
||||
assert(!diagnostics.propertyMap.peekStack);
|
||||
assert(diagnostics.propertyMap.stepThread);
|
||||
assert(diagnostics.reservedPropertyReasons.peekStack.includes('computed string access'));
|
||||
});
|
||||
|
||||
test('WeakMap thread state operations are not treated as reflective property access', () => {
|
||||
const {code, diagnostics} = transform(definitions + `
|
||||
const states = new WeakMap(); states.set(task, 9);
|
||||
[states.get(task), task.peekStack()].join(',');
|
||||
`);
|
||||
assert.equal(execute(code), '9,7');
|
||||
assert(diagnostics.propertyMap.peekStack);
|
||||
});
|
||||
|
||||
test('static Thread methods use declaration identity instead of a class name', () => {
|
||||
const {code, diagnostics} = transform(definitions + `A.getIdFromTargetAndBlock();`);
|
||||
assert.equal(execute(code), 13);
|
||||
assert(diagnostics.propertyMap.getIdFromTargetAndBlock);
|
||||
});
|
||||
|
||||
test('Rollup class expressions and constructor aliases preserve the ownership annotation', () => {
|
||||
const {code, diagnostics} = transform(`
|
||||
const Original = class LinkedClass {
|
||||
static __spOwnerThread() {}
|
||||
static getIdFromTargetAndBlock() { return 5; }
|
||||
pushStack() { return LinkedClass.getIdFromTargetAndBlock() + 3; }
|
||||
};
|
||||
const Alias = Original;
|
||||
const task = new Alias();
|
||||
[Alias.getIdFromTargetAndBlock(), task.pushStack()].join(',');
|
||||
`);
|
||||
assert.equal(execute(code), '5,8');
|
||||
assert(diagnostics.propertyMap.getIdFromTargetAndBlock && diagnostics.propertyMap.pushStack);
|
||||
});
|
||||
|
||||
test('dynamic access through a scheduler alias preserves the whole family', () => {
|
||||
const {code, diagnostics} = transform(definitions + `
|
||||
const alias = runtime.sequencer;
|
||||
const name = ['step', 'Thread'].join('');
|
||||
alias[name](task);
|
||||
`);
|
||||
assert.equal(execute(code), 7);
|
||||
for (const entry of diagnostics.schedulingProperties.Sequencer) {
|
||||
assert.equal(entry.renamed, null);
|
||||
assert(entry.reasons.includes('dynamic access to an internal scheduling object'));
|
||||
}
|
||||
});
|
||||
|
||||
test('dynamic reflection and prototype enumeration preserve observable method names', () => {
|
||||
for (const expression of [
|
||||
`Reflect.get(task, ['peek', 'Stack'].join('')).call(task)`,
|
||||
`Reflect?.get(task, ['peek', 'Stack'].join('')).call(task)`,
|
||||
`['peek', 'Stack'].join('') in task`,
|
||||
`Reflect.ownKeys(Object.getPrototypeOf(task)).includes(['peek', 'Stack'].join(''))`
|
||||
]) {
|
||||
const {code, diagnostics} = transform(definitions + expression);
|
||||
assert.equal(execute(code), execute(definitions + expression));
|
||||
assert(!diagnostics.propertyMap.peekStack);
|
||||
assert(diagnostics.reservedPropertyReasons.peekStack.length);
|
||||
}
|
||||
});
|
||||
|
||||
test('reflected own methods remain visible after an addon replaces a scheduling hook', () => {
|
||||
for (const reflection of [
|
||||
`Object.hasOwn(alias, key)`,
|
||||
`alias.hasOwnProperty(key)`,
|
||||
`Object.prototype.hasOwnProperty.call(alias, key)`
|
||||
]) {
|
||||
const {code, diagnostics} = transform(definitions + `
|
||||
const alias = runtime.sequencer;
|
||||
alias.stepThreads = () => 23;
|
||||
const key = ['step', 'Threads'].join('');
|
||||
${reflection};
|
||||
`);
|
||||
assert.equal(execute(code), true);
|
||||
assert(!diagnostics.propertyMap.stepThreads);
|
||||
}
|
||||
});
|
||||
|
||||
test('unknown receivers and unrelated definitions do not inherit scheduling ownership', () => {
|
||||
for (const extra of [
|
||||
`function read(thread) { return thread.peekStack(); } read(task);`,
|
||||
`const external = {peekStack() { return 9; }}; external.peekStack();`,
|
||||
`class Thread { peekStack() { return 11; } } new Thread().peekStack();`
|
||||
]) {
|
||||
const {code, diagnostics} = transform(definitions + extra);
|
||||
assert.equal(execute(code), execute(definitions + extra));
|
||||
assert(!diagnostics.propertyMap.peekStack);
|
||||
assert(diagnostics.reservedPropertyReasons.peekStack.length);
|
||||
}
|
||||
});
|
||||
|
||||
test('reassigned aliases preserve unknown receivers and dynamic scheduling accesses', () => {
|
||||
for (const extra of [
|
||||
`let alias = runtime.sequencer; alias = JSON.parse('{"stepThread":19}'); alias.stepThread;`,
|
||||
`let alias; alias = runtime.sequencer; alias[['step', 'Thread'].join('')](task);`
|
||||
]) {
|
||||
const {code, diagnostics} = transform(definitions + extra);
|
||||
assert.equal(execute(code), execute(definitions + extra));
|
||||
assert(!diagnostics.propertyMap.stepThread);
|
||||
assert(diagnostics.reservedPropertyReasons.stepThread.length);
|
||||
}
|
||||
});
|
||||
|
||||
test('the linked pause addon uses the same scheduling names as the VM and program', async () => {
|
||||
const require = createRequire(new URL('../../scratch-vm/package.json', import.meta.url));
|
||||
const VM = require('./src/virtual-machine'), {lower} = require('./src/compiler/continuation');
|
||||
const original = new VM();
|
||||
let playerVM;
|
||||
const descriptors = {};
|
||||
try {
|
||||
const project = fixtureProject({iterations: 100000});
|
||||
project.monitors = []; project.targets[0].costumes = [];
|
||||
project.targets[0].blocks.change.next = 'wait';
|
||||
project.targets[0].blocks.wait = {opcode: 'control_wait', parent: 'change', next: null,
|
||||
topLevel: false, shadow: false, fields: {}, inputs: {DURATION: [1, [4, '0']]}};
|
||||
await original.loadProject(JSON.stringify(project));
|
||||
const input = original.exportSpecializedProjectBuildInput();
|
||||
const sourcePack = JSON.parse(await fs.readFile(new URL('../generated/runtime-sources.json', import.meta.url)));
|
||||
const threadId = Object.keys(sourcePack.sources).find(id => id.endsWith('scratch-vm/src/engine/thread.js'));
|
||||
const changed = parse(sourcePack.sources[threadId], {sourceType: 'module'});
|
||||
const cls = changed.program.body.find(node => t.isClassDeclaration(node) && node.id.name === 'Thread');
|
||||
cls.body.body.find(method => method.key.name === 'pushStack').params.push(t.identifier('unexpected'));
|
||||
await assert.rejects(linkPlayer({sourcePack: {...sourcePack,
|
||||
sources: {...sourcePack.sources, [threadId]: generate(changed).code}}, input,
|
||||
program: lower(input, 42), seed: 'changed-signature', boot: ''}, () => {
|
||||
throw new Error('Must reject changed ownership contracts before linking');
|
||||
}), /Thread methods changed/);
|
||||
const result = await linkPlayer({sourcePack, input, program: lower(input, 42), seed: 'pause-property-audit',
|
||||
addons: {pause: true}, boot: `const vm = new Scaffolding.VM();
|
||||
ScaffoldingAddons.run({vm}, {pause: true});
|
||||
globalThis.renamedVM = vm;
|
||||
globalThis.renamedReady = vm.loadProject(${JSON.stringify(JSON.stringify(input.project))});`}, rollup);
|
||||
const element = () => ({style: {}, appendChild() {}, insertBefore() {}, setAttribute() {}, getContext() { return null; }});
|
||||
for (const [name, value] of Object.entries({window: globalThis, self: globalThis,
|
||||
document: {hidden: false, createElement: element, head: element(), body: element()}})) {
|
||||
descriptors[name] = Object.getOwnPropertyDescriptor(globalThis, name);
|
||||
Object.defineProperty(globalThis, name, {value, configurable: true, writable: true});
|
||||
}
|
||||
new vm.Script(result.code).runInThisContext();
|
||||
await globalThis.renamedReady; playerVM = globalThis.renamedVM;
|
||||
const key = result.diagnostics.propertyMap.stepThreads;
|
||||
const sequencer = playerVM.runtime.sequencer;
|
||||
assert(key && typeof sequencer[key] === 'function');
|
||||
assert.equal(sequencer.stepThreads, undefined);
|
||||
assert.notEqual(sequencer[key], Object.getPrototypeOf(sequencer)[key], 'pause hook overrides the renamed method');
|
||||
const value = () => playerVM.runtime.getTargetForStage().variables.answer.value;
|
||||
playerVM.greenFlag(); playerVM.runtime._step();
|
||||
const before = value();
|
||||
assert(before > 0 && before < 100000);
|
||||
playerVM.setPaused(true);
|
||||
for (let tick = 0; tick < 4; tick++) playerVM.runtime._step();
|
||||
assert.equal(value(), before);
|
||||
playerVM.setPaused(false); playerVM.runtime._step();
|
||||
assert(value() > before);
|
||||
playerVM.stopAll(); playerVM.greenFlag(); playerVM.runtime._step();
|
||||
assert(value() > 0 && value() < 100000);
|
||||
} finally {
|
||||
original.quit(); playerVM?.quit();
|
||||
delete globalThis.renamedVM; delete globalThis.renamedReady;
|
||||
for (const [name, descriptor] of Object.entries(descriptors)) {
|
||||
if (descriptor) Object.defineProperty(globalThis, name, descriptor); else delete globalThis[name];
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import {test} from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {createRequire} from 'node:module';
|
||||
import {fixtureProject} from './specialized-fixtures.mjs';
|
||||
import {installForTest} from './specialized-differential.mjs';
|
||||
const require = createRequire(new URL('../../scratch-vm/package.json', import.meta.url));
|
||||
const VM = require('./src/virtual-machine');
|
||||
const FakeRenderer = require('./test/fixtures/fake-renderer');
|
||||
const {lower} = require('./src/compiler/continuation');
|
||||
|
||||
test('movement, effects and pen renderer operations match JS AOT', async () => {
|
||||
const previousDocument = globalThis.document;
|
||||
globalThis.document = {hidden: false};
|
||||
const project = fixtureProject();
|
||||
project.monitors = []; project.extensions = ['pen']; project.targets[0].blocks = {};
|
||||
const sprite = structuredClone(project.targets[0]);
|
||||
Object.assign(sprite, {isStage: false, name: 'Sprite', variables: {}, x: 0, y: 0, direction: 90,
|
||||
size: 100, visible: true, draggable: false, rotationStyle: 'all around', layerOrder: 1});
|
||||
project.targets.push(sprite);
|
||||
const block = (opcode, parent, next, inputs = {}, fields = {}) => ({opcode, parent, next, inputs, fields, topLevel: !parent});
|
||||
sprite.blocks = {
|
||||
hat: block('event_whenflagclicked', null, 'clear'),
|
||||
clear: block('pen_clear', 'hat', 'down'),
|
||||
down: block('pen_penDown', 'clear', 'repeat'),
|
||||
repeat: block('control_repeat', 'down', 'up', {TIMES: [1, [4, '10']], SUBSTACK: [2, 'move']}),
|
||||
move: block('motion_changexby', 'repeat', 'effect', {DX: [1, [4, '2']]}),
|
||||
effect: block('looks_changeeffectby', 'move', null, {CHANGE: [1, [4, '5']]}, {EFFECT: ['ghost', null]}),
|
||||
up: block('pen_penUp', 'repeat', null)
|
||||
};
|
||||
const run = async specialized => {
|
||||
const vm = new VM(), renderer = new FakeRenderer(), trace = [];
|
||||
for (const name of ['createPenSkin', 'penClear', 'penLine', 'penPoint', 'penStamp', 'destroySkin', 'destroyDrawable', 'draw']) renderer[name] = () => 1;
|
||||
renderer.getNativeSize = () => [480, 360];
|
||||
for (const name of ['updateDrawablePosition', 'updateDrawableEffect', 'penClear', 'penLine', 'penPoint', 'penStamp']) {
|
||||
const original = renderer[name];
|
||||
renderer[name] = (...args) => { trace.push([name, JSON.parse(JSON.stringify(args))]); return original.apply(renderer, args); };
|
||||
}
|
||||
try {
|
||||
vm.attachRenderer(renderer); vm.setCompilerOptions({enabled: true});
|
||||
await vm.loadProject(JSON.stringify(project));
|
||||
if (specialized) installForTest(vm, lower(vm.exportSpecializedProjectBuildInput(), 51));
|
||||
trace.length = 0; vm.greenFlag();
|
||||
for (let tick = 0; tick < 50 && vm.runtime.threads.length; tick++) vm.runtime._step();
|
||||
assert(!vm.runtime.threads.length);
|
||||
return trace;
|
||||
} finally { vm.quit(); }
|
||||
};
|
||||
const expected = await run(false), actual = await run(true);
|
||||
assert(expected.some(([method]) => method === 'penLine'));
|
||||
assert.deepEqual(actual, expected);
|
||||
if (previousDocument === undefined) delete globalThis.document; else globalThis.document = previousDocument;
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
// CPU regression runner for Scratch Benchmark 441947766, supplied by the caller.
|
||||
// Preserve every script in the linked program; invoke one original Bench Workload
|
||||
// at a time to exclude its wall-clock score calculation, GPU work and cloud I/O.
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import vmModule from 'node:vm';
|
||||
import assert from 'node:assert/strict';
|
||||
import {createHash} from 'node:crypto';
|
||||
import {createRequire} from 'node:module';
|
||||
import {performance} from 'node:perf_hooks';
|
||||
import {rollup} from 'rollup';
|
||||
import JSZip from '@turbowarp/jszip';
|
||||
import {linkPlayer} from '../src/specialized/link.mjs';
|
||||
|
||||
const [projectPath, outputPath = '../work/acceptance/scratch-benchmark-report.json', seed = 'scratch-benchmark'] = process.argv.slice(2);
|
||||
if (!projectPath) throw new Error('Usage: node test/specialized-scratch-benchmark.mjs <original.sb3|project.json> [report.json] [seed]');
|
||||
const require = createRequire(new URL('../../scratch-vm/package.json', import.meta.url));
|
||||
const VM = require('./src/virtual-machine');
|
||||
const {lower} = require('./src/compiler/continuation');
|
||||
const sourcePack = JSON.parse(await fs.readFile(new URL('../generated/runtime-sources.json', import.meta.url)));
|
||||
const bytes = await fs.readFile(projectPath);
|
||||
const project = JSON.parse(bytes[0] === 0x50 ? await (await JSZip.loadAsync(bytes)).file('project.json').async('string') : bytes);
|
||||
const names = ['PRNG', 'TerrainGen', 'Raycaster', 'Quicksort', 'ListSearch', 'StringParse'];
|
||||
assert(names.every(name => project.targets.some(target => target.name === name)), 'Expected original Scratch Benchmark 441947766');
|
||||
project.monitors = [];
|
||||
for (const target of project.targets) {
|
||||
target.costumes = []; target.sounds = [];
|
||||
for (const variable of Object.values(target.variables)) if (variable[2]) variable.length = 2;
|
||||
if (!names.includes(target.name)) continue;
|
||||
let found = false;
|
||||
for (const hat of Object.values(target.blocks)) {
|
||||
if (hat.opcode !== 'event_whenbroadcastreceived') continue;
|
||||
for (let id = hat.next; id;) {
|
||||
const block = target.blocks[id];
|
||||
if (block.opcode === 'procedures_call' && block.mutation.proccode === 'Bench Workload') {
|
||||
block.next = null; found = true; break;
|
||||
}
|
||||
id = block.next;
|
||||
}
|
||||
}
|
||||
assert(found, `Missing workload in ${target.name}`);
|
||||
}
|
||||
const baseline = new VM();
|
||||
baseline.setCompilerOptions({enabled: true}); baseline.setTurboMode(true);
|
||||
let specialized;
|
||||
const globals = {};
|
||||
const originalRandom = Math.random;
|
||||
try {
|
||||
await baseline.loadProject(JSON.stringify(project));
|
||||
const input = baseline.exportSpecializedProjectBuildInput();
|
||||
// Match the public compiler's seed hashing.
|
||||
let numericSeed = 2166136261;
|
||||
for (const char of seed) numericSeed = Math.imul(numericSeed ^ char.charCodeAt(0), 16777619);
|
||||
const buildStarted = performance.now();
|
||||
const built = await linkPlayer({sourcePack, input, program: lower(input, numericSeed >>> 0), seed,
|
||||
boot: `const vm = new Scaffolding.VM(); globalThis.benchmarkVM = vm; vm.setTurboMode(true);
|
||||
globalThis.benchmarkReady = vm.loadProject(${JSON.stringify(JSON.stringify(input.project))});`}, rollup);
|
||||
const buildMs = performance.now() - buildStarted;
|
||||
const element = () => ({style: {}, appendChild() {}, insertBefore() {}, setAttribute() {}, getContext() { return null; }});
|
||||
for (const [key, value] of Object.entries({window: globalThis, self: globalThis,
|
||||
document: {hidden: false, createElement: element, head: element(), body: element()}})) {
|
||||
globals[key] = Object.getOwnPropertyDescriptor(globalThis, key);
|
||||
Object.defineProperty(globalThis, key, {value, configurable: true, writable: true});
|
||||
}
|
||||
new vmModule.Script(built.code).runInThisContext();
|
||||
await globalThis.benchmarkReady;
|
||||
specialized = globalThis.benchmarkVM;
|
||||
let random;
|
||||
const seededRandom = () => {
|
||||
random = (Math.imul(random, 1664525) + 1013904223) >>> 0;
|
||||
return random / 4294967296;
|
||||
};
|
||||
const run = (vm, name, iterations) => {
|
||||
random = 1;
|
||||
const target = vm.runtime.getSpriteTargetByName(name);
|
||||
const started = performance.now();
|
||||
for (let index = 0; index < iterations; index++) {
|
||||
vm.runtime.startHats('event_whenbroadcastreceived', {BROADCAST_OPTION: `Bench ${name}`}, target);
|
||||
while (vm.runtime.threads.length) vm.runtime._step();
|
||||
}
|
||||
return (performance.now() - started) / iterations;
|
||||
};
|
||||
const values = (vm, name) => structuredClone(Object.values(vm.runtime.getSpriteTargetByName(name).variables).map(v => v.value));
|
||||
const report = {project: 'https://scratch.mit.edu/projects/441947766/',
|
||||
sourceSHA256: createHash('sha256').update(bytes).digest('hex'), node: process.version, seed,
|
||||
codeSHA256: createHash('sha256').update(built.code).digest('hex'),
|
||||
scope: 'Same-realm CPU execution, full linked/minified program, original workload procedures; excludes GPU, assets and score timing',
|
||||
warmupRounds: 3, measuredRounds: 5, buildMs, dispatchGroups: built.diagnostics.dispatchGroups.length, results: []};
|
||||
Math.random = seededRandom;
|
||||
if (process.env.BENCH_PROFILE) {
|
||||
const {Session} = await import('node:inspector/promises');
|
||||
const session = new Session(); session.connect();
|
||||
run(specialized, 'Raycaster', 1000);
|
||||
await session.post('Profiler.enable'); await session.post('Profiler.start');
|
||||
run(specialized, 'Raycaster', 5000);
|
||||
const {profile} = await session.post('Profiler.stop'); session.disconnect();
|
||||
await fs.writeFile(`${outputPath}.cpuprofile`, JSON.stringify(profile));
|
||||
await fs.writeFile(`${outputPath}.js`, built.code);
|
||||
await fs.writeFile(`${outputPath}.maps.json`, JSON.stringify(built.diagnostics.propertyMap));
|
||||
run(baseline, 'Raycaster', 1000);
|
||||
run(baseline, 'Raycaster', 5000);
|
||||
report.additionalWarmup = {Raycaster: 6000};
|
||||
}
|
||||
for (const name of names) {
|
||||
const samples = {aot: [], specialized: []};
|
||||
const iterations = name === 'ListSearch' ? 5 : 50;
|
||||
for (let round = 0; round < 8; round++) {
|
||||
let aot, fused;
|
||||
if (round % 2) { fused = run(specialized, name, iterations); aot = run(baseline, name, iterations); }
|
||||
else { aot = run(baseline, name, iterations); fused = run(specialized, name, iterations); }
|
||||
assert.deepEqual(values(specialized, name), values(baseline, name), `${name} state mismatch`);
|
||||
if (round >= 3) { samples.aot.push(aot); samples.specialized.push(fused); }
|
||||
}
|
||||
const median = values => [...values].sort((a, b) => a - b)[2];
|
||||
const aotMs = median(samples.aot), specializedMs = median(samples.specialized);
|
||||
const result = {name, iterations, aotMs, specializedMs, slowdown: specializedMs / aotMs - 1,
|
||||
p95AOT: Math.max(...samples.aot), p95Specialized: Math.max(...samples.specialized), samples};
|
||||
report.results.push(result);
|
||||
console.log(`${name}: ${(result.slowdown * 100).toFixed(1)}%`, JSON.stringify(samples));
|
||||
}
|
||||
report.passed = report.results.every(result => result.slowdown <= 0.1);
|
||||
await fs.mkdir(path.dirname(outputPath), {recursive: true});
|
||||
await fs.writeFile(outputPath, JSON.stringify(report, null, 2));
|
||||
assert(report.passed, '10% median CPU regression gate');
|
||||
} finally {
|
||||
Math.random = originalRandom;
|
||||
baseline.quit(); specialized?.quit();
|
||||
delete globalThis.benchmarkVM; delete globalThis.benchmarkReady;
|
||||
for (const [key, descriptor] of Object.entries(globals)) {
|
||||
if (descriptor) Object.defineProperty(globalThis, key, descriptor); else delete globalThis[key];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs/promises';
|
||||
import {createRequire} from 'node:module';
|
||||
import {chromium} from '@playwright/test';
|
||||
import {fixtureProject, costumeSVG, costumeId} from './specialized-fixtures.mjs';
|
||||
const require = createRequire(import.meta.url);
|
||||
const JSZip = require('@turbowarp/jszip');
|
||||
const bytes = await new JSZip().file('project.json', JSON.stringify(fixtureProject()))
|
||||
.file(`${costumeId}.svg`, costumeSVG).generateAsync({type: 'nodebuffer'});
|
||||
const browser = await chromium.launch({headless: true});
|
||||
try {
|
||||
const context = await browser.newContext({acceptDownloads: true, locale: 'en-US'});
|
||||
const page = await context.newPage();
|
||||
page.on('pageerror', error => console.error('page error:', error.stack));
|
||||
page.on('requestfailed', request => console.error('request failed:', request.url().slice(0, 200), request.failure()));
|
||||
await page.goto(new URL('../dist/standalone.html', import.meta.url).href);
|
||||
await context.setOffline(true);
|
||||
await page.locator('input[type=radio][value=file]').check();
|
||||
await page.locator('input[type=file][accept=".sb,.sb2,.sb3"]').setInputFiles({name: 'fixture.sb3', mimeType: 'application/octet-stream', buffer: bytes});
|
||||
await page.getByRole('button', {name: 'Package', exact: true}).waitFor({timeout: 30000});
|
||||
const download = page.waitForEvent('download', {timeout: 180000});
|
||||
await page.getByRole('button', {name: 'Package', exact: true}).click();
|
||||
const file = await download.catch(async error => { console.error((await page.locator('body').innerText()).slice(-2000)); throw error; });
|
||||
const path = new URL('../../work/acceptance/standalone-output.html', import.meta.url);
|
||||
await file.saveAs(path.pathname);
|
||||
const html = await fs.readFile(path, 'utf8');
|
||||
assert(html.includes('player-resources') && !html.includes('sourceMappingURL='));
|
||||
const player = await context.newPage();
|
||||
player.on('pageerror', error => console.error('player error:', error.message));
|
||||
await player.addInitScript(() => {
|
||||
Object.defineProperty(window, 'player', {configurable: true, set(value) {
|
||||
Object.defineProperty(window, 'player', {value});
|
||||
value.on('loaded', () => { window.testLoaded = true; });
|
||||
value.on('error', event => { window.testError = event.message; });
|
||||
}});
|
||||
});
|
||||
await player.goto(path.href);
|
||||
await player.waitForFunction(() => window.testLoaded || window.testError);
|
||||
assert.equal(await player.evaluate(() => window.testError), undefined);
|
||||
await player.evaluate(() => window.player.start());
|
||||
await player.waitForFunction(() => window.player && [...document.querySelectorAll('.sc-monitor-value')].some(node => node.textContent === '53'));
|
||||
await player.evaluate(() => window.player.dispose());
|
||||
console.log('Offline standalone UI built and played an HTML player without filesystem plugins or remote code resolution.');
|
||||
} finally { await browser.close(); }
|
||||
@@ -0,0 +1,54 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {test} from 'node:test';
|
||||
import {createRequire} from 'node:module';
|
||||
import {auditArtifact, stepBody, stepMethods, dispatchGroups} from '../src/specialized/link.mjs';
|
||||
import {fixtureProject} from './specialized-fixtures.mjs';
|
||||
import {prepareExtension} from '../src/specialized/extensions.mjs';
|
||||
const require = createRequire(new URL('../../scratch-vm/package.json', import.meta.url));
|
||||
const VM = require('./src/virtual-machine');
|
||||
const {lower} = require('./src/compiler/continuation');
|
||||
test('structural audit tracks aliases and computed execution APIs', () => {
|
||||
for (const code of ['eval("x")', 'const F = Function; new F("x")', 'globalThis["Function"]("x")',
|
||||
'(0, eval)("x")', 'const e = eval; e.call(null, "x")', 'import("x")', 'self.importScripts("x")', 'setTimeout("x", 0)', 'function* f() {}']) {
|
||||
assert.throws(() => auditArtifact(code), /forbidden execution paths/);
|
||||
}
|
||||
auditArtifact('function step(thread) { while (thread.pc) switch(thread.pc) { case 1: thread.pc=0; break; } }');
|
||||
});
|
||||
test('VM access in a deferred extension method is rejected during the build', async () => {
|
||||
const url = 'https://test.invalid/extension.js';
|
||||
for (const source of ['function later() { return Scratch.vm; }', 'function later() { return vm.runtime; }',
|
||||
'function later() { Scratch.extensions.loadExtensionURL("x"); }']) {
|
||||
await assert.rejects(prepareExtension({url, sources: {[url]: source}}, {}, () => { throw new Error('Should not reach linking'); }),
|
||||
/cannot access|cannot load/);
|
||||
}
|
||||
});
|
||||
test('seeded continuations share dispatch with lifecycle logic and cap switch regions', async () => {
|
||||
const vm = new VM();
|
||||
try {
|
||||
const project = fixtureProject();
|
||||
const blocks = project.targets[0].blocks;
|
||||
for (let i = 0; i < 300; i++) {
|
||||
blocks[`hat${i}`] = {opcode: 'event_whenflagclicked', parent: null, next: null, topLevel: true, fields: {}, inputs: {}};
|
||||
}
|
||||
await vm.loadProject(JSON.stringify(project));
|
||||
const input = vm.exportSpecializedProjectBuildInput();
|
||||
const a = lower(input, 1), b = lower(input, 2);
|
||||
assert.notDeepEqual(a.blocks.map(block => block.id), b.blocks.map(block => block.id));
|
||||
assert.equal(new Set(a.hats.map(hat => hat.pc)).size, 301);
|
||||
const code = stepBody(a.blocks);
|
||||
assert(code.includes('retireThread') && code.includes('__spFrames.pop'));
|
||||
for (const group of code.split('switch (frame.__spPC)').slice(1)) assert((group.match(/case /g) || []).length <= 256);
|
||||
assert(!code.includes('function*') && !code.includes('function step'));
|
||||
const methods = stepMethods(a.blocks);
|
||||
const groups = dispatchGroups(a.blocks);
|
||||
assert(groups.length > 1);
|
||||
assert.equal(Object.keys(methods).length, groups.length + 1, 'groups must be separate VM methods');
|
||||
assert(!methods.stepThread.includes('case '), 'scheduler must not contain the entire program');
|
||||
assert(groups.every(group => group.length <= 256));
|
||||
assert(groups.some(group => new Set(group.map(block => block.owner)).size > 1));
|
||||
assert(groups.every(group => group.length === 1 || group.reduce((sum, b) => sum + b.code.join('\n').length, 0) <= 12000));
|
||||
const encoded = groups.flatMap((group, index) => group.map((block, offset) =>
|
||||
({...block, id: (index + 1) * 256 + offset, dispatchGroup: index + 1})));
|
||||
assert(stepMethods(encoded).stepThread.includes('switch (pc >>> 8)'), 'production routing uses encoded group PCs');
|
||||
} finally { vm.quit(); }
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import {createRequire} from 'node:module';
|
||||
import {createHash} from 'node:crypto';
|
||||
import {rollup} from 'rollup';
|
||||
import {linkPlayer} from '../src/specialized/link.mjs';
|
||||
import {fixtureProject} from './specialized-fixtures.mjs';
|
||||
const require = createRequire(new URL('../../scratch-vm/package.json', import.meta.url));
|
||||
const VM = require('./src/virtual-machine');
|
||||
const {lower} = require('./src/compiler/continuation');
|
||||
const sourcePack = JSON.parse(await fs.readFile(new URL('../generated/runtime-sources.json', import.meta.url)));
|
||||
const vm = new VM();
|
||||
try {
|
||||
await vm.loadProject(JSON.stringify(fixtureProject()));
|
||||
const input = vm.exportSpecializedProjectBuildInput();
|
||||
const data = {input, program: lower(input, 100), seed: 'parity', boot: 'const vm = new Scaffolding.VM(); vm.quit();'};
|
||||
const node = await linkPlayer({...data, sourcePack}, rollup);
|
||||
const directory = new URL('../../work/acceptance/', import.meta.url);
|
||||
await fs.writeFile(new URL('worker-input.json', directory), JSON.stringify({data, expected: createHash('sha256').update(node.code).digest('hex')}));
|
||||
await fs.writeFile(new URL('worker.html', directory), '<!DOCTYPE html><html><body>Worker acceptance</body></html>');
|
||||
for (const file of ['specialized-worker.js', 'rollup.browser.js', 'bindings_wasm_bg.wasm']) {
|
||||
await fs.copyFile(new URL(`../generated/${file}`, import.meta.url), new URL(file, directory));
|
||||
}
|
||||
} finally { vm.quit(); }
|
||||
+26
-152
@@ -3,7 +3,6 @@ const webpack = require('webpack');
|
||||
const HtmlWebpackPlugin = require('html-webpack-plugin');
|
||||
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
|
||||
const CopyWebpackPlugin = require('copy-webpack-plugin');
|
||||
const AddBuildIDToOutputPlugin = require('./src/build/add-build-id-to-output-plugin');
|
||||
const GenerateServiceWorkerPlugin = require('./src/build/generate-service-worker-plugin');
|
||||
const EagerDynamicImportPlugin = require('./src/build/eager-dynamic-import-plugin');
|
||||
|
||||
@@ -13,66 +12,27 @@ const base = {
|
||||
mode: isProduction ? 'production' : 'development'
|
||||
};
|
||||
const dist = path.resolve(__dirname, 'dist');
|
||||
const buildId = isProduction ? require('./src/build/generate-scaffolding-build-id') : null;
|
||||
const nodeCompatibilityAliases = {
|
||||
'node:crypto': require.resolve('crypto-browserify'),
|
||||
'node:buffer': require.resolve('buffer/'),
|
||||
// The GitHub package exports ESM but its current package metadata has no
|
||||
// Webpack 4-compatible main/module field.
|
||||
'sb3c-vm-obfuscator$': path.resolve(__dirname, 'node_modules', 'sb3c-vm-obfuscator', 'src', 'index.mjs'),
|
||||
// Use the self-contained browser distribution in both targets. The Node
|
||||
// entry pulls in ESM dependencies which Webpack 4 cannot parse directly.
|
||||
'javascript-obfuscator$': path.resolve(__dirname, 'node_modules', 'javascript-obfuscator', 'dist', 'index.browser.js')
|
||||
'scratch-vm$': path.resolve(__dirname, '../scratch-vm/src/index.js'),
|
||||
'scratch-sb1-converter$': path.resolve(__dirname, '../scratch-vm/node_modules/scratch-sb1-converter/index.js'),
|
||||
'@turbowarp/scratch-storage$': path.resolve(__dirname, 'node_modules/@turbowarp/scratch-storage/src/index.js'),
|
||||
'text-encoding$': path.resolve(__dirname, 'src/scaffolding/text-encoding/index.js'),
|
||||
'@vernier/godirect$': path.resolve(__dirname, '../scratch-vm/node_modules/@vernier/godirect/src/godirect.js')
|
||||
};
|
||||
const compiledProjectJavaScriptRules = [
|
||||
{
|
||||
// Webpack 4 treats .mjs as strict ESM and otherwise rejects named imports
|
||||
// from CommonJS dependencies used by Babel's AST packages.
|
||||
test: /\.mjs$/,
|
||||
include: /node_modules/,
|
||||
type: 'javascript/auto'
|
||||
},
|
||||
{
|
||||
test: /\.(?:mjs|js)$/,
|
||||
loader: 'babel-loader',
|
||||
include: [/node_modules[\\/]scratch-[^\\/]+[\\/]src/],
|
||||
options: {
|
||||
babelrc: false,
|
||||
presets: ['@babel/preset-env']
|
||||
}
|
||||
},
|
||||
{
|
||||
test: /\.mjs$/,
|
||||
type: 'javascript/auto',
|
||||
loader: 'babel-loader',
|
||||
include: [/node_modules[\\/]sb3c-vm-obfuscator[\\/]src/],
|
||||
options: {
|
||||
babelrc: false,
|
||||
// javascript/auto enables CJS named-import interop for Babel's parser
|
||||
// packages while preserving ESM so Webpack can bundle relative modules.
|
||||
presets: [['@babel/preset-env', {modules: false}]]
|
||||
}
|
||||
},
|
||||
{
|
||||
test: /javascript-obfuscator[\\/]dist[\\/]index\.browser\.js$/,
|
||||
type: 'javascript/auto',
|
||||
use: [
|
||||
{
|
||||
loader: 'babel-loader',
|
||||
options: {
|
||||
babelrc: false,
|
||||
presets: [['@babel/preset-env', {modules: false}]]
|
||||
}
|
||||
},
|
||||
{
|
||||
// Its UMD wrapper assumes a browser global named `self`.
|
||||
loader: path.resolve(__dirname, 'src', 'build', 'global-self-loader.js')
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
const compiledProjectJavaScriptRules = [{
|
||||
test: /\.(?:png|svg|wav)$/,
|
||||
include: /scratch-storage[\\/]src[\\/]builtins/,
|
||||
loader: path.resolve(__dirname, 'src/build/bytes-loader.js')
|
||||
}, {
|
||||
test: /\.[cm]?js$/,
|
||||
include: [path.resolve(__dirname, '../scratch-vm/src'), /node_modules[\\/]@vernier[\\/]godirect[\\/]src/],
|
||||
type: 'javascript/auto',
|
||||
loader: 'babel-loader',
|
||||
options: {babelrc: false, presets: [['@babel/preset-env', {modules: false}]]}
|
||||
}];
|
||||
const compiledProjectAudioRule = {
|
||||
test: /\.(?:mp3|wav)$/i,
|
||||
exclude: /scratch-storage[\\/]src[\\/]builtins/,
|
||||
use: [{
|
||||
// The compiler only needs extension block definitions, not sample bytes.
|
||||
loader: path.resolve(__dirname, 'src', 'build', 'noop-loader.js')
|
||||
@@ -94,100 +54,8 @@ const getVersion = () => {
|
||||
};
|
||||
const version = getVersion();
|
||||
|
||||
const makeScaffolding = ({full}) => ({
|
||||
...base,
|
||||
devtool: isProduction ? '' : 'source-map',
|
||||
output: {
|
||||
filename: 'scaffolding/[name].js',
|
||||
path: dist
|
||||
},
|
||||
entry: full ? {
|
||||
'scaffolding-full': './src/scaffolding/export.js',
|
||||
addons: './src/addons/index.js'
|
||||
} : {
|
||||
'scaffolding-min': './src/scaffolding/export.js'
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'text-encoding$': path.resolve(__dirname, 'src', 'scaffolding', 'text-encoding'),
|
||||
'htmlparser2$': path.resolve(__dirname, 'src', 'scaffolding', 'htmlparser2'),
|
||||
'scratch-translate-extension-languages$': path.resolve(__dirname, 'src', 'scaffolding', 'scratch-translate-extension-languages', 'languages.json'),
|
||||
'scratch-parser$': path.resolve(__dirname, 'src', 'scaffolding', 'scratch-parser')
|
||||
}
|
||||
},
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /\.jsx?$/,
|
||||
loader: 'babel-loader',
|
||||
include: [
|
||||
path.resolve(__dirname, 'src'),
|
||||
/node_modules[\\/]scratch-[^\\/]+[\\/]src/
|
||||
],
|
||||
options: {
|
||||
babelrc: false,
|
||||
presets: ['@babel/preset-env']
|
||||
}
|
||||
},
|
||||
{
|
||||
test: /\.(svg|png)$/i,
|
||||
use: [{
|
||||
loader: 'url-loader'
|
||||
}]
|
||||
},
|
||||
...(full ? [{
|
||||
test: /\.mp3$/i,
|
||||
use: [{
|
||||
loader: 'url-loader',
|
||||
options: {
|
||||
esModule: false
|
||||
}
|
||||
}]
|
||||
}] : [{
|
||||
test: /\.mp3$/i,
|
||||
use: [{
|
||||
loader: path.resolve(__dirname, 'src', 'build', 'noop-loader.js')
|
||||
}]
|
||||
}]),
|
||||
{
|
||||
test: /\.css$/i,
|
||||
use: [
|
||||
{
|
||||
loader: 'style-loader',
|
||||
options: {
|
||||
// This function is stringified and run in a web environment
|
||||
insert: (styleElement) => {
|
||||
var el = document.head || document.body || document.documentElement;
|
||||
el.insertBefore(styleElement, el.firstChild);
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
loader: 'css-loader',
|
||||
options: {
|
||||
modules: {
|
||||
localIdentName: 'sc-[local]',
|
||||
exportLocalsConvention: 'camelCase',
|
||||
},
|
||||
}
|
||||
}
|
||||
],
|
||||
}
|
||||
]
|
||||
},
|
||||
resolveLoader: {
|
||||
// Replace worker-loader with our own modified version
|
||||
modules: [path.resolve(__dirname, 'src', 'build', 'inline-worker-loader'), 'node_modules'],
|
||||
},
|
||||
plugins: [
|
||||
...(buildId ? [new AddBuildIDToOutputPlugin(buildId)] : []),
|
||||
...(process.env.BUNDLE_ANALYZER === (full ? 'scaffolding-full' : 'scaffolding-min') ? [new BundleAnalyzerPlugin()] : [])
|
||||
]
|
||||
});
|
||||
|
||||
const commonFrontendPlugins = () => [
|
||||
new webpack.DefinePlugin({
|
||||
'process.env.SCAFFOLDING_BUILD_ID': buildId ? JSON.stringify(buildId) : '("development-" + Math.random().toString().substring(2))',
|
||||
'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'development')
|
||||
})
|
||||
];
|
||||
@@ -222,6 +90,7 @@ const makeWebsite = () => ({
|
||||
compiledProjectAudioRule,
|
||||
{
|
||||
test: /\.png|\.svg$/i,
|
||||
exclude: /scratch-storage[\\/]src[\\/]builtins/,
|
||||
use: isStandalone ? {
|
||||
loader: 'url-loader'
|
||||
} : {
|
||||
@@ -243,7 +112,10 @@ const makeWebsite = () => ({
|
||||
patterns: [
|
||||
{
|
||||
from: 'static'
|
||||
}
|
||||
},
|
||||
{from: 'generated/specialized-worker.js'},
|
||||
{from: 'generated/rollup.browser.js'},
|
||||
{from: 'generated/bindings_wasm_bg.wasm'}
|
||||
]
|
||||
}),
|
||||
new webpack.DefinePlugin({
|
||||
@@ -296,6 +168,7 @@ const makeNode = () => ({
|
||||
'@fiahfy/icns': '@fiahfy/icns',
|
||||
'cross-fetch': 'cross-fetch',
|
||||
'sha.js': 'sha.js',
|
||||
'specialized-link': 'commonjs ./specialized-link.cjs',
|
||||
},
|
||||
module: {
|
||||
rules: [
|
||||
@@ -303,19 +176,20 @@ const makeNode = () => ({
|
||||
compiledProjectAudioRule,
|
||||
{
|
||||
test: /\.png|\.svg$/i,
|
||||
exclude: /scratch-storage[\\/]src[\\/]builtins/,
|
||||
use: 'file-loader'
|
||||
}
|
||||
]
|
||||
},
|
||||
plugins: [
|
||||
...commonFrontendPlugins(),
|
||||
new CopyWebpackPlugin({patterns: [{from: 'generated/specialized-link.cjs'}]}),
|
||||
...(process.env.BUNDLE_ANALYZER === 'node' ? [new BundleAnalyzerPlugin()] : [])
|
||||
],
|
||||
});
|
||||
|
||||
module.exports = [
|
||||
makeScaffolding({full: true}),
|
||||
makeScaffolding({full: false}),
|
||||
|
||||
...(process.env.BUILD_MODE === 'node' ? [
|
||||
makeNode()
|
||||
] : [
|
||||
|
||||
Reference in new issue
Block a user