Starter Shape
Start with the smallest app that proves the boundary: one web UI, one Rust backend, one capability file, one typed IPC wrapper, and one release smoke path.
Folder Shape
my-app/
package.json
vite.config.ts
src/
app.tsx
tauri-api.ts
tauri-api.test.ts
src-tauri/
Cargo.toml
tauri.conf.json
capabilities/
default.json
src/
lib.rs
commands.rs
state.rs
Keep the frontend and native boundary obvious. Do not scatter raw invoke calls across the UI.
Frontend Wrapper
import { invoke } from "@tauri-apps/api/core";
export type OpenProjectRequest = {
path: string;
};
export type OpenProjectResponse = {
name: string;
itemCount: number;
};
export function openProject(input: OpenProjectRequest) {
return invoke<OpenProjectResponse>("open_project", input);
}
This does not make IPC type-safe by itself. It gives you one place to test names, payloads, validation assumptions, and frontend error handling.
Rust Command
use serde::Serialize;
#[derive(Serialize)]
struct OpenProjectResponse {
name: String,
item_count: usize,
}
#[tauri::command]
fn open_project(path: String) -> Result<OpenProjectResponse, String> {
if path.trim().is_empty() {
return Err("path is required".into());
}
Ok(OpenProjectResponse {
name: "example".into(),
item_count: 0,
})
}
pub fn run() {
tauri::Builder::default()
.invoke_handler(tauri::generate_handler![open_project])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
Validate before touching the filesystem, shell, network, or user data. The command boundary is not a formality.
Capability File
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Main window permissions",
"windows": ["main"],
"permissions": [
"core:default"
]
}
Add plugin permissions only when a feature needs them. Prefer narrow scoped permissions over broad defaults.
Add Complexity In This Order
| Step | Add | Stop if |
|---|---|---|
| 1 | One validated Rust command | The UI needs broad shell/filesystem authority |
| 2 | Typed IPC wrapper and frontend mock tests | Raw command strings spread across screens |
| 3 | App state through Builder::manage and State<'_, T> | State needs async locking and ownership is unclear |
| 4 | Official plugin | The permission file grants more than the feature needs |
| 5 | Sidecar | You cannot define args, logs, ports, cleanup, and release packaging |
| 6 | Updater | Signing keys, endpoints, and artifact generation are not planned |
| 7 | Mobile smoke | Required plugins do not document Android/iOS support |
What To Exclude From v1
- Custom plugin system unless commands are already too large or reusable.
- Sidecars for work that can be done safely in Rust commands.
- Dynamic shell arguments without validators.
- Remote API access from the webview unless the threat model is explicit.
- Mobile support claims before physical device or emulator smoke tests.
- Auto-update until you know your signing, endpoints, rollback, and key storage story.