Hi, I'm Blake Williams

I'm a full-stack developer living in Boston, MA.

*full-stack meaning I really enjoy writing Rails, Elixir, React (Native), and sometimes HTML & (S)CSS.

August 2019
#go #macos #objective-c

Handling macOS URL schemes with Go

For a while I’ve had this idea of a custom browser handler in macOS that would have configurable rules for determining which browser a URL should open. I finally got around to building it which led to a lot of learning of what to-do and what not to-do when it comes to Cocoa apps and Go interop. If that sounds interesting you can find the code on GitHub, otherwise this post goes over how the core functionality of the application, macOS http/https URL scheme handling was written.

Initial Approaches

This project had a really bumpy start. The first task was to get a .app calling an executable. After getting this working, I soon realized that the url isn’t passed via STDIN, and it’s not available in os.Args. Turns out, macOS passes it via an event manager which Go doesn’t have access to.

The next approach was to define a simple AppleScript that would listen for a URL open event, then call the executable passing the URL. This seemed promising but sadly I couldn’t get it working.

With both of those options not panning out, there was clearly one choice left. Writing some Objective-C.

Objective-C and cgo

Thankfully Go has amazing support for C interop via cgo. Not only was using Objective-C possible, but it has strong support too. This meant that the Objective-C strategy was good to go (pun intended, sorry not sorry).

After a lot of trail and error, this is a minimal implementation that can actually listen for, and handle URL events.

To get started, first we need to define our Go code.

// main.go
package main

/*
#cgo CFLAGS: -x objective-c
#cgo LDFLAGS: -framework Cocoa
#include "browse.h"
*/
import "C"

import (
    "os/exec"
)

var urlListener chan string = make(chan string)

func main() {
    go C.RunApp()
    url := <-urlListener
    // replace with implementation
    cmd := exec.Command("open", "-a", "Safari", url)
    cmd.Run()
}

//export HandleURL
func HandleURL(u *C.char) {
    urlListener <- C.GoString(u)
}

This is pretty straightforward, we’re defining the main package, then using cgo to tell the compiler to pass the CFLAGS -x objective-c telling it that we’re compiling Objective-C code. We’re also passing LDFLAGS which is telling the linker that we want to link the Cocoa framework. Finally, we import the header file that we’ll see in just a second. This is where our Objective-C code will go.

We also define a function that’s exported to C, HandleURL. The //export HandleURL directive above tells the compiler to make this function globally available in our C code. It’s also worth noting that the arguments it receives are from C so we end up receiving a C string which then has to be converted to a Go string via C.GoString.

The Objective-C pieces come in two parts, the header file and the source file.

// browse.h
#import <Cocoa/Cocoa.h>

extern void HandleURL(char*);

@interface BrowseAppDelegate: NSObject<NSApplicationDelegate>
    - (void)handleGetURLEvent:(NSAppleEventDescriptor *) event withReplyEvent:(NSAppleEventDescriptor *)replyEvent;
@end

void RunApp();

The header file is pretty straightforward. We import Cocoa since this is technically going to be a Cocoa application. We define the exported Go function HandleURL as an external function that accepts a string and returns nothing so we're able to call it from Objective-C.

Next we have to define an NSApplicationDelegate subclass. This is an object that defines lifecycle event methods that a Cocoa application will call. We have to implement some of the callbacks in order to hook up our event listener. We also define a method of our own, handleGetUrlEvent:withReplyEvent that we’ll define and use in just a second to receive URL events.

Finally, we define another function RunApp that will be called via Go to start the Cocoa application.

// browse.m
#include "browse.h"

@implementation BrowseAppDelegate
- (void)applicationWillFinishLaunching:(NSNotification *)aNotification
{
    NSAppleEventManager *appleEventManager = [NSAppleEventManager sharedAppleEventManager];
    [appleEventManager setEventHandler:self
                       andSelector:@selector(handleGetURLEvent:withReplyEvent:)
                       forEventClass:kInternetEventClass andEventID:kAEGetURL];
}

