Skip to content

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 default go 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 embedded pkg/server.StaticFS and reverse-proxies only /api/* HTTP requests to the selected loopback pkg/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 the window.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 under cmd/iterion-desktop/; the supported repo entry points are the task desktop:* targets, which run task studio:build first and then pass Wails' short -s flag. The SPA is embedded into pkg/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 xvfb

These 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:

sh
# 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-specific PKG_CONFIG_PATH_<arch>_unknown_linux_gnu and 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 -dev output's pkgconfig dir in the closure, not just gtk's and webkit's. The script derives that from nix-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 baked RUNPATH), and nix's libEGL cannot find a non-NixOS system's GPU drivers (it looks under the nix store, not /usr/lib/.../dri). At runtime it aborts with Could not create default EGL display: EGL_BAD_PARAMETER and 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 -dev packages + a Go 1.26 toolchain, build OUTSIDE devbox so no NIX_LDFLAGS -rpath is baked:

sh
env -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-amd64 in a plain shell: no /nix/store paths, libEGL/libwebkit2gtk-4.1 resolving under /lib/x86_64-linux-gnu. (The canonical Taskfile target desktop:build:linux:amd64 does the equivalent via the system pkg-config; env -i outside 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.

bash
# 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):

bash
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:

bash
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/pkgconfig

The 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:

bash
devbox run -- task desktop:package:linux:amd64:deb:fast
sudo dpkg -i build/bin/iterion-desktop-linux-amd64.deb

Cold 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 .deb itself is also bigger).
  • The Version: field is stamped <semver>+fast.<commit> — visible in dpkg -l iterion-desktop so you can't confuse it with a prod build.
  • No AppImage is produced (the prod :deb task produces one as a side-effect; the fast variant skips it because build-deb.sh doesn't consume it).

After running any :fast task, pkg/server/static/ contains the unminified bundle. Before producing release artefacts, wipe it and rebuild:

bash
rm -rf pkg/server/static
devbox run -- task studio:build

DO 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:

TargetBuild host
linux/amd64any Linux x86_64 (devcontainer OK)
linux/arm64Linux aarch64 (CI uses ubuntu-24.04-arm)
darwin/universalmacOS — single .app for Intel + Apple Silicon (CI uses macos-14; lipo via -platform darwin/universal)
windows/amd64Windows x64 (CI uses windows-latest)
windows/arm64Windows x64 cross to arm64 (Wails handles this)

CI flow (.github/workflows/desktop-release.yml)

  1. actions/setup-go + actions/setup-node.
  2. go install wails@latest and go install task@latest; both end up in $GOPATH/bin, which is appended to GITHUB_PATH.
  3. Linux only: apt-get install of the headers + AppImage tooling listed above; linuxdeploy and linuxdeploy-plugin-gtk fetched from github.com/linuxdeploy.
  4. task studio:build (single source of truth — same task as local dev).
  5. task desktop:build:<os>:<arch> per matrix entry.
  6. Per-package step (zip / nsis-portable / appimage).
  7. readelf smoke-check on the AppImage to confirm the runtime header arch.
  8. 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:amd64 succeeds locally.
  • The produced build/bin/iterion-desktop-linux-amd64 opens a window, loads the editor, can run a .bot workflow end-to-end.
  • The AppImage produced by task desktop:package:linux:amd64 runs on a freshly-installed Debian or Ubuntu without devbox installed.
  • Drop a v* tag → desktop-release.yml job 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).