Guide

desktop

Some teams want the app in the dock or the Start menu: a window that opens offline, saves files where they choose and keeps running when the browser is closed. @kitr/desktop is the desktop platform for Windows, macOS and Linux. It wraps the same app/ you ship to the browser in an Electron window and gives you a typed channel between the window and the Node.js process behind it.

Electron bundles its own Chromium and Node.js, so the app renders and behaves the same on every machine. kitr adds the platform on top: kitr dev runs the window with hot reload through electron-vite, kitr build builds installers with electron-builder, one TypeScript type describes every call between the window and the main process, and @kitr/desktop/provider moves the platform and storage packages to their Electron drivers.

The code runs in three places. @kitr/desktop/client and the provider run in the window (and in a plain browser, where they stand aside). @kitr/desktop/main runs in Electron's main process. @kitr/desktop/preload runs in the preload script between the two. For a smaller download on the operating system's own webview, use Tauri instead.

The examples on this page ship the Acme orders app to the desktop: Ada's warehouse team at Acme checks orders, saves invoices as PDF files and watches order sync progress, with the same code the browser runs at app.acme.com.

When to use it

Use @kitr/desktop for most desktop apps. Electron ships Chromium inside the app, so the page your team tests is the page every user runs, on Windows 10, an old MacBook and a Linux kiosk alike. The main process is plain Node.js, so any npm package works there. Electron has the larger community, the most third-party guides and a mature toolchain for installers, auto-update and code signing.

Use Tauri when download size and memory matter more than identical rendering. A Tauri app is a few megabytes because it uses the webview the OS already has (WebView2 on Windows, WKWebView on macOS, WebKitGTK on Linux). The trade-off is that those engines differ between platforms, and native code is written in Rust, so you need the Rust toolchain to build. Both platforms load the same client build and register the same platform and storage drivers, so the app code does not change when you switch.

Install

Add a desktop platform named app:

kitr add desktop app

First time? Run npm i -g kitr once. Inside a project, the global kitr runs your project's own version.

desktop is the platform type and app is the name you address it by (kitr dev app). Without a name, kitr add desktop logs No name given: calling the desktop platform "app". and uses app. A name must be a short identifier: letters, digits, - and _, starting with a letter.

What kitr add desktop app does:

  • Installs @kitr/desktop and adds it to packages in kitrrc.ts.
  • Adds the packages the platform needs when they are missing, each with its own setup: Router (and React before it), Platform, which writes config/platform.ts, and Storage, which writes config/storage.ts and app/storage.ts.
  • Installs electron, electron-vite and electron-builder as dev dependencies.
  • Adds () => import('@kitr/desktop/provider') to providers in kitrrc.ts.
  • Writes app: desktop({...}) into platforms in kitrrc.ts, with the flags you passed as options, and adds import { desktop } from '@kitr/desktop'.
  • Sets the dev, build and start scripts in package.json to kitr dev, kitr build and kitr start.
  • Writes platforms/app/ipc.ts, main.ts, preload.ts, electron.vite.config.ts and electron-builder.yml, and app/ipc.ts for the window. Files that already exist are kept.
  • Sets "main" in package.json to dist/app/electron/main/index.js, so Electron and electron-builder find the built main process.
  • Adds a kitr: platform app section to AGENTS.md: how to run and build, where IPC is declared and implemented, and to keep context isolation and the sandbox on.

@kitr/desktop asks no questions. Platform options are flags on the same command, named as in the options table under Configure:

kitr add desktop app --title "Acme Orders" --width 1280 --height 860 --appId com.acme.orders

First time? Run npm i -g kitr once. Inside a project, the global kitr runs your project's own version.

Flag Default Effect
--port 3200 Dev server port the window loads from.
--title the package name Window title and product name.
--width, --height 1200, 800 Initial window size.
--minWidth, --minHeight Smallest window size.
--appId com.example.<app> Reverse-DNS bundle identifier.
--no-devtools Do not open devtools in development.
--no-scaffold Register the platform without writing platforms/app/ or app/ipc.ts.
--no-install Edit package.json without running the package manager.
--dry-run Print what would change.

The desktop kit already has an app desktop platform next to a site web platform, with a home page that calls the typed IPC. Start a new project from it:

kitr new acme-orders --kit desktop

First time? Run npm i -g kitr once. Inside a project, the global kitr runs your project's own version.

kitr platforms remove app takes the platform out of kitrrc.ts, and with the last desktop platform also the desktop import and the provider line. It leaves platforms/app/ for you to delete. kitr remove desktop does all of that for every desktop platform, and takes @kitr/desktop out of packages and the dependencies; the Electron dev dependencies and the "main" field stay, so remove those yourself.

Configure

@kitr/desktop has no config file in config/. Its options live on the desktop() call in kitrrc.ts:

