Web views
The webview
package puts a web engine inside your layout. You can load a URL or HTML,
run JavaScript in the page, and pass JSON messages both ways through a
bridge.
The package is optional. The web engine is linked only into apps that import it.
Which engine runs where
| Platform | Engine | Shipped by |
|---|---|---|
| macOS | WKWebView | the OS |
| iOS | WKWebView | the OS |
| Android | android.webkit.WebView | the OS |
| Windows | WebView2 | a separate Microsoft runtime (see below) |
| Web (WebAssembly) | an <iframe> over the canvas | the browser |
| Linux | CEF (Chromium Embedded Framework) | your app |
Everywhere except Linux the engine belongs to the operating system, so its security updates come with the OS. Linux has no system engine an app can rely on, so a Linux app bundles Chromium itself. Shipping on Linux explains what that means for you.
The constant
webview.Supported
is false on platforms without an engine: the BSDs, and Linux builds with
CGO_ENABLED=0. There the code still compiles and runs. The view takes up its
space and draws nothing.
Show a page
host, view := webview.NewHost("data:text/html," + url.PathEscape(page))
host.SetMinSize(geo.NewSize(400, 300))NewHost
returns two values. The
Host is the
widget you put in your layout. The
WebView
is what you drive. The engine is created when the host is first laid out in a
window, and the URL loads then.
A web page has no natural size, so the host’s minimum size is zero. Give it a
layout that stretches it, such as the center of a container.NewBorder, or
set a floor with SetMinSize.
To change pages later, call LoadURL, Reload, StopLoading, GoBack or
GoForward. LoadHTML(html, baseURL) shows HTML you pass in, but only once
the view is attached; before that it does nothing.
Overlays and the two hosting models
On macOS, iOS, Android, Windows and the web, the page is a separate OS view
placed over Bellui’s window. Bellui cannot draw on top of it. Pop-ups, menus,
dialogs and tooltips that overlap the page are hidden behind it, and
screenshots taken with Canvas.Capture show a blank area there. Lay out your
UI so nothing needs to cover the page. See Native views
for the full list of limits.
On Linux, Chromium renders off-screen and Bellui draws the result like any
other widget. Overlays appear above the page, Capture includes it, and
clipping and opacity apply. The page also takes part in Tab navigation. While
the page has focus, Tab moves within the page, and Ctrl+Tab or Ctrl+Shift+Tab
moves back to Bellui’s widgets.
Talk to the page
The bridge carries JSON between Go and the page’s JavaScript. It is off until you enable it, and it only accepts messages from origins you trust:
// A data: URL has no origin of its own, so trust exactly that.
view.EnableBridge(webview.TrustOrigins(webview.OriginOpaque))
view.Handle("greet", func(m webview.Message) (any, error) {
var arg struct{ Name string }
if err := m.Decode(&arg); err != nil {
return nil, err
}
if arg.Name == "" {
return nil, errors.New("name is empty")
}
return "Hello, " + arg.Name + "!", nil
})Call EnableBridge before the view is attached, which in practice means
before the window is shown. Later calls are ignored and logged. The policy is
checked for every incoming message, including those from frames inside the
page:
TrustOrigins(...)accepts exact origins, such as"https://example.com". UseOriginOpaquefordata:URLs andabout:blank, and"file://"for files loaded from disk.TrustAllOrigins()accepts anything. Use it only when the view can never show content you did not write.- A nil policy accepts nothing.
The page gets a window.bellui object:
| In the page | In Go |
|---|---|
await window.bellui.call(name, arg) | answered by the function registered with view.Handle(name, ...) |
window.bellui.post(value) | delivered to view.OnMessage |
window.bellui.handle(name, fn) | called by view.Call(name, arg, onResult) |
window.bellui.onMessage = fn | receives view.Post(value) |
Here is the page side of the example:
const page = `<!doctype html>
<html><body style="font-family: sans-serif">
<input id="name" value="Ada"> <button id="hi">Greet</button>
<p id="out"></p>
<script>
document.getElementById("hi").onclick = async function () {
var name = document.getElementById("name").value;
document.getElementById("out").textContent =
await window.bellui.call("greet", {name: name});
};
window.bellui.handle("title", function () { return document.title || "untitled"; });
</script>
</body></html>`A Go handler’s return value resolves the page’s promise. An error rejects it with the error’s message. A call to a name with no handler is rejected too, so the page never waits forever.
Going the other way, view.Call runs a function the page registered:
ask := widget.NewButton("Ask the page for its title", func() {
view.Call("title", nil, func(result json.RawMessage, err error) {
if err != nil {
log.Println("call failed:", err)
return
}
status.SetText("Page title: " + string(result))
})
})Its callback runs exactly once: with the result, or with an error such as
ErrNotAttached or ErrBridgeDisabled.
Run JavaScript
| Method | What it does |
|---|---|
Eval(js) | runs a script and ignores the result |
EvalResult(js, onResult) | runs a script and reports the value of its last expression as JSON |
EvalFunc(body, args) | runs body as a function whose parameters are the keys of args |
EvalFuncResult(body, args, onResult) | the same, reporting the return value |
Eval takes source code. If you build it by joining strings that came from
outside your app, a quote in that text becomes code. EvalFunc passes values
as JSON data instead, so prefer it for anything that is not a constant:
setTitle := widget.NewButton("Set page title", func() {
// The entry's text arrives in the page as data, never as code.
view.EvalFunc("document.title = title;", webview.Args{"title": title.Text})
})Control navigation
// Keep the view on local content: refuse links to anywhere else.
view.OnNavigate = func(r webview.NavigationRequest) bool {
return strings.HasPrefix(r.URL, "data:")
}
view.OnLoadChanged = func(e webview.LoadEvent) {
status.SetText("Page: " + e.State.String())
}OnNavigate is asked before each navigation and blocks it by returning false.
The NavigationRequest
tells you the URL, what started it (a link, a form, history, a reload), whether
it is the main frame, and whether the page asked for a new window. There is no
second window, so an allowed new-window request opens in the same view.
OnLoadChanged reports each change of the main frame’s state: loading,
complete or failed. A main-frame navigation you blocked, or one you stopped
with StopLoading, is reported as failed with ErrNavigationCancelled.
The bridge policy decides who may talk to your app. OnNavigate decides what
may be shown. A view that can reach the open web needs both.
Threading
Call every method on the UI thread. Every callback, from messages to load
events, also runs on the UI thread. A bridge handler holds up the engine while
it runs, so hand slow work to a goroutine and send the answer later with
Post.
Platform differences
- Web: the bridge,
EvalandCallreach same-origin content only.OnNavigatesees only navigations the app starts, andCanGoBackis always false. - Web and Android: the bridge is added when the page loads, so the page’s
first lines may run before
window.belluiexists. - Android and Windows: a script that throws reports a null result, not an
error. For the same reason, a
view.Callto a page that has no bridge waits until the view is destroyed instead of failing at once. - Windows and Linux:
LoadHTMLignores its base URL. Relative links in the HTML do not resolve, and its origin is alwaysOriginOpaque. - Windows: every navigation the user starts, including a form submission, is reported as a link click.
Shipping on Windows
WebView2 needs the Microsoft Edge WebView2 Runtime. Windows 11 and Microsoft
Edge include it, but Windows Server and LTSC images may not. Without it the
view stays blank and attaching fails with
ErrRuntimeMissing.
If your app must work everywhere, ship Microsoft’s Evergreen Bootstrapper with
it.
Shipping on Linux
On Linux your app carries Chromium. bellui package places the pinned CEF
files next to your binary, and the app finds them there at run time. The
BELLUI_CEF_DIR environment variable points it somewhere else. A binary
started without the files leaves the view blank and logs ErrRuntimeMissing.
Browsing data (cookies, local storage and the HTTP cache) is kept between runs,
in a webview-cef folder in the app’s storage, like the system engines on
other platforms. Two functions change that. Both must be called before the
first web view is attached, and both do nothing on other platforms:
SetCEFProfileDir(dir)moves the data todir.SetCEFProfileInMemory()keeps it in memory only, like a private browsing window.
Chromium locks its data folder. If a second copy of your app starts while the first is running, the second one’s web views stay blank. Give each copy its own folder, keep the data in memory, or allow only one running copy.