Wails in Practice #4 Tray Residence and a Global Shortcut — Capture Fast from the Background
So far the notes app ends its process when the window closes. A note tool’s real value is popping up the instant you need it, so this post turns the app into a background app that resides in the tray and captures a new note from anywhere via a global shortcut. Here we deal honestly with one constraint of Wails v2 before moving on.
This is the fourth of ten posts (two parts).
- #1 Designing a real project · #2 SQLite local database · #3 Full-text search and data flow
- #4 Tray residence and a global shortcut — capture fast from the background ← this post
- #5 Signing and notarization — how a shipped app earns trust
- #6 CI/CD automated releases — three-platform delivery with GitHub Actions
The constraint first: v2 has no built-in tray #
Let us start honestly. Wails v2 has no built-in system tray API. This feature arrives officially in v3, as noted in #1. To use a tray in v2 you attach an external Go library, and the most widely used is getlantern/systray. Window management is handled by the Wails runtime, tray icon and menu by systray.
Knowing this constraint matters because old tutorials or an AI will sometimes hand you code that “adds a tray to Wails” by calling an API that does not exist in v2. External library in v2, built-in API in v3 — take that distinction as the reference.
Change window close from quit to hide #
The first condition for a background app is that the process stays alive after the window closes. Prevent the quit in Wails’s OnBeforeClose hook and hide the window.
// wired to options.App in main.go: OnBeforeClose: app.beforeClose
func (a *App) beforeClose(ctx context.Context) (prevent bool) {
runtime.WindowHide(ctx) // hide the window and
return true // block the actual quit
}Returning true cancels the quit in Wails. Now the window’s close button tucks the app into the tray instead of shutting it down. The actual quit is provided separately via the tray menu’s “Quit” item with runtime.Quit(ctx). Without this separation the user loses any way to fully close the app, so always keep both.
The tray: icon and menu #
systray runs its own event loop, so start it in a separate goroutine after Wails has started. Put at least “New note”, “Show window”, and “Quit” in the tray menu.
func (a *App) startTray() {
systray.Run(func() {
systray.SetIcon(trayIcon) // icon bytes embedded via //go:embed
systray.SetTooltip("Notes")
mNew := systray.AddMenuItem("New note", "Open the quick-capture window")
mShow := systray.AddMenuItem("Show window", "Show the main window")
systray.AddSeparator()
mQuit := systray.AddMenuItem("Quit", "Quit the app")
for {
select {
case <-mNew.ClickedCh:
a.openQuickCapture()
case <-mShow.ClickedCh:
runtime.WindowShow(a.ctx)
case <-mQuit.ClickedCh:
runtime.Quit(a.ctx)
return
}
}
}, nil)
}A menu item’s click arrives on that item’s ClickedCh channel, so receive them with select and run the corresponding action. Showing the window again is runtime.WindowShow, covered in intro #4.
The global shortcut: capture from outside the app #
A note tool’s core experience is capturing instantly with one shortcut even while using another app. It must work when the app does not hold focus, so you need a global shortcut — also absent from the v2 runtime, so use an external library (golang.design/x/hotkey and the like).
func (a *App) registerHotkey() {
hk := hotkey.New([]hotkey.Modifier{hotkey.ModCtrl, hotkey.ModShift}, hotkey.KeyN)
if err := hk.Register(); err != nil {
runtime.LogError(a.ctx, "failed to register shortcut: "+err.Error())
return
}
go func() {
for range hk.Keydown() {
a.openQuickCapture() // Ctrl+Shift+N opens the capture window
}
}()
}openQuickCapture is a function that brings a small capture window to the front. Show the window and give it focus, and the user leaves a note just by entering a title and saving. Saving calls the CreateNote built in #3 directly, and the notes:changed event it emits refreshes the main window’s list automatically too. This flow — every window going current even when added from outside the app — is the payoff of putting the truth of the data in Go in #3.
Global shortcuts carry two cautions. One is that they can collide with a combination another app already uses (let the user change it where possible), and the other is that macOS may require an accessibility (input monitoring) permission. Prepare a permission prompt on first launch.
How it changes in v3 #
Wails v3 builds the system tray into the runtime and officially supports multiple windows. So in v3 you make the tray icon and menu directly with the Wails API, no external library like systray, and can pop the capture window cleanly as a separate window. This post’s concepts (close-to-hide, tray menu composition, capture via global shortcut, refresh via events) stay the same in v3; what changes is that the tray is built with the built-in API instead of an external library.
Summary #
- Wails v2 has no built-in system tray or global shortcut. Attach them with external libraries like
getlantern/systrayandgolang.design/x/hotkey. v3 builds the tray in. - The first condition for a background app is returning
truefromOnBeforeCloseto turn window close into hide rather than quit. Provide the actual quit separately from the tray menu. - Receive tray menu item clicks on the
ClickedChchannel and handle them withselect. Keep at least “New note, Show window, Quit”. - A global shortcut works even when the app lacks focus. Saving calls the existing
CreateNote, and thenotes:changedevent refreshes every window. - Next post signs and notarizes this app so it runs without warnings on other people’s machines.