Desktop apps
The build is a folder a desktop shell can show. Here is how to make a Tauri or an Electron app of a project, call the native side from a page, and ship it.
A desktop shell is a window that shows a web page, and a native side the page can ask for what a browser cannot do: a file on disk, a folder dialog, a notification, a process. wf build writes a folder of static files (chapter 29), and that folder is what a shell shows.
Nothing in the language changes for a desktop app. A page reaches the native side through a plain script under src/ (chapter 32), one typed function per thing it asks for, and the shell decides what that script can reach.
| Tauri | Electron | |
|---|---|---|
| The native side is written in | Rust | JavaScript, on Node.js |
| The window is | the system's own web view | a Chromium shipped inside the app |
| A page reaches the native side through |
|
what your preload script exposes |
| You need | Rust and the Tauri CLI | Node.js |
Both run the same pages, the same runtime and the same security policy. Choose by the language you want on the native side.
The build for a desktop
The same project builds for the web and for a desktop. Two settings leave out what only a website needs:
json
{
"build": { "compress": false },
"meta": { "sitemap": false }
}
build.compress: false— the shell packs the folder into the app, so a.gzbeside every file would ship every file twice.meta.sitemap: false— nositemap.xmlorrobots.txt; nothing crawls an app.meta.site_urland the link-preview tags can stay unset too.- Leave
offlineout — the files are already on the machine, so there is nothing for a service worker to keep.
A single-page build and a static one both work (chapter 26), and addresses stay as they are: both shells below answer a path the build has no file for with index.html, so reloading the window on /settings shows the settings page.
An app is often used with no network. A font named in meta.fonts comes from another origin; for one that is always there, put the file in public/ and declare it with @font-face in a .css file under src/ (chapter 15).
Tauri
Setting up
Install the Tauri CLI once — and what Tauri needs on your system, listed in its prerequisites:
bash
Then, in the project's folder:
bash
That writes src-tauri/ beside src/: the Rust side, its Cargo.toml and tauri.conf.json. WebFluent never reads src-tauri/, and Tauri runs the two wf commands in the project's folder. Change two things in src-tauri/tauri.conf.json:
json
{
"identifier": "com.example.notes",
"app": { "withGlobalTauri": true }
}
identifier— a reverse-domain name of your own.cargo tauri buildrefuses the default,com.tauri.dev.withGlobalTauri— puts Tauri's JavaScript API onwindow.__TAURI__, every plugin's included, so a plain script uses it with noimportand no npm.
Leave app.security.csp at null: every page already carries its policy (below).
Working on it
bash
runs wf serve and opens the app's window on it. Saving a .wf file rebuilds and reloads the window, and a build that fails shows its error over the page, as it does in a browser (chapter 38). Saving a Rust file rebuilds the app. If dev.port is not 3000, change devUrl to match.
bash
runs wf build, embeds build/ in the binary, and writes the installers under src-tauri/target/release/bundle/.
Calling Rust
A command is a Rust function a page may call. Write it in src-tauri/src/lib.rs and list it in run():
rust
use tauri::Emitter;
#[tauri::command]
fn greet(name: &str) -> String {
format!("Hello, {name}!")
}
#[tauri::command]
fn start_sync(app: tauri::AppHandle) {
std::thread::spawn(move || {
for done in [25, 50, 75, 100] {
// … a quarter of the work …
let _ = app.emit("sync-progress", done);
}
});
}
// in run(), before .run(…):
// .invoke_handler(tauri::generate_handler![greet, start_sync])
A page calls a command through a script that names it: one function per command, typed with JSDoc, so .wf code calls greet(name) like any other function and the checker holds every call to its types:
js
// src/native.js
/**
* Whether the page is inside the app, rather than a browser tab.
* @returns {boolean}
*/
function inApp() {
return "__TAURI__" in window;
}
/**
* Call a Rust command. What its `Err` holds arrives as an `Error`.
* @param {string} command
* @param {object} [args]
* @returns {Promise<any>}
*/
function invoke(command, args) {
if (!inApp()) return Promise.reject(new Error("This needs the desktop app."));
return window.__TAURI__.core.invoke(command, args).catch((err) => {
throw new Error(typeof err === "string" ? err : JSON.stringify(err));
});
}
/**
* @param {string} name
* @returns {Promise<string>}
*/
function greet(name) {
return invoke("greet", { name });
}
/** Start syncing; how far it has got arrives as `sync-progress`. */
function startSync() {
return invoke("start_sync");
}
- Arguments are named. A command's parameters are the keys of the object, in camelCase: a Rust
note_idis{ noteId }. - A result is a promise. A command that returns
Result<T, E>resolves with theT; itsErrrejects, andinvokemakes it anError, so a page'scatch ereadse.message. inApp()is false in a browser tab —wf serveopened in a browser,wf test,wf verify— where there is no native side. Each function answers there instead of throwing, so every page still loads.
The page awaits the function in an action:
wf
page Hello(path: "/", title: "Hello", description: "Says hello from Rust.") {
state name = ""
state reply = ""
action ask() {
try {
reply = await greet(name)
} catch e {
reply = e.message
}
}
Heading("Hello").h1
if !inApp() {
Alert("Open this in the app to talk to Rust.").info
}
Input(bind: name, label: "Your name")
Button("Greet").primary { on click { ask() } }
Text(reply)
}
Hearing from Rust
start_sync above returns at once and reports as it goes, with app.emit(…). A page hears an event for as long as it shows, so the listener belongs to the page's lifetime — mount: starts it and cleanup: stops it when the page, branch or item leaves:
js
// src/events.js
/**
* Hear an event the native side emits, until the function it returns is called.
* @param {string} name
* @param {(payload: any) => void} handler
* @returns {() => void}
*/
function onEvent(name, handler) {
if (!inApp()) return () => {};
const listening = window.__TAURI__.event.listen(name, (e) => handler(e.payload));
return () => { listening.then((stop) => stop()); };
}
wf
page Sync(path: "/sync", title: "Sync", description: "How far the sync has got.") {
state done = 0
state problem = ""
action setDone(n: Number) { done = n }
action start() {
try {
await startSync()
} catch e {
problem = e.message
}
}
Stack(gap: .md,
mount: (n) => onEvent("sync-progress", setDone),
cleanup: (stop) => stop()) {
Heading("Sync").h1
Progress(value: done, max: 100)
Text("{done}%")
Button("Sync now") { on click { start() } }
Text(problem)
}
}
Plugins
A dialog, the file system, notifications, the clipboard, opening a link in the browser: each is a Tauri plugin. The CLI adds one — the crate, the line in run(), and its default permission in src-tauri/capabilities/default.json:
bash
With withGlobalTauri on, its API is on window.__TAURI__.dialog. Wrap what a page uses, as with a command:
js
// src/dialogs.js
/**
* Ask for a folder; `null` when the reader cancels.
* @returns {Promise<?string>}
*/
function pickFolder() {
if (!inApp()) return Promise.resolve(null);
return window.__TAURI__.dialog.open({ directory: true });
}
wf
page Library(path: "/library", title: "Library", description: "Where the notes are kept.") {
state folder = ""
action choose() {
let picked = await pickFolder()
if picked != null {
folder = picked
}
}
Heading("Library").h1
Button("Choose a folder") { on click { choose() } }
if folder != "" {
Text("Notes are kept in {folder}.")
} else {
Text("No folder yet.").muted
}
}
A plugin's call fails until its permission is granted. cargo tauri add grants the default set; anything beyond it — a path the fs plugin may read, for one — is named in the same capabilities file.
The security policy
With build.csp on — it is for a project wf init makes — every page says where it may send requests (chapter 23). Tauri sends each call as a request: to http://ipc.localhost on Windows and Android, and to ipc://localhost on macOS, Linux and iOS. Name the first:
json
{ "meta": { "connect": ["http://ipc.localhost"] } }
meta.connect takes web origins only, so ipc://localhost cannot be named. Where the policy blocks that request, Tauri sends the call over its other channel instead, and logs a warning: IPC custom protocol failed, Tauri will now use the postMessage interface instead. The call works either way.
Electron
Setting up
In the project's folder:
bash
Electron's two files go in electron/, not under src/: every .js file under src/ is linked into the pages, and these run in Node, outside them. Point package.json at the first:
json
{
"main": "electron/main.js",
"scripts": { "start": "electron ." }
}
The main process opens the window and serves the build to it. file:// would break the build's absolute addresses (/app.js is the root of the disk there), so the build is served from an app:// origin of its own:
js
// electron/main.js
const { app, BrowserWindow, dialog, ipcMain, net, protocol } = require("electron");
const fs = require("node:fs");
const path = require("node:path");
const { pathToFileURL } = require("node:url");
const BUILD = path.join(__dirname, "..", "build");
// To the page, app://localhost is an ordinary origin: absolute paths,
// fetch and its security policy work as they do on a site.
protocol.registerSchemesAsPrivileged([
{ scheme: "app", privileges: { standard: true, secure: true, supportFetchAPI: true } },
]);
const isFile = (p) => fs.statSync(p, { throwIfNoEntry: false })?.isFile() ?? false;
// A file of the build; a route the router draws gets index.html.
function serve(request) {
const file = path.join(BUILD, decodeURIComponent(new URL(request.url).pathname));
const found =
file.startsWith(BUILD + path.sep) &&
([file, path.join(file, "index.html")].find(isFile) ??
(path.extname(file) ? null : path.join(BUILD, "index.html")));
return found
? net.fetch(pathToFileURL(found).href)
: new Response("Not found", { status: 404 });
}
function openWindow() {
const win = new BrowserWindow({
width: 1000,
height: 700,
webPreferences: { preload: path.join(__dirname, "preload.js") },
});
// Packaged, the window shows the build; while you work, `wf serve`.
win.loadURL(app.isPackaged ? "app://localhost/" : "http://localhost:3000/");
}
app.whenReady().then(() => {
protocol.handle("app", serve);
ipcMain.handle("greet", (_event, name) => `Hello, ${name}!`);
ipcMain.handle("start-sync", (event) => {
for (const done of [25, 50, 75, 100]) {
// … a quarter of the work …
event.sender.send("sync-progress", done);
}
});
ipcMain.handle("pick-folder", async (event) => {
const win = BrowserWindow.fromWebContents(event.sender);
const { canceled, filePaths } = await dialog.showOpenDialog(win, {
properties: ["openDirectory"],
});
return canceled ? null : filePaths[0];
});
openWindow();
app.on("activate", () => {
if (BrowserWindow.getAllWindows().length === 0) openWindow();
});
});
app.on("window-all-closed", () => {
if (process.platform !== "darwin") app.quit();
});
Calling Node
A page has no Node in it. The preload script runs beside the page and hands it exactly what it may ask for — one function per channel, never ipcRenderer itself:
js
// electron/preload.js
const { contextBridge, ipcRenderer } = require("electron");
const events = ["sync-progress"];
contextBridge.exposeInMainWorld("native", {
greet: (name) => ipcRenderer.invoke("greet", name),
startSync: () => ipcRenderer.invoke("start-sync"),
pickFolder: () => ipcRenderer.invoke("pick-folder"),
onEvent: (name, handler) => {
if (!events.includes(name)) throw new Error(`No event called ${name}`);
const listener = (_event, payload) => handler(payload);
ipcRenderer.on(name, listener);
return () => ipcRenderer.removeListener(name, listener);
},
});
In an Electron app, one src/native.js takes the place of the three Tauri scripts above. It keeps their names, so the Hello, Sync and Library pages do not change at all:
js
// What electron/preload.js exposed, under the names the pages call.
/**
* Whether the page is inside the app, rather than a browser tab.
* @returns {boolean}
*/
function inApp() {
return "native" in window;
}
const outside = () => Promise.reject(new Error("This needs the desktop app."));
/**
* @param {string} name
* @returns {Promise<string>}
*/
function greet(name) {
return inApp() ? window.native.greet(name) : outside();
}
/** Start syncing; how far it has got arrives as `sync-progress`. */
function startSync() {
return inApp() ? window.native.startSync() : outside();
}
/**
* Ask for a folder; `null` when the reader cancels.
* @returns {Promise<?string>}
*/
function pickFolder() {
return inApp() ? window.native.pickFolder() : Promise.resolve(null);
}
/**
* Hear an event the native side emits, until the function it returns is called.
* @param {string} name
* @param {(payload: any) => void} handler
* @returns {() => void}
*/
function onEvent(name, handler) {
return inApp() ? window.native.onEvent(name, handler) : () => {};
}
Working on it
Run the dev server, then the app, in two terminals:
bash
bash
Saving a .wf file rebuilds and reloads the window. A change to electron/ needs the app restarted.
The page's policy needs nothing added: app://localhost is the page's own origin, as http://localhost:3000 is while you work.
Packaging
Electron Forge and electron-builder both package an app from package.json. Whichever you use, run wf build first, and make sure the package holds electron/ and build/ — src/ is not needed at run time. With electron-builder (npm install --save-dev electron-builder):
json
{
"main": "electron/main.js",
"scripts": {
"start": "electron .",
"dist": "wf build && electron-builder"
},
"build": { "files": ["electron/**", "build/**"] }
}
Checked by the test suite
Every code block in the guide is parsed, checked and type-checked on each release.