Skip to content

Tauri: wrap and package

Exercise tauri-wrap-and-package-annex.pdf · PDF

Your app is finished and documented. Your cousin wants to try it. What do you send? The repo link? Then your cousin has to install Node.js, npm and your dependencies before npm run dev does anything. And your grandmother?

A user wants one file: double-click, Next, Next, Finish, an icon in the menu. Until now your app has only run on a developer's machine. An app nobody can install is a prototype with good manners. This page gets it out of the workshop: you put your SvelteKit app in its own window, decide what travels with it, give it an icon and build your first package.

What you will learn

  • Explain what a Tauri app is: a web frontend drawn by the system WebView, plus a small native core in Rust
  • Wrap a SvelteKit app in six steps, with adapter-static and ssr = false
  • Read tauri.conf.json and say what identifier, version, frontendDist and bundle are for
  • Decide what must travel in the installer, and declare extra files in bundle > resources
  • Generate every icon size from one image with tauri icon
  • Build a package with tauri build and find it on disk
  • The trap: why "it works in tauri dev" does not mean it works installed

The suitcase

Keep one picture in your head for the whole page: packaging is packing a suitcase.

You are travelling to a house you have never seen. What you need and the house does not have goes in the suitcase. What the house already has, like a bed and running water, stays at home. Pack too little and you arrive without shoes. Pack the whole wardrobe and you pay for 100 kg.

Your app is the traveller. The user's computer is the house.

What Tauri is

Tauri is a tool that puts your web frontend in a native window. The window is drawn by the WebView the operating system already has, and a small program written in Rust opens it and talks to the system.

A WebView is a browser engine without the browser: no address bar, no tabs, only the part that draws HTML and CSS and runs JavaScript. Rust is a compiled programming language. You will read a few lines of it, and you do not need to learn it to package an app.

Tauri 2.0 came out on 2 October 2024, and it is the major version you use here. The official docs say that "a minimal Tauri app can be less than 600KB". That is possible because it does not carry a browser. The house already has the bed:

System The WebView already there Engine family
Windows WebView2 Chromium, the engine of Edge and Chrome
macOS WKWebView WebKit, the engine of Safari
Linux WebKitGTK WebKit
flowchart TB
    subgraph APP ["What you ship"]
        F["<b>Frontend</b><br>build/: HTML, CSS, JS<br>the clothes"]
        R["<b>Rust core</b><br>src-tauri/: opens the window, can touch files<br>the traveller's hands"]
        F <-->|"invoke(): JavaScript calls<br>a Rust function by name"| R
    end
    subgraph OS ["What the house already has"]
        W["<b>System WebView</b><br>draws your frontend<br>the bed"]
    end
    W -->|"draws"| F

The line between the two halves is a bridge. In Rust you mark a function as a command. In JavaScript you call it by name and wait for the answer:

#[tauri::command]
fn my_custom_command() {
    println!("I was invoked from JavaScript!");
}

#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    tauri::Builder::default()
        .invoke_handler(tauri::generate_handler![my_custom_command])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}
import { invoke } from '@tauri-apps/api/core';

invoke('my_custom_command');

The other way: Electron

Electron, the tool behind VS Code and Discord, packs its own copy of Chromium in every app. The bed travels in the suitcase.

Tauri Electron
Browser engine The system's WebView Its own Chromium, inside the app
Weight Light Heavy
Your CSS meets Three different engines The same engine everywhere

Tauri's price is on the last row. If you only tested your app in Chrome, some things will look different in the Tauri window on Linux: a font, a scrollbar, a date input. Look for those differences on purpose.

Careful: Tauri does not turn Svelte into a native app

Your Svelte code is still HTML, CSS and JavaScript, drawn by a browser engine. What is native is the window around it and the Rust program behind it.

Before you start: the toolchain

Building a Tauri app needs Rust and some system libraries. Your classroom PC already has them. To work at home, install them once.

sudo apt install libwebkit2gtk-4.1-dev build-essential curl wget file libxdo-dev libssl-dev libayatana-appindicator3-dev librsvg2-dev
curl --proto '=https' --tlsv1.2 https://sh.rustup.rs -sSf | sh

