Introduction
This article introduces you to a specific but exciting topic and is the sequel to my previous article. If you are keen on Rust integrations, please read Node & Rust: Friendship Forever. The NAPI-rs Way.
I suppose all of you, dear colleagues work or at least know about VSCode. Did you think about the technologies used in VSCode creation? You probably will be surprised if I tell you that VSCode is mainly written on Typescript. But stop… Typescript and Javascript are typical for web or backend-based applications, and VSCode is a standalone UI application. Is it possible to create a Javascript-based standalone UI application? Yes, it is!
If we had discussed this topic a couple of months ago, I would have recommended ElectronJS if you were looking for a way to create a standalone Javascript application. Also, I would provide you the following list of popular Electron-based applications.
Microsoft Teams
Zoom
Slack for Desktop
WordPress for Desktop
Skype
Discord
WhatsApp Desktop
Postman
MongoDB Compass
But the modern IT World does not stand still, and we’ve already had a powerful ElectronJS competitor (it could be its killer in the nearest future, BTW).
Meet Tauri!
If you want to get a brief comparison Tauri with Electron, please, read this article. Goodbye Electron. Hello Tauri will also be helpful if you want to understand Tauri pros and some brief technical details.
There is a brief comparison for my impatient readers.
Framework | "Front-end" | "Back-end" |
Electron | Chromium browser | NodeJS |
Tauri | Native Webview | Rust-compiled code |
One small note regarding Native Webview meant above. You can find ultimate information on this topic here. In a nutshell, Tauri applications use as HTML renderer Webkit (safari engine) on MacOS, Microsoft Edge WebView2 on Windows, and WebKitGTK on Linux (port of Webkit for Linux). Pay attention to the fact that a Tauri application could behave differently on different platforms according to the information above.
What thoughts would we conclude regarding the table above? Tauri is about performance and simplicity! As a developer who spent several years on Electron-related projects, I’m pretty sure NodeJS could be a bottleneck for the following reasons.
NodeJS is a heavyweight solution with complicated architecture. I mean V8, LibUV with Event Loop, etc.
NodeJS is not a good choice if we need to implement heavy processes like image, data processing, or complicated math calculations.
Inter-Process Communication (Electron IPC) is a way of communication between the "Frontend" and "Backend" in Electron. Its functionality is overcomplicated in coding.
Implementing a multithreading NodeJS-based "Backend" in our Electron-based application could be a nightmare.
Tauri demolishes all of the cons above for the following reasons.
Rust-complied code contains only the needed minimum of functionality (without redundant architectural stuff like V8 or LibUV).
Rust is multithreading-friendly and allows us to get multi-platform implementations.
Rust is full of useful memory-safe mechanisms that prevent developers from making the mistakes, and as a result, we get high-quality predictable code.
Rust-complied code is also more performative than NodeJS-based.
In my opinion, the pros above are critical for the "Backend." That’s why according to the reasons above, I found Tauri approach as a perspective.
BTW if you are not a Rust expert and want to know something new about Rust multithreading , please read Multi-threading for Impatient Rust Learners.
The Objective
Of course, Tauri is something new. Despite this, it has good documentation. There are many interesting articles on this topic, and I recommend the following resources reading or watching.
Tauri - a video allows us to do the first steps
Goodbye Electron. Hello Tauri - provides an exciting example including events between "Frontend" and "Backend"
Tauri - Next.js - explains how to provide NextJS-based "Frontend"
My objective is to provide you with something new to run and test. I created a Tauri application with NextJS & Ant Design-based "Frontend" with some "Backend" calculations that look heavyweight. This application shows us Progress Bar on a screen, and related "progress" data is prepared on the "Backend" (Rust) side.
First Steps
Let’s get started!
Create "Frontend" part
npx create-next-app@latest --use-npm --typescript
Answer the following questions…
Install Tauri dependencies
cd tauri-nextjs-demo
npm i --save-dev @tauri-apps/cli
npm i @tauri-apps/api --save
Updates
Update next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
// Note: This feature is required to use NextJS Image in SSG mode.
// See https://nextjs.org/docs/messages/export-image-api for different workarounds.
images: {
unoptimized: true,
},
};
module.exports = nextConfig;
Update scripts section in package.json
{
"scripts": {
"dev": "next dev",
"build": "next build",
"export": "next export",
"start": "next start",
"tauri": "tauri",
"lint": "next lint"
}
}
Initialize "Backend" (Tauri) part
npm run tauri init
Answer the following questions…
Answer the following questions…
src-tauri folder contains our backend part.
"Backend" functionality
The first bootstrapped version contains a minimal set of functionality. Let’s fix it.
Please, open src-tauri/src/main.rs and put the following code.
#![cfg_attr(
all(not(debug_assertions), target_os = "windows"),
windows_subsystem = "windows"
)]
use tauri::Window;
use std::{thread, time};
#[derive(Clone, serde::Serialize)]
struct Payload {
progress: i16,
}
#[tauri::command]
async fn progress_tracker(window: Window){
let mut progress = 0;
loop {
window.emit("PROGRESS", Payload { progress }).unwrap();
let delay = time::Duration::from_millis(100);
thread::sleep(delay);
progress += 1;
if progress > 100 {
break;
}
}
}
fn main() {
tauri::Builder::default()
.invoke_handler(tauri::generate_handler![progress_tracker])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
Pay attention to the points below.
progress_tracker function should be called from the "Frontend" (Typescript) part.
#[tauri::command] is an attribute that defines the function above as a Javascript-friendly
window: Window parameter should be passed from the "Frontend" side.
The loop inside progress_tracker returns a number every 100ms 100 times.
Pay attention on .invoke_handler(tauri::generate_handler![progress_tracker]) in main function. You must "register" your Frontend-friendly function.
Also, you need to change tauri.identifier value in src-tauri/tauri.conf.json. Say, to com.buchslava.dev in my case. After that, change build.beforeBuildCommand value to npm run build && npm run export in the file above. It’s important because in this example we work with NextJS SSG.
"Frontend" first scratches.
Let’s move to our "Frontend" part.
Move to the project’s root folder and put the following code into src/pages/index.tsx
import { invoke } from "@tauri-apps/api/tauri";
import { listen } from "@tauri-apps/api/event";
import { useEffect, useState } from "react";
interface ProgressEventPayload {
progress: number;
}
interface ProgressEventProps {
payload: ProgressEventPayload;
}
export default function Home() {
const [busy, setBusy] = useState<boolean>(false);
useEffect(() => {
// listen what can Rust part tell us about
const unListen = listen("PROGRESS", (e: ProgressEventProps) => {
console.log(e.payload.progress);
});
return () => {
unListen.then((f) => f());
};
}, []);
return (
<div>
{!busy && (
<button
onClick={() => {
setBusy(true);
setTimeout(async () => {
const { appWindow } = await import("@tauri-apps/api/window");
// call Rust function, pass the window
await invoke("progress_tracker", {
window: appWindow,
});
setBusy(false);
}, 1000);
}}
>
Start Progress
</button>
)}
</div>
);
}
It’s time to run the example…
npm run tauri dev
Let’s open Developer Console (Right click on the screen → Inspect → Switch to Console tab) and press "Start Progress" button.
Congrats! We finished the basic Touri stuff and it’s time to focus on "Frontend" upgrading.
You can find this solution here.
Add UI part
We need to add a Progress Bar widget to the screen and show the progress on it instead of Console. First, install Ant Design dependency.
npm i antd --save
Second, remove all content from src/styles/Home.module.css. Third, put the following content into src/styles/globals.css.
body {
position: relative;
width: 100vw;
height: 100vh;
font-family: sans-serif;
overflow-y: hidden;
display: flex;
justify-content: center;
align-items: center;
}
Fourth, put the following code into src/pages/index.tsx instead the existing.
import { invoke } from "@tauri-apps/api/tauri";
import { listen } from "@tauri-apps/api/event";
import { useEffect, useState } from "react";
import { Button, Progress } from "antd";
interface ProgressEventPayload {
progress: number;
}
interface ProgressEventProps {
payload: ProgressEventPayload;
}
export default function Home() {
const [busy, setBusy] = useState<boolean>(false);
const [progress, setProgress] = useState<number>(0);
useEffect(() => {
const unListen = listen("PROGRESS", (e: ProgressEventProps) => {
setProgress(e.payload.progress);
});
return () => {
unListen.then((f) => f());
};
}, []);
return (
<div>
<div style={{ width: "70vw" }}>
<Progress percent={progress} />
</div>
<Button
type="primary"
disabled={busy}
onClick={() => {
setBusy(true);
setTimeout(async () => {
const { appWindow } = await import("@tauri-apps/api/window");
await invoke("progress_tracker", {
window: appWindow,
});
setBusy(false);
}, 1000);
}}
>
Start Progress
</Button>
</div>
);
}
Let’s look at the result…
npm run tauri dev
Looks good. But I’m a suspicious guy, and I must be 100% sure that everything between Rust and NextJS parts stays together. I want to add a timer to the "Frontend" screen. As a result, Progress and Timer should work simultaneously without stops.
Let’s put the following code into src/pages/index.tsx instead the existing.
import { invoke } from "@tauri-apps/api/tauri";
import { listen } from "@tauri-apps/api/event";
import { useEffect, useState } from "react";
import { Button, Progress } from "antd";
interface ProgressEventPayload {
progress: number;
}
interface ProgressEventProps {
payload: ProgressEventPayload;
}
export default function Home() {
const [busy, setBusy] = useState<boolean>(false);
const [progress, setProgress] = useState<number>(0);
const [timeLabel, setTimeLabel] = useState<string>();
useEffect(() => {
const timeIntervalId = setInterval(() => {
setTimeLabel(new Date().toLocaleTimeString());
}, 1000);
const unListen = listen("PROGRESS", (e: ProgressEventProps) => {
setProgress(e.payload.progress);
});
return () => {
clearInterval(timeIntervalId);
unListen.then((f) => f());
};
}, []);
return (
<div>
<div style={{ position: "fixed", top: 20, left: 20 }}>{timeLabel}</div>
<div style={{ width: "70vw" }}>
<Progress percent={progress} />
</div>
<Button
type="primary"
disabled={busy}
onClick={() => {
setBusy(true);
setTimeout(async () => {
const { appWindow } = await import("@tauri-apps/api/window");
await invoke("progress_tracker", {
window: appWindow,
});
setBusy(false);
}, 1000);
}}
>
Start Progress
</Button>
</div>
);
}
It’s time to make the last stitch. Till we have progress functionality, we need to stop it somehow. The following modifications allow us to do it.
src-tauri/src/main.rs
#![cfg_attr(
all(not(debug_assertions), target_os = "windows"),
windows_subsystem = "windows"
)]
use tauri::Window;
use std::{thread, time};
use std::sync::{Arc, RwLock};
#[derive(Clone, serde::Serialize)]
struct Payload {
progress: i16,
}
#[tauri::command]
async fn progress_tracker(window: Window){
// New code
let stop = Arc::new(RwLock::new(false));
let stop_clone = Arc::clone(&stop);
let handler = window.once("STOP", move |_| *stop_clone.write().unwrap() = true);
// / New code
let mut progress = 0;
loop {
// New code
if *stop.read().unwrap() {
break;
}
// / New code
window.emit("PROGRESS", Payload { progress }).unwrap();
let delay = time::Duration::from_millis(100);
thread::sleep(delay);
progress += 1;
if progress > 100 {
break;
}
}
window.unlisten(handler); // New code
}
fn main() {
tauri::Builder::default()
.invoke_handler(tauri::generate_handler![progress_tracker])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
src/pages/index.tsx
import { invoke } from "@tauri-apps/api/tauri";
import { listen } from "@tauri-apps/api/event";
import { useEffect, useState } from "react";
import { Button, Progress } from "antd";
interface ProgressEventPayload {
progress: number;
}
interface ProgressEventProps {
payload: ProgressEventPayload;
}
export default function Home() {
const [busy, setBusy] = useState<boolean>(false);
const [progress, setProgress] = useState<number>(0);
const [timeLabel, setTimeLabel] = useState<string>();
useEffect(() => {
const timeIntervalId = setInterval(() => {
setTimeLabel(new Date().toLocaleTimeString());
}, 1000);
const unListen = listen("PROGRESS", (e: ProgressEventProps) => {
setProgress(e.payload.progress);
});
return () => {
clearInterval(timeIntervalId);
unListen.then((f) => f());
};
}, []);
return (
<div>
<div style={{ position: "fixed", top: 20, left: 20 }}>{timeLabel}</div>
<div style={{ width: "70vw" }}>
<Progress percent={progress} />
</div>
<Button
type="primary"
disabled={busy}
onClick={() => {
setBusy(true);
setTimeout(async () => {
const { appWindow } = await import("@tauri-apps/api/window");
await invoke("progress_tracker", {
window: appWindow,
});
setBusy(false);
}, 1000);
}}
>
Start Progress
</Button>
{/* New code */}
<Button
type="primary"
disabled={!busy}
onClick={async () => {
const { appWindow } = await import("@tauri-apps/api/window");
await appWindow.emit("STOP");
setProgress(0);
setBusy(false);
}}
>
Stop Progress
</Button>
{/* / New code */}
</div>
);
}
Looks persuasive!
Frontend Backend Communication in Tauri: Implementing Progress Bars and Interrupt Button will tell you more regarding the technique above.
You can find the related source here.
The Fasts
Finally, I want to focus on build stuff. Let’s build the app. BTW, I’m working under MacOS. Please, read this one if you want to get more about Tauri build. Let’s build!
npm run tauri build
The next information will help you understand where and what you can find regarding the result of the build. You can find your build in /src-tauri/target/release/bundle.
In MacOS, you will find the standalone application /src-tauri/target/release/bundle/macos with the installer-based build /src-tauri/target/release/bundle/dmg.
The most exciting thing here is the 4.7Mb application and 2.3Mb installer. Can you believe it? 4.7Mb of Rust & NextJS & Ant Design!
Do you want to compare Tauri’s result with Electron’s one???
Honestly, when I got this result, my memories from my past returned. I remember 20mb hard disks and IBM PC XT.
I also thought about the following. Amazing! I can put an application from 2023 to my PC from 1990. Sounds like a time machine!
PS: Thanks to Eduardo Speroni for helpful notes that improve the article.