Build a Desktop App with Wails #4: System Integration — Dialogs, Menus, and Window Control
Everything up to “Build a Desktop App with Wails #3: Connecting Go and the Frontend — Method Bindings and Events” happened inside the window. We have the flow where a button click runs a Go method and the result shows up on screen, but as it stands the app is no different from a web app running in a browser tab. What makes a desktop app feel like a desktop app is integration with the OS. Native file dialogs, the menu bar at the top of the screen, and control over window size and position are the topics of this post.
It runs in eight parts (six main plus two deep-dives).
- #1 What Wails is — lightweight desktop apps in Go
- #2 Project structure and the dev loop — wails dev and bindings
- #3 Connecting Go and the frontend — method bindings and events
- #4 System integration — dialogs, menus, and window control ← this post
- #5 Real-world features — persisting settings and error handling
- #6 Build and distribution — packaging per platform
Every feature in this post lives in the github.com/wailsapp/wails/v2/pkg/runtime package. There is one shared rule: every runtime function takes a context as its first argument. If you stored the ctx received in the startup hook on your struct back in #3, you are already set.
Native dialogs — opening and saving files #
On the web you would use <input type="file"> to open a file, but a Wails app can bring up the OS native dialog directly. Users get the exact look they are used to, and you receive a path string that Go can work with right away.
func (a *App) OpenTodoFile() (string, error) {
path, err := runtime.OpenFileDialog(a.ctx, runtime.OpenDialogOptions{
Title: "Open todo file",
Filters: []runtime.FileFilter{
{DisplayName: "JSON files (*.json)", Pattern: "*.json"},
},
})
if err != nil || path == "" {
return "", err // path is an empty string when the user cancels
}
data, err := os.ReadFile(path)
if err != nil {
return "", err
}
return string(data), nil
}Saving is handled by SaveFileDialog. The only difference is that you can suggest a default file name.
path, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{
Title: "Export",
DefaultFilename: "todos.json",
})When you need to ask or inform the user, use MessageDialog. The icon and button set change with the dialog type.
choice, err := runtime.MessageDialog(a.ctx, runtime.MessageDialogOptions{
Type: runtime.QuestionDialog,
Title: "Confirm deletion",
Message: "Delete all completed items?",
})
// on macOS, choice comes back as the string "Yes" or "No"OpenFileDialog returns an empty string and a nil error on cancel, so if you skip the empty-string check, a cancel turns into an attempt to open an empty path. Make a habit of treating cancel as a normal flow.Application menus — the menu bar and shortcuts #
The menu bar is built with the menu package and attached through the app options. Each menu item can carry a keyboard shortcut and a click callback.
import (
"github.com/wailsapp/wails/v2/pkg/menu"
"github.com/wailsapp/wails/v2/pkg/menu/keys"
)
appMenu := menu.NewMenu()
fileMenu := appMenu.AddSubmenu("File")
fileMenu.AddText("Open", keys.CmdOrCtrl("o"), func(_ *menu.CallbackData) {
runtime.EventsEmit(app.ctx, "menu:open")
})
fileMenu.AddSeparator()
fileMenu.AddText("Quit", keys.CmdOrCtrl("q"), func(_ *menu.CallbackData) {
runtime.Quit(app.ctx)
})
err := wails.Run(&options.App{
Title: "todo",
Menu: appMenu,
// ...
})keys.CmdOrCtrl maps automatically to Cmd on macOS and to Ctrl on Windows and Linux, so you never write the platform branch yourself. When a menu callback should trigger something in the frontend, the clean arrangement is the one above: emit an event as covered in #3 and handle it in the frontend with EventsOn.
menu.AppMenu() and menu.EditMenu() with appMenu.Append and the standard items are filled in for you.Window control — size, position, and state #
Window operations also go through runtime functions. The ones you will reach for most often are these.
| Function | Effect |
|---|---|
WindowSetTitle(ctx, t) | Change the title bar text |
WindowSetSize(ctx, w, h) | Resize the window |
WindowSetMinSize / WindowSetMaxSize | Constrain the size |
WindowCenter(ctx) | Center on screen |
WindowFullscreen / WindowUnfullscreen | Toggle full screen |
WindowGetSize / WindowGetPosition | Read the current size and position |
The initial and minimum sizes are more reliably set through Width, Height, MinWidth, and MinHeight on options.App; the runtime functions are for changing things dynamically while running. While a document is being edited, for example, you might use WindowSetTitle to add a modified marker to the title.
The pattern of saving the last window size and position on close and restoring them on the next launch is also built from these functions. Read them with WindowGetSize and WindowGetPosition, store them in your settings file, and put them back in startup with WindowSetSize and WindowSetPosition. The storing itself is the topic of the next post.
Clipboard #
Clipboard access takes exactly two functions.
func (a *App) CopyResult(text string) error {
return runtime.ClipboardSetText(a.ctx, text)
}
func (a *App) PasteText() (string, error) {
return runtime.ClipboardGetText(a.ctx)
}Unlike the browser clipboard API, which demands permission prompts and a secure context, a desktop app talks to the OS directly. All that remains on the frontend is UI work such as a “Copied” confirmation.
Opening the default browser, and the security boundary #
When a user clicks an external link inside the app, having the page open inside the WebView is rarely what you want. Hand it off to the system default browser.
runtime.BrowserOpenURL(a.ctx, "https://wails.io")This is a good point to lay out the security structure of a Wails app once. The frontend runs inside a WebView, so it cannot read arbitrary local files or launch processes on its own. Every action that touches OS resources goes through a Go method. You decide what to expose through bindings, so when you create a wide entry point — say a method that takes a file path and opens it as given — put validation next to it.
Wrapping up #
Three things matter most from this post.
- OS integration features live in the
runtimepackage, and every function takes a context as its first argument. - Dialogs treat cancel (an empty string) as a normal flow, and menus connect to the frontend by emitting events.
- All local resource access goes through Go methods, so the design of your bindings is the security boundary of the app.
In the next post, “Build a Desktop App with Wails #5: Real-World Features — Persisting Settings and Error Handling”, we make the todo data survive an app restart and walk through the flow where a Go error travels all the way to a notification in the frontend.