Install the Microsoft C++ Build Tools with the "Desktop development with C++" option. Then:

winget install --id Rustlang.Rustup
rustup default stable-msvc

WebView2 is already part of Windows 10 (April 2018 release or later) and Windows 11.

Once a project is wrapped, npm run tauri info prints a report of what your machine has and what is missing.

Wrapping a SvelteKit app in six steps

Do this on a new git branch, for example tauri, in your project from Unit 3 and later. The example on this page is NoteBox, a tiny notes app.

1. Install the tools.

npm install -D @sveltejs/adapter-static @tauri-apps/cli
npm install @tauri-apps/api

2. Export static files. There is no server inside a window, so SvelteKit must produce plain files. An adapter is the SvelteKit plugin that shapes the build for the place it will run, and adapter-static writes a folder of plain files called build/. Find where your project says adapter-auto and replace it. Where it says so depends on how old your project template is:

import adapter from '@sveltejs/adapter-static';
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';

export default defineConfig({
    plugins: [
        sveltekit({
            adapter: adapter({ fallback: 'index.html' })
        })
    ]
});
import adapter from '@sveltejs/adapter-static';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';

/** @type {import('@sveltejs/kit').Config} */
const config = {
    preprocess: vitePreprocess(),
    kit: {
        adapter: adapter({ fallback: 'index.html' })
    }
};

export default config;

Keep any other options your file already has. The fallback is the one HTML page that loads your app and then shows the right route. Without it, the window has nothing to open.

3. Render in the WebView, not on a server. Create src/routes/+layout.js with one line:

export const ssr = false;

4. Add a shortcut for the command-line tool. In package.json, under scripts:

"tauri": "tauri"

5. Create the native half.

npm run tauri init

It asks six questions:

Question Answer
App name Your app's name
Window title What the title bar shows
Frontend dist (where the built web files are) ../build
Dev server URL http://localhost:5173
Frontend dev command npm run dev
Frontend build command npm run build

6. Run it in its own window.

npm run tauri dev

The first run compiles about 400 Rust packages, called crates. Expect several minutes. Do something else while it runs. Then a window appears, with no address bar, no tabs and its own entry in the taskbar. It is your app.

Careful: ssr = false does not make your app worse

SSR (server-side rendering) exists so a server can send ready-made HTML. A desktop window has no server: the files are already on the disk. Turning SSR off is not a shortcut. It is the truth about where the app runs.

When it goes wrong

What you see Why Fix
The build fails or the page is empty, and you have a +page.server.js, a form action or a +server.js route Those need a server, and a static export has none Move the data loading to +page.js, which runs in the WebView, or to a fetch inside the component
The port is already in use A forgotten npm run dev in another terminal Close it. Tauri starts its own
tauri: not found Step 4 is missing, or npm install did not run after step 1 Do those steps
A white window frontendDist is not ../build, or the fallback is missing Check both
Camera or microphone features from Unit 5 do nothing WebKitGTK does not offer everything Chrome does. It has no voice recognition from the Web Speech API, and the camera may need extra work Write it in your README as a known limitation, and keep those features web only

The label on the suitcase

tauri init created a new folder. Four things in it matter for now:

In src-tauri/ What it is
tauri.conf.json The label on the suitcase: name, version, identifier, icons, what to pack
src/lib.rs The Rust program, about ten lines
icons/ The app's icons
Cargo.toml The package.json of the Rust side

Open tauri.conf.json and read the label:

Key What it is
productName The name the user sees: window, menu entry, package file name
version Goes in the package name. The updater uses it to decide what is newer
identifier A unique id for the app in reverse-domain form
build.frontendDist Where the built frontend is: ../build for SvelteKit
build.devUrl The dev server Tauri opens in tauri dev: http://localhost:5173
bundle.targets Which packages to make: "all" or a list of formats
bundle.icon The icon files
bundle.resources Extra files that must travel with the app

Change two of them before you build:

  • Set identifier to something of yours, such as com.iessonferrer.notebox. A reverse domain is a web domain written backwards. It can only contain letters, digits, hyphens and dots. Leave the default com.tauri.dev and tauri build refuses to start.
  • Set version to 0.1.0. It ends up in the file name of the package.