// kitrrc.ts
import { defineKitrConfig } from 'kitr/app'
import { desktop } from '@kitr/desktop'
import { web } from '@kitr/web'

export default defineKitrConfig({
  packages: ['@kitr/react', '@kitr/router', '@kitr/platform', '@kitr/storage', '@kitr/web', '@kitr/desktop'],
  providers: [
    () => import('@kitr/platform/provider'),
    () => import('@kitr/storage/provider'),
    () => import('@kitr/web/provider'),
    () => import('@kitr/desktop/provider'),
  ],
  platforms: {
    site: web({ mode: 'spa' }),
    app: desktop({ title: 'Acme Orders', width: 1280, height: 860, appId: 'com.acme.orders' }),
  },
})
Option Default Description
port 3200 Port of the Vite dev server the window loads with kitr dev app. Read on every run.
title the package name Window title, and productName in electron-builder.yml. acme-orders becomes Acme Orders.
width 1200 Initial window width in pixels.
height 800 Initial window height in pixels.
minWidth Smallest width the user can resize to.
minHeight Smallest height the user can resize to.
appId com.example.<app> Reverse-DNS bundle identifier in electron-builder.yml. The default lower-cases the title and keeps letters and digits: Acme Orders gives com.example.acmeorders.
devtools true Open Chromium devtools, detached, when the window loads the dev server.

Every option except port is written into the scaffolded files when you run kitr add. The window size goes into platforms/app/main.ts, the title and appId into electron-builder.yml. Changing them in kitrrc.ts later does not rewrite those files: edit the files, which are yours, or delete one and run kitr platforms scaffold app to write it again.

The platform writes the packaging config from the options above. With title: 'Acme Orders' and appId: 'com.acme.orders' it is:

# platforms/app/electron-builder.yml
appId: com.acme.orders
productName: Acme Orders
directories:
  output: dist/app/release
files:
  - dist/app/client/**
  - dist/app/electron/**
  - package.json
mac:
  category: public.app-category.productivity
win:
  target: nsis
linux:
  target: AppImage

and the build config for the two Node-side bundles:

// platforms/app/electron.vite.config.ts
import { defineConfig, externalizeDepsPlugin } from 'electron-vite'

/** Main and preload only. The renderer is the kitr client build in dist/app/client. */
export default defineConfig({
  main: {
    root: __dirname,
    plugins: [externalizeDepsPlugin()],
    build: { lib: { entry: 'main.ts' }, outDir: '../../dist/app/electron/main' },
  },
  preload: {
    root: __dirname,
    plugins: [externalizeDepsPlugin()],
    build: { lib: { entry: 'preload.ts' }, outDir: '../../dist/app/electron/preload' },
  },
})

The window itself is the kitr client build in dist/app/client, built by Vite with the kitr Vite plugin, not by electron-vite.

Variable Used for
KITR_APP_<OPTION> Overrides one option for one run of the CLI: KITR_APP_PORT=3300 kitr dev app. The platform name is upper-cased.
ELECTRON_RENDERER_URL Set by kitr dev app to the dev server URL. createMainWindow() loads it instead of the built client.
KITR_PLATFORM Set to app by kitr dev and kitr build, so the Vite plugin builds this platform into dist/app.
CSC_LINK, CSC_KEY_PASSWORD Read by electron-builder to sign installers. See "Sign the installers".

Read or change one option from the command line with kitr config app (lists every option), kitr config app port 3300 or kitr config app minWidth --unset. The other platform types each have their own guide: Web, Server, Mobile and Tauri. Platforms lists them side by side.

Open your first window

Run the desktop platform:

kitr dev app

First time? Run npm i -g kitr once. Inside a project, the global kitr runs your project's own version.

kitr starts Vite on port 3200, waits until it answers, then starts Electron through electron-vite with ELECTRON_RENDERER_URL=http://localhost:3200. The window opens on your app, with devtools detached next to it. Edit a component and the window updates in place, like the browser does.

The scaffold already declared two commands in platforms/app/ipc.ts and wrote a typed client for the window:

// app/ipc.ts
import { createIpcClient } from '@kitr/desktop/client'
import type { Ipc, IpcPush } from '../platforms/app/ipc'

/** Typed renderer-side client. `ipc.available` is false in a plain browser, so the web build still runs. */
export const ipc = createIpcClient<Ipc, IpcPush>()

Show the app version from the main process in the footer:

// app/components/app-version.tsx
import { useEffect, useState } from 'react'
import { ipc } from '#app/ipc'

export function AppVersion() {
  const [version, setVersion] = useState<string>()
  useEffect(() => {
    if (ipc.available) void ipc.invoke('app.version').then(setVersion)
  }, [])
  return <small>{version ? `Acme Orders ${version}` : 'Acme Orders on the web'}</small>
}

