Bundling a new external CLI tool (macOS DMG + Windows)
The repeatable playbook for adding a new external command-line tool to the packaged editor so it works out of the box on both the macOS .dmg and the Windows nsis installer. Companion to editor-toolchain.md (the resolver/provisioner reference) — this doc is the cross-platform bundle checklist, the part that has bitten us twice with stale comments.
Step 0 — decide the mechanism: BUNDLE vs PROVISION
Governing rule (from before-pack.cjs + editor-toolchain.md): "bundle nothing that can be downloaded." Pick by whether the tool has a clean download path:
| Provision on-demand (preferred) | Bundle into the app | |
|---|---|---|
| When | Tool has an npm package or a stable, checksummed cross-platform download (Node, JDK, ffmpeg, ffprobe, gltf-transform, gltfpack, android-sdk) | Tool has no npm distribution and is small + core to a common import path (toktx, msdf-atlas-gen) |
| Where it lands | <userData>/toolchain on first use, via the Build Support dialog | Inside the app: Contents/Resources/bin (mac) / resources\bin (win) |
| Ships in installer? | No — keeps the base app lean | Yes — +~3 MB each |
| Playbook | "Adding a new tool" in editor-toolchain.md (REGISTRY + INSTALLABLE + install() branch + *Provision.ts) | This doc |
If provisioning fits, use that path and stop here. The rest of this doc is the bundle path.
The bundle path — one stager, per-platform branches, one destination
Every stager stages its binary into build/bin/, which electron-builder.yml ships verbatim as extraResources: from build/bin → to bin. The beforePack stage hooks (engine/scripts/stage-*.cjs, fanned out from engine/scripts/before-pack.cjs) branch on context.electronPlatformName and stage whatever the build machine has installed — so a LOCAL dist:mac AND a local dist:win both bundle:
- macOS (
darwin) → relocate the Homebrew//usr/localbinary + its non-system dylib closure intobuild/bin/(install_name_tool→@loader_path/<name>, ad-hoc re-sign). - Windows (
win32) → copy the installed.exe(+ any sibling DLL) intobuild/bin/. No relocation (Windows resolves a sibling DLL from the.exe's own dir). Install the tool once, likebrew installon mac (e.g.winget install KhronosGroup.KTX-Software). - other platforms → no-op.
CI is the exception that still downloads. A CI runner has nothing installed, so .github/workflows/release-windows.yml pre-stages build/bin/ via a pinned, sha256-verified DOWNLOAD of each tool's Windows release BEFORE npm run dist:win. The stager's win32 branch is idempotent (it skips when build/bin/<tool> already exists), so it no-ops on top of the CI download. Two fill mechanisms — local: copy-installed · CI: verified-download — one destination.
Runtime resolution is shared: engine/electron/main.ts resolveBundled(envVar, name) (only when app.isPackaged) points MODOKI_<TOOL> at resources/bin/<name>, appending .exe on win32.
Checklist for a new bundled tool foo
Register it in the toolchain —
engine/toolchain/index.ts:- Add
'foo'to theToolIdunion. - Add a
REGISTRYentry:envVar: 'MODOKI_FOO',bin: 'foo',versionArgs(match the tool's real flag —msdf-atlas-genuses['-version'], not--version; verify the exit code is 0), amissingHint. Do not add it toINSTALLABLE(it's bundled, not downloaded). - Give it a
userData/extraCandidate keyed offMODOKI_TOOLCHAIN_DIRonly if a dev override is wanted.
- Add
Surface it in Build Support —
engine/.../editor/panels/BuildSupportDialog.tsx: add it to aGROUPSentry (+ a new group label if needed). Grouping is curated — a registered-but-ungrouped tool will NOT render.Wire runtime resolution —
engine/electron/main.ts: addresolveBundled('MODOKI_FOO', 'foo')beside the toktx/msdf calls..exe+resources/binare handled for you. Iffooneeds sibling DLLs/dylibs, stage them next to the binary (OS same-dir search resolves them); note it in the stage scripts.Write the stager —
engine/scripts/stage-foo.cjs(copystage-toktx.cjsfor a single-sibling tool,stage-msdf.cjsfor a full dylib-closure tool). Branch oncontext.electronPlatformName(do NOT use a blanket!== 'darwin'early-return — that skips Windows):win32→stageFooWin32(): resolve the INSTALLED binary (MODOKI_FOOenv → PATH viawhere→ the standard install dir, e.g.%ProgramFiles%\Foo\bin), copyfoo.exe(+ any sibling DLL) intobuild/bin/. Make it idempotent — skip whenbuild/bin/foo.exealready exists (CI pre-stages it). Sanity-run the staged copy (mind that some tools print--versionto stderr on Windows).darwin(or undefined) → resolveMODOKI_FOO→which foo→ the standard install path; copy the binary (+ dylib closure) intobuild/bin/, relocate absolute load paths to@loader_path/<name>(install_name_tool), ad-hoc re-sign (codesign --sign -), then sanity-run--version.- other platforms → return.
- Be graceful on every branch: missing binary →
console.warn+return, never throw (a build machine withoutfoomust still build; the app degrades to a manual-install hint). - Register it in
engine/scripts/before-pack.cjs(await stageFoo(context)).
Add the CI download step — a
Stage foostep in.github/workflows/release-windows.yml, before the build step, mirroringStage toktx/Stage msdf-atlas-gen. A CI runner has nothing installed, so it DOWNLOADS the Windows release (thewin32stager branch above then no-ops via idempotency):yaml- name: Stage foo for bundling shell: bash run: | FOO_VER=1.2.3 FOO_SHA=<sha256 of the pinned win64 asset> # never install unverified bytes mkdir -p build/bin url="https://github.com/<org>/foo/releases/download/v${FOO_VER}/foo-${FOO_VER}-win64.zip" if curl -fsSL "$url" -o foo.zip && echo "${FOO_SHA} *foo.zip" | sha256sum -c -; then 7z x -y foo.zip -ofoo-extract >/dev/null # 7-Zip preinstalled on windows-latest cp foo-extract/**/foo.exe build/bin/ || echo "[stage-foo] WARN: layout unexpected — no bundle" else echo "[stage-foo] WARN: download/verify failed — building WITHOUT bundled foo" fi- If upstream ships only an NSIS installer (like KTX), download the
.exeinstaller and 7z-extract it (do NOT run it) — that's theStage toktxpattern. If it ships a portable zip (like msdf), just extract. Compute the sha256 from the actual pinned asset and hard-code it. - Ship any sibling DLLs too (KTX needs
ktx.dll; msdf is statically linked → exe only). - Keep it graceful — a failed stage leaves
build/bin/as-is so the build still succeeds.
- If upstream ships only an NSIS installer (like KTX), download the
Update the docs in the SAME change (doc-conventions rule — a fact lives in one place):
- The bundled-tools note in
electron-builder.yml(thewin:comment block) if the tool set changes. - The bundled-tools line in editor-toolchain.md "Platform scope".
- Add a row to the table below.
- The bundled-tools note in
Verify:
- macOS:
npm run dist:mac, mount the DMG, confirmContents/Resources/bin/fooruns and Build Support shows it present. (npm run verify:packagedcovers the mac--dirsmoke.) - Windows:
npm run dist:winLOCALLY (bundles the tool you installed) — confirmfoo.exelands inrelease\win-unpacked\resources\binand runs; OR push av*tag / runrelease-windows.ymlmanually for the CI-downloaded release artifact. - Extend
engine/tests/plugins/toolchainResolve.test.tsfor the per-platform.exeresolution andengine/tests/electron/packagingManifest.test.tsfor the extraResources manifest.
- macOS:
Current bundled tools (reference)
| Tool | Env var | macOS stager | Windows source (pinned) | Sibling files |
|---|---|---|---|---|
| toktx (KTX2 encode) | MODOKI_TOKTX | stage-toktx.cjs (+ libktx.4.dylib) | KTX-Software NSIS .exe, v4.4.2, 7z-extracted | ktx.dll (win), libktx.4.dylib (mac) |
| msdf-atlas-gen (MTSDF font atlas) | MODOKI_MSDF_ATLAS_GEN | stage-msdf.cjs (+ libpng16/libtinyxml2/libfreetype) | Chlumsky win64 .zip, v1.4 | none on win (statically linked) |
Did the playable-ad build add a new bundled tool? — NO (recorded 2026-07-19)
The playable-ad export (docs/plans/advideo-playable-export-plan.md) is the reason this playbook was written, but it added zero new external CLI tools. Recorded here so it isn't re-investigated:
- The single-file inliner (
engine/plugins/inlinePlayable.ts) is pure Node (zlib/fs) — it gzips the builtdist/into one self-extractingindex.html. No binary to bundle. - The playable asset profile (
engine/plugins/playable-profile.ts) doesn't add a converter — it layers aggressive overrides on the EXISTING pipeline: textures → WebP (the already-bundledsharpnative module,asarUnpacked), HDR → downscaled Radiance (Node), GLB → meshopt (already-provisionedgltfpack/gltf-transform). It deliberately skips the KTX2 transcoders (WebP-only), so it needs fewer tools than a normal build, not more. - The one hard tool dependency it introduces is
msdf-atlas-gen— a playable build STUBS runtime MSDF (engine/plugins/playable-msdf-stub.ts; the@zappar/msdf-generatorworker can't fold into a single file), so a text playable MUST ship a pre-baked MTSDF atlas (Font Inspector → Apply). That bake shells out tomsdf-atlas-gen— which is already bundled (row above), on both platforms.
Net: nothing to add to the bundle for the playable feature. If a FUTURE feature needs a genuinely new tool, follow the checklist above.
Gotchas learned the hard way
- The stagers branch per-platform — they are NOT "macOS-only." Each stager stages the tool the build machine has installed on BOTH
darwin(relocate Homebrew + dylibs) andwin32(copy the.exe+ DLL); onlylinux/other return early. A comment claiming "macOS-only" or "Windows unsupported" is stale — this exact confusion has misled reviews. (See thewin32branch note below.) versionArgsare per-tool.msdf-atlas-genprints its version on-version(single dash) and exits 0; a wrong flag makes the resolver's probe fail and the tool reads as "absent."- Never run an upstream
.exeinstaller in CI to get the payload — 7z-extract it. Running it needs admin/elevation and pollutes the runner. - Always pin + sha256-verify the download. An unpinned
@latestor unverified byte stream is a supply- chain hole; every existing stager verifies before copying. - Graceful-degrade, always. Every stage path (mac hook + win step) must survive a missing/failed tool by leaving
build/bin/without it — the runtime resolver already falls back to source assets or a manual-install hint. A hard failure would break unrelated dev builds. - Local
dist:winstaging (thewin32stager branch). The beforePack stagers now have awin32branch (stage-toktx.cjsstageToktxWin32,stage-msdf.cjsstageMsdfWin32) that mirrors the macOS path: it copies an INSTALLED tool off the build machine —toktx.exe+ its siblingktx.dll(resolved fromMODOKI_TOKTX→ PATH →%ProgramFiles%\KTX-Software\bin), andmsdf-atlas-gen.exe(fromMODOKI_MSDF_ATLAS_GEN→ PATH; single static exe, no siblings). No download / no 7z / no NSIS extraction in the build. So a Windows dev installs the tools ONCE (winget install KhronosGroup.KTX-Software; for msdf-atlas-gen, unzip Chlumsky's-win64.zipand setMODOKI_MSDF_ATLAS_GEN) — exactly symmetric tobrew install …beforedist:mac. The macOS code path is untouched (Mac never enters the branch), so this is safe onmainfor both platforms.- Idempotent, so CI is unaffected. The branch skips when
build/bin/<tool>already exists.release-windows.ymlstill pre-stages via its verified download steps (a CI runner has nothing installed), and the beforePack branch then no-ops. CI keeps downloading (reproducible, pinned + sha256); a local dev box copies what it installed. Two fill mechanisms, one destination. - Not installed on the dev box → the branch warns + skips (source-texture / install-hint fallback), exactly like a Mac without the Homebrew tool.
build/bin/is gitignored.
- Idempotent, so CI is unaffected. The branch skips when