Skip to content
GroundworkGroundwork
Esc
↑↓navigate↵open⌘Jpreview
On this page

Building apps

Starting a desktop app from the Electrobun template, its desktop defaults, and how it handles the system's mode and scale.

templates/desktop-app is the desktop demo and a starting point. Its layout:

File Does
src/bun/index.ts The main process: opens the window and answers the view’s requests
src/mainview/index.html The view’s page, with data-gw-theme on <html>
src/mainview/index.ts Imports the theme CSS, the components and the app, and connects to the main process
src/shared/requests.ts The app’s own requests, typed for both sides
desktop.config.ts Which desktop defaults the app keeps
electrobun.config.ts How the app and its views are bundled
hutch.config.ts Pins the Electrobun release and leaves dependencies to Bun

The electrobun package from npm only starts Hutch, Electrobun’s build tool, which downloads the release’s SDK and core into .hutch/. Its APIs come from there, not from node_modules, so type-checking runs electrobun prepare first.

Script Does
bun run start Builds and opens the app
bun run dev The same, rebuilding on every change
bun run build A release build for the system it runs on

Electrobun doesn’t cross-compile, so a release for every system needs a CI runner on each. The repository’s Desktop workflow builds the template on Linux, macOS and Windows and uploads each build, and makes a starting point for an app’s own.

On Linux the app needs GTK 3, WebKitGTK 4.1, Ayatana AppIndicator and librsvg installed, even with CEF. On Ubuntu, building needs their -dev packages:

sudo apt-get install libgtk-3-dev libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev

Desktop defaults

@timmo001/groundwork-desktop gives a webview window what people expect from a browser, so an app doesn’t rebuild them. Each is on unless desktop.config.ts turns it off, so an empty config keeps them all:

import { defineDesktopConfig } from "@timmo001/groundwork-desktop";

export default defineDesktopConfig({ tray: false });
Option Default Does
tray On A tray icon that shows the window and quits. Takes { image, title } to change its icon
notifications Off System notifications instead of notices in the app
zoom 1 The page zoom on top of the system’s scale
zoomKeys On Ctrl + and - zoom, Ctrl+0 resets, remembered across launches
contextMenu On Cut, copy, paste, select all and link actions on right click
find On Find in the page with Ctrl+F, F3 and Ctrl+G for the next match
clipboard On Reading and writing the clipboard from the view
reload On F5 and Ctrl+R reload, Ctrl+Shift+R hard reloads
history On Back and forward with Alt+Left and Right and the mouse’s side buttons
externalLinks On Links out of the app open in the system browser
externalSchemes ["mailto"] Schemes besides http(s) that open outside the app
downloads On A notice when a download finishes, with where it went
singleInstance On Opening a second copy brings the first to the front
windowState On The window opens at the size and place it closed at
devTools On F12 and Ctrl+Shift+I open the dev tools, in dev builds only
spellcheck On The system spell checker in text fields
deepLinks On Passes the app’s deep links (urlSchemes) to the view
omarchyTheme On Passes the desktop’s Omarchy theme to the view, and every switch

Notices in the app sit in the bottom end corner, the find bar in the top end corner, and neither moves the page. The window’s size, place and zoom are kept in desktop-state.json in the app’s data folder.

Some depend on the system:

Default Linux macOS Windows
Spell check Not yet Yes Not yet
Deep links Not yet Yes Not yet
Passkeys Not in WebKitGTK 2.54 Yes Yes

Spell check and deep links follow Electrobun as it adds them. WebKitGTK gained WebAuthn in mid 2026, so passkeys reach Linux with the WebKitGTK release that ships it.

Main process and view

The package has an entry point for each side. The main process opens the window with openDesktopWindow, and the view connects with connectDesktop. The app defines its RPC with its own electrobun import, so it keeps Electrobun’s types for the release it pins, and adds its own requests beside the desktop ones:

