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.
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 thesystray
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'sarm64
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.
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.
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..
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.
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.
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
andicon.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!