- (void)handleGetURLEvent:(NSAppleEventDescriptor *)event
           withReplyEvent:(NSAppleEventDescriptor *)replyEvent {
    HandleURL((char*)[[[event paramDescriptorForKeyword:keyDirectObject] stringValue] UTF8String]);
}
@end

void RunApp() {
    [NSAutoreleasePool new];
    [NSApplication sharedApplication];
    BrowseAppDelegate *app = [BrowseAppDelegate alloc];
    [NSApp setDelegate:app];
    [NSApp run];
  }

There’s a lot of code here, but it’s largely boilerplate. We define the implementation for our BrowseAppDelegate and implement a NSApplicationDelegate callback, applicationWillFinishLaunching. We use this to get the shared event manager. Now that we have the event manager, we can add an event handler for URL open events that calls the handleGetURLEvent:withReplyEvent method we declared in our interface and define below.

In handleGetURLEvent:withReplyEvent we get the string value from the event, cast it from an NSString to a C string via UTF8String . We then need to cast that new C string to char* to prevent a compiler warning, but it’s not necessary for the code to compile or run.

Lastly, we have our RunApp function. This calls the necessary boilerplate methods for a Cocoa app, allocates memory for a new BrowseAppDelegate, sets it as the application's delegate so our callbacks will be called, and tells the application to run.

We can make sure the code compiles by running go build.

Whew, that’s a lot of code just to get a single URL. Sadly, this still isn’t useful on its own. For the app to work we need to package the executable in a .app. Fortunately, this is mostly just more boilerplate.

Run the following in a terminal to create the .app along with the compiled application.

mkdir -p Browse.app/Contents/MacOS
go build -o Browse.app/Contents/MacOS/Browse

Last but not least, we need to create the plist. This defines metadata about the application including that we can handle http/https url's.

To get the .app to register with macOS as a browser/URL handler, you can paste the following code into a new file, Browse.app/Contents/Info.plist.

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
  <dict>
    <key>CFBundleDevelopmentRegion</key>
    <string>en</string>
    <key>CFBundleDisplayName</key>
    <string>Browse</string>
    <key>CFBundleExecutable</key>
    <string>Browse</string>
    <key>CFBundleIdentifier</key>
    <string>com.blakeorwhatever.browse</string>
    <key>CFBundleInfoDictionaryVersion</key>
    <string>6.0</string>
    <key>CFBundleName</key>
    <string>Browse</string>
    <key>CFBundlePackageType</key>
    <string>APPL</string>
    <key>CFBundleShortVersionString</key>
    <string>1.0</string>
    <key>CFBundleVersion</key>
    <string>1.0</string>
    <key>CFBundleURLTypes</key>
    <array>
      <dict>
        <key>CFBundleURLName</key>
        <string>Web site URL</string>
        <key>CFBundleURLSchemes</key>
        <array>
          <string>http</string>
          <string>https</string>
        </array>
      </dict>
      <dict>
        <key>CFBundleURLName</key>
        <string>FTP site URL</string>
        <key>CFBundleURLSchemes</key>
        <array>
          <string>ftp</string>
        </array>
      </dict>
      <dict>
        <key>CFBundleURLName</key>
        <string>Local file URL</string>
        <key>CFBundleURLSchemes</key>
        <array>
          <string>file</string>
        </array>
      </dict>
    </array>
  </dict>
</plist>

Now you should have a completely working macOS app! With that, we can drag and drop Browse.app into the Applications folder. This should register Browse.app as a browser and we can set it as the default browser in System Preferences -> General -> Default web browser.

Now each time you open a URL, your Go application handles the URL and should open Safari. It doesn’t add any new functionality, but this opens a whole world of possibilities for handling URL’s in macOS. It’s also worth noting that you can define your own URL schemes or handle URL schemes besides just http and https.