In the Electron window the footer reads Acme Orders 0.1.0, the version from package.json. In the browser (kitr dev site) it reads Acme Orders on the web, because there is no main process to ask.

Declare an IPC command

Every call from the window to the main process is declared once, as a TypeScript type, in platforms/app/ipc.ts. The key is the channel name and the value is the function signature the window sees. Main, preload and the window all import this one file, so a typo or a wrong argument is a type error, not a silent undefined.

// platforms/app/ipc.ts
/**
 * The IPC contract shared by main, preload and the renderer. Add a command here and to
 * `channels`, implement it in main.ts, call it from the app with `ipc.invoke('...')`.
 */
export type Ipc = {
  'app.version': () => string
  'dialog.openFile': (filters?: Array<{ name: string; extensions: string[] }>) => string | null
  'invoices.save': (invoice: { id: string; customer: string; pdf: Uint8Array }) => string | null
}

/** Events pushed from main to the renderer. */
export type IpcPush = {
  'app.focus': (focused: boolean) => void
  'orders.syncProgress': (percent: number) => void
}

/** Every channel above, at run time: preload lets only these (and kitr:*) through. */
export const channels = {
  'app.version': true,
  'dialog.openFile': true,
  'invoices.save': true,
  'app.focus': true,
  'orders.syncProgress': true,
} satisfies Record<keyof Ipc | keyof IpcPush, true>

channels is the same list at run time, for the preload script's allow list (Limit which channels reach the window). The satisfies makes a channel missing from it, or left over after you delete a command, a type error. Name channels <area>.<verb>, as above. Arguments and return values cross the process boundary through the structured clone algorithm: plain objects, arrays, strings, numbers, dates, Uint8Array and Map work, while functions, class instances and DOM nodes do not. A command may return a value or a promise; the window always gets a promise. Channels starting with kitr: are reserved for the ones handleKitrIpc() serves.

Implement a command in the main process

In the main process, handleIpc<Ipc>(ipcMain, handlers) registers one handler per channel. Each handler gets Electron's IpcMainInvokeEvent first, then the arguments the window passed. TypeScript requires a handler for every channel in Ipc, with the right argument and return types.

// platforms/app/main.ts
import { writeFile } from 'node:fs/promises'
import { app, BrowserWindow, dialog, ipcMain, protocol } from 'electron'
import { createIpcEmitter, createMainWindow, handleIpc, handleKitrIpc, registerAppScheme } from '@kitr/desktop/main'
import type { Ipc, IpcPush } from './ipc'

const window = { title: 'Acme Orders', width: 1280, height: 860 }

registerAppScheme(protocol)

const push = createIpcEmitter<IpcPush>(() => BrowserWindow.getAllWindows()[0]?.webContents)

handleIpc<Ipc>(ipcMain, {
  'app.version': () => app.getVersion(),
  'dialog.openFile': async (_event, filters) => {
    const result = await dialog.showOpenDialog({ properties: ['openFile'], filters })
    return result.canceled ? null : (result.filePaths[0] ?? null)
  },
  'invoices.save': async (_event, invoice) => {
    const result = await dialog.showSaveDialog({
      title: `Save invoice for ${invoice.customer}`,
      defaultPath: `${invoice.id}.pdf`,
      filters: [{ name: 'PDF', extensions: ['pdf'] }],
    })
    if (result.canceled || !result.filePath) return null
    await writeFile(result.filePath, invoice.pdf)
    return result.filePath
  },
})

app.whenReady().then(async () => {
  handleKitrIpc(ipcMain, app)
  const win = await createMainWindow({ ...window, devtools: true })
  win.on('focus', () => push.emit('app.focus', true))
  win.on('blur', () => push.emit('app.focus', false))
  app.on('activate', () => {
    if (BrowserWindow.getAllWindows().length === 0) void createMainWindow(window)
  })
})

app.on('window-all-closed', () => {
  if (process.platform !== 'darwin') app.quit()
})

This is the scaffolded main.ts with an invoices.save handler added. When a handler throws, the promise in the window rejects with Electron's message: Error invoking remote method 'invoices.save': Error: EACCES: permission denied, open '/Volumes/Archive/inv_2041.pdf'. When no handler is registered for a channel, it rejects with Error invoking remote method 'invoices.save': Error: No handler registered for 'invoices.save'.

Don't trust the arguments because they are typed. The types hold for your own code, but the main process can read and write any file, so check paths and sizes in the handler as you would on a server.

Call the main process from a component

In the window, ipc.invoke(channel, ...args) sends the call and resolves with the handler's return value. The channel name autocompletes and the arguments are checked against Ipc:

// app/components/invoices/save-invoice-button.tsx
import { useState } from 'react'
import { ipc } from '#app/ipc'

