Both Fyne Mines and Tunnel Launcher are written in Go on top of the Fyne toolkit, and both shipped Linux and Windows binaries only. Fyne can target MacOS, but I wrote in the Tunnel Launcher release post that I would need help for a MacOS release, because I own no Mac and I don’t like shipping a binary that I’ve never started once. In this post I explain how I solved this dilemma.

A MacOS VM on a Linux box

OSX-KVM runs MacOS under KVM, and my workstation has enough capacity to run some virtual machines. The repo ships shell scripts around qemu, but my other VMs live in libvirt, so I wrote a MacOS.xml domain based on the Catalina template in the repo. Three things in it are not obvious.

The repo contains a 4 MB OVMF_CODE_4M.fd but only stale 2 MB varstores. All three OVMF_VARS*.fd files are byte identical, so the one named OVMF_VARS-1920x1080.fd sets no resolution at all. I copied a matching 4 MB varstore from /usr/share/OVMF/ instead. The resolution comes from OpenCore, UEFI/Output/Resolution in config.plist, which also has to be written into the ESP inside OpenCore.qcow2.

The video device needs an explicit PCI address on bus 0x00. Left alone, libvirt parks vmware-svga behind an auto-added pcie-pci-bridge, OVMF never initializes it, and you get a black screen with vcpu0 spinning at 100%. The NIC belongs at slot 0x12, because config.plist injects built-in=01 at PciRoot(0x0)/Pci(0x12,0x0).

After that it boots MacOS Sonoma to the login screen at 1920x1080, with 16 GiB of RAM and networking over virtio-net. The system disk is a different story. The repo notes claim virtio-blk works, but that text dates from the Catalina era. I tried it twice. OpenCore lists the MacOS entry and the kernel loads, then it lands on the prohibitory sign, because the kernel has no driver to mount the root volume. The boot disk stays on emulated AHCI.

The first thing you notice after logging in is a white desktop. The login wallpaper renders, because that one is a pre-rendered PNG, but the desktop picture and the transparency in the menu bar and Dock never appear. A virtual Hackintosh has no graphics acceleration. A detail that turned into a problem, but is solved later in this post.

Getting the SDK without an Apple ID

For an iOS app you need Xcode, and Xcode only runs on MacOS. For a Fyne desktop app that is not going to the App Store don’t need Xcode. What you do need is the MacOS SDK, which is not included in fyne-cross.

The documented route is fyne-cross darwin-sdk-extract, which runs in Docker on Linux and takes Apple’s Command Line Tools dmg. That dmg lives behind a developer.apple.com login. Inside the VM, xcode-select --install pulls the same Command Line Tools through Software Update and asks for nothing, so you can extract the SDK from an installation you already have. There are also third party mirrors of the SDK on GitHub where you can just download the required files.

Version 11.3, the one fyne-cross names in its own documentation, is too old. The link fails on _SecTrustCopyCertificateChain, a symbol that Go’s crypto/x509 needs and that arrived in MacOS 12. With 12.3 it resolves:

mkdir -p ~/SDKs && cd ~/SDKs
curl -LO https://github.com/joseluisq/macosx-sdks/releases/download/12.3/MacOSX12.3.sdk.tar.xz
tar xf MacOSX12.3.sdk.tar.xz

Making fyne-cross produce an app bundle

With the SDK in place the build still failed on every framework. fyne-cross 1.6.3 mounts the SDK at /sdk and passes CGO_LDFLAGS=--sysroot /sdk -F/System/Library/Frameworks -L/usr/lib, but the zig in the darwin image applies the sysroot to -L and not to -F, so no -framework lookup resolves. Overriding CGO_LDFLAGS does not help, since fyne-cross appends its own copy last and docker takes the last one. Passing -extldflags does not help either, because the fyne packager supplies its own -ldflags. What works is telling the image about the path:

docker build -t fyne-cross-darwin-sdk - <<EOF
FROM fyneio/fyne-cross-images:darwin
RUN mkdir -p /System/Library && ln -s /sdk/System/Library/Frameworks /System/Library/Frameworks
EOF