Careful: the identifier is not just a name

It names the folder where the user's data lives. Change it in a later version and the user's notes seem to disappear, because the app now looks in a different folder.

What goes in the box?

Your project folder is huge. The installer is tiny. So what travels?

In the installer Not in the installer
build/: your HTML, CSS and JS node_modules/
The compiled Rust program Your source files, such as src/routes/+page.svelte
The icons Node.js and npm
Files you declare, such as resources/welcome-notes.json Rust and cargo
A licence text file, if you declare one Storybook stories
WebView2 itself
The user's own notes

node_modules is the kitchen. The installer carries the dish.

Two rows deserve a second look:

  • WebView2. By default, the Windows installer does not carry it. If it is missing, the installer downloads a small program, the bootstrapper, that installs it. Carrying the full offline installer inside your package adds about 127 MB.
  • The user's notes. They do not exist yet when you install. They are created later, on the user's disk. You will meet them again when you uninstall.

The missing file

NoteBox reads its welcome notes from src-tauri/resources/welcome-notes.json, through a Rust command. In tauri dev it works. Then you run the release program from another folder, and a yellow panel says "Welcome notes not found".

What changed? In dev, the app reads from your project folder, where every file is at hand. A packaged app only has what was packed. You arrived without shoes.

The fix is one line of the label, in bundle > resources:

"bundle": {
  "active": true,
  "targets": "all",
  "resources": ["resources/welcome-notes.json"],
  "icon": ["icons/32x32.png", "icons/128x128.png", "icons/128x128@2x.png", "icons/icon.icns", "icons/icon.ico"]
}

Paths are relative to src-tauri/. Patterns work too: "resources/**/*" takes every file in that folder and its subfolders.

In the installed app the file lands in the resource folder: /usr/lib/NoteBox/resources/ in our .deb, and next to the .exe on Windows. The path changes per system, so your Rust code never writes it by hand. It asks Tauri instead:

handle.path().resolve("resources/welcome-notes.json", BaseDirectory::Resource)

Careful: \"it works in tauri dev\" proves nothing about the package

tauri dev runs inside your project. The only honest test is the installed package, on a machine or a user account that has never seen the project.

The app icon

A package with the default Tauri icon says "unfinished" before the app even opens. So design one in Figma, in a 1024 × 1024 frame.

  • One shape, and two or three flat colours.
  • No text.
  • Transparent outside the tile.
  • Keep the subject inside a safe area, away from the edges.
  • Look at it at 128, 32 and 16 px. If your neighbour cannot say what it is at 16 px, remove detail. Do not add it.

Export the frame as PNG at 1x and save it as app-icon.png in the project root. Then run one command:

npm run tauri icon

It reads ./app-icon.png, a square PNG or SVG with transparency, and writes the whole set to src-tauri/icons/:

File For
32x32.png, 128x128.png, 128x128@2x.png, icon.png Linux
icon.icns macOS
icon.ico Windows: layers of 16, 24, 32, 48, 64 and 256 px in one file

There is nothing to edit afterwards: bundle > icon already lists these files.

Your first package

npm run tauri build

This one command does three things:

flowchart LR
    A["<b>npm run build</b><br>the frontend<br>into build/"] --> B["<b>Rust compile</b><br>release mode<br>the program"]
    B --> C["<b>Bundler</b><br>packs everything for<br>the current system"]
    C --> D["<b>Packages</b><br>src-tauri/target/<br>release/bundle/"]

The bundler is the part of Tauri that makes the packages. On Ubuntu it makes a .deb, an .rpm and an .AppImage. To go faster, ask for one:

npm run tauri build -- --bundles deb

The extra -- tells npm that what follows belongs to Tauri, not to npm.

Release mode is a different build from dev, so everything compiles again. Expect several more minutes. When it ends, the last lines print the path:

On Ubuntu On Windows
Option --bundles deb, also rpm and appimage --bundles nsis, also msi
Result src-tauri/target/release/bundle/deb/NoteBox_0.1.0_amd64.deb src-tauri\target\release\bundle\nsis\NoteBox_0.1.0_x64-setup.exe