export function SaveInvoiceButton({ id, customer }: { id: string; customer: string }) {
  const [savedTo, setSavedTo] = useState<string | null>(null)
  const [error, setError] = useState<string>()

  async function save() {
    const res = await fetch(`/api/invoices/${id}.pdf`)
    const pdf = new Uint8Array(await res.arrayBuffer())
    try {
      setSavedTo(await ipc.invoke('invoices.save', { id, customer, pdf }))
    } catch (e) {
      setError((e as Error).message)
    }
  }

  if (!ipc.available) return <a href={`/api/invoices/${id}.pdf`} download>Download invoice</a>
  return (
    <>
      <button type="button" onClick={save}>Save invoice as PDF</button>
      {savedTo ? <p>Saved to {savedTo}</p> : null}
      {error ? <p role="alert">{error}</p> : null}
    </>
  )
}

Clicking the button for inv_2041 opens the native save dialog with inv_2041.pdf filled in. Pick a folder and the page shows Saved to /Users/ada/Documents/inv_2041.pdf. Cancel it and savedTo stays null. In the browser the same component renders a plain download link instead.

Push events from the main process

Some things start in the main process: a window gains focus, a background sync makes progress, a menu item is clicked. Declare them in IpcPush, send them with createIpcEmitter<IpcPush>() in main, and listen with ipc.on() in the window.

// platforms/app/sync.ts
import { BrowserWindow } from 'electron'
import { createIpcEmitter } from '@kitr/desktop/main'
import type { IpcPush } from './ipc'

const push = createIpcEmitter<IpcPush>(() => BrowserWindow.getAllWindows()[0]?.webContents)

export async function syncOrders(batches: string[][]) {
  for (const [index, batch] of batches.entries()) {
    await fetch('https://app.acme.com/api/orders/sync', { method: 'POST', body: JSON.stringify(batch) })
    push.emit('orders.syncProgress', Math.round(((index + 1) / batches.length) * 100))
  }
}

createIpcEmitter(target) takes a function, not a window, so it finds the current window at send time. When it returns undefined (no window open yet, or all closed), emit() does nothing. In the window, ipc.on() returns the unsubscribe function, which fits useEffect:

// app/components/orders/sync-progress.tsx
import { useEffect, useState } from 'react'
import { ipc } from '#app/ipc'

export function SyncProgress() {
  const [percent, setPercent] = useState(0)
  useEffect(() => ipc.on('orders.syncProgress', setPercent), [])
  return <progress max={100} value={percent}>{percent}%</progress>
}

After the first of four batches the bar shows 25%, after the last one 100%. Outside Electron, ipc.on() returns a no-op unsubscribe, so the effect is safe in the browser too.

Send a message without waiting for an answer

ipc.send(channel, ...args) fires a one-way message, for things the window reports but does not wait on, such as analytics or log lines. It is not typed against Ipc, and handleIpc() does not receive it: listen with Electron's ipcMain.on() in main.

// app/lib/orders/log-view.ts
import { ipc } from '#app/ipc'

export function logOrderViewed(orderId: string) {
  ipc.send('orders.viewed', orderId)
}
// platforms/app/main.ts
import { ipcMain } from 'electron'

ipcMain.on('orders.viewed', (_event, orderId: string) => {
  console.log(`[acme] order ${orderId} viewed`)
})
// logOrderViewed('ord_1042') => prints "[acme] order ord_1042 viewed" in the terminal running kitr dev

Prefer invoke() when the window needs a result or should know about a failure. send() gives neither.

Keep the web build working

The same app/ ships to the browser through your web platform, where there is no Electron. kitr gives you two checks:

Check True when
ipc.available The preload bridge is present, so invoke() will reach main.
isElectron() The bridge is present, or the user agent contains Electron.

Use ipc.available before calling the main process. Without the bridge, ipc.invoke() rejects with [@kitr/desktop] IPC bridge "kitrIpc" is not available (not running in Electron, or the preload script did not call exposeIpc), ipc.on() returns a no-op and ipc.send() does nothing. Use isElectron() for anything that only depends on running in the shell:

// app/components/title-bar.tsx
import { isElectron } from '@kitr/desktop/client'

export function TitleBar() {
  // Electron draws the window title; the browser tab shows document.title instead.
  if (isElectron()) return null
  return <header className="border-b px-4 py-2 font-semibold">Acme Orders</header>
}

For behaviour that differs per platform but is not specific to Electron, such as opening links or reading the app version, use the platform service instead of isElectron(). It answers the same way in Tauri and on phones.

Don't sniff the user agent for Electron yourself, and don't import electron in app/: the window runs sandboxed and the import fails at run time. Everything the window needs from Node goes through an IPC command.

Limit which channels reach the window

exposeIpc(contextBridge, ipcRenderer) in the preload script puts a small bridge at window.kitrIpc with invoke, on and send. Nothing else from Node or Electron crosses into the page. On its own the bridge forwards any channel name, so the scaffolded preload passes allow to forward only the channels ipc.ts declares:

