Skip to content

Tauri Setup

The app runs in the browser — but it doesn't have to. Tauri is an incredible framework for building native desktop apps from web frontends — it wraps the Lustre frontend in a native desktop window using the operating system's built-in webview. The Gleam code, HTML, and CSS don't change at all; Tauri simply provides the frame around them and a Rust backend that can talk to the OS.

Because we already have a Lustre project served by lustre_dev_tools, the manual setup path is the right one — it adds Tauri on top of what's already there rather than scaffolding everything from scratch. The approach is inspired by this excellent write-up by Wesley Moore.

Setting up Tauri adds a whole src-tauri/ directory alongside the existing client code[1]:

sh
doable/
└── client/
    ├── package.json              # @tauri-apps/cli added
    └── src-tauri/                # Tauri project root
        ├── .gitignore            # excludes /target and generated schemas
        ├── Cargo.toml            # Rust manifest + tauri dependencies
        ├── Cargo.lock            # pinned dependency tree
        ├── build.rs              # Tauri build script
        ├── tauri.conf.json       # app name, window, build commands
        ├── capabilities/
   └── default.json      # permission grants for the webview
        ├── icons/                # app icons for all platforms
        └── src/
            ├── lib.rs            # Tauri app entrypoint
            └── main.rs           # binary that calls lib::run()

Install Rust

Tauri's backend compiles from Rust, so the Rust toolchain — rustc, cargo, and rustup — needs to be installed before the Tauri CLI can do anything.

sh
brew install rustup
rustup-init
sh
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
sh
# Download and run rustup-init.exe from https://rustup.rs

Restart the shell so the new ~/.cargo/bin directory is on PATH, then verify with:

sh
rustc --version
cargo --version

Initialization

Inside client/, run the Tauri initializer:

sh
cd client
bun tauri init

The CLI asks six questions. Answer them like this:

QuestionAnswer
What is your app name?Doable
What should the window title be?Doable
Where are your web assets, relative to src-tauri/tauri.conf.json?../dist
What is the url of your dev server?http://localhost:1234
What is your frontend dev command?gleam run -m lustre/dev start
What is your frontend build command?gleam run -m lustre/dev build

The web assets path points to ../distlustre_dev_tools's output directory when building for production. In development, Tauri loads the page from the dev URL instead, so ../dist is only used during bun tauri build.

@tauri-apps/cli lands in package.json as a dev dependency:

json
// client/package.json

{
  "private": true,
  "devDependencies": {
    "@iconify-json/heroicons": "^1.2.3",
    "@iconify/tailwind4": "^1.2.3",
    "@tailwindcss/cli": "^4.2.4",
    "@tauri-apps/cli": "^2.10.1",
    "daisyui": "^5.5.19"
  }
}

Configuration

tauri.conf.json is the single source of truth for the app's identity, window defaults, and build commands:

json
// client/src-tauri/tauri.conf.json

{
  "$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
  "productName": "Doable",
  "version": "0.1.0",
  "identifier": "com.tauri.dev",
  "build": {
    "frontendDist": "../dist",
    "devUrl": "http://localhost:1234",
    "beforeDevCommand": "gleam run -m lustre/dev start",
    "beforeBuildCommand": "gleam run -m lustre/dev build"
  },
  "app": {
    "windows": [
      {
        "title": "Doable",
        "width": 800,
        "height": 600,
        "resizable": true,
        "fullscreen": false
      }
    ],
    "security": {
      "csp": null
    }
  },
  "bundle": {
    "active": true,
    "targets": "all",
    "icon": [
      "icons/32x32.png",
      "icons/128x128.png",
      "icons/128x128@2x.png",
      "icons/icon.icns",
      "icons/icon.ico"
    ]
  }
}