After that both architectures build. Unlike the Linux and Windows targets, darwin requires an -app-id, and the first run pulls a container image of a few gigabytes.

~/go/bin/fyne-cross darwin -arch=amd64,arm64 -app-id com.tqdev.fyne-mines \
  -macosx-sdk-path ~/SDKs/MacOSX12.3.sdk -image fyne-cross-darwin-sdk

My package.sh now builds the image, runs this for both architectures and zips the two .app directories, and release.sh reads the version from FyneApp.toml, tags the commit and uploads six artifacts per release. Fyne Mines 1.1.4 went out as “OSX support” that morning.

The bundles are unsigned, because notarization needs a paid developer account, so MacOS refuses to open them on first launch. Use the Open entry in the right click menu, or drop the quarantine flag:

xattr -dr com.apple.quarantine fyne-mines.app

Fyne needs a GPU

Fyne draws everything through OpenGL 2, with shaders, so an ancient OpenGL 1.1 implementation is not enough. Neither of my two test machines has acceleration, because both of them are virtual. The same missing GPU shows up as two different failures, and each platform needs its own workaround.

Patching glfw on MacOS

On MacOS the app exits immediately with:

FormatUnavailable: NSGL: Failed to find suitable pixel format

GLFW asks MacOS for NSOpenGLPFAAccelerated, which is a hard constraint, and Apple’s own CPU renderer is not accelerated, so on a host without a GPU nothing matches and window creation fails. The upstream issue for this, glfw#2080, is still open, so I vendored glfw into third_party with a replace in go.mod and patched nsgl_context.m. The glfw Go module is 3.9 MB on its own, which is a lot lighter than vendoring the whole dependency tree.

Watch out for one trap while doing this. The Go build cache does not take C sources into account, so my first patched build silently reused a cached object and behaved exactly like the unpatched one. The go-gl package carries an upstreamTreeSHA constant for this purpose, and bumping it invalidates the cache.

The first version, released as 1.1.5, only took the CPU renderer when GLFW_SOFTWARE_RENDERER was set. That works from a terminal, but open and a double click from Finder do not pass environment variables, so the app still refused to start the normal way. The fix is to let the failure be the detection. When initWithAttributes: returns nil, which is exactly the “no accelerated renderer here” signal, the patch retries once with kCGLRendererGenericFloatID, Apple’s CPU renderer. A machine with a GPU never reaches the retry and behaves as before. That went out as 1.1.6, and the game is playable on the CPU, just slower.

Installing Mesa3D on Windows

Windows has the same hole. It ships only the GDI generic OpenGL 1.1 implementation, so on a machine without working graphics drivers the window never comes up. Normally the GPU driver solves this, but over RDP or in a virtual machine there is no driver to install.

The answer there is Mesa3D for Windows, which replaces opengl32.dll with a software renderer, either system wide or next to the exe. I tested Mesa 26.0.6 on Windows 11. Note that Mesa is not a fallback in front of the vendor driver. Once its opengl32.dll is in place it is the OpenGL implementation for that process, and it does its own tiering internally, from a Direct3D 12 backed driver down to the CPU.

I did try to ship it per app, with the DLLs inside the release zip, and released that as 1.2.0. It was not a success. The shipped opengl32.dll is a small stub that loads a 62 MB megadriver next to it, which is more than the application itself by a wide margin. The system wide install has none of these problems, so I deleted that release and documented the requirement in the README instead.

Tunnel Launcher has a tray icon

Porting MacOS suppport to Tunnel Launcher took an afternoon. The patched glfw moved over as is, the same custom image and SDK produce the two bundles. The difference is that Tunnel Laucnher has a tray icon. The tray code links against NSStatusBar and NSStatusItem, so the tray lands in the MacOS menu bar. I ran the bundle in the same VM, the tray worked, and v0.1.7 went out with MacOS artifacts.

Future work

Signing and notarization are still missing, both on Windows and on OSX. Apple Silicon is untested on real hardware, and software rendering is slower than the real thing. If you run either app on real Mac, I would like to hear how it went.