// src/shared/requests.ts
export type AppRequests = {
  readonly systemInfo: () => { readonly platform: string };
};
// src/bun/index.ts
import Electrobun, { BrowserView } from "electrobun/main";
import { openDesktopWindow, type DesktopRequests, type DesktopSchema } from "@timmo001/groundwork-desktop/main";

import config from "../../desktop.config.ts";
import type { AppRequests } from "../shared/requests.ts";

const appRequests: AppRequests = { systemInfo: () => ({ platform: process.platform }) };

await openDesktopWindow(Electrobun, {
  title: "My app",
  url: "views://mainview/index.html",
  frame: { width: 760, height: 640, x: 200, y: 160 },
  config,
  createRPC: (requests: DesktopRequests) =>
    BrowserView.defineRPC<DesktopSchema<AppRequests>>({
      maxRequestTime: 10_000,
      handlers: { requests: { ...requests, ...appRequests } },
    }),
});
// src/mainview/index.ts
import { connectDesktop, type DesktopSchema } from "@timmo001/groundwork-desktop/view";
import { Electroview } from "electrobun/view";

import type { AppRequests } from "../shared/requests.ts";

const desktop = await connectDesktop<AppRequests>((messages) => {
  const rpc = Electroview.defineRPC<DesktopSchema<AppRequests>>({ maxRequestTime: 10_000, handlers: { messages } });

  new Electroview({ rpc });

  return rpc;
});

const { platform } = await desktop.request.systemInfo();

desktop.request also has the desktop requests, such as openExternal, clipboardRead and clipboardWrite. desktop.notify shows a notice, as a system notification when the config turns them on. desktop.config and desktop.dev say what’s on and whether it’s a dev build.

The demo’s “This machine” card uses each of these: it asks the main process what it runs on, copies the details, sends a notice and opens these docs in the browser.

Theme and mode

The view sets data-gw-theme and leaves out data-gw-mode, so it follows the system’s light or dark mode through prefers-color-scheme. An app with its own setting sets data-gw-mode instead, as on the web.

On Linux, WebKitGTK takes the mode from GTK 3, not from the desktop’s color scheme setting. A desktop that only sets the color scheme, as Omarchy does, shows the light mode. Setting gtk-application-prefer-dark-theme=true in ~/.config/gtk-3.0/settings.ini, or running the app with GTK_THEME=Adwaita:dark, gives the dark one.

Omarchy theme

Where Omarchy is installed, the app matches its theme. The main process reads the theme in use from ~/.local/state/omarchy/current/theme/colors.toml and passes it to the view, then again on every theme switch. Pass it to pageTheme in the view:

const page = pageTheme({ ...themeOptions, themes: coreThemeChoices });

await connectDesktop<AppRequests>(createRPC, {
  onOmarchyTheme: (theme) => void page.setOmarchyTheme(theme),
});

page.state().omarchy is then a theme choice, such as “Omarchy (Tokyo Night)”, to list in the theme picker. It covers the Omarchy theme’s own scheme, light or dark. Until someone picks another theme it’s the one in use, and while it’s picked a switch on the desktop follows through. The boot script keeps the last one, so it applies before the first paint. Set omarchyTheme: false in desktop.config.ts to leave it out.

Scaling

GTK 3 only takes whole numbers in GDK_SCALE, so on Linux a fractional scale such as 1.6 renders at 1 and the app looks too small. openDesktopWindow makes up the difference with the page zoom: it divides GDK_SCALE by its whole part and zooms the webview by the rest.

Set GROUNDWORK_DESKTOP_ZOOM to scale on top of that, such as 0.8 to match a browser started with --force-device-scale-factor=0.8. It defaults to 1. The config’s zoom and the zoom keys multiply on top again.

Both are read when the app starts, from its own environment, so they reach a built app the same way as a dev build. A desktop session sets them once for every app, such as with Hyprland’s env.

Last updated on