// platforms/app/preload.ts
import { contextBridge, ipcRenderer } from 'electron'
import { allowChannels, exposeIpc } from '@kitr/desktop/preload'
import { channels } from './ipc'

// Only the channels declared in ipc.ts (and kitr's own kitr:* channels) reach the renderer.
exposeIpc(contextBridge, ipcRenderer, { allow: allowChannels(channels) })

allowChannels() takes the channels record or a plain array, and always lets the kitr: channels through. allow also takes any (channel) => boolean. A call to any other channel rejects with IPC channel "fs.readAnything" is not allowed; on() and send() for it do nothing. A hand-written allow must keep the kitr: channels, because the provider's storage and platform drivers use them.

Warning The allow list is your boundary if the window ever loads content you did not write, such as a remote page or user HTML. Keep it to the channels the app uses, and keep the scaffold's contextIsolation: true, sandbox: true and nodeIntegration: false. Turning any of them off gives page scripts direct access to Node.js and the file system.

To expose the bridge under another name (two bridges in one window, say), pass the same key to both sides:

// app/admin-ipc.ts
import { createIpcClient } from '@kitr/desktop/client'
import type { AdminIpc } from '../platforms/app/admin-ipc'

// preload: exposeIpc(contextBridge, ipcRenderer, { key: 'acmeAdminIpc' })
export const adminIpc = createIpcClient<AdminIpc>('acmeAdminIpc')

isElectron() and the provider only look for the default kitrIpc key, so keep the default bridge too.

Unregister handlers

handleIpc() returns a function that removes every handler it registered. Use it for handlers that belong to a feature you switch off at run time, or when you register them again after a reload:

// platforms/app/admin.ts
import { ipcMain } from 'electron'
import { handleIpc } from '@kitr/desktop/main'
import type { AdminIpc } from './admin-ipc'

let stop: (() => void) | undefined

export function enableAdminTools() {
  stop = handleIpc<AdminIpc>(ipcMain, {
    'teams.list': async () => [{ id: 'team_1', name: 'Warehouse', lead: 'Grace' }],
  })
}

export function disableAdminTools() {
  stop?.()
}

After disableAdminTools(), adminIpc.invoke('teams.list') rejects with No handler registered for 'teams.list'. Electron allows one handler per channel: registering the same channel twice throws Attempted to register a second handler for 'teams.list', so remove the old handlers first.

Configure the main window

createMainWindow(options) creates a BrowserWindow with secure defaults, wires the preload script and loads the right URL. It takes every BrowserWindow option plus four of its own:

Option Default Description
devUrl process.env.ELECTRON_RENDERER_URL URL to load in development. kitr dev sets the variable.
clientDir ../../client next to the main bundle The built client, served at app://-/ when there is no dev URL.
preload ../preload/index.js next to the main bundle The built preload script.
devtools true Open devtools, detached, when loading the dev URL. Never in a build.
width, height 1200, 800 Any BrowserWindow option is passed through.
show false The window is shown on ready-to-show, so it never flashes white.
webPreferences contextIsolation: true, nodeIntegration: false, sandbox: true Merged over the defaults.

A second window for one order, with a fixed size and a title bar that blends into the page on macOS:

// platforms/app/order-window.ts
import { createMainWindow } from '@kitr/desktop/main'

export async function openOrderWindow(orderId: string) {
  const win = await createMainWindow({
    title: `Order ${orderId}`,
    width: 720,
    height: 640,
    resizable: false,
    titleBarStyle: 'hiddenInset',
  })
  await win.loadURL(win.webContents.getURL().replace(/\/?$/, `/orders/${orderId}`))
  return win
}

createMainWindow() resolves after the first page has loaded, with the BrowserWindow. The default paths assume the layout the scaffold builds (dist/app/electron/main, dist/app/electron/preload, dist/app/client); pass clientDir and preload if you change outDir in electron.vite.config.ts.

Serve the built client

In development the window loads the Vite dev server. In a build there is no server, so the window loads app://-/ and the main process answers from dist/app/client. Two functions make that work, and createMainWindow() calls the second for you:

  • registerAppScheme(protocol) marks app:// as a standard, secure scheme with fetch and streaming support, so absolute asset paths, fetch(), cookies and service workers behave as on https://. Call it at the top of main.ts, before app.whenReady(): Electron only accepts it before the app is ready.
  • serveClient(protocol, clientDir) answers app:// requests from the folder. A path that ends in / serves its index.html. A path with no file falls back to index.html (or _shell.html), so client-side routes such as app://-/orders/ord_1042 load the app. A path that climbs out of the folder (../) gets 403 Forbidden.

Call serveClient() yourself only when you load the client without createMainWindow():

// platforms/app/main.ts
import { join } from 'node:path'
import { app, BrowserWindow, protocol } from 'electron'
import { APP_SCHEME, registerAppScheme, serveClient } from '@kitr/desktop/main'

