DEV Community

Oscar Mendoza
Oscar Mendoza

Posted on

Building a simple system tray app with Go

System tray in go
Hands on: Let's build a system tray application that shows us the current local time, with the possibility to choose some other countries.
We are going to use Getlantern's library systray.

package main

import (
    "fmt"
    "io/ioutil"

    "github.com/getlantern/systray"
)

func main() {
    systray.Run(onReady, onExit)
}

func onReady() {
    systray.SetIcon(getIcon("assets/clock.ico"))
    systray.SetTitle("I'm alive!")
    systray.SetTooltip("Look at me, I'm a tooltip!")
}

func onExit() {
    // Cleaning stuff here.
}

func getIcon(s string) []byte {
    b, err := ioutil.ReadFile(s)
    if err != nil {
        fmt.Print(err)
    }
    return b
}
Enter fullscreen mode Exit fullscreen mode

You can try this code right away.
The only thing that do is to show a simple message with an icon in the system tray; that's all.
Let's split up this a bit. In out main() the only thing that we do is to call a new instance of the library systray with the function Run, this function receives a couple a parameters where we are defining what to do onReady and when the application exits.
SetIcon, SetTitle and SetTooltip are pretty straightforward. The only thing we should have in consideration is that SetIcon takes as parameter the icon we want to display as a []byte. We use the function getIcon to read the .ico file and return the []byte.

Hint: A good place to find free icons is IconArchive.

Now, we don't want to have I'm alive! as title, so we are going to update each second with the local value of the timer; let's update our onReady() function and add a couple more that help us to get the time value:

func onReady() {
    systray.SetIcon(getIcon("assets/clock.ico"))
    for {
        systray.SetTitle(getTime())
        systray.SetTooltip("Look at me, I'm a tooltip!")
        time.Sleep(1 * time.Second)
    }
}

func getTime() string {
    t := time.Now()
    hour, min, sec := t.Clock()
    return ItoaTwoDigits(hour) + ":" + ItoaTwoDigits(min) + ":" + ItoaTwoDigits(sec)
}

// ItoaTwoDigits time.Clock returns one digit on values, so we make sure to convert to two digits
func ItoaTwoDigits(i int) string {
    b := "0" + strconv.Itoa(i)
    return b[len(b)-2:]
}
Enter fullscreen mode Exit fullscreen mode

If we run, we'll see our clock being updated each second; but this is not the best way to implement an autoupdater. Implementing time.Sleep() will let us pause current instance, but we don't want to block our main process. In this case we are going to use a Go routine.

go func() {
    for {
    systray.SetTitle(getTime())
    systray.SetTooltip("Look at me, I'm a tooltip!")
        time.Sleep(1 * time.Second)
    }
}()
Enter fullscreen mode Exit fullscreen mode

Now it's time to add some menu items. We can easily do it with systray.AddMenuItem("Title", "Tooltip"). This function returns us a channel when pressed. So, I'm going to add some items like this.

localTime := systray.AddMenuItem("Local time", "Local time")
hcmcTime := systray.AddMenuItem("Ho Chi Minh time", "Asia/Ho_Chi_Minh")
sydTime := systray.AddMenuItem("Sydney time", "Australia/Sydney")
gdlTime := systray.AddMenuItem("Guadalajara time", "America/Mexico_City")
sfTime := systray.AddMenuItem("San Fransisco time", "America/Los_Angeles")
systray.AddSeparator()
mQuit := systray.AddMenuItem("Quit", "Quits this app")
Enter fullscreen mode Exit fullscreen mode

Also, in the top of the code, let's add a way to track the timezone in use:

var (
    timezone string
)
Enter fullscreen mode Exit fullscreen mode

And replace systray.SetTitle(getTime()) for systray.SetTitle(getClockTime(timezone)).
Now, a way to fetch when a menu item is pressed.

go func() {
    for {
        select {
    case <-localTime.ClickedCh:
            timezone = "Local"
        case <-hcmcTime.ClickedCh:
            timezone = "Asia/Ho_Chi_Minh"
        case <-sydTime.ClickedCh:
            timezone = "Australia/Sydney"
        case <-gdlTime.ClickedCh:
            timezone = "America/Mexico_City"
        case <-sfTime.ClickedCh:
            timezone = "America/Los_Angeles"
        case <-mQuit.ClickedCh:
            systray.Quit()
            return
        }
    }
}()
Enter fullscreen mode Exit fullscreen mode

Aaaand, let's modify our getTime function in order to be a bit more compatible with our timezone selector.

func getClockTime(tz string) string {
    t := time.Now()
    utc, _ := time.LoadLocation(tz)

    hour, min, sec := t.In(utc).Clock()
    return ItoaTwoDigits(hour) + ":" + ItoaTwoDigits(min) + ":" + ItoaTwoDigits(sec)
}
Enter fullscreen mode Exit fullscreen mode

