Desktop build
desktop/ is an Electron shell around the same web bundle: main.ts serves dist/
under app://scmjs/ and answers the game-data IPC (search the disk, extract, pick a
folder), preload.ts exposes it as window.scmjsDesktop (typed in
src/gamedata/desktop.ts). Extracted files go to the user data directory and are served
under the same base, so the renderer finds them as bundled.
npm run build:desktop # web build in desktop mode + main bundle + electron-builder for this OS
npm run desktop # bundle the main process and run Electron against dist/
scripts/build-desktop.mjs is those steps, and its arguments say what the packaging step
builds — with none it is this OS and the targets electron-builder.yml lists for it, which
is what CI runs:
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)
Before the bundle it runs scripts/vendor-plugins.mjs — as npm run dev and
npm run build do, in their predev / prebuild hooks — which writes each default
plugin's own source, at the tag src/plugins/defaults.ts pins, into the gitignored
plugins/, where src/plugins/builtin.ts globs it into the build. See
docs/plugins.md for why: the short version is 890 KB gzipped off a first
visit, and an installed app or a container that starts with all five plugins and no
network.
It only fetches what is not already there at the pinned version, so the first build after
a clone needs a connection and every one after it is offline and instant. --force
re-fetches, --clean removes the directory, --list prints the specs, and
SCMJS_SKIP_VENDOR=1 skips the step for a build with no network and no copy yet — that
bundle then fetches its defaults at startup, the way the editor did before. GITHUB_TOKEN,
if set, keeps the one file-list request per plugin off the anonymous rate limit; CI sets it.
--skip-plugins is the same skip for build:desktop alone.
A directory in plugins/ carries a vendored.json naming the spec it came from, which is
how the script knows which copies are its own: those it brings up to date or removes when
they stop being defaults, and one you put there by hand it leaves alone.
Platforms are win / mac / linux, architectures x64 / ia32 / arm64 / armv7l /
universal, and any electron-builder target name (nsis, dmg, zip,
AppImage, deb, …) applies to every platform named; --publish <mode> and anything after
a bare -- go to electron-builder as they are. --skip-web and --skip-main reuse the
bundles on disk when only the packaging is being changed. Cross-building is electron-builder's
business: the script warns about the combinations that need tooling the machine may not have
(a macOS installer anywhere but on a Mac, an NSIS one without wine) and runs them anyway —
--dir and zip cross-build with nothing installed.
Windows gets an NSIS installer and a zip, not electron-builder's portable target.
That target is a 7-Zip SFX, and its NSIS template (app-builder-lib/templates/nsis/portable.nsi)
does RMDir /r $INSTDIR and then re-extracts the whole app into %TEMP% on every launch —
there is no cache, whether or not unpackDirName is pinned — so it pays a multi-hundred-megabyte
unpack each time, before any of our code exists to say so. The one thing it offers to cover that
wait is portable.splashImage, a single .bmp handed to the NSIS BgImage plugin, which paints a
backdrop over the whole desktop; it cannot animate, and the boot splash in index.html cannot
help because Electron has not started. A zip is unpacked once by the user and every launch after
it is the ordinary one measured above.
electron-builder.yml packages dist/ and desktop/dist/ only — never node_modules
(everything is bundled by Vite) and never the game data a developer's public/ holds.
Builds are unsigned for now.
What the download weighs is almost all Electron. The Windows zip is about 158 MB, of which the
Electron executable is 103 MB compressed and the app's own asar 5 MB (20 MB unpacked, 14 MB of it
TypeScript shipped twice: the transpile worker the plugin loader runs .ts plugins through,
and its main-thread fallback). electronLanguages: [en-US] drops Chromium's other 54 UI locales, which were 50 MB
unpacked and 12 MB of the zip; the editor has no translations, so nothing is lost. The DirectX and
Vulkan DLLs (dxcompiler.dll, dxil.dll, vk_swiftshader.dll; 38 MB unpacked, 15 MB zipped)
are Chromium's WebGPU and software-Vulkan back ends, which a canvas-2D editor never reaches — they
could go in an afterPack hook, but that needs a run on real Windows first and has not been done. The first run opens maximized (1400 × 900 is what restoring it
down gives back); after that the window comes back the size, position and maximized state it
was left at, kept in window.json in the user data directory and saved half a second after
the last move or resize as well as on close, so a session that ends in a crash or a kill still
remembers. A position that no longer lands on any attached screen is dropped and the platform
places the window. Closing the window (or quitting) while the open map has unsaved changes is
held back in the main process and handed to the editor, which asks with its own Close Scenario
dialog — Save goes through the ordinary File ▸ Save path (which, with a file handle from the
open or save picker, writes in place; see below); in a browser tab the same preference
arms beforeunload, where all the page can do is make the browser ask its own generic question
(src/hooks/useCloseGuard.ts). The icon comes from public/icon.png, the same file electron-builder
turns into the .ico / .icns. SCMJS_DEV_URL=http://localhost:5173 npm run desktop
points the window at the dev server.
Saving (src/hooks/useMapFileActions.ts#saveDocument, src/services/mapIo.ts) keeps the File
System Access handle a Chromium browser or Electron gives for a file — from showOpenFilePicker,
a drop's getAsFileSystemHandle() (requested inside the drop event) or showSaveFilePicker —
in mapFileHandleAtom, so Ctrl+S writes in place after one permission prompt; without a handle it
goes through the save picker, and without the API (Firefox, Safari) it downloads, and the toast
says which happened (pushToastAtom, components/chrome/Toasts.tsx). The Save dialog
(SaveMapDialog, payload.copy for Save Copy As) previews editor/save.ts#planSave and hands
the built bytes to saveDocument; askDialog lets a caller — Close Scenario's Save — await its
answer the way guardedAction awaits the close confirmation. The options confirmed there are
kept per document in saveOptionsAtom and reused by Ctrl+S.