Build a Tray/GUI Desktop Application in Go 1.16+

Build a Tray/GUI Desktop Application in Go 1.16+

Learn how to build a cross-platform (windows, mac, linux) desktop tray application that can launch HTML5 windows in Go 1.16+ and two packages.

·

15 min read

I've been using Go extensively for my solo startup's backend REST service, but the time came where I needed a desktop client that could interact with this service while also managing files at the OS level (symlinks, virtual filesystems, etc.) and facilitating peer-to-eer connections for data transfers. While Go is extremely well suited to these purposes, it's not not known for excelling at desktop or GUI.

Will go ever be good for GUI? (/r/golang)

It isn't because the language itself is incapable, rather there simply aren't many packages (yet) available that make building a GUI easy or time effective for a software developer who just wants to build for customers and move fast.

So why not just use something like Electron?

While I could probably make something like that work and reap the benefits of the UI feature provided, my hard requirements (below) were much more suitable to Go. I decided I'd trade some challenges with UI in exchange for ease implementing my key features for the desktop, given my experience.

- builds for Mac (dmg), Windows (exe), and Linux (deb)
- minimal (or no) 3rd party installations required for users
- authentication via 3rd party identity provider URL & callback
- runs as a tray application, launch custom windows for features
- manage files at the OS level; symlinks, mounts, virutal FS, etc.
- manage concurrent peer / remote connections for file sync

I collected actively maintained packages and went about testing them. There were a few that looks promising but didn't quite fit my personal needs or style of development. I really wanted to use fyne but it both doesn't have a tray feature and requires the application to be a single window, so it didn't fit my use case.

Have a look at them for yourselves before you commit to my approach in this tutorial.

Please note that fyne and the systray package used in this tutorial are inoperable together; they both block the main thread.

Alright, enough back story... let's get this tutorial started!

Setup

This tutorial is written for the following dev environment:

  • Go 1.16
  • MacOS Catalina (dev & builds)
  • Windows 10 Pro & Ubuntu 20.04 (testing)
  • amd64 architecture, untested on Apple M1's arm64 chipset yet

You can find a copy of the completed code for this tutorial here:

https://github.com/ctrlshiftmake/example-tray-gui

Installs

Initialize your repository go mod init <your-repo> and install the following packages.

go get github.com/getlantern/systray
go get github.com/zserge/lorca

The lorca project requires Chrome (or chromium compatible browser) to be installed. If not present, the windows launched should prompt you to download and install it.

Build Tools

We're going to use a few tools to setup the build process, so let's get them installed.

First, install brew and node / npm if you don't already have them.

brew.sh

nodejs.org

Then, install the following packages using these commands.

go get github.com/akavel/rsrc

npm install --global create-dmg

brew install graphicsmagick imagemagick

Additionally, we're going to need Docker in order to build for Linux, so install that.

docker.com/get-started

Development

Step 1 - Tray Application

Have a look at the examples in the systray package to get an idea of what features it provides that you might want to use; we'll only be using a minimal set of them.

github.com/getlantern/systray/tree/master/e..

Create a tray icon

Let's first prepare the icon we'll use, systray requires that a png image be converted into a byte slice for both unix (linux/mac) and windows. Thankfully, they've provided a couple scripts to quickly convert them to a compatible format.

github.com/getlantern/systray/blob/master/e.. github.com/getlantern/systray/blob/master/e..

Example icon PNG

Create an icon directory, put your icon.png into that folder and convert it using these scripts. You should end up with both iconwin.go and iconunix.go files.

If you run the windows bash script, you'll also need to first convert your PNG to an ICO, you can use the following website to convert this. You may as well create it now, as it'll be necessary for building.

cloudconvert.com/png-to-ico

Basic tray application

Let's create a tray package, an OnReady and OnQuit function for the tray, set the Icon and add an option to quit the application to the tray.

tray/tray.go

package tray 

