Files
Motionity/PACKAGING.md
T
2026-08-14 13:20:30 +02:00

18 KiB
Raw Blame History

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-st is the single-threaded core, chosen deliberately. The default @ffmpeg/core is built with pthreads and needs SharedArrayBuffer, which requires COOP/COEP cross-origin isolation, which would break the Pixabay, Unsplash and Google Fonts requests the editor makes.
  • The two @ffmpeg/* packages are dependencies, not devDependencies, so the Docker vendor stage can npm ci --omit=dev without pulling in electron. That makes electron-builder want to bundle them into the asar too, so build.files excludes node_modules/** outright — the packaged app requires nothing but electron and node builtins, and the copies it loads live in src/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:

  1. 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.
  2. Exclude the repository's dist/ directory in the endpoint protection agent.
  3. Build on a machine or CI runner without that agent.
  4. Ship dist/win-unpacked/electron-builder --win dir is 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, vendor and icon steps run once on the Windows side, and the WSL build reads them back through /mnt/c — one worktree, no second checkout;
  • -UseWsl delegates only linux-appimage and linux-flatpak; add -WinInWsl to send win / win-nsis / win-portable as 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-desktop is 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 blind wsl -- lands there;
  • missing tooling fails before the build with the apt or flatpak install line 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 .ps1 files are stored with CRLF, so a multi-line here-string handed to bash -lc arrives with a \r on every line (set: - : invalid option, cd: $'/path\r': No such file or directory). ConvertTo-BashScript strips it.
  • Never combine set -e with an explicit exit 0 under bash -lc. A login shell sources ~/.bash_logout, Ubuntu's ends in a clear_console test 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. -l has to stay, because node from nvm or fnm is only on the login PATH.

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:

  1. Google Fonts — the font picker loads families through WebFont.load, and src/js/init.js lists them from the Google Fonts API. Offline, text falls back to a system font. Only Inter (the UI font) is bundled.
  2. Pixabay — the Images, Videos and Audio browsers search Pixabay live.
  3. 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.