From Github Actions to Woodpecker - Rust GUI cross compilation

I am building a small GUI application called CraftIP. Minecraft players can use it to bypass the NAT using an online proxy hosted by me. In other words, it makes it easy to make a local Minecraft server public to simplify playing with friends. The program itself is built with Rust and Egui, which is a simple-to-use immediate mode GUI library.

The CraftIP application

Fig. 1:

How CraftIP looks like

Previously on GitHub, I used macOS and Windows runners to compile the software. After switching to Forgejo/Codeberg, I needed an alternative. As a CI/CD pipeline I choose Woodpecker, a super simple CI solution, that does cloning the repo and run Docker containers. Nothing more.

After some research I found two promising projects:

Windows packaging

To cross compile a GUI program for Windows, it needs to be link against system libraries. Luckily, the program xwin does the trick by downloading the Windows system libraries, to be able to link against them.

Building for Windows

To speed up the build process, I packaged the Windows system libraries and headers in a Docker container, which can then be used to cross-compile the Rust application for Windows x86 (32 and 64 bit).

The resulting Windows compilation pipeline becomes super simple:

when:
  - event: push
    branch: main

matrix:
  ARCH:
    - i686-pc-windows-msvc
    - x86_64-pc-windows-msvc

steps:
  - name: build
    image: codeberg.org/craftip/builder.windows:latest
    environment:
      CARGO_TERM_COLOR: always
    commands: |
      cargo xwin build --target ${ARCH} --release --bin client-gui

The cargo xwin command sets all the required environment variables for the compilation process.

To set the correct icon in the .exe file, the library winresource was added to the dependencies in Cargo.toml like so:

[build-dependencies]
winresource = "0.1"


[package.metadata.winresource]
FileDescription = "CraftIP"
ProductName = "CraftIP"
OriginalFilename = "CraftIP.exe"
LegalCopyright = "Copyright © 2026"

By adding these lines in the build.rs file, the correct icon gets assigned to the .exe file:

use {
    std::{env, io},
    winresource::WindowsResource,
};

fn main() -> io::Result<()> {
  if env::var_os("CARGO_CFG_WINDOWS").is_some() {
    WindowsResource::new()
      // This path can be absolute, or relative to your crate root.
      .set_icon("../build/resources/logo-win.ico")
      .compile()?;
  }
}

As a result you get two binaries, one for 32bit Windows for legacy reasons and one for 64 bit Windows.

CraftIP.exe on windows

Fig. 2:

The CraftIP.exe on Windows

macOS

macOS is a little more complex than that since both the SDK as well as a special linker are required. Luckily, the cargo-zigbuild project works on solving both of these issues.

variables:
  - &macos_archs 'x86_64-apple-darwin aarch64-apple-darwin universal2-apple-darwin'

steps:
  - name: build
    image: codeberg.org/craftip/builder.macos:latest
    environment:
      MACOS_ARCHS: *macos_archs
      CARGO_TERM_COLOR: always
    commands: |
      for target in $MACOS_ARCHS; do
        cargo zigbuild --release --target $target --bin client-gui --manifest-path client-gui/Cargo.toml
      done
  - name: build dmg
    image: codeberg.org/jurek/libdmg-hfsplus:latest
    commands: |
      export DMG_OUTPUT_PATH="target/CraftIP.dmg"
      sh ./build/build-mac.sh

Since cargo zigbuild only creates binaries, these binaries have to be encapsulated in a nice looking .app folder. This .app folder can then be distributed using .dmg files, which behave like a .iso but with some macOS specific features.

Creating a .app file is relatively straightforward:

$ tree CraftIP.app
CraftIP.app
└── Contents
    ├── Info.plist
    ├── MacOS
    │   └── CraftIP
    └── Resources
        └── logo.icns

All this is created with a little shell script, but it is nothing special.

Generating DMG’s

To create the .dmg two more things are required:

As a .DS_Store file, I decided to just use one that I had generated before on MacOS.

To generate the DMG volume on Linux, I used the program genisoimage. This is able to create a dmg file that works on macOS.

genisoimage -D \
      -V "CraftIP Installer" \
      -no-pad \
      -R -uid 0 -gid 0 \
      -hfs \
      -o "/tmp/CraftIP.uncompressed.dmg" "/tmp/dmg"

Unfortunately however, this dmg file is not compressed yet. For this, I used libdmg-hfsplus which does the job.

dmg "/tmp/CraftIP.uncompressed.dmg" "/tmp/CraftIP.dmg"

/tmp/CraftIP.dmg is now the final compressed dmg for distribution.

For further details, check out the shell script I use for CraftIP.

Permissions using genisoimage

Initially I was using the -r flag, which according to the documentation clears all write bits. This is problematic however, since then the .app folder becomes read-only, requiring the root-permissions to delete the .app folder from the /Applications folder in the future.

CraftIP permission problem

Fig. 3:

The error you get when trying to uninstall the app

In the case of CraftIP this is problematic, since the Application self-updates by replacing the binary with the newer version. This stops working, if the whole folder is read-only.

$ ls -l /Applications/CraftIP.app/Contents/MacOS
total 70424
-r-xr-xr-x@ 1 jurek  admin  36056144 Jul 10 12:08 CraftIP

Using the -R -uid 0 -gid 0 flags instead, fixes the issue. The write permissions are kept. When copying the CraftIP.app to the /Applications folder, they will persist so that the user can uninstall the application without root permissions.

$ ls -l /Applications/CraftIP.app/Contents/MacOS
total 70272
-rwxr-xr-x@ 1 jurek  admin  35977424 Jul 27 17:50 CraftIP

Takeaway

Windows and macOS applications can be cross-compiled and packaged in Docker containers. This is extremely valuable, since these containers can be used far in the future and continue to work. In theory, this should require less maintenance than using native runners that have to be constantly updated.

Github actions can be fully replaced with fully self-hosted Woodpecker CI that can be hosted everywhere.