Building the Iterion Desktop app
Iterion ships an optional desktop wrapper built with Wails v2. The desktop binary embeds the studio SPA and the Iterion HTTP server inside a native window via WebKit (Linux/macOS) or WebView2 (Windows).
This document captures the build pipeline for all four supported platforms and the rationale behind the structural choices that aren't obvious from the sources alone.
Architecture rappel
- Sources Go: cmd/iterion-desktop/ — gated by
//go:build desktop. The defaultgo test ./...does not require Wails. - Wails config: cmd/iterion-desktop/wails.json (and not at the repo root — see "wails.json location" below).
- AssetServer:
Assets = nil, so Wails delegates to the custom handler in asset_proxy.go. That handler serves SPA/static paths from the GUI binary's embeddedpkg/server.StaticFSand reverse-proxies only/api/*HTTP requests to the selected loopbackpkg/server(the default per-project headless daemon, or the in-process fallback). The studio is embedded via//go:embed all:static— never copied into the Wails frontend dir. - Build tasks: Taskfile.yml § Desktop. One target per GOOS/GOARCH plus
desktop:package:linux:<arch>for AppImage.
wails.json location & -skipbindings
Wails CLI insists on running go build from the directory holding wails.json, and it generates JS bindings against the main package in that directory. Since our Go main lives in cmd/iterion-desktop/, the file is co-located there. The Taskfile uses a per-task dir: cmd/iterion-desktop so the working directory is always correct.
We pass two Wails flags that aren't part of the default scaffold:
-skipbindings: the studio consumes bindings via thewindow.go.main.App.*globals injected by the Wails runtime, not via the static JS shims Wails generates. Skipping bindings generation removes a redundant codegen step.-s: skip Wails' scaffold frontend build. There is no frontend to build undercmd/iterion-desktop/; the supported repo entry points are thetask desktop:*targets, which runtask studio:buildfirst and then pass Wails' short-sflag. The SPA is embedded intopkg/server/static, and the handler in asset_proxy.go serves the index and static chunks directly from that GUI embed, while/api/*is the only traffic reverse-proxied to the selected loopback server.
-tags desktop,webkit2_41 is required on Linux to opt into the modern WebKit2 GTK 4.1 ABI. Without webkit2_41 Wails would target webkit2gtk-4.0, which Debian trixie / Ubuntu 24.04 no longer ship.
System dependencies
Wails build = native compile = system dev headers. The default path uses the host package manager (apt) — that's what the devcontainer and CI runners use. devbox install links only the runtime output of gtk3/webkitgtk, so their headers and .pc files (the -dev outputs) are absent and a plain devbox run -- go build -tags desktop,webkit2_41 fails at pkg-config.
If you can't use apt/sudo (a restricted shell, a bare Nix box), you don't have to — nix can provide the headers; see Alternative: nix-provided headers below.
Debian / Ubuntu (devcontainer + GitHub Actions runners)
sudo apt-get install -y \
libgtk-3-dev libwebkit2gtk-4.1-dev libsoup-3.0-dev \
fuse libfuse2t64 dpkg-dev patchelf desktop-file-utils \
gtk-update-icon-cache librsvg2-common xvfbThese packages are wired into .devcontainer/devcontainer.json's postCreateCommand, and into the Linux deps step of .github/workflows/desktop-release.yml.
macOS
Xcode Command Line Tools provide everything Wails needs (WebKit lives in the SDK, no extra brew formulas required). The CI runs macos-14 (Apple Silicon) and produces a single darwin/universal .app via Wails' -platform darwin/universal (lipo'd amd64 + arm64).
Windows
The CI runs on windows-latest. Wails uses WebView2 which is bundled with modern Windows; the build needs nsis only for the installer step (-nsis flag in the Taskfile target). On the runner this is auto-installed via the [Wails CLI] download.
Alternative: nix-provided headers (no apt / no sudo)
nixpkgs does ship the gtk3/webkitgtk headers — devbox just doesn't realise the -dev outputs. You can realise them yourself from the same nixpkgs the devbox flake pins (fetched from cache.nixos.org, not compiled) and point pkg-config at them, with no apt and no sudo. The wrapper does it for you:
# compile / vet / test the desktop package (no GUI needed):
scripts/desktop/nix-pkgconfig-env.sh go test -tags desktop,webkit2_41 ./cmd/iterion-desktop/
# a RUNNABLE GUI binary additionally needs the `production` tag — Wails' Linux
# app impl is gated behind `production` (or `dev`); without it the binary starts
# and prints "Wails applications will not build without the correct build tags"
# (it fell back to app_default_unix.go). `wails build` injects this tag for you;
# a plain `go build` must pass it:
scripts/desktop/nix-pkgconfig-env.sh \
go build -tags desktop,webkit2_41,production -o ./iterion-desktop ./cmd/iterion-desktop/Two non-obvious things the script handles (and that trip up a hand-rolled attempt):
- The nix pkg-config wrapper ignores a bare
PKG_CONFIG_PATH— it reads a target-specificPKG_CONFIG_PATH_<arch>_unknown_linux_gnuand overwrites the plain one. The script exports the target variable. - gtk/webkit resolve a large tree of transitive
Requires:, so the path must include every-devoutput'spkgconfigdir in the closure, not just gtk's and webkit's. The script derives that fromnix-store -qR.
This is enough to compile, vet, and run the Go tests for the desktop package (including the cloud-connection code and its integration tests). Producing an installable bundle (.deb/AppImage) still wants the apt tooling (dpkg-dev, patchelf, linuxdeploy, …); the nix path covers the Go build, not the packaging.
⚠ The nix path is for COMPILING/TESTING, not for a binary you RUN on a non-NixOS host. A binary linked against nix gtk/webkit also pulls nix's Mesa/
libEGL/libGL(via the bakedRUNPATH), and nix'slibEGLcannot find a non-NixOS system's GPU drivers (it looks under the nix store, not/usr/lib/.../dri). At runtime it aborts withCould not create default EGL display: EGL_BAD_PARAMETERand the window is black (only the native GTK menu bar paints). A runnable / distributable Linux GUI must link the SYSTEM webkit + libEGL so it can reach the host's GPU drivers. On a host that already has the apt-devpackages + a Go 1.26 toolchain, build OUTSIDE devbox so noNIX_LDFLAGS-rpathis baked:shenv -i HOME="$HOME" PATH=/usr/local/go/bin:/usr/bin:/bin \ CGO_ENABLED=1 CC=/usr/bin/gcc CXX=/usr/bin/g++ \ PKG_CONFIG=/usr/bin/pkg-config \ PKG_CONFIG_PATH=/usr/lib/x86_64-linux-gnu/pkgconfig:/usr/share/pkgconfig \ go build -tags desktop,webkit2_41,production \ -o build/bin/iterion-desktop-linux-amd64 ./cmd/iterion-desktop/Verify it is clean with
ldd build/bin/iterion-desktop-linux-amd64in a plain shell: no/nix/storepaths,libEGL/libwebkit2gtk-4.1resolving under/lib/x86_64-linux-gnu. (The canonical Taskfile targetdesktop:build:linux:amd64does the equivalent via the system pkg-config;env -ioutside devbox is the fallback when you can't easily get a clean PATH.)
Building locally (Linux)
The codebase is wired for both devbox run -- workflows and host-native workflows. Devbox sets up Go + Node + Task; apt provides webkit/gtk dev headers. The Wails CLI is installed on demand into $GOPATH/bin.
# 1. Install Wails CLI (one-off, per dev environment)
devbox run -- task desktop:install-tools
# 2. Build studio + desktop binary
export PATH="$(go env GOPATH)/bin:$HOME/.local/bin:$PATH"
devbox run -- task desktop:build:linux:amd64
# → build/bin/iterion-desktop-linux-amd64 (~46 MB)
# 3. Optional: smoke-test under Xvfb
xvfb-run -a -s '-screen 0 1280x800x24' \
timeout 8s build/bin/iterion-desktop-linux-amd64
# Expected: "Editor server listening on http://localhost:<port>"Packaging as AppImage
The AppImage build needs the AppImage tooling (~20 MB):
mkdir -p "$HOME/.local/bin"
curl -fsSL -o "$HOME/.local/bin/linuxdeploy" \
https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-x86_64.AppImage
curl -fsSL -o "$HOME/.local/bin/linuxdeploy-plugin-gtk" \
https://raw.githubusercontent.com/linuxdeploy/linuxdeploy-plugin-gtk/master/linuxdeploy-plugin-gtk.sh
chmod +x "$HOME/.local/bin/"linuxdeploy*
export PATH="$HOME/.local/bin:$PATH"
devbox run -- task desktop:package:linux:amd64
# → ./iterion-desktop-linux-amd64.AppImage (~110 MB, includes WebKit)The script (scripts/desktop/build-appimage.sh) runs linuxdeploy with --appimage-extract-and-run, so it works inside containers and CI runners that lack /dev/fuse.
Caveat: pkg-config inside devbox shell
devbox's Nix-wrapped pkg-config rewrites PKG_CONFIG_PATH to its own Nix store (which has no webkitgtk dev outputs). When invoking Wails directly inside devbox run --, prepend the system pkg-config to PATH so the wrapper is bypassed:
export PATH="/usr/bin:$PATH"
export PKG_CONFIG=/usr/bin/pkg-config
export PKG_CONFIG_PATH=/usr/lib/x86_64-linux-gnu/pkgconfig:/usr/share/pkgconfigThe Taskfile entries already invoke wails through the system PATH; this hint only matters for ad-hoc wails build runs.
Fast .deb build (local iteration only)
For tight inner loops where you want a real .deb to dpkg -i but don't care about minification or the AppImage:
devbox run -- task desktop:package:linux:amd64:deb:fast
sudo dpkg -i build/bin/iterion-desktop-linux-amd64.debCold build runs in ~50–60s (vs 90–150s for the prod task), warm rebuild ~10–20s. The .deb file path and contents layout are identical to the prod build — only the bundle inside changes.
Tradeoffs vs desktop:package:linux:amd64:deb:
- SPA bundle is unminified (~3–4× larger; embedded in the binary, so the
.debitself is also bigger). - The
Version:field is stamped<semver>+fast.<commit>— visible indpkg -l iterion-desktopso you can't confuse it with a prod build. - No AppImage is produced (the prod
:debtask produces one as a side-effect; the fast variant skips it becausebuild-deb.shdoesn't consume it).
After running any :fast task, pkg/server/static/ contains the unminified bundle. Before producing release artefacts, wipe it and rebuild:
rm -rf pkg/server/static
devbox run -- task studio:buildDO NOT distribute a fast-built .deb. The CI release path remains the source of truth for shipped artefacts.
Cross-compiling
Wails relies on platform-specific WebView toolchains and CGO, so cross-builds generally don't work. Each target must be built on a matching host:
| Target | Build host |
|---|---|
linux/amd64 | any Linux x86_64 (devcontainer OK) |
linux/arm64 | Linux aarch64 (CI uses ubuntu-24.04-arm) |
darwin/universal | macOS — single .app for Intel + Apple Silicon (CI uses macos-14; lipo via -platform darwin/universal) |
windows/amd64 | Windows x64 (CI uses windows-latest) |
windows/arm64 | Windows x64 cross to arm64 (Wails handles this) |
CI flow (.github/workflows/desktop-release.yml)
actions/setup-go+actions/setup-node.go install wails@latestandgo install task@latest; both end up in$GOPATH/bin, which is appended toGITHUB_PATH.- Linux only:
apt-get installof the headers + AppImage tooling listed above;linuxdeployandlinuxdeploy-plugin-gtkfetched from github.com/linuxdeploy. task studio:build(single source of truth — same task as local dev).task desktop:build:<os>:<arch>per matrix entry.- Per-package step (zip / nsis-portable / appimage).
readelfsmoke-check on the AppImage to confirm the runtime header arch.- Ed25519 signing + manifest publication via scripts/desktop/sign-release.sh and scripts/desktop/generate-manifest.sh.
Smoke checklist before shipping a release
task desktop:build:linux:amd64succeeds locally.- The produced
build/bin/iterion-desktop-linux-amd64opens a window, loads the editor, can run a.botworkflow end-to-end. - The AppImage produced by
task desktop:package:linux:amd64runs on a freshly-installed Debian or Ubuntu without devbox installed. - Drop a
v*tag →desktop-release.ymljob is green for all five matrix entries (darwin/universal, windows/amd64, windows/arm64, linux/amd64, linux/arm64 — the two macOS jobs are collapsed into one universal build).
