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.openExternalfrom 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."
Run it. Main owns the dialog and the browser hand-off — and it refuses URLs whose protocol isn't on the allowlist.
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.