NoteBox's .deb weighs 2.9 MB. Try it on your own computer: double-click the file, or run sudo apt install ./NoteBox_0.1.0_amd64.deb with your own file name.

Careful: one build does not give you every system

tauri build packages for the system it runs on. A build on Ubuntu gives you no Windows installer. To reach the others, you use a robot.

Key takeaways

  • Tauri puts your web frontend in a native window. The system WebView draws it and a small Rust core opens it, so no browser travels in the package.
  • Wrapping a SvelteKit app takes adapter-static with a fallback, ssr = false, then tauri init and tauri dev. Server-only code cannot come along.
  • tauri.conf.json is the label on the suitcase. The identifier must be your own, in reverse-domain form, and it names the user's data folder.
  • node_modules is the kitchen and the installer carries the dish. Only build/, the Rust program, the icons and what you list in bundle > resources travel.
  • tauri icon makes every icon size from one square image.
  • tauri build makes packages for the current system only. Test the installed package, not tauri dev.

Quick check

1. What draws the interface of a Tauri app on the user's computer?
  • A copy of Chromium packed inside the app
  • The WebView the operating system already has
  • The Rust core, which converts Svelte into native controls
  • A small web server installed with the app

Tauri uses the system WebView: WebView2 on Windows, WKWebView on macOS and WebKitGTK on Linux. Carrying Chromium is what Electron does. Your Svelte code stays HTML, CSS and JavaScript, and the Rust core only opens the window and talks to the system.

2. Why does a Tauri-wrapped SvelteKit app set ssr = false?
  • Because SSR makes the Rust compilation much slower
  • Because the WebView cannot run any JavaScript from SvelteKit
  • Because the window has no server to render pages on
  • Because static files are not allowed inside a package

SSR lets a server send ready-made HTML. In a desktop window the files are already on the disk and no server exists, so the pages render in the WebView.

3. Your project has a +server.js route. What happens when you export it with adapter-static?
  • Tauri converts the route into a Rust command by itself
  • It cannot work, because a static export has no server to run it
  • It keeps working, but only on Linux
  • The installer adds a copy of Node.js to run it

adapter-static only produces plain files. +page.server.js, form actions and +server.js all need a server. Move the data loading to +page.js or to a fetch in the component.

4. Which of these travels inside the installer of your app?
  • The node_modules folder
  • The Node.js runtime
  • The file src/routes/+page.svelte
  • The compiled files in build/

node_modules and your sources are the kitchen: they are needed to cook, not to eat. After npm run build, the result lives in build/, and that is what the package carries, along with the Rust program, the icons and your resources.

5. An app reads src-tauri/resources/prices.csv through a Rust command. It works in tauri dev, but the installed version says the file is missing. What do you change?
  • Add the file to bundle > resources in tauri.conf.json
  • Change build.devUrl so it points to the file
  • Run npm run tauri dev on the user's computer
  • Rename the file to match the identifier

This is the same bug as the welcome notes. In dev the app reads from your project folder, and a package only has what was packed. bundle > resources declares extra files, with paths relative to src-tauri/.

6. You run tauri build and it refuses to start, complaining about com.tauri.dev. Which key do you edit?
  • productName
  • version
  • identifier
  • bundle.targets

com.tauri.dev is the default identifier, and tauri build rejects it. Write your own in reverse-domain form, using only letters, digits, hyphens and dots.

7. A classmate builds on Ubuntu and wants a Windows installer for a friend. What result should they expect from npm run tauri build?
  • A .exe and a .deb, because the bundler covers every system
  • A .exe only, if the WebView2 runtime is downloaded first
  • Linux packages only, because it packages for the current system
  • No packages at all, because Tauri needs Windows to build

The bundler works for the system it runs on. On Ubuntu that means .deb, .rpm and .AppImage. A Windows installer needs a Windows machine, and the usual way to get one is a CI service.

You have a package now, but its installer still looks like nobody's. Next you design what the user sees between the double-click and the first screen: Installers and the wizard.