Hint: the timezone identifier is known as IANA time zone identifier; google it like that. You can easly get the identifier from the city you need in time.is.

So, all our code should look like:

package main

import (
    "fmt"
    "io/ioutil"
    "strconv"
    "time"

    "github.com/getlantern/systray"
)

var (
    timezone string
)

func main() {
    systray.Run(onReady, onExit)
}

func onReady() {
    timezone = "Local"

    systray.SetIcon(getIcon("assets/clock.ico"))

    localTime := systray.AddMenuItem("Local time", "Local time")
    hcmcTime := systray.AddMenuItem("Ho Chi Minh time", "Asia/Ho_Chi_Minh")
    sydTime := systray.AddMenuItem("Sydney time", "Australia/Sydney")
    gdlTime := systray.AddMenuItem("Guadalajara time", "America/Mexico_City")
    sfTime := systray.AddMenuItem("San Fransisco time", "America/Los_Angeles")
    systray.AddSeparator()
    mQuit := systray.AddMenuItem("Quit", "Quits this app")

    go func() {
        for {
            systray.SetTitle(getClockTime(timezone))
            systray.SetTooltip(timezone + " timezone")
            time.Sleep(1 * time.Second)
        }
    }()

    go func() {
        for {
            select {
            case <-localTime.ClickedCh:
                timezone = "Local"
            case <-hcmcTime.ClickedCh:
                timezone = "Asia/Ho_Chi_Minh"
            case <-sydTime.ClickedCh:
                timezone = "Australia/Sydney"
            case <-gdlTime.ClickedCh:
                timezone = "America/Mexico_City"
            case <-sfTime.ClickedCh:
                timezone = "America/Los_Angeles"
            case <-mQuit.ClickedCh:
                systray.Quit()
                return
            }
        }
    }()
}

func onExit() {
    // Cleaning stuff here.
}

func getClockTime(tz string) string {
    t := time.Now()
    utc, _ := time.LoadLocation(tz)

    hour, min, sec := t.In(utc).Clock()
    return ItoaTwoDigits(hour) + ":" + ItoaTwoDigits(min) + ":" + ItoaTwoDigits(sec)
}

// ItoaTwoDigits time.Clock returns one digit on values, so we make sure to convert to two digits
func ItoaTwoDigits(i int) string {
    b := "0" + strconv.Itoa(i)
    return b[len(b)-2:]
}

func getIcon(s string) []byte {
    b, err := ioutil.ReadFile(s)
    if err != nil {
        fmt.Print(err)
    }
    return b
}
Enter fullscreen mode Exit fullscreen mode

🎉

Do you know a different/better way to implement any of this features?
Seems that we are hardcoding the timezone selection/channels, can you find a way optimize this part?

You can find all the code ready to run in github.com/Osuka42g/simple-clock-systray. Hack on! 🍻

Top comments (8)

Collapse
 
bgadrian profile image
Adrian B.G.

I'm learning Go now and I haven't done a .exe since ages, now it would be a good time to make some developer tools.
:( Tons of ideas - shortage of time.

Collapse
 
kataras profile image
Gerasimos (Makis) Maropoulos • Edited

Good article Oscar, I was implementing a sys tray icon for Iris using this library some months ago but it's not really cross-platform by-default, it requires from user to install some dependencies, i.e some linux packages (on windows systems it works out-of-the-box), so be careful when using this library, your users will not be able to run your package with just go get!

proof-of-concept: github.com/kataras/iris/tree/witht...

Collapse
 
osuka42 profile image
Oscar Mendoza

Thank you @kataras ! I will run this on different OS and update the requirements :)

Collapse
 
barben360 profile image
Barben360

Hi, I think your program may eventually crash if you are unlucky.

Indeed, you have 2 concurrent processes that try to read/write timezone variable. You should have a lock on this variable.

More generally, your algorithm could be more efficient I think, by calling systray.SetTooltip(timezone + " timezone") only when timezone is changed (directly after your switch-case) and calling systray.SetTitle(getClockTime(timezone)) right at the same moment to avoid having a possible 1 second delay between your click and time being changed.

Collapse
 
gelembjuk profile image
Roman Gelembjuk

Did you ever try to use this systray package together with some GUI golang package?
I tried to use GTK gotk3 to build some windows and run it together with github.com/getlantern/systray/ and i was not success.
This systray package blocks access to xwindows system from GTK package.
Do you have such experience?

Collapse
 
romanesko profile image
Roman Bykovskiy

Had the same issue with webview. Found this fork where the problem is solved, take a look: github.com/ghostiam/systray

Collapse
 
foresthoffman profile image
Forest Hoffman

Huh, that's really cool. I didn't know that library existed. Thanks for sharing Oscar!

Collapse
 
ehsun7b profile image
Ehsun Behravesh

Has anyone tried it on Windows 11? It won't work for me.