tauri
Ship the same app to Windows, macOS and Linux as a small Tauri binary on the operating system's webview.
Some desktop apps have to be small: a download of a few megabytes, a window that starts fast and idles on little memory. kitr/tauri is a desktop platform for Windows, macOS and Linux built on Tauri 2. It wraps the same app/ you ship to the browser in a small Rust binary around the webview the operating system already has, and gives you typed calls from your components to Rust functions.
Tauri renders with the OS webview: WebView2 (Chromium-based) on Windows, WKWebView (Safari's engine) on macOS and WebKitGTK on Linux. Native code is Rust. kitr adds the platform on top: kitr dev runs the window with hot reload, kitr build builds installers through the Tauri CLI, defineCommands types every #[tauri::command] from TypeScript, commandQuery and commandMutation plug those commands into Query, and kitr/tauri/provider moves the platform and storage packages to their Tauri drivers.
kitr/tauri/client and the provider run in the window, and in a plain browser, where they stand aside. The Rust project runs as the app's native process. For most desktop apps we recommend Desktop (Electron) instead, which ships its own browser engine so the app renders the same on every OS.
The examples on this page ship the Acme orders app to the desktop: Ada's warehouse team at Acme lists orders, exports invoices to disk and watches order sync progress, with the same code the browser runs at app.acme.com.
When to use it
Use kitr/tauri when download size, start-up time and memory matter more than identical rendering everywhere: an internal tool on old laptops, a tray utility, an app distributed over slow connections. Installers are a few megabytes because the browser engine is not inside them. Tauri's permission system also makes you list every native capability the window may use.
Use Desktop (Electron) for most other apps. Electron ships Chromium with the app, so the page your team tests is the page every user runs, while Tauri's three webviews can differ in CSS support, media codecs and Web APIs. Electron's native side is Node.js, with the larger community and ecosystem, where Tauri's is Rust and needs the Rust toolchain on every build machine. 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
Building needs Rust and the system libraries Tauri uses. Follow Tauri's prerequisites for your OS first: Rust through rustup everywhere, the Xcode command line tools on macOS, the Microsoft C++ Build Tools and WebView2 on Windows, and webkit2gtk with a few other packages on Linux.
Then add a Tauri platform named app:
kitr add tauri appFirst time? Run npm i -g kitr once. Inside a project, the global kitr runs your project's own version.
tauri is the platform type and app is the name you address it by (kitr dev app). Without a name, kitr add tauri logs No name given: calling the tauri platform "app". and uses app. A name must be a short identifier: letters, digits, - and _, starting with a letter.
What kitr add tauri app does:
- Installs
@kitr/tauriand 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
@tauri-apps/apiand@tauri-apps/plugin-store, and@tauri-apps/clias a dev dependency. - Adds
() => import('@kitr/tauri/provider')toprovidersinkitrrc.ts. - Writes
app: tauri({...})intoplatformsinkitrrc.ts, with the flags you passed as options, and addsimport { tauri } from 'kitr/tauri'. - Sets the
dev,buildandstartscripts inpackage.jsontokitr dev,kitr buildandkitr start. - Writes the Rust project in
platforms/app/src-tauri/:tauri.conf.json,Cargo.toml,build.rs,src/main.rs,src/lib.rswith agreetcommand,capabilities/default.json,.gitignore(ignoring/targetand/gen/schemas) andicons/README.md. Files that already exist are kept. - Writes
app/commands.ts, the typed mirror of the Rust commands. - Adds a
kitr: platform appsection toAGENTS.md: how to run and build, how to add a command, and where permissions go.
kitr/tauri asks no questions. Platform options are flags on the same command, named as in the options table under Configure:
kitr add tauri app --title "Acme Orders" --width 1280 --height 860 --appId com.acme.ordersFirst 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 |
Recorded in kitrrc.ts; see the options table. |
|
--no-scaffold |
Register the platform without writing platforms/app/ or app/commands.ts. |
|
--no-install |
Edit package.json without running the package manager. |
|
--dry-run |
Print what would change. |
The tauri kit already has an app Tauri platform next to a site web platform, with a home page that calls a Rust command and listens to an event. Start a new project from it:
kitr new acme-orders --kit tauriFirst 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 leaves platforms/app/ for you to delete. kitr remove tauri then takes @kitr/tauri out of packages and the dependencies; it leaves the provider line, the tauri import and the @tauri-apps/* packages, so remove those yourself.
Configure
kitr/tauri has no config file in config/. Its options live on the tauri() call in kitrrc.ts:
// kitrrc.ts
import { defineKitrConfig } from 'kitr/app'
import { tauri } from 'kitr/tauri'
import { web } from 'kitr/web'
export default defineKitrConfig({
packages: ['@kitr/react', '@kitr/router', '@kitr/platform', '@kitr/storage', '@kitr/web', '@kitr/tauri'],
providers: [
() => import('@kitr/platform/provider'),
() => import('@kitr/storage/provider'),
() => import('@kitr/web/provider'),
() => import('@kitr/tauri/provider'),
],
platforms: {
site: web({ mode: 'spa' }),
app: tauri({ title: 'Acme Orders', width: 1280, height: 860, appId: 'com.acme.orders' }),
},
})
| Option | Default | Description |
|---|---|---|
port |
3200 |
Port of the Vite dev server, and build.devUrl in tauri.conf.json. |
title |
the package name | Window title and productName. acme-orders becomes Acme Orders. Also names the Rust crate: 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> |
identifier in tauri.conf.json. The default lower-cases the title and keeps letters and digits: Acme Orders gives com.example.acmeorders. |
devtools |
true |
Accepted for parity with Desktop; the Tauri scaffold does not use it. Debug builds of Tauri always allow the webview inspector. |
Every option is written into the scaffolded files when you run kitr add, and the Tauri CLI reads those files, not kitrrc.ts. Changing an option later does not rewrite them: edit tauri.conf.json, which is yours, or delete it and run kitr platforms scaffold app to write it again. This includes port: if you change it, change build.devUrl too, or the window waits on the old port.
With title: 'Acme Orders' and appId: 'com.acme.orders' in a project whose package.json has version 0.1.0, the platform writes:
// platforms/app/src-tauri/tauri.conf.json
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Acme Orders",
"version": "0.1.0",
"identifier": "com.acme.orders",
"build": {
"frontendDist": "../../../dist/app/client",
"devUrl": "http://localhost:3200"
},
"app": {
"windows": [
{
"title": "Acme Orders",
"width": 1280,
"height": 860
}
],
"security": {
"csp": null
}
},
"bundle": {
"active": true,
"targets": "all",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/[email protected]",
"icons/icon.icns",
"icons/icon.ico"
]
}
}
frontendDist points at this platform's client build, so kitr build app and tauri build agree on where the bundle is. Every other key is Tauri's configuration. The scaffolded Rust crate depends on tauri, tauri-plugin-store, tauri-plugin-opener, serde and serde_json, and the main window gets the core:default, store:default and opener:default permissions.
| 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. |
KITR_PLATFORM |
Set to app by kitr dev and kitr build, so the Vite plugin builds this platform into dist/app. |
TAURI_ENV_* |
Set by the Tauri CLI. The Vite plugin adds the prefix to envPrefix for Tauri platforms, so import.meta.env.TAURI_ENV_PLATFORM reads darwin, windows or linux in the window. |
Read or change one option from the command line with kitr config app (lists every option), kitr config app minWidth 960 or kitr config app minWidth --unset. The other platform types each have their own guide: Web, Server, Mobile and Desktop. Platforms lists them side by side.
Open your first window
Tauri needs the app icons listed in tauri.conf.json before it compiles. Generate every size from one 1024×1024 PNG:
cd platforms/app
pnpm exec tauri icon ../../design/acme-orders-icon.png
cd ../..
The icons land in platforms/app/src-tauri/icons/. Then run the platform:
kitr dev appFirst 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 runs tauri dev --no-watch in platforms/app. The first run compiles the Rust crate and its dependencies, which takes a few minutes; later runs take seconds. The window opens on your app and updates in place when you edit a component. With --no-watch, Tauri does not rebuild when Rust files change: stop and run kitr dev app again after editing src-tauri.
The scaffold wrote one Rust command and its typed mirror:
// platforms/app/src-tauri/src/lib.rs
/// Called from the app as `commands.greet({ name })` (app/commands.ts).
#[tauri::command]
fn greet(name: &str) -> String {
format!("Hello, {name}!")
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_store::Builder::new().build())
.plugin(tauri_plugin_opener::init())
.invoke_handler(tauri::generate_handler![greet])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
// app/commands.ts
import { defineCommands } from 'kitr/tauri/client'
/** Mirrors the `#[tauri::command]` functions in platforms/app/src-tauri/src/lib.rs. */
export type Commands = {
greet: (args: { name: string }) => string
}
/** `await commands.greet({ name })`: typed. Guard with `isTauri()` for web builds. */
export const commands = defineCommands<Commands>()
Call it from a component:
// app/components/welcome.tsx
import { isTauri } from 'kitr/tauri/client'
import { useEffect, useState } from 'react'
import { commands } from '#app/commands'
export function Welcome() {
const [greeting, setGreeting] = useState('Welcome to Acme Orders')
useEffect(() => {
if (isTauri()) void commands.greet({ name: 'Ada' }).then(setGreeting)
}, [])
return <h1>{greeting}</h1>
}
In the Tauri window the heading reads Hello, Ada!, computed in Rust. In the browser (kitr dev site) it stays Welcome to Acme Orders.
Add a Rust command
A command is a Rust function marked #[tauri::command] and listed in generate_handler!. It takes named arguments and returns anything serde can serialise. Add one that lists orders by status:
// platforms/app/src-tauri/src/lib.rs
use serde::Serialize;
#[derive(Serialize, Clone)]
#[serde(rename_all = "camelCase")]
struct Order {
id: String,
customer: String,
total_cents: u32,
status: String,
}
#[tauri::command]
fn list_orders(status: String) -> Vec<Order> {
vec![
Order { id: "ord_1042".into(), customer: "Ada".into(), total_cents: 12000, status: "paid".into() },
Order { id: "ord_1043".into(), customer: "Grace".into(), total_cents: 8000, status: "open".into() },
]
.into_iter()
.filter(|order| order.status == status)
.collect()
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_store::Builder::new().build())
.plugin(tauri_plugin_opener::init())
.invoke_handler(tauri::generate_handler![list_orders])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
Then declare it in app/commands.ts, with the same name and the TypeScript shape of its arguments and result:
// app/commands.ts
import { defineCommands } from 'kitr/tauri/client'
export interface Order {
id: string
customer: string
totalCents: number
status: 'open' | 'paid' | 'shipped'
}
/** Mirrors the `#[tauri::command]` functions in platforms/app/src-tauri/src/lib.rs. */
export type Commands = {
list_orders: (args: { status: Order['status'] }) => Order[]
export_invoice: (args: { invoiceId: string; path: string }) => string
ship_order: (args: { id: string; carrier: string }) => Order
sync_orders: () => void
}
export const commands = defineCommands<Commands>()
await commands.list_orders({ status: 'paid' }) resolves to [{ id: 'ord_1042', customer: 'Ada', totalCents: 12000, status: 'paid' }]. Three naming rules come from Tauri, and defineCommands follows them as they are:
- The command name is the Rust function name, snake_case:
list_orders, notlistOrders. - Argument keys are the Rust parameter names in camelCase:
invoice_id: Stringin Rust is{ invoiceId }in TypeScript. - Result fields are what serde writes. Add
#[serde(rename_all = "camelCase")]to the struct, as above, to gettotalCentsinstead oftotal_cents.
A command with no arguments is declared as () => T and called with no argument: commands.sync_orders(). The type in app/commands.ts is a promise you make by hand: TypeScript cannot read Rust, so a renamed Rust parameter compiles on both sides and fails at run time. Change both files in the same commit.
Return an error from Rust
Return Result<T, String> (or any error type that implements Serialize) and the promise in the window rejects with the error value. Async commands run off the main thread, which suits file and network work; they must take owned arguments (String, not &str):
// platforms/app/src-tauri/src/lib.rs
#[tauri::command]
async fn export_invoice(invoice_id: String, path: String) -> Result<String, String> {
let url = format!("https://app.acme.com/api/invoices/{invoice_id}.pdf");
let bytes = reqwest::get(&url)
.await
.map_err(|e| e.to_string())?
.bytes()
.await
.map_err(|e| e.to_string())?;
let file = std::path::Path::new(&path).join(format!("{invoice_id}.pdf"));
std::fs::write(&file, &bytes).map_err(|e| format!("Could not save {invoice_id}: {e}"))?;
Ok(file.display().to_string())
}
This example uses reqwest, which you add with cargo add reqwest in platforms/app/src-tauri. In the window, catch the rejection like any other:
// app/features/invoices/export.ts
import { commands } from '#app/commands'
export async function exportInvoice(invoiceId: string, folder: string) {
try {
return { ok: true as const, path: await commands.export_invoice({ invoiceId, path: folder }) }
} catch (error) {
return { ok: false as const, message: String(error) }
}
}
// exportInvoice('inv_2041', '/Users/ada/Documents') => { ok: true, path: '/Users/ada/Documents/inv_2041.pdf' }
// exportInvoice('inv_2041', '/Volumes/ReadOnly') => { ok: false, message: 'Could not save inv_2041: Read-only file system (os error 30)' }
The rejected value is the Rust error as serialised, a plain string here, not an Error object, so read it with String(error) rather than error.message.
Read a command with a query
To cache a command's result, share it between components and refresh it after a change, wrap it with commandQuery(command, name) and pass the result to queryFrom from Query:
// app/features/orders/queries.ts
import { queryFrom } from 'kitr/query'
import { commandQuery } from 'kitr/tauri/client'
import { commands } from '#app/commands'
export const ordersByStatus = queryFrom(commandQuery(commands.list_orders, 'list_orders'))
// app/routes/orders.tsx
import { createFileRoute } from '@tanstack/react-router'
import { useQuery } from 'kitr/query'
import { ordersByStatus } from '#app/features/orders/queries'
export const Route = createFileRoute('/orders')({
component: OrdersPage,
})
function OrdersPage() {
const { data: orders, isPending, error } = useQuery(ordersByStatus.options({ status: 'paid' }))
if (isPending) return <p>Loading orders…</p>
if (error) return <p role="alert">{error.message}</p>
return (
<ul>
{orders.map((order) => (
<li key={order.id}>
{order.customer}: ${(order.totalCents / 100).toFixed(2)}
</li>
))}
</ul>
)
}
The page lists Ada: $120.00. commandQuery builds the contract: the cache key is ['tauri', name, input] (here ['tauri', 'list_orders', { status: 'paid' }]), fetch calls the command, and source is 'tauri' for devtools. Pass the Rust name as the second argument, because a function from defineCommands does not know its own name.
Change data with a mutation
commandMutation(command, { effects }) does the same for commands that change something. Start with the Rust command:
// platforms/app/src-tauri/src/lib.rs
#[tauri::command]
fn ship_order(id: String, carrier: String) -> Result<Order, String> {
if carrier.is_empty() {
return Err(format!("Pick a carrier before shipping {id}"));
}
// ... update the order in your local database or call https://app.acme.com/api
Ok(Order { id, customer: "Ada".into(), total_cents: 12000, status: "shipped".into() })
}
Then wrap it. The effects say which cached queries to refresh when it succeeds:
// app/features/orders/mutations.ts
import { mutationFrom } from 'kitr/query'
import { commandMutation } from 'kitr/tauri/client'
import { commands } from '#app/commands'
export const shipOrder = mutationFrom(
commandMutation(commands.ship_order, {
effects: { invalidate: [['tauri', 'list_orders']] },
}),
)
// app/features/orders/ship-button.tsx
import { useMutation } from 'kitr/query'
import { shipOrder } from './mutations'
export function ShipButton({ id }: { id: string }) {
const ship = useMutation(shipOrder)
return (
<button type="button" disabled={ship.isPending} onClick={() => ship.mutate({ id, carrier: 'UPS' })}>
{ship.isPending ? 'Shipping…' : 'Ship order'}
</button>
)
}
When ship_order resolves, every list_orders query refetches, because invalidation matches the key prefix ['tauri', 'list_orders']. effects also takes a function of the result and input, as in Query's own mutation effects. When the Rust command returns Err, ship.error holds it as a KitrError and nothing is invalidated.
Listen to events from Rust
Rust pushes events to the window with emit, for progress, background results or menu clicks. Event names may contain letters, digits, -, /, : and _, so use orders-sync-progress rather than dots:
// platforms/app/src-tauri/src/lib.rs
use tauri::{AppHandle, Emitter};
#[tauri::command]
async fn sync_orders(app: AppHandle) -> Result<(), String> {
for batch in 1..=4u32 {
// ... upload one batch of orders to https://app.acme.com/api/orders/sync
app.emit("orders-sync-progress", batch * 25).map_err(|e| e.to_string())?;
}
Ok(())
}
In a component, useTauriEvent(event, handler) subscribes for as long as the component is mounted and passes each event's payload to the handler:
// app/features/orders/sync-progress.tsx
import { useTauriEvent } from 'kitr/tauri/client'
import { useCallback, useState } from 'react'
import { commands } from '#app/commands'
export function SyncProgress() {
const [percent, setPercent] = useState(0)
useTauriEvent<number>('orders-sync-progress', useCallback((value: number) => setPercent(value), []))
return (
<div>
<button type="button" onClick={() => void commands.sync_orders()}>Sync orders</button>
<progress max={100} value={percent}>{percent}%</progress>
</div>
)
}
Clicking "Sync orders" moves the bar to 25, 50, 75 and 100. The hook re-subscribes when event or handler changes, so pass a stable handler: wrap it in useCallback, as above, or define it outside the component. Don't pass an inline arrow function, which unsubscribes and subscribes again on every render and can miss events in between. Outside Tauri the hook does nothing.
Send events to Rust
emitTauri(event, payload) sends an event to the Rust side and to every window. Use it for things the window reports and does not wait on. In a plain browser it resolves and does nothing.
// app/features/orders/opened.ts
import { emitTauri } from 'kitr/tauri/client'
export function reportOrderOpened(orderId: string) {
return emitTauri('order-opened', { orderId })
}
// await reportOrderOpened('ord_1042') => undefined
Listen in Rust from the builder's setup hook. The payload arrives as a JSON string:
// platforms/app/src-tauri/src/lib.rs
use tauri::Listener;
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_store::Builder::new().build())
.plugin(tauri_plugin_opener::init())
.setup(|app| {
app.listen("order-opened", |event| {
println!("[acme] order opened: {}", event.payload());
});
Ok(())
})
.invoke_handler(tauri::generate_handler![list_orders, export_invoice, ship_order, sync_orders])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
// reportOrderOpened('ord_1042') => prints [acme] order opened: {"orderId":"ord_1042"}
When the window needs an answer, use a command instead: emitTauri returns nothing from Rust.
Keep the web build working
The same app/ ships to the browser through your web platform, where there is no Tauri runtime. isTauri() returns true only inside a Tauri webview (it checks for window.__TAURI_INTERNALS__). In a browser:
| Call | Outside Tauri |
|---|---|
isTauri() |
false |
commands.<name>() |
Rejects, because @tauri-apps/api finds no Tauri runtime. |
useTauriEvent() |
Does nothing. |
emitTauri() |
Resolves to undefined and sends nothing. |
| The provider | Registers no drivers; the web defaults stay. |
So guard command calls, and offer the web alternative next to them:
// app/features/invoices/export-button.tsx
import { isTauri } from 'kitr/tauri/client'
import { exportInvoice } from './export'
export function ExportButton({ invoiceId }: { invoiceId: string }) {
if (!isTauri()) return <a href={`/api/invoices/${invoiceId}.pdf`} download>Download invoice</a>
return (
<button type="button" onClick={() => void exportInvoice(invoiceId, '/Users/ada/Documents')}>
Export invoice
</button>
)
}
For behaviour that differs per platform but is not specific to Tauri, such as opening links or reading the app version, use the platform service instead of isTauri(). It answers the same way in Electron and on phones. Don't import @tauri-apps/api at the top of shared modules either: kitr/tauri/client loads it lazily, only when a command or event actually runs.
Grant the window permissions
Tauri denies every plugin command the window has not been granted. Grants live in capability files in platforms/app/src-tauri/capabilities/; the scaffold writes one for the main window:
// platforms/app/src-tauri/capabilities/default.json
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Capabilities for the main window",
"windows": ["main"],
"permissions": ["core:default", "store:default", "opener:default"]
}
core:default covers events, windows and the app APIs the provider uses. store:default lets the storage driver read and write, and opener:default lets platform.openExternal() open web links. Your own #[tauri::command] functions need no entry: Tauri allows the app's commands by default. When the window calls a plugin command it was not granted, the promise rejects with a message naming the command and the permissions that would allow it; add one of those to permissions. The capabilities guide covers scopes and per-window grants.
Warning The scaffold sets
"csp": nullintauri.conf.json, which turns off Tauri's content security policy so development works with any asset. Before you ship, set a CSP that allows only your own origin andhttps://app.acme.com, and grant only the permissions the app uses. Every permission is reachable from any script that runs in the window.
Add a Tauri plugin
Native features beyond the scaffold (dialogs, notifications, the file system, global shortcuts) come as Tauri plugins. The Tauri CLI adds the Rust crate, registers it in lib.rs, installs the JavaScript package and adds the default permission in one step:
cd platforms/app
pnpm exec tauri add dialog
cd ../..
Then call the plugin's JavaScript API, guarded like any Tauri call:
// app/features/invoices/pick-folder.ts
import { isTauri } from 'kitr/tauri/client'
export async function pickExportFolder(): Promise<string | null> {
if (!isTauri()) return null
const { open } = await import('@tauri-apps/plugin-dialog')
const folder = await open({ directory: true, title: 'Export invoices to…' })
return typeof folder === 'string' ? folder : null
}
Import the plugin lazily, as above, so the web build never loads it. If tauri add cannot edit lib.rs because you changed its shape, it prints the line to add by hand.
Keep data on the user's machine
Items you declare with Storage persist through Tauri's store plugin in kitr.json in the app's data folder, not in the webview's localStorage, which some OSes clear. 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 tauriStoreDriver(), which loads every value once at boot and saves each change to disk in the background. The scaffold already registers tauri_plugin_store in lib.rs and grants store:default; without either, the values live in memory only and every write logs [kitr/storage] tauri:store: with the plugin's error.
On macOS, with identifier: 'com.acme.orders', the file is ~/Library/Application Support/com.acme.orders/kitr.json. Values are kept as strings; any other value another part of the app put in kitr.json is skipped on load. To keep kitr's items in another file, register the driver yourself with a provider of your own:
// app/providers/orders-store.ts
import { defineProvider } from 'kitr/app'
import { isTauri } from 'kitr/tauri/client'
import { tauriStoreDriver } from 'kitr/tauri/provider'
export default defineProvider({
name: 'orders-store',
register(app) {
if (isTauri()) app.drivers.register('storage', tauriStoreDriver('acme-orders.json'), { priority: 20 })
},
})
The registry keeps the driver with the highest priority, and the Tauri provider registers its own at the default, so priority 20 wins. Add () => import('#app/providers/orders-store') to providers in kitrrc.ts.
Use the platform service in Tauri
The provider also registers a driver for Platform, so code that uses the platform service works in Tauri without an isTauri() check:
| Member | In Tauri |
|---|---|
type |
'desktop', the same as Electron |
native, standalone |
true |
os |
'macos', 'windows', 'linux' or 'unknown', from the user agent |
appVersion() |
version from tauri.conf.json, through @tauri-apps/api/app |
openExternal(url) |
Opens the system browser through @tauri-apps/plugin-opener; falls back to window.open() when that package is missing |
exit() |
Exits through @tauri-apps/plugin-process; falls back to window.close() when that package is missing |
onAppState(fn) |
true when the page is visible, false when the window is minimised or hidden |
kitr add installs neither JavaScript package. The Rust side of the opener plugin is in the scaffold, so for links that open in the system browser install its JavaScript half:
pnpm add @tauri-apps/plugin-opener
For exit(), add the process plugin on both sides with pnpm exec tauri add process in platforms/app. Then:
// 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 Tauri, "Service status" opens Ada's default browser and "Quit Acme Orders" ends the process. In the browser the first opens a new tab and the second is not rendered.
Build installers
kitr build app builds the client, then the Rust binary and the installers for the machine you run it on:
kitr build appFirst time? Run npm i -g kitr once. Inside a project, the global kitr runs your project's own version.
It runs vite build (the client into dist/app/client), then tauri build in platforms/app, and logs app: installers are in platforms/app/src-tauri/target/release/bundle. With "targets": "all", that folder holds msi/ and nsis/ on Windows, macos/ (the .app) and dmg/ on macOS, and deb/, rpm/ and appimage/ on Linux. The version is version in tauri.conf.json, written from package.json when you ran kitr add; keep the two in step.
For a faster build with debug symbols and the webview inspector, pass --debug:
kitr build app --debugFirst time? Run npm i -g kitr once. Inside a project, the global kitr runs your project's own version.
tauri build --debug puts the bundles in target/debug/bundle instead, although kitr's closing log line still names the release folder. The release build is optimised and takes longer the first time.
kitr build app passes only --debug to the Tauri CLI. For one bundle format or another target, build the client with kitr and run the Tauri CLI yourself:
KITR_PLATFORM=app pnpm exec vite build
cd platforms/app && pnpm exec tauri build --bundles dmg
Tauri builds for the OS it runs on. Use a CI runner per OS for the others, as Tauri's distribution guide describes.
Run the production build locally
There is no kitr start step for Tauri. kitr start app logs app: run the installed app or the binary in platforms/app/src-tauri/target/release and exits. Run the binary from there, or install the bundle:
kitr build app
open "platforms/app/src-tauri/target/release/bundle/macos/Acme Orders.app"
That is the app your users get: the client from dist/app/client embedded in the binary, no dev server.
Sign the installers
Unsigned apps trigger Gatekeeper on macOS and SmartScreen on Windows. The Tauri CLI signs during tauri build when it finds signing settings, and kitr build passes your environment through to it. On macOS that is the identity of a certificate in the keychain:
export APPLE_SIGNING_IDENTITY="Developer ID Application: Acme Inc (TEAMID1234)"
kitr build app
Notarization, Windows certificates and Linux signing have their own settings; see Tauri's macOS, Windows and Linux signing guides. kitr adds no signing settings of its own.
Warning Never commit certificates, private keys or their passwords. Keep them in your CI's secret store and set them only on the job that builds releases.
Switch to Electron
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. Rust commands become IPC handlers in platforms/app/main.ts, and commands.<name>() calls become ipc.invoke('<area>.<verb>') from kitr/desktop/client:
kitr platforms remove app
kitr add desktop appFirst time? Run npm i -g kitr once. Inside a project, the global kitr runs your project's own version.
Port each command to a handler as the Desktop guide shows, then remove @kitr/tauri as described under Install. Storage values do not move: Tauri's kitr.json and Electron's kitr-storage.json are separate files.
Commands
kitr/tauri adds no generators. The Tauri platform answers these commands:
| Command | What it does |
|---|---|
kitr add tauri <name> |
Adds a Tauri platform: installs the Tauri packages, writes platforms.<name>, adds router, platform and storage, scaffolds platforms/<name>/src-tauri/ and app/commands.ts. |
kitr dev <name> |
Starts Vite on port and runs tauri dev --no-watch in platforms/<name>. |
kitr build <name> |
Runs vite build, then tauri build. Flag: --debug. |
kitr start <name> |
Logs where the built binary is. Tauri has no start step. |
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. |
The Tauri CLI works from platforms/app for everything else: pnpm exec tauri icon, pnpm exec tauri add <plugin>, pnpm exec tauri info (prints the toolchain and webview versions).
kitr doctor checks each Tauri platform and reports:
- an error for each missing package:
@tauri-apps/api,@tauri-apps/cli,@tauri-apps/plugin-store. Fix:pnpm add <package>(-Dfor the CLI). - an error when
platforms/app/src-tauri/tauri.conf.jsonis missing. Fix:kitr platforms scaffold app.
It does not check the Rust toolchain; pnpm exec tauri info does.
Errors
| Message | When | What to do |
|---|---|---|
The Rust error value, such as Could not save inv_2041: ... |
A command returned Err. The promise rejects with that value. |
Handle it where you call the command; read it with String(error). |
| A message naming a plugin command and the permissions that allow it | The window called a plugin command its capability does not grant. | Add one of the named permissions to capabilities/default.json. |
A TypeError from @tauri-apps/api |
A command ran outside Tauri, in a browser or a test without mocks. | Guard with isTauri(). In tests, use mockIPC(). |
Platform name "<name>" must be a short identifier (site, api, app, phone...) |
kitr add tauri got a name with spaces or symbols. |
Use letters, digits, - and _. |
tauri has no option "<flag>" (options: port, title, width, height, minWidth, minHeight, appId, devtools) |
kitr add tauri got an unknown flag. |
Use one of the listed options; names are camelCase. |
tauri dev --no-watch exited with code 1 |
The Rust build failed or the window could not start. kitr logs it and keeps Vite running. | Read Cargo's output above it. A missing icon or system library is the usual cause; pnpm exec tauri info checks the toolchain. |
tauri build exited with code 1 |
Compiling or bundling failed. | Read the output above it; generate icons with tauri icon if they are missing. |
Testing
Unit tests run in Node or happy-dom, where there is no Tauri runtime. Tauri ships mockIPC() in @tauri-apps/api/mocks for this: it installs a fake runtime on window, routes every command to a function you write, and with shouldMockEvents delivers events too. isTauri() returns true while it is installed.
This test checks a command, the query built from it, an event reaching a component, and the platform driver:
// test/tauri.test.tsx
import { act, cleanup, renderHook } from '@testing-library/react'
import { clearMocks, mockIPC } from '@tauri-apps/api/mocks'
import { emit } from '@tauri-apps/api/event'
import { platformProvider } from 'kitr/platform/provider'
import { isTauri, useTauriEvent } from 'kitr/tauri/client'
import { tauriProvider } from 'kitr/tauri/provider'
import { createTestApp } from 'kitr/testing'
import { useState } from 'react'
import { afterEach, describe, expect, it } from 'vitest'
import { commands, type Order } from '#app/commands'
import { ordersByStatus } from '#app/features/orders/queries'
const orders: Order[] = [
{ id: 'ord_1042', customer: 'Ada', totalCents: 12000, status: 'paid' },
{ id: 'ord_1043', customer: 'Grace', totalCents: 8000, status: 'open' },
]
function fakeRust() {
mockIPC(
(cmd, args) => {
if (cmd === 'list_orders') return orders.filter((o) => o.status === (args as { status: string }).status)
if (cmd === 'plugin:app|version') return '2.4.0'
throw new Error(`unexpected command ${cmd}`)
},
{ shouldMockEvents: true },
)
}
const onProgress = (set: (n: number) => void) => (n: number) => set(n)
afterEach(() => {
cleanup()
clearMocks()
delete (window as any).__TAURI_INTERNALS__
})
describe('Acme Orders in Tauri', () => {
it('calls list_orders and caches it under a tauri key', async () => {
fakeRust()
expect(isTauri()).toBe(true)
expect(await commands.list_orders({ status: 'paid' })).toEqual([orders[0]])
expect(ordersByStatus.key({ status: 'open' })).toEqual(['tauri', 'list_orders', { status: 'open' }])
expect(await ordersByStatus.fetch({ status: 'open' })).toEqual([orders[1]])
})
it('delivers sync progress events to a component', async () => {
fakeRust()
const { result } = renderHook(() => {
const [percent, setPercent] = useState(0)
const [handler] = useState(() => onProgress(setPercent))
useTauriEvent<number>('orders-sync-progress', handler)
return percent
})
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0))
await emit('orders-sync-progress', 50)
})
expect(result.current).toBe(50)
})
it('answers the platform service as a desktop app', async () => {
fakeRust()
const app = await createTestApp({ providers: [tauriProvider, 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')
})
it('stays out of the way in a browser', () => {
expect(isTauri()).toBe(false)
})
})
Install the fake before the code under test runs. After each test, unmount components first (cleanup()), so useTauriEvent unsubscribes while the fake is still there, then call clearMocks(). clearMocks() empties window.__TAURI_INTERNALS__ but leaves the object, so delete it too, or isTauri() stays true in the next test. The short wait inside act lets useTauriEvent finish subscribing before the event is emitted, because it loads the event API lazily. Test the Rust side with cargo test in platforms/app/src-tauri, and the real window with kitr dev app.
Next steps
- API reference
- Desktop: the recommended desktop platform, with Chromium in the app and Node.js IPC
- Query: cache and refresh command results with
commandQueryandcommandMutation - Platform: app version, external links, app state and exit on every platform
- Platforms: every platform type and the commands they share
- Tauri documentation