desktop
Some teams want the app in the dock or the Start menu: a window that opens offline and saves files where users choose. @kitr/desktop 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 behaves the same on every machine. kitr adds:
kitr devwith hot reload through electron-vite, andkitr buildfor installers through electron-builder.- One TypeScript type describing every call between the window and the main process.
@kitr/desktop/provider, which moves platform and storage to their Electron drivers.
The code runs in three places: @kitr/desktop/client in the window (standing aside in a plain browser), @kitr/desktop/main in Electron's main process, and @kitr/desktop/preload between the two.
The examples ship the Acme orders app to the desktop: a warehouse team checks orders, saves invoices as PDF files and watches sync progress, with the code the browser runs at app.acme.com.
When to use it
Use @kitr/desktop for most desktop apps. Chromium ships inside the app, so every user runs the page your team tested, and the main process is plain Node.js, so any npm package works.
Use Tauri when download size matters more than identical rendering: it uses the system webview, which differs between platforms, and needs Rust to build. Your app code stays the same either way.
Install
Add a desktop platform named app:
kitr add desktop appFirst time? Run npm i -g @kitr/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 it uses app. A name starts with a letter and holds letters, digits, - and _.
Everything kitr add changes
What kitr add desktop app does:
- Installs
@kitr/desktopand adds it topackagesinkitrrc.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 writesconfig/storage.tsandapp/storage.ts. - Installs
electron,electron-viteandelectron-builderas dev dependencies. - Adds
() => import('@kitr/desktop/provider')toprovidersinkitrrc.ts. - Writes
app: desktop({...})intoplatformsinkitrrc.ts, with the flags you passed as options, and addsimport { desktop } from '@kitr/desktop'. - Sets the
dev,buildandstartscripts inpackage.jsontokitr dev,kitr buildandkitr start. - Writes
platforms/app/ipc.ts,main.ts,preload.ts,electron.vite.config.tsandelectron-builder.yml, andapp/ipc.tsfor the window. Files that already exist are kept. - Sets
"main"inpackage.jsontodist/app/electron/main/index.js, so Electron and electron-builder find the built main process. - Adds a
kitr: platform appsection toAGENTS.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.ordersFirst time? Run npm i -g @kitr/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 has an app desktop platform next to a site web platform and a home page that calls the typed IPC:
kitr new acme-orders --kit desktopFirst time? Run npm i -g @kitr/kitr once. Inside a project, the global kitr runs your project's own version.
Removing the platform
kitr platforms remove app takes the platform out of kitrrc.ts (with the last one, also the desktop import and provider line). It leaves platforms/app/ for you to delete. kitr remove desktop does this for every desktop platform and removes @kitr/desktop; the Electron dev dependencies and the "main" field stay.
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/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. |
With title: 'Acme Orders' and appId: 'com.acme.orders', the packaging config 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.
| 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 with kitr config app, kitr config app port 3300 or kitr config app minWidth --unset.
Open your first window
Run the desktop platform:
kitr dev appFirst time? Run npm i -g @kitr/kitr once. Inside a project, the global kitr runs your project's own version.
kitr starts Vite, waits until it answers, then starts Electron. The window opens on your app with devtools beside it, and edits update in place.
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 Electron 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.
Declare an IPC command
Every call from the window to the main process is declared once in platforms/app/ipc.ts: the key is the channel name and the value is the function signature. Main, preload and the window import this one file, so a typo or wrong argument is a type error.
// 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's allow list. Name channels <area>.<verb>; kitr: names are reserved.
Arguments and results must be plain data: objects, arrays, strings, numbers, dates, Uint8Array and Map work; functions and class instances do not.
Implement a command in the main process
handleIpc<Ipc>(ipcMain, handlers) registers one handler per channel; each gets Electron's IpcMainInvokeEvent, then the window's arguments. TypeScript requires a handler for every channel in Ipc.
// 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.
Call the main process from a component
ipc.invoke(channel, ...args) resolves with the handler's return value; the channel name autocompletes and 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}
</>
)
}
The button opens the native save dialog with inv_2041.pdf filled in; cancelling leaves savedTo as null. In the browser the component renders a plain download link.
Push events from the main process
Some things start in the main process, like sync progress. Declare them in IpcPush, send 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 that finds the current window at send time; with no window, emit() does nothing. 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>
}
Outside Electron, ipc.on() returns a no-op unsubscribe, so the effect is safe in the browser.
Send a message without waiting for an answer
ipc.send(channel, ...args) fires a one-way message, such as a log line. It is not typed against Ipc; 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
Keep the web build working
The same app/ ships to the browser, where there is no Electron. 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. |
Check ipc.available before calling the main process. Without the bridge, ipc.invoke() rejects, 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 is not specific to Electron, such as opening links or reading the app version, use the platform service; it answers the same way in Tauri and on phones.
Limit which channels reach the window
exposeIpc(contextBridge, ipcRenderer) in the preload puts a bridge at window.kitrIpc with invoke, on and send; nothing else from Node crosses into the page. 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 an array and always lets the KITR_CHANNELS through, which the provider's storage, platform and device drivers use. allow also takes any (channel) => boolean; a hand-written one must keep KITR_CHANNELS. Any other channel rejects with IPC channel "fs.readAnything" is not allowed.
To expose the bridge under another name, 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 that bridge too.
Unregister handlers
handleIpc() returns a function that removes every handler it registered, for features you switch off at run time:
// 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?.()
}
Configure the main window
createMainWindow(options) creates a BrowserWindow with secure defaults, wires the preload 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
}
It resolves after the first page loads. Pass clientDir and preload if you change outDir in electron.vite.config.ts.
Serve the built client
In a build there is no dev 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)makesapp://behave likehttps://for asset paths,fetch(), cookies and service workers. Call it at the top ofmain.ts, beforeapp.whenReady().serveClient(protocol, clientDir)answersapp://requests from the folder. A path with no file falls back toindex.html, so client routes likeapp://-/orders/ord_1042load the app; a path that climbs out of the folder gets403.
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`)
})
Requests 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
Storage items persist in a JSON file in Electron's userData folder, not Chromium's localStorage. You declare and read them 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's electronStorageDriver() loads every value at boot and writes changes in the background; 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. A missing or invalid file starts the store empty.
Run the device packages in Electron
Secure storage, Notifications, Files, Updater and Native each have an Electron driver. The same handleKitrIpc(ipcMain, app) call that serves storage serves all of them, with nodeIntegration off:
| Package | Main-process work | Channels |
|---|---|---|
secure-storage |
safeStorage encrypts; ciphertext in kitr-secure-storage.json in userData |
kitr:secure:get, set, remove, keys |
notifications |
Notification, timers for scheduled ones, app.setBadgeCount; click and close are pushed back |
kitr:notify:permission, show, schedule, cancel, cancel-all, pending, badge; kitr:notify:click, close |
files |
dialog.showOpenDialog and showSaveDialog, then fs/promises on the paths they returned |
kitr:files:open, choose-save, read, write, stat, list, reveal |
updater |
electron-updater: check, download with progress, quitAndInstall |
kitr:updater:check, download, install; kitr:updater:available, progress |
native |
clipboard, Touch ID, net.isOnline(), nativeTheme, powerMonitor, shell.openExternal |
kitr:native:*, kitr:shell:open; kitr:native:theme, power |
The window gets only what it earned: files are touched only at paths a dialog returned, secure storage refuses to store without an operating system vault, and openExternal opens only http:, https:, mailto: and tel: links. Options go in the third argument:
// platforms/app/main.ts
import { join } from 'node:path'
import { app, ipcMain } from 'electron'
import { handleKitrIpc } from '@kitr/desktop/main'
app.whenReady().then(() => {
const stop = handleKitrIpc(ipcMain, app, {
storageFile: join(app.getPath('userData'), 'acme-orders.json'),
secureFile: join(app.getPath('userData'), 'acme-vault.json'),
externalProtocols: ['https:', 'mailto:', 'acme:'],
})
app.on('will-quit', stop) // removes the handlers, timers and power listeners
})
Device handlers and electron-updater
handleKitrIpc() returns a function that removes the device handlers. To register only those, call handleKitrDevices(ipcMain, app, options). electron-updater is imported when the updater is first used; kitr add desktop and kitr add updater add it to dependencies, where electron-builder packs it.
Use the platform service on the desktop
The provider registers a desktop driver for Platform, so platform code works in Electron without isElectron() checks:
| 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 the default browser and "Quit Acme Orders" closes the app; in the browser the first opens a tab and the second is not rendered. 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 appFirst time? Run npm i -g @kitr/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:
vite build: the client intodist/app/client.electron-vite build -c platforms/app/electron.vite.config.ts: main and preload intodist/app/electron.electron-builder --config platforms/app/electron-builder.yml: installers intodist/app/release.
The scaffolded electron-builder.yml makes an NSIS installer on Windows, a .dmg on macOS and an AppImage on Linux, versioned from 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 installerFirst time? Run npm i -g @kitr/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 an unpacked folder you can run directly, quicker than an installer when testing packaging.
Run the production build locally
kitr start app runs electron ., which starts the built main process from "main" in package.json and loads the built client over app://:
kitr build app --no-pack
kitr start appFirst time? Run npm i -g @kitr/kitr once. Inside a project, the global kitr runs your project's own version.
This is the app your users get, without installing it. If the window stays blank, check that "main" 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. 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. CI runners for each OS are the reliable route; see the multi-platform build guide.
Sign the installers
Unsigned apps trigger Gatekeeper on macOS and SmartScreen on Windows. electron-builder signs during packaging when it finds a certificate in the environment kitr build passes through:
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. macOS notarization also needs Apple credentials; see electron-builder's code signing and macOS pages.
Switch to Tauri
App code does not change between the two desktop platforms. The native side does: 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 appFirst time? Run npm i -g @kitr/kitr once. Inside a project, the global kitr runs your project's own version.
Port each handler to a #[tauri::command] in platforms/app/src-tauri/src/lib.rs, as the Tauri guide shows, then remove @kitr/desktop. Storage values do not move between Electron's kitr-storage.json and Tauri's kitr.json.
Commands
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>.electron-updateris not checked: it is only needed once you use the updater. - an error when
platforms/app/main.tsis missing. Fix:kitr platforms scaffold app. - a warning when
"main"inpackage.jsonis notdist/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. |
@kitr/desktop: the operating system has no secret vault (safeStorage); secrets are not stored |
secureStorage.set() on a machine with no keychain, or on Linux with only the basic_text backend. |
Install and unlock GNOME Keyring or KWallet. Nothing was written. |
@kitr/desktop: <path> was not chosen in a dialog; refusing to access it |
A files call named a path no open or save dialog returned this session. | Let the person pick it with files.open() or files.save(). |
@kitr/desktop: opening <scheme> links is not allowed |
openExternal() got a URL other than http, https, mailto or tel. |
Pass externalProtocols to handleKitrIpc() to allow a scheme of your own. |
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, with no Electron. The IPC pieces take ipcMain, ipcRenderer and contextBridge as arguments, so a test passes small fakes and runs main, preload and the window in one process. This test checks invoices.save end to end, the platform service and storage writes:
// 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/)
})
})
Real handlers call Electron's dialog and fs, which do not exist in Vitest. Keep logic in plain functions that take what they need (saveInvoice(invoice, writeFile)) and the handleIpc() handlers thin. Test the real window with kitr dev app, and the packaged app with kitr build app --dir and kitr start app.