Quick tour of the fields that matter:

  • beforeDevCommand and beforeBuildCommand tell Tauri to start lustre_dev_tools before opening the window. Running bun tauri dev is enough — no need to start the dev server separately.
  • devUrl points at the lustre_dev_tools dev server. In dev mode, Tauri loads the page from this URL and waits for it to be ready before showing the window.
  • csp is null for now. Content Security Policy matters for production, but we'll leave it open while developing.
  • The icon list covers the formats each target platform expects. The initializer drops placeholder icons into icons/ so builds work out of the box.

Capabilities

Tauri v2 uses a capability system to control what the webview is allowed to do. The generated capabilities/default.json grants the built-in defaults:

json
// client/src-tauri/capabilities/default.json

{
  "$schema": "../gen/schemas/desktop-schema.json",
  "identifier": "default",
  "description": "enables the default permissions",
  "windows": ["main"],
  "permissions": ["core:default"]
}

core:default covers the baseline set of Tauri APIs — window management, event system, logging — without opening up anything sensitive like filesystem access or shell execution. Additional permissions get added here as plugins are introduced.

The Rust App

Tauri's Rust side is minimal scaffolding. build.rs runs the Tauri build helper which generates type stubs and validates the configuration at compile time:

rust
// client/src-tauri/build.rs

fn main() {
    tauri_build::build()
}

The app logic lives in lib.rs, separated from main.rs so the same code compiles on mobile targets (which don't use a main binary):

rust
// client/src-tauri/src/lib.rs

#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    tauri::Builder::default()
        .setup(|app| {
            if cfg!(debug_assertions) {
                app.handle().plugin(
                    tauri_plugin_log::Builder::default()
                        .level(log::LevelFilter::Info)
                        .build(),
                )?;
            }
            Ok(())
        })
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

main.rs is a single call to lib::run(), plus a Windows-specific attribute that suppresses the extra console window that would otherwise appear in release builds:

rust
// client/src-tauri/src/main.rs

// Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]

fn main() {
    app_lib::run();
}

Cargo.toml declares the Rust crate and its dependencies:

toml
# client/src-tauri/Cargo.toml

[package]
name = "app"
version = "0.1.0"
description = "A Tauri App"
authors = ["you"]
license = ""
repository = ""
edition = "2021"
edition = "2024"
rust-version = "1.77.2"
rust-version = "1.94.1"

[lib]
name = "app_lib"
crate-type = ["staticlib", "cdylib", "rlib"]

[build-dependencies]
tauri-build = { version = "2.5.6", features = [] }

[dependencies]
serde_json = "1.0"
serde = { version = "1.0", features = ["derive"] }
log = "0.4"
tauri = { version = "2.10.3", features = [] }
tauri-plugin-log = "2"

The [lib] section compiles the crate as three output types so the same code works as a shared library, a dynamic library (for mobile), and a normal Rust library. tauri-build is a build-time dependency only; the runtime dependencies are tauri itself plus serde/serde_json for JSON serialization and tauri-plugin-log for logging.

The initializer still scaffolds edition = "2021" and an older rust-version. Bump both to edition = "2024" and the current stable rust-version so you get the latest Rust language improvements.

Running the Desktop App

sh
cd client
bun tauri dev

Tauri compiles the Rust binary, starts lustre_dev_tools via beforeDevCommand, then opens the native window pointing at http://localhost:1234. Hot reloading works out of the box: editing a .gleam file triggers a lustre_dev_tools reload and the window refreshes.

First build is slow — that's normal

The first bun tauri dev compiles hundreds of Rust crates from scratch, so expect it to sit for a few minutes. Every run after that is much faster thanks to incremental compilation.

Tauri only wraps the frontend, so the Gleam server still needs to be running for the /api routes to respond. Nothing to do here — it's already up as a service via docker compose (or gleam run if you're running it manually).

What's Next

A browser has a refresh button; the desktop app doesn't. If the task list goes stale, the user is stuck. We'll fix that next by adding a View menu with a Reload action.


  1. See commit d06a921 on GitHub ↩︎