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.