func OnReady() {
    systray.SetIcon(icon.Data)

    mQuit := systray.AddMenuItem("Quit", "Quit example tray application")

    sigc := make(chan os.Signal, 1)
    signal.Notify(sigc, syscall.SIGTERM, syscall.SIGINT)

    for {
        select {
        case <-mQuit.ClickedCh:
            systray.Quit()
        case <-sigc:
            systray.Quit()
        }
    }

func OnQuit() {
}

main.go

func main() {
    systray.Run(tray.OnReady, tray.OnQuit)
}

We now have a simple tray application that can be quit from a menu option and will handle a system termination (ex: Ctrl+C in terminal) without issue.

Launch Google.com in a default browser (optional)

You might want to have some easy access menu options for directing a user to a web application or helpdesk external to the desktop application, so let's add a simple menu option that will open Google as an example.

go get github.com/skratchdot/open-golang/open

Then add the new option to our menu with a separating line

tray/tray.go

package tray 

func OnReady() {
    systray.SetIcon(icon.Data)

    mGoogleBrowser := systray.AddMenuItem("Google in Browser", "Opens Google in a normal browser")
systray.AddSeparator()
    mQuit := systray.AddMenuItem("Quit", "Quit example tray application")

    sigc := make(chan os.Signal, 1)
    signal.Notify(sigc, syscall.SIGTERM, syscall.SIGINT)

    for {
        select {
        case <-mGoogleBrowser.ClickedCh:
            err := open.Run("https://www.google.com")
            if err != nil {
                fmt.Println(err)
            }
        case <-mQuit.ClickedCh:
            systray.Quit()
        case <-sigc:
            systray.Quit()
        }
    }

func OnQuit() {
}

Step 2 - HTML5 Views

There are many ways you could use lorca to create HTML5 windows, but in this tutorial we'll create a singleton that will help us:

  • Maintain a list of views we've created and provide functions to open them
  • Manage window state, so that you cannot open the same window twice
  • Assist in graceful shutdown of both open windows and listener services

Singleton pattern is contentious among Go developers because it can make things like testing difficult. It's worth considering alterantives beyond this tutorial.

Serving www and graceful shutdown

We'll start by creating a singleton that will have a Listener on our localhost that serves an embedded filesystem where we can host our HTML files. This singleton will also use a WaitGroup in order to help facilitate a graceful shutdown whenever a quit occurs.

views/www/index.html

<!DOCTYPE html>
<html>
    <head>
        <title>Hello, World!</title>
    </head>
    <body>
        <h1>Hello, World!</h1>
    </body>
</html>

views/views.go

package views

const PORT = 8080
const HOST = "localhost"

var once sync.Once

//go:embed www
var fs embed.FS

type Views struct {
    WaitGroup *sync.WaitGroup
    Shutdown  chan bool
}

var views *Views

func Get() *Views {
    once.Do(func() {
        l := make(map[string]*View)

        views = &Views{
            WaitGroup: &sync.WaitGroup{},
            Shutdown:  make(chan bool),
        }

        views.WaitGroup.Add(1)
        go func(*Views) {
            defer views.WaitGroup.Done()
            ln, err := net.Listen("tcp", fmt.Sprintf("%s:%d", HOST, PORT))
            if err != nil {
                log.Fatal(err)
            }
            defer ln.Close()

            go func() {
                _ = http.Serve(ln, http.FileServer(http.FS(fs)))
            }()
            <-views.Shutdown
        }(views)
    })
    return views
}

When you first call views.Get() function it will begin serving the www folder on loalhost:8080, create a WaitGroup that we can use to block until our goroutine has properly closed the listener and provides a Shutdown channel we can use to initiate this shutdown. Later in the tutorial you'll see we use the WaitGroup to additionally ensure all UI windows launched have been closed, but for now, let's simply make sure when the tray exits we'll have a graceful shutdown of the listener.

tray/tray.go

...
func OnQuit() {
    close(views.Get().Shutdown)
}

main.go

func main() {
    views := views.Get()
    defer views.WaitGroup.Wait()
    systray.Run(tray.OnReady, tray.OnQuit)
}

Hello, World!

Now it's finally time to get a window launching! Let's add the following to views...

views/views.go

...

type View struct {
    url    string
    width  int
    height int
    isOpen bool
}

... 

func Get() *Views {
    once.Do(func() {
        l := make(map[string]*View)

        l["Hello"] = &View{
            url:    fmt.Sprintf("http://%s/www/index.html", fmt.Sprintf("%s:%d", HOST, PORT)),
            width:  600,
            height: 280,
        }

...
    })
    return views
}

func (v *Views) getView(name string) (*View, error) {
    view, ok := v.list[name]
    if !ok {
        return nil, fmt.Errorf("View '%s' not found", name)
    }
    if view.isOpen {
        return nil, fmt.Errorf("View is already open")
    }
    return view, nil
}

And a new function to open our window...

views/view-index.go

func (v *Views) OpenIndex() error {
    view, err := v.getView("Hello")
    if err != nil {
        return err
    }

    v.WaitGroup.Add(1)
    go func(wg *sync.WaitGroup) {
        defer wg.Done()

        ui, err := lorca.New("", "", view.width, view.height)
        if err != nil {
            log.Fatal(err)
        }
        defer ui.Close()

        err = ui.Load(view.url)
        if err != nil {
            log.Fatal(err)
        }

        view.isOpen = true

        select {
        case <-ui.Done():
        case <-v.Shutdown:
        }

        view.isOpen = false

    }(v.WaitGroup)

    return nil
}

And finally, add the following to the OnReady function in our tray package

tray/tray.go

func OnReady() {

...
    mHelloWorld := systray.AddMenuItem("Hello, World!", "Opens a simple HTML Hello, World")
...

    for {
        select {

        case <-mHelloWorld.ClickedCh:
            err := views.Get().OpenIndex()
            if err != nil {
                fmt.Println(err)
            }
        ...
        }
    }
}

Binding Go and Javascript

Having a static HTMl page is great, but you're likely going to want to do something in these windows, so let's use the lorca Bind functions to display our application's version in our HTML page.

config/config.go

package config

var (
    ApplicationVersion string = "development"
)

We'll learn how to embed the actual version when building the application later in this tutorial, but when running it locally it should always read "development". Let's update the index page now...

views/www/index.html

<!DOCTYPE html>
<html>
    <head>
        <title>Hello, World!</title>
    </head>
    <body>
        <h1>Hello, World!</h1>
        <div>The application version is: <span id="version"></span></div>
        <script>
            const v = document.getElementById("version");
            const render = async () => {
                let appVersion = await window.appVersion();
                v.innerHTML = `<b>${appVersion}</b>`;
            };
            render();
        </script>
    </body>
</html>

The Javascript will specifically be looking for a function called appVersion() which we can bind to our Go code by adding the following...

views/view-index.go

type info struct {
    sync.Mutex
}

func (i *info) appVersion() string {
    i.Lock()
    defer i.Unlock()
    return config.ApplicationVersion
}

func (v *Views) OpenIndex() error {
    ...
    go func(wg *sync.WaitGroup) {
        ...
        defer ui.Close()

        i := info{}

        err = ui.Bind("appVersion", i.appVersion)
        if err != nil {
            log.Fatal(err)
        }

        err = ui.Load(view.url)
        ...
    }(v.WaitGroup)
    return nil
}

Now, the next time you launch the "Hello, World!" window you should see development

Step 3 - Cross-platform Builds

We have a system tray application, it can launch an amazingly useful window and now we want to distribute it to our users on Mac, Windows and Linux. Let's set up our build process.

This tutorial does NOT cover code signing, the resulting applications for Mac and Windows will require users to allow them to run. Please consider this a starting point for your build process only.

Setup the build environment

We need to establish a few files in order to better control our environment variables for the build. Create an .env file and add this to your .gitignore so it's not checked into the repostiory. It's not crucial for this tutorial, but it's best practice so you don't accidentally put secret keys into your repo.

.env

VERSION=1.0.0
NAME=ExampleTrayGUI
NAME_LOWER=example-tray-gui

And create a Makefile so we can easily source environment variables when running or building (when running command like make build or make run)

Makefile

ifneq (,$(wildcard ./.env))
    include .env
    export
endif

ROOT=$(shell pwd)

For all of our builds, we're going to use the -ldflags build option in order to insert data into our application at build time. In this tutrial, we'll map the VERSION environment variable to our config package's ApplicationVersion variable. This same technique is extremely useful for getting things like application secrets, keys, etc. into your build without including them in your code.

Let's create a build directory and the following file:

build/flags.sh

#!/bin/sh
PKGCONFIG="github.com/ctrlshiftmake/example-tray-gui/config"

LD_FLAG_MESSAGE="-X '${PKGCONFIG}.ApplicationVersion=${VERSION}'"

LDFLAGS="${LD_FLAG_MESSAGE}"

Replace the github repository name as necessary, but essentially this will map the constant string variable to the VERSION environment variable when this script is called (next sections).

Build for Mac

Let's start with Mac because it will be arguably the easiest, given I'm running on a Mac. First, we're going to need to create a high resolution icsn version of our icon, so let's grab a 512x512 PNG and convert it using an online converter and place icon.icsn in the icons package directory.

512px icon

cloudconvert.com/png-to-ico

Next, create a plist file, which we'll use to embed MacOS metadata into our package.

build/darwin/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>CFBundleExecutable</key>
    <string>ExampleTrayGUI</string>
    <key>CFBundleIconFile</key>
    <string>icon.icns</string>
    <key>CFBundleIdentifier</key>
    <string>com.Example.TrayGUI</string>
    <key>NSHighResolutionCapable</key>
    <string>True</string>
    <key>LSUIElement</key>
    <string>1</string>
    <key>CFBundleDisplayName</key>
    <string>Example TrayGUI</string>
</dict>
</plist>

And now for the actual build instructions, which will do the following for us:

  • Make an output bin directory and application directory
  • Perform the go build process, including using the ldflags we made previously
  • Copy the plist and icon.icns into the application directory
  • Use the create-dmg application to make an installer, with our icon
  • Cleanup our bin directory and rename the dmg to something appropriate

build/build-darwin.sh

#!/bin/sh
source flags.sh

APP="${NAME}.app"
BUILD_DIR="../bin/${VERSION}/"

rm -rf ${BUILD_DIR}/"$APP"/
mkdir -p ${BUILD_DIR}/"$APP"/Contents/{MacOS,Resources}

GOOS=darwin GOARCH=amd64 go build -o ${BUILD_DIR}/"$APP"/Contents/MacOS/${NAME} -ldflags="${LDFLAGS}" ../main.go

cp ./darwin/Info.plist ${BUILD_DIR}/"${APP}"/Contents/Info.plist
cp ../icon/icon.icns ${BUILD_DIR}/"${APP}"/Contents/Resources/icon.icns

cd ${BUILD_DIR}

rm *.dmg
create-dmg --dmg-title="${NAME}" --overwrite "${APP}"
mv *.dmg ${NAME}_${VERSION}_mac_amd64.dmg
rm -rf "${APP}"

Finally, let's add to our Makefile to include an appropriate command to build for darwin

build-darwin:
    source .env && cd build; sh build-darwin.sh

Now if you go to the root of your repository and type in make build-darwin it should output a DMG into a bin directory. Be sure to include /bin in your .gitignore so you don't commit the results!

Build for Windows

Suprisingly, the next easiest build is for Windows, so let's get that going! If you haven't already, convert the original PNG image to an ICO and place it into your icon package as iconwin.ico.

Much like the MacOS build, the Windows build will require some metadata. We're going to place this into a manifest file, which will be converted to a .syso file that the Go build process will recognize when targetting the windows OS.

build/windows/ExampleTrayGUI.exe.manifest

<?xml version="1.0" encoding="UTF-8" standalone="yes" ?>
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">
  <assemblyIdentity
    type="win32"
    name="Github.com.ctrlshiftmake.ExampleTrayGUI"
    version="1.0.0.0"
    processorArchitecture="*"
  />
 <description>ExampleTrayGUI</description>
 <trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
   <security>
     <requestedPrivileges>
       <requestedExecutionLevel level="asInvoker" uiAccess="false" />
       </requestedPrivileges>
   </security>
 </trustInfo>
</assembly>

And now let's create the build instructions, which will do the following for us:

  • Make an output bin directory to place the executable
  • Build the .syso file needed form the manifest in our repository root
  • Perform the Go build process, including all ldflags previously created
  • Use Mac's ditto to package the executable into a ZIP file
  • Cleanup all intermediary files created
#!/bin/sh
source flags.sh

APP="${NAME}.exe"
LDFLAGS="${LDFLAGS} -H windowsgui"
BUILD_DIR="../bin/${VERSION}/"

rm -rf ${BUILD_DIR}/"${APP}"

rsrc -arch amd64 -ico ../icon/iconwin.ico -manifest "./windows/ExampleTrayGUI.exe.manifest" -o ../ExampleTrayGUI.syso

GOOS=windows GOARCH=amd64 go build -o ${BUILD_DIR}/"${APP}" -ldflags="${LDFLAGS}" ../main.go

ditto -c -k --sequesterRsrc ${BUILD_DIR}/"${APP}" ${BUILD_DIR}/${NAME}_${VERSION}_windows_amd64.zip

rm -rf ${BUILD_DIR}/"${APP}"
rm -rf ../ExampleTrayGUI.syso

And finally, let's add a command to our Makefile

build-windows:
    source .env && cd build; sh build-windows.sh

Build for Linux (ubuntu)

On the home stretch, only one OS left to build for! Now, this one is a bit more involved because unfortunately the systray package won't build from Mac on Linux. There are likely similar limitations for other OS's but thankfully Docker can help us here.

This build process is slightly different, we're going to use a Dockerfile to build an Ubuntu image and copy the entire repository directory contents, including your .env and .sh files. Then we'll spawn an instance and send specific docker commands to initiate the build process and copy the results back to our bin directory.

Ubuntu is required as systray has specific Linux library requirements to build

First, let's create our Dockefile for the image build instructions

build/linux/Dockerfile

FROM ubuntu

ENV GO111MODULE=on \
    CGO_ENABLED=1 \
    GOOS=linux \
    GOARCH=amd64

ARG DEBIAN_FRONTEND=noninteractive

RUN apt-get update && apt-get install -y sudo wget

RUN useradd -m docker && echo "docker:docker" | chpasswd && adduser docker sudo

RUN wget -c https://dl.google.com/go/go1.16.4.linux-amd64.tar.gz -O - | sudo tar -xz -C /usr/local
ENV PATH="/usr/local/go/bin:${PATH}"

RUN apt-get install -y wget gcc libgtk-3-dev libappindicator3-dev make

WORKDIR /example-tray-gui
COPY ../go.mod .
COPY ../go.sum .
RUN go mod download

COPY ../ .

Then, let's make our Linux .sh build instructions. It's important to note that in this case we included the metadata necessary for the application and a desktop icon in the script itself, which will both build the application and produce a .deb file we can use to install.

#!/bin/bash
source flags.sh

APP=${NAME_LOWER}
APPDIR=../bin/${VERSION}/${APP}

mkdir -p $APPDIR/usr/bin
mkdir -p $APPDIR/usr/share/applications
mkdir -p $APPDIR/usr/share/icons/hicolor/1024x1024/apps
mkdir -p $APPDIR/usr/share/icons/hicolor/256x256/apps
mkdir -p $APPDIR/DEBIAN

CC="gcc" CGO_ENABLED=1 GOOS=linux GOARCH=amd64 go build -o $APPDIR/usr/bin/$APP -ldflags="${LDFLAGS}" ../main.go

cp ../icon/icon.png $APPDIR/usr/share/icons/hicolor/1024x1024/apps/${APP}.png
cp ../icon/icon.png $APPDIR/usr/share/icons/hicolor/256x256/apps/${APP}.png

cat > $APPDIR/usr/share/applications/${APP}.desktop << EOF
[Desktop Entry]
Version=${VERSION}
Type=Application
Name=$APP
Exec=$APP
Icon=$APP
Terminal=false
StartupWMClass=ExampleTrayGUI
EOF

cat > $APPDIR/DEBIAN/control << EOF
Package: ${APP}
Version: ${VERSION}
Section: base
Priority: optional
Architecture: amd64
Maintainer: Grant Moore <grantmoore3d@gmail.com>
Description: Example Tray GUI Application
EOF

dpkg-deb --build $APPDIR
mv ${APPDIR}.deb ../bin/${VERSION}/${NAME}_${VERSION}_linux_amd64.deb
rm -rf ${APPDIR}

And finally, add the following commands to our Makefile, which will both prepare a Docker instance, build for Linux and cleanup the image.

build-linux: docker-build-linux docker-clean

docker-build-linux:
    docker build -t example-tray-gui -f build/linux/Dockerfile .
    docker run -v $(ROOT)/bin:/example-tray-gui/bin -t example-tray-gui bash -c 'export VERSION=${VERSION} && export NAME=${NAME} && export NAME_LOWER=${NAME_LOWER} && cd build; bash build-linux.sh'

docker-clean:
    docker rm $(shell docker ps --all -q)
    docker rmi $(shell docker images | grep example-tray-gui | tr -s ' ' | cut -d ' ' -f 3)

There you have it, with any luck, if you type in the make build-linux command you should get a resulting deb file you can install on most Linux distros.

First time you run this command, Docker may take a while to prepare the image. Subsequent runs will be much faster as it will cache a lot of the initial image build process on your machine.

Build Everything

Finally, if you'd like, you can add a simple build line to your Makefile to make it easy to build for all platforms. If you run any of the executables, you should notice that the "Hello World" page displays 1.0.0 as the version instead of development

ifneq (,$(wildcard ./.env))
    include .env
    export
endif

ROOT=$(shell pwd)

run:
    go run main.go

build: build-darwin build-windows build-linux

build-darwin:
    source .env && cd build; sh build-darwin.sh

build-windows:
    source .env && cd build; sh build-windows.sh

build-linux: docker-build-linux docker-clean

docker-build-linux:
    docker build -t example-tray-gui -f build/linux/Dockerfile .
    docker run -v $(ROOT)/bin:/example-tray-gui/bin -t example-tray-gui bash -c 'export VERSION=${VERSION} && export NAME=${NAME} && export NAME_LOWER=${NAME_LOWER} && cd build; bash build-linux.sh'

docker-clean:
    docker rm $(shell docker ps --all -q)
    docker rmi $(shell docker images | grep example-tray-gui | tr -s ' ' | cut -d ' ' -f 3)

Final Thoughts

There are a lot of nauanced details not described in this tutorial for the sake of breivity, but I hope that this serves as a good starting point for your own application. Always check the finished repository if you're unsure as it's my source of truth and by no means treat it as perfect code.

https://github.com/ctrlshiftmake/example-tray-gui

I hope this has been helpful, enjoy building your tray/GUI desktop application in Go!

Did you find this article valuable?

Support Owen Moore by becoming a sponsor. Any amount is appreciated!