# Developing Electron Desktop

> Use when working on the Trilium Electron desktop app (`apps/desktop`) — adding or changing an `electronApi` method / IPC channel, touching `preload.ts`, `main.ts`, `services/window.ts` or any main-process service (tray, printing, dialogs, import/export, spellcheck, autostart, security settings), the `trilium-app://` protocol, launching or debugging the desktop build, or writing tests for desktop code. Covers the process/security model, the four-file recipe for a new Electron API (plus the handler-module map, the send/sendSync/invoke transport table, ipcMain crash-safety and the shell input validators), a runnable ipc-parity checker, triage for `trilium-app://` protocol and WebContents failures (STATUS_BREAKPOINT, (blocked:origin), non-streaming SSE, blocked webview/permission), running (`pnpm desktop:start`) and the known launch errors, and how desktop specs mock `electron`.

- Skill: `triliumnext/developing-electron-desktop` (Agent Skill, multi-file: 3 files)
- Install (CLI): `npx skillmds@latest add triliumnext/developing-electron-desktop`
- Raw SKILL.md: https://api.skillmd.com/api/skills/triliumnext/developing-electron-desktop/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: AI & ML
- Author: triliumnext (https://skillmd.com/u/triliumnext)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/triliumnext/developing-electron-desktop

---


# Developing the Electron desktop app

`apps/desktop` runs the **server and the client in one Electron process**: the main process boots `@triliumnext/core` + the Express app from `@triliumnext/server`, and the renderer is the ordinary `apps/client` bundle. There is no HTTP between them — see "How the renderer reaches the server" below. `apps/desktop/src/main.ts` is the entry point; `services/window.ts` creates windows and owns the window/spellcheck IPC.

## Layout

```
apps/desktop/
  src/main.ts                    # startup: platform provider, core init, Express app, windows, tray, IPC setup
  src/preload.ts                 # the ONLY bridge renderer ↔ main (contextBridge → window.electronApi)
  src/protocol.ts                # trilium-app:// scheme → dispatch into Express in-process
  src/ipc_messaging_provider.ts  # replaces the WebSocket with ipcMain/webContents.send
  src/platform_provider.ts       # DesktopPlatformProvider (isElectron, getEnv, crash)
  src/services/window.ts         # BrowserWindow creation, webPreferences, window/spellcheck/nav IPC
  src/services/*.ts              # one module per concern: tray, printing, dialog, import, export,
                                 #   restore, shell, auto_launch, backup_passphrase, security_settings,
                                 #   custom_dictionary, onenote (+ loopback_oauth), referer, request,
                                 #   startup_metrics, web_contents_security
  src/*.spec.ts, services/*.spec.ts   # vitest, `pnpm --filter desktop test`
  spec/build-checks/artifacts.spec.ts # verifies the built dist
  e2e/                           # Playwright against the built app (`pnpm --filter desktop e2e`)
  scripts/build.ts               # esbuild bundle + asset copy into dist/
  electron-forge/                # packaging (forge.config.ts, icons, dmg, portable/safe-mode launchers)
```

## Process and security model

- **`nodeIntegration: false`, `contextIsolation: true`, `webviewTag: true`** on every window (`services/window.ts`). The renderer has no Node, no `require("electron")`, no `@electron/remote` (removed — never reintroduce it). Everything crosses through the preload bridge.
- **`web_contents_security.ts`** vets every `<webview>` in `will-attach-webview` and decides `window.open`: the app shell's root URL becomes an extra window in the opener's renderer process (that is how the client opens new windows — no IPC channel), everything else is denied and allow-listed URLs are routed to the OS. If a new feature needs a popup or a webview privilege, change it there — never relax `webPreferences` at the call site.
- **Main-process handlers validate their input** and never trust a path from the renderer: OS pickers run in the main process (`dialog.ts`, `import.ts`, `restore.ts`) and hand back a location the *user* chose; `import.ts` mints single-use access grants rather than accepting a path; `shell.ts` gates every channel with a validator that throws. Follow the same shape for anything that touches the filesystem or the OS.
- **Secrets stay out of the DB where they must**: `security_settings.ts` reads `data_dir/security.json`, `backup_passphrase.ts` uses the OS keyring — the passphrase must not travel inside the backup it protects.
- **`services/request.ts`** (`ElectronRequestProvider`) uses Electron's `net` so sync honours the system proxy; `referer.ts` keeps hosts that require an http(s) `Referer` working from the `trilium-app://` origin.

## How the renderer reaches the server

- The UI loads from **`trilium-app://app/`**, a privileged custom scheme (`protocol.ts`). `registerTriliumAppScheme()` **must run before `app.ready`** (Electron ignores `registerSchemesAsPrivileged` afterwards and navigation aborts with `(blocked:origin)`); `setupTriliumAppProtocol(expressAppPromise)` installs the handler that synthesises a Node request/response and dispatches through the real Express app — session, CSRF, multer and error middleware all run. Requests arriving before the server is built simply wait on the promise. `apps/server/src/services/electron_request.ts` tags them so auth/CSRF middleware can tell them from external TCP traffic. The same two functions are reused by `apps/edit-docs`.
- **WebSocket is replaced by IPC**: `ipc_messaging_provider.ts` implements `MessagingProvider` over `webContents.send` / `ipcMain.on`, one client per `webContents.id`; the client side picks it up through `window.electronApi.ws` (`apps/client/src/services/ws.ts`). Don't open a TCP WebSocket from desktop code.
- Window creation is gated on core init, not full server startup, so the renderer spins up while Express is still building (`coreInitializedPromise` / `expressAppPromise` in `main.ts`).

## The `main()` prologue runs before the database

Everything from the top of `main()` down to `dbProvider.loadFromFile(…)` runs before `app.on("ready")` and long before `initializeCore()` wires core's SQL layer. Chromium switches (`app.commandLine.appendSwitch`, `app.disableHardwareAcceleration`) must be applied before `ready`, which forces that shape — current readers are `lang` via `getElectronLocale()`, `smoothScrollEnabled` (#10559) and `hardwareAccelerationEnabled` (#10572). Three rules hold in that window:

- **`options.getOptionOrNull()` always returns `null` there.** It falls back to `getSql()`, which throws before the provider is wired, so an option-derived switch silently takes its default (#10559). Read pre-`ready` values with `readDbOption(dbProvider, name)` instead.
- **Keep the prologue await-free.** `app`, `config` and `dataDirs` are static imports precisely so `ready` cannot fire before the database is open. Adding an `await` — including a `await import(…)` for something already statically available — reintroduces the race that lands a switch too late.
- **Open the database once.** The `BetterSqlite3Provider` the switches read from is the same instance handed to `initializeCore({ dbConfig: { provider } })`; don't open a second connection. The single-instance lock check stays *before* the open, so a second launch exits without touching the file.

## Adding an Electron API (renderer → main)

Four files, always together:

1. **Interface** — add the method to the right group interface in `packages/commons/src/lib/electron_api_interface.ts` (`ElectronWindowApi`, `ElectronShellApi`, `ElectronPrintingApi`, …; the groups are listed on `ElectronApi` at the bottom of the file, each with a one-line purpose). New concern → new `ElectronXxxApi` interface and a new key on `ElectronApi`. Doc-comment the method: the client only sees this file.
2. **Preload** — implement it in `apps/desktop/src/preload.ts` inside the matching group of the `contextBridge.exposeInMainWorld("electronApi", { … } satisfies ElectronApi)` literal. `satisfies` makes the typecheck fail until preload matches the interface. Keep the preload thin: marshal arguments to `ipcRenderer.send` / `invoke` / `sendSync`; no logic.
3. **Handler** — register the `ipcMain` handler in the service module that owns the concern (`services/shell.ts`, `services/printing.ts`, …, each with a `setupXxxHandlers()` called from `main.ts`), or in `setupWindowing()` in `services/window.ts` for window/spellcheck/navigation. Channel names are kebab-case verbs (`open-external`, `set-full-screen`, `backup-passphrase-set`). Pick the IPC style by shape:
   - `ipcMain.on(channel, handler)` — fire-and-forget (`ipcRenderer.send`);
   - `ipcMain.handle(channel, handler)` — async request/response (`ipcRenderer.invoke`);
   - `ipcMain.on` + `event.returnValue = …` — synchronous query (`ipcRenderer.sendSync`); use sparingly, it blocks the renderer.
   Main → renderer events go the other way: `webContents.send(channel, …)` in main, an `ipcRenderer.on` subscription exposed as `onXxx(callback)` in preload.
4. **Tests** — `apps/desktop/src/preload.spec.ts` (asserts the exposed API shape and that each method sends/invokes the right channel; extend the `exposedApi` assertions) and the owning service's `*.spec.ts` for the handler.

Then call it from the client as `window.electronApi?.group.method()` — always optional-chained, since the same client runs in the browser. `window.electronApi` is declared in `apps/client/src/types.d.ts`; gate desktop-only UI on `isElectron()` from `apps/client/src/services/utils.ts` (server side: `utils.isElectron`).

### A new handler module is dead until `main.ts` calls its `setupX()`

`ipcMain.on`/`handle` only registers when the module's setup function actually runs, and there is **no startup error** if you forget. The symptom is silent at the source: a `send` channel no-ops, and a `sendSync` channel **hangs the renderer forever**, because synchronous IPC blocks the whole renderer process waiting for a reply that never comes. The setup calls live in one block in `main.ts` (plus `ipcMessaging.init()` further down). Adding a module means adding the call there.

### Which module owns my channel?

The preload API *group* name does not map 1:1 to a handler module — infer from this table, not from the group:

| setup fn (called in `main.ts`) | module | channels it owns |
|---|---|---|
| `setupWindowing()` | `services/window.ts` | the bulk (~31): window lifecycle, zoom, theme, title bar, full-screen, min/max, dev tools, background material, `navigation-history*`, **clipboard** (`copy-image-to-clipboard`, `read-clipboard-text`), **spellchecker language** channels, `web-contents-action` |
| `setupShellHandlers()` | `services/shell.ts` | `open-external`, `open-path`, `show-item-in-folder`, `open-file-url`, `download-url`, `open-custom` |
| `setupPrintingHandlers()` | `services/printing.ts` | `print-note`, `export-as-pdf`, `export-as-pdf-preview`, `save-pdf`, `get-printers`, `print-from-preview`, `print-progress` |
| `registerSecurityIpcHandlers()` | `services/security_settings.ts` | `security-set-backend-scripting`, `security-set-sql-console`, `security-set-lan-access` (all three via `registerToggleHandler`) |
| `setupStartupMetricsIpc()` | `services/startup_metrics.ts` | `report-startup-metric` |
| `setupSystemTray()` | `services/tray.ts` | `reload-tray` |
| `setupCustomDictionary()` | `services/custom_dictionary.ts` | `add-word-to-dictionary` |
| `ipcMessaging.init()` | `ipc_messaging_provider.ts` | `trilium-ws-from-renderer` (the ws bridge; the channel names are the `IPC_FROM_RENDERER`/`IPC_TO_RENDERER` constants) |

Note the splits that defeat guessing by group: spellcheck's `add-word-to-dictionary` is in **custom_dictionary.ts** while the spellchecker-language channels are in **window.ts**, and the **clipboard** handlers live in **window.ts**, not a clipboard module.

### Transport must match the handler kind

Mismatch it and the failure is silent or fatal, never a clear error:

| Renderer need | preload call | main side | if mismatched |
|---|---|---|---|
| fire-and-forget, no return | `ipcRenderer.send(ch, …)` | `ipcMain.on(ch, (event, …) => {})` | sending to a `handle`-only channel is a **silent no-op** |
| synchronous value (**blocks renderer**) | `ipcRenderer.sendSync(ch, arg)` | `ipcMain.on(ch, (event) => { event.returnValue = x })` | forgetting `event.returnValue` **hangs the renderer** |
| async value (Promise) | `ipcRenderer.invoke(ch, …)` | `ipcMain.handle(ch, async (event, …) => x)` | no `handle` registered ⇒ the promise rejects `"No handler registered for '<ch>'"` |
| main → renderer push | `ipcRenderer.on(ch, cb)` + an unsubscribe | `webContents.send(ch, data)` (**not** `ipcMain`) | push channels have **no** `ipcMain` handler — don't "fix" their absence |

**Multiplexed channel:** `navigation-history` is one channel serving several preload methods via a method-name first argument and an `event.returnValue` switch.

### Crash-safety

An unhandled throw inside an `ipcMain.on` listener crashes the **entire main process** — there is no renderer-side rejection to catch it. `shell.ts` carries this warning verbatim next to `open-custom`. Wrap every handler body:

```ts
electron.ipcMain.on("my-channel", (_event, arg: string) => {
    try {
        doThing(validateArg(arg));
    } catch (e) {
        getLog().error(`my-channel failed: ${coreUtils.safeExtractMessageAndStackFromError(e)}`);
    }
});
```

For an `ipcMain.handle` whose contract is `Promise<string>`, the catch should also **return** the error string, since the renderer awaits it — that is what `open-path`/`open-file-url` do.

### Check the wiring with `ipc-parity.mjs`

A direction-aware parity diff across interface ↔ preload ↔ `ipcMain` handlers ↔ spec. Run it after wiring a channel, or to audit drift:

```bash
node .claude/skills/developing-electron-desktop/scripts/ipc-parity.mjs
```

It reports renderer→main channels with no handler (these hang or no-op), transport/handler-kind mismatches, handler modules whose `setupX()` is never called, handlers with no preload caller (`print-note`/`export-as-pdf` are known legacy orphans), and preload channels with no `preload.spec.ts` assertion. It whitelists push-only channels and is channel-granular, so the multiplexed `navigation-history` doesn't false-positive. Exit code 1 on a fatal finding.

## Validating untrusted renderer input

The renderer is XSS-reachable, so it is **untrusted**. Every fs/shell/url channel validates in the main process and throws on violation. The five validators in `apps/desktop/src/services/shell.ts` are exported and unit-tested:

- `validateOpenExternalUrl` — scheme allowlist from `SHELL_OPEN_EXTERNAL_PROTOCOLS` (commons); blocks Follina (`ms-msdt:`/`search-ms:`), the `smb:`/`ldap:` NTLM leak, and `file:`/`data:`/`jar:`.
- `validateOpenPath` / `validateOpenCustomPath` — canonicalize and sandbox to the data dir / tmp dir; implicitly blocks UNC paths and traversal; reject null bytes and nonexistent files.
- `validateOpenFileUrl` — require `file:` with an empty hostname (blocks the `file://attacker/share` UNC NTLM leak); normalize `file://C:/` → `file:///C:/`.
- `validateDownloadUrl` — same-origin lock by scheme + hostname + port. It cannot use `URL.origin`, because the custom scheme is opaque-origin (`"null"`).

Add a validator for any new channel that takes a path, a URL, or anything else the main process will act on.

## Strings and platform code

- Main-process user-facing text (tray menu, dialogs, error boxes) goes through `import { t } from "i18next"` with keys in `apps/server/src/assets/translations/en/server.json`. Never hardcode.
- Platform checks in main use `process.platform`; code shared with core uses `isElectron()`/`isMac()`/`isWindows()` from `@triliumnext/core` utils (functions, only after `initializeCore()`).
- The preload is compiled to **CJS** (`src/preload.compiled.cjs`, gitignored) — dev by `scripts/electron-start.mts`, prod by `apps/desktop/scripts/build.ts` — because Electron's sandboxed renderer can only load CJS preloads. Don't import ESM-only things into `preload.ts`.

## The main bundle is ESM with code splitting

`scripts/build.ts` builds `src/main.ts` with `buildBackend(..., { format: "esm" })`: the
production entry is **`dist/main.mjs`** plus lazy chunks under `dist/chunks/` (the generated
`dist/package.json` points Electron's `main` at it). The preload and `image_worker.cjs` stay
CJS — the sandboxed renderer can't load an ESM preload, and the worker is spawned by its
`.cjs` path. Three rules follow:

- **`__dirname` in bundled code means the bundle root, even inside a chunk** — the ESM banner
  in `scripts/build-utils.ts` resolves a chunk's `__dirname` one level up on purpose, because
  bundled code locates `preload.cjs`, `image_worker.cjs` and `assets/` as siblings of the
  entry. Don't "simplify" the banner, and don't path-math around it in app code.
- **Dynamically importing a CommonJS package needs the interop read**: `const mod = await
  import("cjs-pkg"); const { x } = mod.default ?? mod;`. Destructuring the namespace directly
  yields `undefined` in split ESM output, and unit tests mock past it. After adding a seam,
  run `node .claude/skills/analyzing-backend-bundle/check-dynamic-imports.mjs apps/desktop/dist`.
- A new heavy dependency belongs behind a dynamic `import()` at its (async) call site, so it
  lands in a lazy chunk instead of the startup path — measured on identical boots, ESM +
  seams took the desktop main process from 348 MB to 287 MB RSS. The
  **`analyzing-backend-bundle` skill** has the measurement tools and the seam patterns.

## Upstream Electron behaviours that bite

- **`session.setSpellCheckerLanguages()` force-enables spell check.** Upstream runs `prefs.SetBoolean(kSpellCheckEnable, !langs.empty())`, so a non-empty language list clobbers an earlier `setSpellCheckerEnabled(false)` — the symptom is spell check reactivating on every launch even though the option is off (#10569). Set the languages **first** and `setSpellCheckerEnabled(enabled)` **last**; `setupSpellcheckForSession()` and `applySpellcheckLanguages()` in `services/window.ts` both re-assert the option afterwards for this reason.
- **`electron.net` joins repeated header values with a bare comma**, where Node's `http` joins cookie arrays with `"; "` — which is why server↔server sync never hits this and desktop sync does. A `Cookie` header replayed from a raw `set-cookie` array arrives as `…HttpOnly,trilium.sid=x`, which `cookie.parse` reads as one junk key: the session cookie is lost and sync 401s with "Logged in session not found" as soon as a response carries any second `Set-Cookie` (a load-balancer affinity cookie) before Trilium's. `absorbSetCookies()` in `apps/server/src/services/request.ts` merges by name into a single `"; "`-joined string; keep any header a new `net`-based request path sends pre-joined (#10548).

## Running

| Command | What it does |
|---|---|
| `pnpm desktop:start` | dev app on port 37743, data in `apps/desktop/data`, Electron profile in `data-electron-37742`; HTTP cache disabled in dev so stale prod assets don't shadow fresh output |
| `pnpm desktop:start-prod` | `build` + run `dist/` like a release (port 37841, separate data dirs) |
| `pnpm --filter desktop electron-forge:make` / `:package` | full installers / unpacked app |
| `pnpm --filter desktop e2e` | Playwright against `dist/main.mjs` (builds first) |

Known launch-time noise and failures — do not "fix" these in app code:

- **`TypeError: Cannot read properties of undefined (reading 'commandLine')`** from `main.ts` (`app.commandLine.appendSwitch(...)`) can appear in the console of Electron-based launches (`desktop:start`, `edit-docs:edit-docs`). The app runs correctly; ignore it unless the user raises it as a bug.
- **`TypeError: Not running in an Electron environment!`** (from `electron-is-dev`) at startup means the shell inherited **`ELECTRON_RUN_AS_NODE=1`** — common inside VS Code's extension host and AI coding agents. `require("electron")` then resolves to the npm stub's path string. Unset it before launching: `unset ELECTRON_RUN_AS_NODE` (bash/zsh) or `Remove-Item Env:ELECTRON_RUN_AS_NODE -ErrorAction SilentlyContinue` (PowerShell).
- **Linux**: `gtk-version=3` and `GlobalShortcutsPortal` switches in `main.ts` are deliberate workarounds (Electron GTK 4 crash; Flatpak/Wayland global shortcuts).

## Testing desktop code

- `pnpm --filter desktop test [pattern]` — Vitest, node environment, `src/**/*.spec.ts`. Specs `vi.mock("electron", …)` and assert on the recorded `ipcMain`/`ipcRenderer` calls (see `preload.spec.ts`, `services/shell.spec.ts` for the pattern). `vitest.config.mts` sets `ELECTRON_OVERRIDE_DIST_PATH` so a dynamic `import("electron")` doesn't blow up where the binary isn't installed — don't remove it.
- The desktop suite also boots server pieces (`TRILIUM_INTEGRATION_TEST: "memory"`), so it is slower than a client spec; keep the pattern narrow.
- `spec/build-checks/artifacts.spec.ts` asserts the contents of a built `dist/` (client, assets, `better-sqlite3`, …). It is outside the default `include`, so run it explicitly (`npx vitest run --config vitest.build.config.mts`) after `pnpm desktop:build` when touching `scripts/build.ts` or the asset copies (`schema.sql`, `llm/skills`, `share-theme/templates`). Known broken as of 2026-08: it dies at *import* time — `resource_dir.ts` calls `process.exit(1)` under vitest because `TRILIUM_RESOURCE_DIR` is unset in that config — before running any assertion, with or without your changes. Fix the harness env or verify the dist by hand; don't read the failure as caused by your change.

## Debugging the protocol / WebContents boundary

When the symptom isn't a missing channel but the renderer page itself failing — white screen, `STATUS_BREAKPOINT`, `(blocked:origin)`, SSE that never streams, a blocked `<webview>`, a denied permission — the cause is `protocol.ts` or `web_contents_security.ts`, not the IPC bridge. Symptom → cause → fix table: [references/protocol-and-security-triage.md](references/protocol-and-security-triage.md).

## Reference map

| File | What it covers |
|---|---|
| [references/protocol-and-security-triage.md](references/protocol-and-security-triage.md) | `trilium-app://` and the WebContents boundary: `STRIPPED_HEADERS`/STATUS_BREAKPOINT, the privileged-scheme registration ordering, the SSE streaming bridge, the frame-origin guard and its trust model, `<webview>` attach hardening, the permission allowlist, window-open and navigation policy, the YouTube embed referer. |
| [scripts/ipc-parity.mjs](scripts/ipc-parity.mjs) | Runnable parity check across interface ↔ preload ↔ handlers ↔ spec (see above). |

Related skills: **writing-unit-tests** (the `vi.mock("electron")` pattern these specs use), **building-client-ui** (the renderer side that calls `window.electronApi`), **developing-capacitor-mobile** (the other non-browser runtime).

