Web pages
The frontend
How pages load during development and in builds, the mygo-runtime package, custom protocols, custom title bars and dropped files.
Windows that show web pages show the pages of the app's frontend: HTML, CSS and JavaScript built with any tools. New projects use TypeScript and Vite, but MyGo only needs a URL to load, during development, and a directory of built files, for builds. Windows of native UI need no frontend, and an app that only has those has none.
How pages load
Windows load pages of the frontend with URLs without a scheme, such as /
or /settings?tab=general:
mygo.NewWindow(mygo.WindowOptions{URL: "/"})
win.Page().LoadURL("/settings")They resolve against:
- during
mygo dev, the dev server atdevUrlin mygo.config.ts, whichdevCommandstarts, e.g.http://localhost:5173/, so the dev server's hot reload works in the app; - in builds,
mygo://localhost/, which serves the files offrontendDistthatmygo buildembeds into the executable after runningbuildCommand.
export default defineConfig({
devUrl: "http://localhost:5173",
devCommand: "bun run dev:web",
buildCommand: "bun run build:web",
frontendDist: "dist",
});Without devUrl, mygo dev serves frontendDist from disk instead. Paths
without a file extension that match no file serve index.html, so
client-side routers work.
The frontend is a secure context, and its origin is mygo://localhost on
macOS and Linux and http://mygo.localhost on Windows, where WebView2
serves custom schemes under http://<scheme>.localhost. Storage such as
localStorage and IndexedDB belongs to an origin, so data a page stores
under mygo dev (the dev server's origin) is not what the built app sees.
Keep data you care about in Go, for example in a file in
App.Path(mygo.PathUserData), and hand it to pages through bound methods.
Windows can also load other content into their page:
win.Page().LoadURL("https://example.com"), a web page;win.Page().LoadHTML(html, baseURL), an HTML string, which reloads load again;win.Page().LoadFile("docs/index.html"), a local file, resolved against the working directory, then the executable's directory (and the Resources directory of a macOS app).
Pages that are not the app's own cannot call Go methods: see who may call.
Without the CLI
mygo build embeds the frontend by calling mygo.SetFrontend, and a
program built with go build can do the same with embed:
//go:embed all:dist
var dist embed.FS
func main() {
web, _ := fs.Sub(dist, "dist")
mygo.SetFrontend(web)
// ...
}Custom protocols
mygo.Protocol.Handle serves a URL scheme from an http.Handler, for
content your app produces or reads at run time: pages, images, files of
the user. Handlers run on their own goroutines and stream their responses:
writes wait while the webview is behind, so a large file is never held in
memory whole. Pages load such URLs like web URLs, with fetch, <img>,
ES modules and relative URLs:
// Thumbnails generated into the cache directory.
cache, _ := mygo.App.Path(mygo.PathCache)
mygo.Protocol.Handle("thumbs", mygo.FileServer(os.DirFS(cache)))
// Anything an http.Handler does.
mygo.Protocol.HandleFunc("api", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"path": r.URL.Path})
})<img src="thumbs://localhost/photo-42.png" />Register schemes before creating the windows that use them.
mygo.FileServer serves an fs.FS with content types and range requests,
falling back to index.html like the frontend. Handling the mygo scheme
replaces the frontend with your handler. Pages of custom schemes are the
app's own: they may call Go methods, see trust.
On Windows, WebView2 takes a response once its handler returned, or
once it wrote 1 MB: the rest then streams, the handler's writes waiting
while WebView2 is behind. WebView2 reads the responses of custom schemes
one at a time, and seems to give the page none of a response before it
read all of it. A handler that keeps a response open, as server-sent
events or long polling do, reaches the page only once it returns, and
past its first megabyte holds back the app's other responses meanwhile:
send such updates with events instead. For the same
reason a <video> or <audio> that asks for the rest of a file
(Range: bytes=X-) gets at most 4 MB: its handler sees the range
narrowed to bytes=X-(X+4 MB-1), answers with that part (206), as
mygo.FileServer and http.ServeFile do, and the element asks for what
follows as it plays. A request's context is canceled once WebView2 lets
go of its response, so that the handler stops writing.
Audio and video are the exception: WebKitGTK's media player does not play
from a custom scheme, so on Linux a <video> or <audio> of one fails with
MEDIA_ERR_SRC_NOT_SUPPORTED (error.code 4), whatever the handler
answers. Serve media from a loopback HTTP server instead, the same on every
platform, and give the page its URL:
ln, err := net.Listen("tcp", "127.0.0.1:0") // a free port
if err != nil {
log.Fatal(err)
}
go http.Serve(ln, mygo.FileServer(os.DirFS(videos)))
mediaURL := "http://" + ln.Addr().String() + "/" // <video src="{mediaURL}clip.mp4">Every program of the machine can reach such a server: serve it only what they may read, or put a secret of the app's in its paths.
Each scheme is its own origin, so a page fetching another scheme makes a
cross-origin request: the frontend reading api://localhost/, or, during
mygo dev, the dev server reading thumbs://localhost/. Responses to the
app's own pages, those that may call Go methods, carry the CORS headers
that let them read the response, and MyGo answers their preflights.
Other sites get none, unless the window trusts them
(PageOptions.TrustedOrigins), and handlers may set their own.
On Windows, fetch a scheme at http://<scheme>.localhost/, where
WebView2 serves it, and another host at http://<host>.<scheme>.localhost/
(myapp://abc/ is http://abc.myapp.localhost/). Such hosts are DNS
names (letters, digits, hyphens and dots) and are reported in lower case;
URLs with another host or a port aren't served there.
Trust
A window trusts the app's own pages: its frontend, pages of custom schemes,
file: and about: pages (LoadHTML without a base URL shows
about:blank), origins in PageOptions.TrustedOrigins, and in development
builds pages served from localhost. Trust follows the page's URL, in a
window or a web view, and changes as it navigates. A
trusted page may:
- call every method bound with
mygo.Bind, and the plugins the app uses; - call
window.mygo.windowto set the title of its window, minimize, maximize, full-screen or close it; - get the camera, the microphone, the location and notifications without a permission handler;
- read the responses of every custom scheme, which carry CORS headers for it;
- hear the paths of the files dropped on it, and of its window's document.
So never serve third-party or user-supplied HTML from a custom scheme, nor
load it with LoadHTML without a base URL, in a page that trusts it: a
plugin's UI or a preview of a user's HTML would get all of the above.
Show it in an untrusted page instead, or from an origin the window does
not trust, such as an https site or a data: URL: it calls no Go
method, and gets no permission unless a
permission handler grants it.
Untrusted pages
PageOptions.Untrusted makes a page someone else's whatever its URL, for
a window or a web view showing a plugin's UI from a
custom scheme. It trusts no origin, TrustedOrigins included, and goes
further than a page of an untrusted origin: what MyGo lets every page do
by default, it lets an untrusted page do only when a handler of the
app's decides.
- Its calls, to bound methods and to
window.mygo.window, are rejected, and its drag regions do nothing. MyGo drops any message it posts above 4 KiB before decoding it, and answers the rest on the main thread, without a goroutine per message. Broadcastdoes not reach it, norEmitto the window its web view is in. Send to it withEmitPage.- It gets no permission without
SetPermissionHandler, opens no window nor the browser withoutSetWindowOpenHandler, downloads nothing withoutOnWillDownloadlisteners, and goes to no other origin than its page's withoutOnWillNavigatelisteners.
It still reads its own scheme, as any page does, and its requests reach
the handler, which tells which page asks with mygo.CallerPage(r.Context()).
That's how a plugin's UI talks to the app, and EmitPage how the app
answers:
mygo.Protocol.HandleFunc("plugin", func(w http.ResponseWriter, r *http.Request) {
p := mygo.CallerPage(r.Context())
plugin := pluginOf[p] // the plugin whose web view made the request
if plugin == nil || r.URL.Host != plugin.ID {
http.NotFound(w, r)
return
}
if r.Method == http.MethodPost {
msg, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 64<<10))
...
PluginReply.EmitPage(p, reply)
return
}
http.FileServerFS(plugin.Files).ServeHTTP(w, r)
})
view, _ := win.NewWebView(mygo.WebViewOptions{
URL: "plugin://" + plugin.ID + "/",
Page: mygo.PageOptions{Untrusted: true},
})
pluginOf[view.Page()] = plugin// In the plugin's page.
await fetch("/message", { method: "POST", body: JSON.stringify(msg) });
window.mygo.on("plugin-reply", (reply) => { ... });A page can make requests to other hosts and schemes too, without reading their responses: check the page, as above, before a handler acts on a request.
Some things reach every page, trusted or not:
- events:
Broadcastsends to every page of every window, andEmitto every web view of a window of native UI. Send to one page withEmitPage. - messages to Go: an untrusted page's calls are rejected, but the app receives and decodes them first, whatever their size, unless the page is untrusted.
window.openandtarget="_blank"links openhttp(s)URLs in the default browser, unlessSetWindowOpenHandlerdecides (links and new windows).- downloads go to the Downloads directory, unless
OnWillDownloaddecides (downloads). - the page may navigate anywhere, unless
OnWillNavigatecancels it (pages). A page that navigates to a trusted URL is trusted.
The mygo-runtime package
MyGo injects its runtime into every page as window.mygo. The
mygo-runtime npm package, which new projects depend on, gives it types
and imports; the generated client is built on it.
import { currentWindow, isMyGo, onFileDrop, runtime } from "mygo-runtime";
if (isMyGo() && runtime().platform === "darwin") {
document.documentElement.classList.add("mac");
}
await currentWindow.toggleMaximize();| Export | What it does |
|---|---|
call(method, ...args), on(event, listener), once(event, listener), event<T>(name) |
untyped calls and events, see bindings |
Channel |
a stream of values from a Go method, see channels |
isCallError(err) |
whether a rejection came from a Go error |
isMyGo() |
whether the page runs in a MyGo window, not in a browser tab of the dev server |
runtime() |
the runtime: platform ("darwin", "linux" or "win32"), windowId (the Go window's ID()) and version; throws outside MyGo |
currentWindow |
the page's window: minimize, maximize, unmaximize, toggleMaximize, isMaximized, toggleFullScreen, close and setTitle |
onFileDrop(listener) |
files dropped on the window, with their paths |
preferences() |
the accent color and the accessibility settings of the desktop; throws outside MyGo |
onPreferences(listener) |
the changes of preferences() |
windowActive(), onWindowActive(listener) |
whether the page's window is drawn as active, the one the user's input goes to, and its changes, see inactive windows |
locale(), direction(), onLocaleChange(listener) |
the language the app shows (App.Locale), such as "zh-Hans", its direction, "ltr" or "rtl", and its changes; the page has them from document start, as mygo.locale and mygo.dir |
mygo-runtime/i18n formats messages of ICU MessageFormat, as package
i18n does in Go. The module mygo generate writes for the app's
catalogs, locales.ts, is built on it, and holds the
messages of every language, which the frontend's build bundles: a page
says its first words in the app's language without waiting for a request.
Opened in a regular browser, which is handy for working on the layout,
pages have no runtime: calls reject and isMyGo() is false.
Custom title bars
A frameless window has no title bar or borders, and the page draws its own.
Mark the elements that drag the window with the CSS property
--app-region: drag:
mygo.NewWindow(mygo.WindowOptions{URL: "/", Frameless: true}).titlebar {
--app-region: drag;
height: 40px;
}
.titlebar .search {
--app-region: no-drag;
}import { currentWindow } from "mygo-runtime";
minimizeButton.onclick = () => currentWindow.minimize();
maximizeButton.onclick = () => currentWindow.toggleMaximize();
closeButton.onclick = () => currentWindow.close();The property is inherited: no-drag excludes parts of a region again, and
buttons, links, inputs and other interactive elements inside a region stay
clickable. Double-clicking a region does what double-clicking a title bar
does.
Frameless windows still resize from their edges. On Linux under Wayland,
GTK decorates frameless windows and windows with a hidden title bar itself,
as GNOME's apps, without a title bar: it draws a shadow and a thin border
around the page, which resize the window from just outside it, and drops
them while the window is maximized or in full screen. Elsewhere on Linux,
where GTK gives windows without decorations no borders (X11, or a window
with DisableShadow, Transparent or Panel), the outermost 5 px of the
page act as the borders: they show resize cursors, and the page gets no
clicks or pointer moves there.
On Linux a frameless window also keeps the menu bar that App.SetMenu or
Window.SetMenu gives it, above the page and so above a custom title bar.
Give such a window an empty menu of its own,
win.SetMenu(mygo.NewMenu(nil)), and show menus from the page instead
(Menu.PopupAt, from a bound method the page calls).
Hidden title bars
A window can keep its window controls and only hide the title bar, so the
page extends under them and draws the rest of the title bar: set
TitleBarStyle to mygo.TitleBarHidden. The controls stay at the top of
the window:
- On macOS, the traffic lights.
mygo.TitleBarHiddenInsetleaves more room around them, andTrafficLightPositionmoves them: the position of the close button's top-left corner in the window. - On Windows, minimize, maximize or restore, and close at the top-right
corner, drawn as Windows 11 draws its own. They fill the height of the
title bar,
TitleBarHeight(32 by default), and hovering maximize opens the snap layouts. The window resizes from its top edge. - On Linux, GTK's own title buttons, where the desktop's button layout puts
them, centered in
TitleBarHeight(by default the height of the desktop's header bars). The layout decides which buttons show, possibly none, as tiling window managers are often set up. A Wayland compositor that decorates windows itself, as KWin, Hyprland and Sway do, has none: its own title bar holds the buttons, and a tiling one shows none. The outer pixels of the page resize the window, as a frameless one's. The window's menu bar hides, as withAutoHideMenuBar: Alt and F10 show it above the page while its menus are open, and its shortcuts work all along.Theme.SetGTKCSSrestyles the buttons, as GTK 4's round ones, for example (GTK's style).
mygo.NewWindow(mygo.WindowOptions{
URL: "/",
TitleBarStyle: mygo.TitleBarHidden,
TitleBarHeight: 40,
})From its first paint, the page learns the room the controls take from
CSS variables on :root:
| variable | |
|---|---|
--mygo-titlebar-height |
the height of the title bar |
--mygo-titlebar-inset-left, --mygo-titlebar-inset-right |
the room the controls take from the left and from the right edge |
--mygo-titlebar-controls-bottom |
where the controls end, from the top edge: the bottom of the traffic lights on macOS, the title bar's height on Linux and Windows |
They are 0px in full screen, where the controls hide (on macOS they come
back with the menu bar). The page's title bar keeps clear of the controls
and, marked with --app-region: drag, moves the window:
.titlebar {
--app-region: drag;
height: var(--mygo-titlebar-height, 40px);
padding-left: var(--mygo-titlebar-inset-left, 0px);
padding-right: var(--mygo-titlebar-inset-right, 0px);
}A sidebar that runs to the top of the window, with the traffic lights over it, starts its items below them:
.sidebar {
padding-top: calc(var(--mygo-titlebar-controls-bottom, 0px) + 8px);
}Transparent: true lets a page with a transparent background show the
desktop through, and Vibrancy puts a blurred material behind it on macOS
and Windows 11 22H2 and later:
mygo.NewWindow(mygo.WindowOptions{
URL: "/",
TitleBarStyle: mygo.TitleBarHidden,
Transparent: true,
Vibrancy: mygo.VibrancySidebar, // Mica on Windows 11
})See examples/frameless for a complete custom title bar. Native UI shows
materials the same way (Vibrancy), as
examples/vibrancy does in a translucent sidebar under an inset title bar.
Dropped files
Files dragged from Finder or Explorer and dropped on the page reach the page with their paths, which DOM drop events do not tell:
import { onFileDrop } from "mygo-runtime";
onFileDrop(({ paths, x, y }) => {
const target = document.elementFromPoint(x, y);
// ...
});and Go, with win.OnFileDrop. The page's own drag and drop keeps working:
its drop listeners still get the File objects, and dropping files where
the page does not handle them no longer replaces the page with the file.
Dark mode
Pages follow the system appearance through the prefers-color-scheme media
query. Declare color-scheme: light dark in CSS so that form controls and
scrollbars follow too, and give windows a BackgroundColor matching the
page in both appearances, which the window shows until the page paints:
:root {
color-scheme: light dark;
}mygo.NewWindow(mygo.WindowOptions{URL: "/", BackgroundColor: "light-dark(#f5f5f7, #1e1e1e)"})mygo.Theme.SetSource(mygo.ThemeDark) forces an appearance, which pages see
through the same media query.
Accent color and accessibility
prefers-reduced-motion and prefers-contrast follow the desktop on their
own. The accent color the desktop uses and the factor it asks text to grow
by are what the media queries do not tell a page: they are CSS variables on
:root from the first paint.
| variable | |
|---|---|
--mygo-accent |
the accent as #rrggbb; unset where the desktop has none, so var(--mygo-accent, AccentColor) falls back to the desktop's own color |
--mygo-text-scale |
the factor text grows by; 1 is the usual size, so calc(1rem * var(--mygo-text-scale, 1)) |
:root {
--accent: var(--mygo-accent, AccentColor);
font-size: calc(1rem * var(--mygo-text-scale, 1));
}preferences() of mygo-runtime returns the settings themselves, current
from document start on, and onPreferences tells their changes, which only
the desktop makes; a component reads the first and subscribes to the
second, as with matchMedia:
import { onPreferences, preferences, type ThemePreferences } from "mygo-runtime";
const apply = ({ reduceTransparency }: ThemePreferences) =>
document.documentElement.classList.toggle("opaque", reduceTransparency);
apply(preferences());
const off = onPreferences(apply);| field | |
|---|---|
accent |
the accent as "#rrggbb", or null where the desktop has none |
reduceMotion |
the desktop asks for less motion, as prefers-reduced-motion |
reduceTransparency |
the desktop asks for opaque surfaces: macOS's Reduce Transparency, Windows's transparency effects turned off; false on Linux, which has no such setting |
highContrast |
the desktop asks for more contrast, as prefers-contrast: more |
textScale |
how many times larger than usual text should be; 1 is usual |
A page that navigates starts with the values of the moment: the script
that tells them at document start is replaced when they change.
mygo.Theme.Preferences() reads them on the Go side, see
native.
Inactive windows
Native apps draw the window the user's input goes to apart from the
others: macOS fades an inactive window's sidebar and the accent of its
selections to gray, Windows draws its Mica as a solid fill, GTK styles it
:backdrop. A page knows which its window is from <html>, which has
the data-mygo-window-inactive attribute while the window is not active:
.sidebar .selected {
background: var(--accent);
color: white;
}
:root[data-mygo-window-inactive] .sidebar .selected {
background: #0000001a;
color: inherit;
}A window is active while it, or one of its flyouts, has the keyboard: a
flyout of the window leaves it active, where DOM focus would leave the
page. Panels, flyouts without a
parent and windows SetActiveAppearance keeps active always are. A page
starts active and is told otherwise as its DOM is ready; windowActive()
of mygo-runtime reads the state and onWindowActive tells its changes.
The material behind a transparent page follows on its own (macOS's in
windows other than panels and flyouts, Windows's Mica). See
native UI for windows that
show it.
Pages that size their window
The page of a flyout or a window with FitContent sizes it, without a
ResizeObserver or a bound method of its own: MyGo's runtime lays html
out at the width of its content, within the window's limits, and the
window takes the root's size as it changes (see
windows). Let html and
body take their content's size: a height: 100% or 100vh on them
keeps the window at the size it has, and a width on html replaces the
content's. Text wider than the maximum wraps, and content taller than it
scrolls.
Links and new windows
Links with target="_blank" and window.open() call the window's open
handler. Without one, http(s) URLs open in the default browser and
anything else is denied; win.Page().SetWindowOpenHandler decides otherwise:
win.Page().SetWindowOpenHandler(func(req mygo.WindowOpenRequest) *mygo.WindowOptions {
if strings.HasPrefix(req.URL, "https://docs.example.com/") {
return &mygo.WindowOptions{Width: 900, Height: 700} // a window of the app
}
go mygo.Shell.OpenExternal(req.URL) // the default browser
return nil
})req.UserInitiated tells the windows a user opened, with a click on the link
or a click or key press whose handler calls window.open(), from the ones a
script opened on its own, as a pop-up from a timer or as the page loads.
Refuse those, as ads in an embedded page:
win.Page().SetWindowOpenHandler(func(req mygo.WindowOpenRequest) *mygo.WindowOptions {
if !req.UserInitiated {
return nil // a pop-up
}
go mygo.Shell.OpenExternal(req.URL)
return nil
})On Linux, WebKitGTK blocks a window.open() without a user's gesture itself,
so the handler is asked only about the ones a gesture made. A script the app
runs itself with Page.Eval counts as a user's gesture on macOS and Linux,
where WebKit runs it as one.
win.Page().OnWillNavigate can cancel the page's own navigations, e.g. to keep the
app's window on the app: see Windows.
Preload scripts
PageOptions.PreloadScript runs JavaScript in every page of the window
before the page's own scripts, once window.mygo exists.
It runs in the top frame only. PreloadAllFrames runs it in the page's
iframes too, of every origin, as each starts, which a browser needs to see
what a player embedded from another site loads. window.mygo isn't in
iframes: to tell the page what an iframe found, post it to window.top.
The web inspector
Development builds have the web inspector: right-click a page and choose
Inspect Element (Inspect on Windows), or call win.Page().OpenDevTools().
Production builds do not, unless built with mygo build -debug or a window's
page sets DevTools: mygo.DevToolsEnabled in WindowOptions.Page.
On Windows, an app takes WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS and
WEBVIEW2_USER_DATA_FOLDER as other WebView2 apps do. With
WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS=--remote-debugging-port=9222,
another process can drive its pages over the DevTools protocol, as a test
runner, Playwright's connectOverCDP or an agent does. This works in every
build, as with Microsoft's loader: whoever starts the app sets its
environment.
On Windows, WebView2's own keys come with the inspector: F5 and Ctrl+R reload
the page (a reload OnWillNavigate is not asked about),
Ctrl+P prints, Ctrl+F finds and Ctrl+Plus zooms only where the inspector is
on. Without it, as in production builds, a page has none of them, as on macOS
and Linux, and only the app's menu items take these keys, such as the View
menu's (RoleViewMenu).