BuildBot

Web to Desktop

The native shell

Lesson 3 of 5

What you'll learn

  • Configure windows and build application menus and a tray icon
  • Use native dialogs and shell.openExternal from the main process
  • Register a protocol handler so myapp:// links open your app

What separates a desktop app from a browser tab is the shell around the page — and all of it lives in the main process. Start with the window itself. BrowserWindow takes dozens of options; the ones you'll actually reach for:

const win = new BrowserWindow({
  width: 1000,
  height: 700,
  minWidth: 600,
  show: false, // create hidden...
  titleBarStyle: "hiddenInset", // macOS: content under the traffic lights
  webPreferences: { preload: path.join(__dirname, "preload.js") },
});
win.once("ready-to-show", () => win.show()); // ...show when painted, no flash

Menus are declared as a template of labels, roles (built-in behaviors like undo, quit, toggleDevTools), and click handlers, then installed app-wide. A Tray is the same idea attached to a menu-bar/system-tray icon:

const menu = Menu.buildFromTemplate([
  { label: "File", submenu: [{ label: "Open…", accelerator: "CmdOrCtrl+O", click: openFile }] },
  { role: "editMenu" },
]);
Menu.setApplicationMenu(menu);

const tray = new Tray("iconTemplate.png");
tray.setContextMenu(Menu.buildFromTemplate([{ label: "Show", click: () => win.show() }]));

Dialogs and the shell module are main-process APIs too. dialog.showOpenDialog resolves to { canceled, filePaths }; shell.openExternal(url) hands a link to the user's default browser instead of navigating your app window:

const { canceled, filePaths } = await dialog.showOpenDialog(win, {
  properties: ["openFile"],
  filters: [{ name: "Markdown", extensions: ["md"] }],
});
if (!canceled) await openDocument(filePaths[0]);

await shell.openExternal("https://example.com/docs");

Deep links

Registering a protocol handler makes myapp://note/42 links launch or focus your app: call app.setAsDefaultProtocolClient("myapp"). On macOS the URL arrives via the open-url event; on Windows and Linux it arrives in the argv of a second app instance, so you take app.requestSingleInstanceLock() and read the URL inside the second-instance event, forwarding it to your window.

openExternal is an attack surface

Deep-link and page-supplied URLs are untrusted input. Passing a file:// or app-crafted URL to shell.openExternal can launch local programs — allowlist protocols (usually just https:) before opening anything you didn't hard-code.

The challenge is a JS model of the shell boundary: the renderer asks for a dialog and an external open through the bridge, and main validates before touching the "OS."

Dialogs and openExternal, guarded (JS model)

Run it. Main owns the dialog and the browser hand-off — and it refuses URLs whose protocol isn't on the allowlist.

Loading editor…
Knowledge check

How does a deep-link URL reach your app on Windows and Linux?

Next: where an app keeps its stuff — the userData directory, settings persistence, and the auto-update loop.

Saved on this device. Sign in to sync your progress everywhere.