Desktop build
desktop/ is an Electron shell around the same web bundle. main.ts serves dist/
under app://scmjs/, searches the disk for the game archives and extracts them into the
app's data folder, opens maps handed over by the file manager, guards the window's close
while a map has unsaved changes, and holds the updater. preload.ts exposes that to the
page as window.scmjsDesktop, typed in src/gamedata/desktop.ts.
npm run desktop # bundle the main process and run Electron against dist/
SCMJS_DEV_URL=http://localhost:5173 npm run desktop # against the dev server instead
npm run build:desktop # web build + main bundle + electron-builder for this OS
npm run build:desktop takes the packaging step's platform, architectures and targets
on the command line; with none it builds this OS with the targets electron-builder.yml
lists, which is what CI does:
npm run build:desktop -- win # Windows: nsis + zip
npm run build:desktop -- win nsis # just the installer
npm run build:desktop -- linux AppImage x64 arm64
npm run build:desktop -- mac dmg arm64
npm run build:desktop -- --dir # unpacked app, no installer: the fast check
npm run build:desktop -- win --skip-web # repackage the dist/ already on disk
npm run build:desktop -- --skip-plugins # leave plugins/ alone (no vendoring fetch)
Platforms are win, mac and linux; architectures x64, ia32, arm64, armv7l
and universal; any electron-builder target name (nsis, dmg, zip, AppImage,
deb, …) applies to every platform named. --skip-web and --skip-main reuse the
bundles on disk, --publish <mode> and anything after a bare -- go to electron-builder
as they are. Cross-building is electron-builder's business: the script warns about
combinations that need tooling the machine may lack (a macOS installer off a Mac, an
NSIS installer without Wine) and runs them anyway; --dir and zip cross-build with
nothing installed. Output lands in release/.
The build also writes THIRD-PARTY-NOTICES.txt into dist/: the license text of every
runtime dependency and of the compiled-in plugins, generated by scripts/lib/notices.mjs
from package.json rather than from a list, so it rides in the web zip, the installers
and the container image without anyone maintaining it.
The desktop build and the container image also carry the runtimes the default plugins
would otherwise download on first use — today eudplib's, Pyodide and the eudplib wheel,
about 15 MB — so building an EUD map needs no network there.
scripts/bundle-plugin-runtimes.mjs reads a runtime.json from each pinned default's
repository, fetches the files it lists (with their licences) and puts them in
dist/plugin-runtime/<plugin>/<version>/; downloads are kept in the gitignored
plugin-runtimes/, so only the first build after a pin moves fetches anything. A plugin
finds its copy by its own version, so one updated past the editor's goes back to
downloading. The hosted editor and the web zip carry none: they are online anyway, and
jsDelivr serves those downloads rather than the site's own bandwidth.
What ships is decided by electron-builder.yml: dist/ and desktop/dist/ only, never
node_modules (Vite bundles everything, mopaq and the extraction included) and never the
game data a developer's public/ may hold. Windows gets an NSIS installer and a zip
(not electron-builder's portable target, which re-extracts the whole app on every
launch), macOS a dmg per architecture, Linux an AppImage and a .deb. The installers
register .scm, .scx and .chk. Asset names carry no version, for the reason under
Download links. Only en-US of Chromium's locales is included, since
the editor has no translations. Builds are unsigned: Windows shows SmartScreen's
"unrecognized app" screen, and macOS refuses an unsigned app until the user clears the
quarantine attribute.
The app's data folder is the platform's usual one for an app named scm-js
(%APPDATA%\scm-js on Windows, ~/.config/scm-js on Linux,
~/Library/Application Support/scm-js on macOS). It holds the extracted game data, the
remembered window geometry in window.json, and startup.log, the milestones of the
last launch (process start, main script, window created, dom-ready, which signal showed
the window, and the window geometry at each step). SCMJS_TRACE=1 echoes the same to
the terminal. When a launch "hangs and then opens", that log is what tells a slow disk, a
virus scanner reading the binaries, and a slow first paint apart.
The launch itself is arranged so that something is on screen early: the window is
created hidden and shown on the renderer's first paint, index.html carries a boot
splash in plain markup so that paint needs no stylesheet or bundle, and the app mounts
its chrome two frames after the splash. The saved maximized state is applied after the
window is shown. The reasons, and the measurements behind them, are in the comments in
desktop/main.ts and src/App.tsx.
Running the Windows build from WSL#
The desktop build cross-builds from WSL, but where the packaged app is run from changes
the launch more than anything in the code: \\wsl.localhost\… is a network share, and
Chromium loading its binaries and then every asset through it takes minutes. The same
build, launched from the WSL filesystem and from C::
| milestone | /mnt/c/… |
\\wsl.localhost\… |
|---|---|---|
| main script evaluated | 47 ms | 628 ms |
| window created | 142 ms | 963 ms |
dom-ready |
215 ms | not within 25 s |
So copy it over first:
npm run build:desktop -- win --dir # release/win-unpacked
cp -r release/win-unpacked /mnt/c/Users/<you>/scmjs-test
SCMJS_TRACE=1 WSLENV=SCMJS_TRACE /mnt/c/Users/<you>/scmjs-test/scmJS.exe
WSLENV forwards the variable into the Windows process. From WSL, the app's data folder
is /mnt/c/Users/<you>/AppData/Roaming/scm-js.
In-app updates#
The desktop app checks GitHub releases at startup (a preference, on by default) and
through Help ▸ Check for Updates…, using electron-updater over the latest*.yml feed
files the release workflow uploads beside the installers. Finding one raises a toast with
a Download button rather than a dialog; nothing downloads or installs unasked, and
installing goes through the same unsaved-changes gate as closing the window. A nightly
build follows the nightlies and a stable build follows stable releases; a preference lets
a stable install opt into nightlies.
Windows, the AppImage and the .deb install updates themselves. macOS can see one but
not install it until the app is code-signed, so the dialog offers the release page there.
A development run (npm run desktop) and an unpacked Linux folder report that they
cannot check, since the updater has nothing it could replace. desktop/updater.ts is the main-process side,
src/editor/updates.ts the state machine and every string, tests/updates.test.ts the
cases.