registerAppScheme(protocol)

app.whenReady().then(async () => {
  serveClient(protocol, join(__dirname, '../../client'))
  const win = new BrowserWindow({ width: 1280, height: 860 })
  await win.loadURL(`${APP_SCHEME}://-/orders`)
})

APP_SCHEME is 'app'. Requests the client makes to https://app.acme.com/api/... go to the network as usual; only app:// is served from disk.

Keep data on the user's machine

Items you declare with Storage persist in a JSON file in Electron's userData folder, not in Chromium's localStorage. You declare and read them the same way as on the web:

// app/storage.ts
import { defineStorage } from '@kitr/storage'

export const ordersView = defineStorage('orders-view', {
  default: { status: 'open' as 'open' | 'paid' | 'shipped', pageSize: 25 },
})

The provider registers electronStorageDriver(), which loads every value once at boot and writes each change in the background over the kitr:storage:load, kitr:storage:set and kitr:storage:remove channels. handleKitrIpc(ipcMain, app) in main.ts serves them and writes kitr-storage.json. Pass a third argument to pick the file:

// platforms/app/main.ts
import { join } from 'node:path'
import { app, ipcMain } from 'electron'
import { handleKitrIpc } from '@kitr/desktop/main'

app.whenReady().then(() => {
  handleKitrIpc(ipcMain, app, { storageFile: join(app.getPath('userData'), 'acme-orders.json') })
})

On macOS the default file is ~/Library/Application Support/Acme Orders/kitr-storage.json. Writes are queued, so two quick changes never interleave. If the file is missing or not valid JSON, the store starts empty. Without handleKitrIpc(), reads find nothing and every write logs [@kitr/storage] electron:ipc: with Electron's "No handler registered" error.

Use the platform service on the desktop

The provider also registers a desktop driver for Platform. Code that uses the platform service works in Electron without a single isElectron() check:

Member In Electron
type 'desktop'
native, standalone true
os 'macos', 'windows', 'linux' or 'unknown', from the user agent
appVersion() app.getVersion() from main, through kitr:app:version
openExternal(url) Opens the system browser with shell.openExternal(), through kitr:shell:open
exit() Quits with app.quit(), through kitr:app:quit
onAppState(fn) true when the page is visible, false when the window is minimised or hidden
// app/components/help-menu.tsx
import { usePlatform } from '@kitr/platform/react'

export function HelpMenu() {
  const platform = usePlatform()
  return (
    <nav>
      <button type="button" onClick={() => void platform.openExternal('https://status.acme.com')}>
        Service status
      </button>
      {platform.is('desktop') ? (
        <button type="button" onClick={() => void platform.exit()}>Quit Acme Orders</button>
      ) : null}
    </nav>
  )
}

In Electron, "Service status" opens Ada's default browser instead of replacing the app's own page, and "Quit Acme Orders" closes the app. In the browser the first opens a new tab and the second is not rendered. These three methods use the kitr:* channels, so they need handleKitrIpc() in main.ts, which the scaffold calls.

Build installers

kitr build app builds everything and packages the app for the machine you run it on:

kitr build app

First time? Run npm i -g kitr once. Inside a project, the global kitr runs your project's own version.

It runs three steps in order and stops at the first failure:

  1. vite build: the client into dist/app/client.
  2. electron-vite build -c platforms/app/electron.vite.config.ts: main and preload into dist/app/electron.
  3. electron-builder --config platforms/app/electron-builder.yml: installers into dist/app/release.

On success it logs app: installers are in dist/app/release. The scaffolded electron-builder.yml makes an NSIS installer (Acme Orders Setup 0.1.0.exe) on Windows, a .dmg on macOS and an AppImage on Linux. The version is version in package.json.

Two flags shorten the build:

kitr build app --no-pack   # client, main and preload only; no electron-builder
kitr build app --dir       # an unpacked app in dist/app/release, no installer

First time? Run npm i -g kitr once. Inside a project, the global kitr runs your project's own version.

--no-pack is the fast check that everything compiles. --dir makes a folder you can run directly, such as dist/app/release/win-unpacked/Acme Orders.exe on Windows or Acme Orders.app in a mac or mac-arm64 folder on macOS. It is quicker than an installer when you test packaging.

Run the production build locally

kitr start app runs electron . in the project root. Electron reads "main" from package.json, so it starts the built main process from dist/app/electron/main/index.js, which loads the built client over app://:

kitr build app --no-pack
kitr start app

First time? Run npm i -g kitr once. Inside a project, the global kitr runs your project's own version.

This is the app your users get, without installing it: no dev server, no devtools, the real app:// serving. If the window stays blank, check that "main" in package.json is dist/app/electron/main/index.js; kitr doctor warns when it is not.

Package for another operating system

