18 KiB
Packaging Motionity
Three distribution targets share one source tree (src/, a plain static app):
| Target | Output | Build host |
|---|---|---|
| Desktop | Motionity Setup 1.0.0.exe, Motionity-1.0.0-x64.AppImage, .flatpak |
Linux or WSL2 builds all of them; Windows builds only the .exe targets |
| Container | motionity:latest, ~86 MB on disk / ~57 MB pulled |
any Docker host |
| Bare metal | src/ behind Node, nginx or Caddy |
any |
0. One prerequisite for every target: vendor the assets
npm install # now required for every target, not just the desktop ones
npm run vendor # ~24 MB, writes src/vendor/ (gitignored)
index.html used to pull jQuery, fabric.js, lottie, pickr, selection-js, the
WebFont loader and Inter from five different CDNs. scripts/vendor.mjs
downloads all of them into src/vendor/ and the app now references only those
local copies. Without this step the page loads but every script tag 404s.
ffmpeg.wasm is handled differently: vendor.mjs copies it out of
node_modules instead of downloading it, which is why npm install is now a
prerequisite everywhere. package-lock.json pins those packages by integrity
hash, so the bytes that reach the image are the bytes npm verified. The asm.js
build this replaced was fetched from a public archive.org mirror with no
integrity check of any kind — a changed object there would have executed in the
page unnoticed.
Two consequences worth knowing:
@ffmpeg/core-stis the single-threaded core, chosen deliberately. The default@ffmpeg/coreis built with pthreads and needsSharedArrayBuffer, which requires COOP/COEP cross-origin isolation, which would break the Pixabay, Unsplash and Google Fonts requests the editor makes.- The two
@ffmpeg/*packages aredependencies, notdevDependencies, so the Docker vendor stage cannpm ci --omit=devwithout pulling in electron. That makes electron-builder want to bundle them into the asar too, sobuild.filesexcludesnode_modules/**outright — the packaged app requires nothing butelectronand node builtins, and the copies it loads live insrc/vendor/ffmpeg/.
The Docker build runs npm ci and the vendor step inside the image, so it is
the one target where you can skip both locally.
1. Desktop — Electron
electron/main.js starts the same static server the bare-metal target uses, on
127.0.0.1 with a random port, and points the window at it.
This is deliberate, do not "simplify" it to loadFile(). Chromium exposes
WebCodecs (VideoEncoder, the fast exporter in src/js/render.js) and
IndexedDB (project storage via localbase) only in a secure context. file://
is not one; a loopback HTTP origin is. Load the app from file:// and export
silently falls back to real-time MediaRecorder capture and projects stop
saving.
npm run dev # run the desktop app from source
npm run dist:win # NSIS installer + portable .exe -> dist/
npm run dist:appimage # .AppImage -> dist/
npm run dist:flatpak # .flatpak -> dist/
npm run dist:linux # both Linux targets
npm run dist:linux:wsl # from Windows: Linux bundles, built in WSL
npm run dist:win:wsl # from Windows: .exe targets, built and left in WSL
npm run dist:win:wsl:portable # same, portable only (needs no Wine in the distro)
Each dist:* script re-runs vendor and regenerates build/icon.png
(scripts/make-icon.cjs rasterises the logo geometry with zlib only — no
ImageMagick, no sharp).
Why desktopName is app.motionity.desktop.desktop
The doubled suffix is correct, do not trim it. Electron reads the root-level
desktopName from package.json and derives the Wayland app_id / X11
WM_CLASS from it with the .desktop suffix stripped, so the value has to be
the desktop file name, not the app id. Flatpak in turn installs the entry as
<appId>.desktop and nothing can rename it — with appId
app.motionity.desktop, the file is app.motionity.desktop.desktop.
linux.syncDesktopName: true makes electron-builder use the same base name for
the AppImage's embedded entry and write a matching StartupWMClass. Without the
pair, the build warns
electron uses desktopName as app_id / WM_CLASS for window association.
reason=desktopName is not set in package.json
and the running window is not linked to its launcher entry: GNOME shows a
generic icon and a second, unpinnable dock item instead of the installed app.
Changing appId means changing desktopName in step with it.
If the NSIS step fails with "Access denied" on Motionity.exe
On a locked-down Windows machine the security agent can take an exclusive lock
on the freshly written 214 MB unsigned dist/win-unpacked/Motionity.exe, and
the 7-Zip step that builds the installer payload then cannot read it. The
symptom is a build that produces dist/win-unpacked/ correctly and dies right
after "Archive size":
.\Motionity.exe : Accès refusé.
WARNING: Cannot open 2 files
The ACL is intact (the owner still has FullControl), so this is a filter driver, not a permissions problem. Fixes, in order of preference:
- Build the Windows targets in WSL, where the agent cannot see the files at
all —
npm run dist:win:wsl, covered in its own section below. Needs no admin rights on the Windows side. - Exclude the repository's
dist/directory in the endpoint protection agent. - Build on a machine or CI runner without that agent.
- Ship
dist/win-unpacked/—electron-builder --win diris unaffected.
The same agent can also quarantine the finished unsigned .exe on write, not
just lock it during the build. That is what -KeepInWsl is for: the artifact
stays in the distro and is uploaded to the release from there, so it never
crosses onto NTFS.
Running npm run dev from a VS Code terminal
VS Code exports ELECTRON_RUN_AS_NODE=1, which turns the electron binary into
plain Node and leaves the electron module empty. main.js detects this and
prints a message. Fix it in the shell:
env -u ELECTRON_RUN_AS_NODE npm run dev # bash
$env:ELECTRON_RUN_AS_NODE=$null; npm run dev # PowerShell
Is WSL enough for AppImage and Flatpak?
Yes, and build-release.ps1 -UseWsl does it for you from Windows (-WinInWsl
moves the .exe targets there too — see below):
./scripts/build-release.ps1 -UseWsl # .exe on Windows, Linux bundles in WSL
./scripts/build-release.ps1 -Targets linux -UseWsl # Linux bundles only
./scripts/publish.ps1 -Tag v2.0.1 -PublishRelease -UseWsl # same, then attach to the Gitea release
Both bundles have been built this way on this machine (Ubuntu, WSL2 kernel
6.18): a 172 MB AppImage and a 133 MB Flatpak, checksums verified with
sha256sum -c. Under the hood:
- the
npm ci,vendorand icon steps run once on the Windows side, and the WSL build reads them back through/mnt/c— one worktree, no second checkout; -UseWsldelegates onlylinux-appimageandlinux-flatpak; add-WinInWslto sendwin/win-nsis/win-portableas well. On a Linux host, or with no target selected for WSL, both warn and change nothing;- the distro is the first installed one that is not
docker-desktop, override with-WslDistro.docker-desktopis skipped deliberately: it is Docker's own LinuxKit VM, with no apt and no home to install the flatpak runtimes into, and it is usually the default distro, so a blindwsl --lands there; - missing tooling fails before the build with the apt or
flatpak installline to run, because electron-builder's own error for an absent flatpak ref is a bare exit code naming neither the ref nor the remote.
Why the build stages in ~/.cache/motionity-build and not in dist/.
electron-builder chmods every file it unpacks from the Electron zip, and /mnt/c
is mounted without the metadata option, so chmod is refused:
⨯ EPERM: operation not permitted, chmod '.../dist/linux-unpacked.tmp/locales/de.pak'
The alternative fix is options = "metadata" under [automount] in
/etc/wsl.conf plus a wsl --shutdown — a global, sudo-and-reboot change to the
distro. Building into ext4 and copying the two finished bundles back into dist/
needs neither, and is faster anyway. Reading src/ over the mount is fine;
nothing chmods the input. The copy back is cp -f, never cp -p — preserving
modes means chmod, which is the EPERM being avoided.
AppImage needs no extra tooling: electron-builder downloads its own appimage
bundle and writes the squashfs itself, no FUSE at build time. To run the result
inside WSL you need libfuse2 (or
./motionity-*.AppImage --appimage-extract-and-run); WSLg gives you the GUI.
Flatpak needs flatpak-builder and the runtimes installed by hand — the
WSL2 kernel has the user namespaces and /dev/fuse that bubblewrap wants, but
there is no xdg-desktop-portal to fall back on.
.exe: yes — see the next section. It is opt-in (-WinInWsl) because Windows
builds those targets natively too; the reason to move them is the endpoint agent
described above, not portability.
One-time distro setup, from PowerShell:
wsl --install -d Ubuntu
Then inside Ubuntu:
sudo apt update
sudo apt install -y nodejs npm libfuse2 # AppImage (libfuse2 only to run it)
sudo apt install -y flatpak flatpak-builder elfutils # Flatpak
sudo dpkg --add-architecture i386 && sudo apt update # only for the NSIS .exe (see below)
sudo apt install -y wine # " "
flatpak remote-add --if-not-exists --user flathub \
https://dl.flathub.org/repo/flathub.flatpakrepo
flatpak install --user -y flathub \
org.freedesktop.Platform//23.08 \
org.freedesktop.Sdk//23.08 \
org.electronjs.Electron2.BaseApp//23.08
Can the .exe be built in WSL too?
Yes, and it is the way out of the "Access denied" failure above, since nothing unsigned is written to a Windows filesystem.
npm run dist:win:wsl # NSIS + portable, built and left in WSL — build only
npm run dist:win:wsl:portable # portable only — no Wine, no sudo needed
npm run release:binaries:wsl # same .exe targets, then uploaded to the Gitea release
npm run release:wsl # everything incl. Linux + the container image push
./scripts/build-release.ps1 -WinInWsl # copy the .exe back into dist/
./scripts/build-release.ps1 -WinInWsl -KeepInWsl # leave it in the distro
Verified on this machine (Ubuntu, WSL2 kernel 6.18): a 138 MB portable
motionity-v2.0.2-win-x64-portable.exe, PE32 executable for MS Windows (GUI), Nullsoft Installer self-extracting archive, with the icon and version resources
applied, byte-identical whether read in the distro or after the copy back into
dist/. The NSIS installer needs the wine setup below and has not been built this
way yet.
What each Windows target needs on the Linux side.
| Target | Wine? | Why |
|---|---|---|
-Targets win-portable |
no | electron-builder's NSIS bundle ships a native Linux makensis, and the exe's icon and version strings are written by the resedit JS package, not by rcedit.exe. |
-Targets win-nsis |
yes, 32-bit capable | NSIS builds its uninstaller by executing the installer stub it has just linked, so a Windows PE has to run. |
-Targets win |
yes | Both of the above in one packaging pass. |
Why the NSIS target cannot avoid Wine. electron-builder links the installer
once with BUILD_UNINSTALLER defined, runs it to produce uninstaller.exe, then
links the real installer, which embeds that file:
templates/nsis/include/installer.nsh does
File "/oname=${UNINSTALL_FILENAME}" "${UNINSTALLER_OUT_FILE}". There is no
option to skip the first pass. The stub it executes is PE32/i386 even for an
x64 app, so a 64-bit-only Wine is not enough either:
sudo dpkg --add-architecture i386
sudo apt update
sudo apt install -y wine
build-release.ps1 probes for a usable wine before packaging (missing or broken
wine is an error; a wine with no i386-windows directory is a warning), because
the failure otherwise arrives ~200 MB into the build naming ntdll rather than the
missing package.
Do not use toolsets.wine=1.0.1 for this. electron-builder can download its
own Wine 11 bundle instead of using the distro's, which looks like it would avoid
the apt install, and it does download and verify cleanly. Its Linux build is
unusable: lib/wine/x86_64-unix/ only, with no *-windows PE builtin directory
and no syswow64, so it fails after the app is already packaged with
wine: failed to load .../wine-11.0-linux-x86_64-*/lib/wine/x86_64-unix/ntdll.dll error c0000135
0024:err:environ:run_wineboot failed to start wineboot 1
c0000135 is STATUS_DLL_NOT_FOUND. Leaving toolsets.wine unset is what makes
electron-builder use the distro's wine on Linux, which is the working path. If
that bundle was already downloaded, rm -rf ~/.cache/electron-builder/wine@1.0.1
reclaims it.
If you cannot install anything in the distro, -Targets win-portable is a
complete answer: a single self-contained .exe, no installer, no Wine, no root.
Why the build also passes win.signExecutable=false. With no certificate
configured, electron-builder still walks the signing path, and on Linux that
path shells out to signtool.exe under Wine before discovering there is
nothing to sign — spawn wine ENOENT, build over. signExecutable: false skips
signing while still applying the icon and version metadata.
(signAndEditExecutable: false would drop those too, which is not wanted.) Both
overrides are passed on the command line for the WSL build only, so a native
Windows build behaves exactly as before. These releases are unsigned either way.
Uploading straight from the distro. With -KeepInWsl, build-release.ps1
writes dist/wsl-artifacts.json naming the distro, the staging directory and the
files it deliberately did not copy back. publish.ps1 reads it and runs the
curl upload inside the distro for those files. The Gitea token reaches WSL
through WSLENV and is written to a mktemp config file by bash — it is in
neither wsl.exe's arguments nor the distro's process table. SHA256SUMS.txt
still covers every artifact, hashes for the staged ones coming from sha256sum
in the distro; it is text, so nothing objects to it landing in dist/.
Two things to know when writing more of this plumbing:
- These
.ps1files are stored with CRLF, so a multi-line here-string handed tobash -lcarrives with a\ron every line (set: - : invalid option,cd: $'/path\r': No such file or directory).ConvertTo-BashScriptstrips it. - Never combine
set -ewith an explicitexit 0underbash -lc. A login shell sources~/.bash_logout, Ubuntu's ends in aclear_consoletest that fails with no tty, and errexit promotes that to the shell's exit status:wsl -e bash -lc 'set -e; exit 0'returns 1.-lhas to stay, because node from nvm or fnm is only on the loginPATH.
Those three refs must match build.flatpak.runtimeVersion / baseVersion in
package.json; build-release.ps1 reads them from there when it checks.
To build inside the distro directly instead — no -UseWsl, and faster still,
since src/ is read locally too:
git clone <repo> ~/motionity && cd ~/motionity
npm ci && npm run dist:linux
wsl.exe writes its own listings as UTF-16LE, which PowerShell 5.1 renders as
NUL-interleaved text — wsl -l -v can look like it has one distro when it has
two. $env:WSL_UTF8 = "1" fixes it.
2. Docker
docker build -t motionity:latest .
docker run --rm -p 8080:8080 motionity:latest
# or: docker compose up --build
The runtime image is static-web-server (Rust) on Alpine — 9 MB of base image,
no Node, no shell tooling, running as UID 65534. Everything above that is the
app itself: 46 MB of bundled stock media plus 20 MB of vendored libraries.
Two knobs if the size matters more than offline completeness:
# -23 MB: MP4/GIF export reports itself unavailable (WEBM export is unaffected).
# There is no runtime download to fall back on any more, by design.
docker build --build-arg WITH_FFMPEG=0 -t motionity:slim .
# -33 MB: drop the bundled royalty-free music library (removes the Audio panel
# presets; uploads still work). Add to .dockerignore:
# src/assets/audio
3. Bare metal
npm run vendor
npm start # http://127.0.0.1:8080
HOST=0.0.0.0 PORT=3000 npm start
scripts/server.cjs is dependency-free: correct MIME types, byte ranges (the
audio and video panels seek), no directory listings, path-traversal guard. Node
18+.
For a real install, deploy/ has a systemd unit plus nginx and Caddy configs
that serve src/ directly, no Node process involved:
sudo cp -r . /opt/motionity
sudo cp deploy/motionity.service /etc/systemd/system/
sudo systemctl enable --now motionity
The secure-context rule, once more
Both the container and the bare-metal targets serve plain HTTP. That is fine on
http://localhost, which browsers treat as secure. It is not fine on a LAN
address or a domain: VideoEncoder disappears (export falls back to slow
real-time capture) and IndexedDB is blocked (projects stop saving), with no
error message beyond a console warning.
Anything beyond localhost needs TLS. deploy/Caddyfile is the shortest path —
it obtains the certificate itself.
What still needs the internet
Vendoring makes the editor start and export offline. Three features remain online by design, and degrade quietly rather than breaking:
- Google Fonts — the font picker loads families through
WebFont.load, andsrc/js/init.jslists them from the Google Fonts API. Offline, text falls back to a system font. Only Inter (the UI font) is bundled. - Pixabay — the Images, Videos and Audio browsers search Pixabay live.
- Unsplash — sample images on the empty-state screen.
MP4 and GIF export used to be a fourth: the encoder came from archive.org at conversion time. It is now vendored, so export works fully offline.