betterglobekey is a macOS-native app: it intercepts the Globe key system-wide, switches keyboard layouts through the Text Input Sources API, draws a system-bezel HUD in AppKit, and prompts for Accessibility like anything from a dmg. (Why it exists is the previous post; this one is about how it’s built.) There is no Swift in the repo and no .xcodeproj — go build compiles the entire thing, Objective-C included. The load-bearing detail: cgo is a full clang driver, and macOS frameworks don’t care what language dials them. They care that you link them, respect the main thread, and hold the right permission. That’s a linker flag per framework, a thread pin, and one API call.
The linking rule fits in a comment block. cgo compiles the preamble with clang, -x objective-c tells clang what it’s reading, and -framework flags link anything Apple ships:
/*
#cgo CFLAGS: -x objective-c
#cgo LDFLAGS: -framework Cocoa
void hudRun(void);
void hudShow(const char *text, const char *subtitle);
*/
import "C"
Any .m file sitting in the package directory gets compiled and linked into the binary by go build — no Makefile, no build phase. To be exact about what that buys: the Objective-C doesn’t disappear, it shrinks. betterglobekey’s glue is 475 lines across five .m files, next to 3.8k lines of Go; each file is a flat list of C functions wrapping exactly the framework calls the app needs, and nothing else lives there. It diffs, reviews, and vendors like any other source file. (The repo also carries an optional Electron companion for editing config — the app itself is the one Go binary.)
The Globe key is a good tour of what the glue has to know, because the Globe key is a lie: it never arrives as a key-down. It surfaces as a flags-changed event with key code 63 — kVK_Function — so a CoreGraphics event tap has to do its own edge detection, and has to notice when fn was used as a modifier rather than tapped:
if (type == kCGEventFlagsChanged &&
CGEventGetIntegerValueField(event, kCGKeyboardEventKeycode) == bgkFnKeyCode) {
bool down = (CGEventGetFlags(event) & kCGEventFlagMaskSecondaryFn) != 0;
if (down && !bgkFnDown) {
bgkFnDown = true; // press: start watching for other keys
bgkFnUsed = 0;
} else if (!down && bgkFnDown) {
bgkFnDown = false; // release: fire only for a clean tap
if (!bgkFnUsed) goHandleFnRelease(bgkFnReverse);
}
}
goHandleFnRelease is a Go function. cgo exports it to C with an //export directive — with one rule that eats an afternoon the first time: the directive can’t live in a file whose preamble defines C functions, so the callback sits in its own file. The tap comes with a second trap: macOS disables it on timeout or heavy input — across sleep, reliably — and tells you via kCGEventTapDisabledByTimeout events. Re-enable it in the callback, or the tap dies silently across a sleep cycle and the app keeps running, deaf.
The second platform rule is threads. AppKit runs on the main thread — not a preference, a requirement — and some APIs (Text Input Sources among them) abort when called from a thread without a running CFRunLoop. Go’s answer is one pin and one funnel: runtime.LockOSThread() in main’s init pins the main goroutine to the main OS thread, where the AppKit run loop lives, and everything else marshals through libdispatch. cgo won’t let you hand C a Go function pointer, so the funnel passes an integer handle and Go looks it up on arrival:
void bgkDispatchMain(unsigned long long handle) {
if ([NSThread isMainThread]) {
bgkRunOnMain(handle); // bgkRunOnMain is the exported Go side
return;
}
dispatch_sync(dispatch_get_main_queue(), ^{
bgkRunOnMain(handle);
});
}
The third rule is permission, and it’s smaller than its reputation. The global event tap needs Accessibility; the entire integration is AXIsProcessTrustedWithOptions with kAXTrustedCheckOptionPrompt — one call that both answers the question and shows the system dialog. The trust attaches to the process, not to a language or an IDE. macOS neither knows nor asks what compiled you.
What Xcode was actually selling: signing and notarization. An unsigned binary downloaded from the internet arrives quarantined and Gatekeeper makes the user fight for it; a raw executable gets no bundle, no Dock icon, no Info.plist. For a daemon that lives in the background and wants none of those, that’s a discount, not a cost. The real cost is the 475 lines — you still write Objective-C, you just stop maintaining a second toolchain to build it.
The platform’s rules are links, threads, and trust. Xcode is one way to follow them. go build turned out to be another.