electron-builder packages for the OS it runs on by default. To pick targets, edit platforms/app/electron-builder.yml with any electron-builder option:

# platforms/app/electron-builder.yml
appId: com.acme.orders
productName: Acme Orders
directories:
  output: dist/app/release
files:
  - dist/app/client/**
  - dist/app/electron/**
  - package.json
mac:
  category: public.app-category.business
  target: [dmg, zip]
win:
  target: [nsis, portable]
linux:
  target: [AppImage, deb]
  category: Office

kitr build app passes only --config and --dir to electron-builder. For one OS, or for flags kitr does not forward, build the code with kitr and run electron-builder yourself:

kitr build app --no-pack
pnpm exec electron-builder --config platforms/app/electron-builder.yml --win --x64

Building a macOS app needs a Mac. Windows installers can be built on Linux and macOS with Wine for some targets; CI runners for each OS are the reliable route, as the multi-platform build guide describes.

Sign the installers

Unsigned apps trigger Gatekeeper on macOS and SmartScreen on Windows. electron-builder signs during packaging when it finds a certificate, and kitr build passes your environment through to it:

export CSC_LINK=./certs/acme-developer-id.p12
export CSC_KEY_PASSWORD="$ACME_CERT_PASSWORD"
kitr build app

CSC_LINK is a path, URL or base64 of the .p12/.pfx certificate, and CSC_KEY_PASSWORD its password. On macOS, notarization also needs your Apple credentials. The options and variables are electron-builder's own; see code signing and the macOS pages. kitr adds no signing settings of its own.

Warning Never commit the certificate or its password. Keep both in your CI's secret store and set them only on the job that builds releases.

Switch to Tauri

The app code does not change between the two desktop platforms: both serve the same client build and register the same platform and storage drivers. What changes is the native side. Electron IPC commands in platforms/app/main.ts become Rust commands, and ipc.invoke() calls become commands.<name>() from @kitr/tauri/client:

kitr platforms remove app
kitr add tauri app

First time? Run npm i -g kitr once. Inside a project, the global kitr runs your project's own version.

Port each handler in main.ts to a #[tauri::command] in platforms/app/src-tauri/src/lib.rs, as the Tauri guide shows, then remove @kitr/desktop as described under Install. Storage values do not move: Electron's kitr-storage.json and Tauri's kitr.json are separate files.

Commands

@kitr/desktop adds no generators. The desktop platform answers these commands:

Command What it does
kitr add desktop <name> Adds a desktop platform: installs Electron's toolchain, writes platforms.<name>, adds router, platform and storage, scaffolds platforms/<name>/.
kitr dev <name> Starts Vite on port and Electron through electron-vite against it. Flag: --port.
kitr build <name> Runs vite build, electron-vite build and electron-builder. Flags: --no-pack, --dir.
kitr start <name> Runs electron ., which starts the built main process.
kitr config <name> [option] [value] Reads or sets an option in kitrrc.ts. --unset removes one.
kitr platforms Lists every platform with its options.
kitr platforms scaffold <name> Writes the scaffolded files that are missing. Existing files are kept.
kitr platforms remove <name> Takes the platform out of kitrrc.ts, with its import and provider when it was the last desktop platform.

Any option is also a flag on dev, build and start for one run: kitr dev app --port 3300.

kitr doctor checks each desktop platform and reports:

  • an error for each missing dev dependency: electron, electron-vite, electron-builder. Fix: pnpm add -D <package>.
  • an error when platforms/app/main.ts is missing. Fix: kitr platforms scaffold app.
  • a warning when "main" in package.json is not dist/app/electron/main/index.js.

Errors

Message When What to do
[@kitr/desktop] IPC bridge "kitrIpc" is not available (not running in Electron, or the preload script did not call exposeIpc) ipc.invoke() ran in a browser, or the preload script did not run. Check ipc.available first. In Electron, check that preload.ts calls exposeIpc() and the preload path is right.
IPC channel "<channel>" is not allowed The preload allow function rejected the channel. Add the channel to the allow list.
Error invoking remote method '<channel>': Error: No handler registered for '<channel>' No handleIpc() handler for the channel, or it was removed. Implement it in main.ts. For kitr:* channels, call handleKitrIpc().
Error invoking remote method '<channel>': <error> The handler in main threw. Read the error after the colon; it is the one main threw.
Attempted to register a second handler for '<channel>' handleIpc() registered a channel that already has a handler. Call the dispose function from the first handleIpc() first.
Platform name "<name>" must be a short identifier (site, api, app, phone...) kitr add desktop got a name with spaces or symbols. Use letters, digits, - and _.
desktop has no option "<flag>" (options: port, title, width, height, minWidth, minHeight, appId, devtools) kitr add desktop got an unknown flag. Use one of the listed options; names are camelCase.
electron-builder --config <path> exited with code 1 Packaging failed. Read electron-builder's output above it: usually a missing icon, signing or a target the OS cannot build.

Testing

Unit tests run in Node or happy-dom, where there is no Electron. kitr's three IPC pieces take Electron's objects as arguments (ipcMain, ipcRenderer, contextBridge), so a test passes small fakes and runs main, preload and the window in one process. The fakes below wire ipcRenderer.invoke straight to the handlers ipcMain.handle registered.

This test checks the invoices.save command end to end, that the desktop driver answers the platform service, and that storage writes land in the file:

// test/desktop.test.ts
import { mkdtemp, readFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { createIpcClient, IPC_BRIDGE_KEY } from '@kitr/desktop/client'
import { handleIpc, handleKitrIpc } from '@kitr/desktop/main'
import { exposeIpc } from '@kitr/desktop/preload'
import { desktopProvider } from '@kitr/desktop/provider'
import { platformProvider } from '@kitr/platform/provider'
import { createTestApp } from '@kitr/testing'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { Ipc, IpcPush } from '../platforms/app/ipc'

type Handler = (event: unknown, ...args: any[]) => unknown

function fakeElectron() {
  const handlers = new Map<string, Handler>()
  const ipcMain = {
    handle: (channel: string, handler: Handler) => void handlers.set(channel, handler),
    removeHandler: (channel: string) => void handlers.delete(channel),
  }
  const ipcRenderer = {
    invoke: async (channel: string, ...args: unknown[]) => {
      const handler = handlers.get(channel)
      if (!handler) throw new Error(`No handler registered for '${channel}'`)
      return handler({}, ...args)
    },
    on: vi.fn(),
    removeListener: vi.fn(),
    send: vi.fn(),
  }
  const contextBridge = {
    exposeInMainWorld: (key: string, value: unknown) => {
      ;(globalThis as any)[key] = value
    },
  }
  return { ipcMain, ipcRenderer, contextBridge }
}

afterEach(() => {
  delete (globalThis as any)[IPC_BRIDGE_KEY]
})

describe('Acme Orders desktop', () => {
  it('saves an invoice through the main process', async () => {
    const electron = fakeElectron()
    const saved: Array<{ id: string; bytes: number }> = []
    handleIpc<Ipc>(electron.ipcMain as any, {
      'app.version': () => '2.4.0',
      'dialog.openFile': () => null,
      'invoices.save': async (_event, invoice) => {
        saved.push({ id: invoice.id, bytes: invoice.pdf.length })
        return `/Users/ada/Documents/${invoice.id}.pdf`
      },
    })
    exposeIpc(electron.contextBridge as any, electron.ipcRenderer as any)

    const ipc = createIpcClient<Ipc, IpcPush>()
    expect(ipc.available).toBe(true)
    const path = await ipc.invoke('invoices.save', {
      id: 'inv_2041',
      customer: 'Ada',
      pdf: new Uint8Array([37, 80, 68, 70]),
    })
    expect(path).toBe('/Users/ada/Documents/inv_2041.pdf')
    expect(saved).toEqual([{ id: 'inv_2041', bytes: 4 }])
  })

  it('answers the platform service and persists storage in userData', async () => {
    const electron = fakeElectron()
    const userData = await mkdtemp(join(tmpdir(), 'acme-orders-'))
    const fakeApp = { getPath: () => userData, getVersion: () => '2.4.0', quit: vi.fn() }
    handleKitrIpc(electron.ipcMain as any, fakeApp as any)
    exposeIpc(electron.contextBridge as any, electron.ipcRenderer as any)

    const app = await createTestApp({ providers: [desktopProvider, platformProvider] })
    const platform = app.container.use('platform')
    expect(platform.type).toBe('desktop')
    expect(platform.native).toBe(true)
    expect(await platform.appVersion()).toBe('2.4.0')

    await platform.exit()
    expect(fakeApp.quit).toHaveBeenCalledOnce()

    const ipc = createIpcClient<Ipc>()
    await (ipc as any).invoke('kitr:storage:set', 'orders-view', '{"status":"paid"}')
    const file = JSON.parse(await readFile(join(userData, 'kitr-storage.json'), 'utf8'))
    expect(file).toEqual({ 'orders-view': '{"status":"paid"}' })
  })

  it('stays out of the way in a browser', async () => {
    const ipc = createIpcClient<Ipc>()
    expect(ipc.available).toBe(false)
    await expect(ipc.invoke('app.version')).rejects.toThrow(/not available/)
  })
})

The handlers in a real app call Electron's dialog and fs, which do not exist in Vitest. Keep the logic in plain functions that take what they need (saveInvoice(invoice, writeFile)), test those directly, and keep the handleIpc() handlers as thin wrappers. Test the real window with kitr dev app, and the packaged app with kitr build app --dir and kitr start app.

Next steps