iTranslated by AI
Getting Started with Rust and Tauri: Building an Image Conversion Desktop App
Hello! I am a new DX engineer working at a manufacturing company.
I started learning Rust last month.
The reasons I'm learning it are:
- I've been using interpreted languages, so I want to challenge myself with a compiled language.
- I want to write code that runs lightning-fast.
- I want to try native app development.
- I want to be able to use WebAssembly to expand the scope of my web app development.
In the midst of this, I tried using Tauri to develop a desktop app with Rust.
I started it casually to kill time during the New Year holidays, but I struggled with various things, so I'm leaving this memo as a record.
This post might be long, so please use the table of contents m(._.)m
App Overview
The app I made this time is, roughly speaking,
A desktop app that compresses and converts major image formats into next-generation extensions, AVIF and WebP. ⚡️
The production period was about 5 to 7 days.
Here is the GitHub repository:
The app name is Tavif.
Since it's an avif conversion app developed using Tauri, a native app development framework for Rust, I named it tavif. (Whatever)
↓ Below is an image of the app in use

(*Note: The image is compressed due to Zenn's file size limitations, so the colors may differ slightly from the actual appearance.)
It has a simple UI, so no special explanation is needed, but:
Select the image files, choose the conversion format (AVIF or WebP), specify the quality in the range of 0-100, and execute to save the compressed and converted images.
Currently, it only supports Windows, and since it's built using GitHub Actions, it can be downloaded from the GitHub releases page.
I haven't obtained any self-signed certificates, so please use it at your own risk when downloading.
A SmartScreen warning might appear, but you can use it by displaying and installing it.
Since it's a local-first app that doesn't use the network, you can use it with peace of mind.
Motivation
The primary reason, as mentioned at the beginning of the article, was that I wanted to learn Rust.
However, if I'm going to develop an app, I wanted at least one person to use it. Personally, when creating AVIF images, I wasn't satisfied with any of the existing apps, so I wanted to try and solve that.
If you search for AVIF conversion tools, there are various options. I used to use an app called "Crushee," which is often recommended online.
However, despite AVIF being a next-generation image format, many apps had lower compression rates than WebP, so there wasn't much merit in converting to AVIF.
That's why I decided to build my own tool, which was the trigger for this app development.
Development Environment
I used Rust and TypeScript as development languages.
For the framework, I used Tauri, a native app development framework for Rust, and Next.js for the frontend implementation.
Below is the general configuration of the development environment:
- Build tool: Tauri
- Styling: TailwindCSS
- State management library: Jotai
- UI library: Ant Design
- Frontend testing: Vitest
- Backend testing: Rust
What is Tauri?
Tauri is a very "hot" native app development framework lately 🔥
Similar to Electron, you can create the frontend using web technologies like HTML, CSS, and JavaScript, and the backend can be created with Rust, which allows for lightning-fast processing.
Tauri is very actively developed and recently had a major version update, showing tremendous progress by supporting Windows, macOS, and Linux, as well as Android and iOS.
Additionally, while Electron uses Chromium for display, which leads to large package sizes, Tauri uses WebView2 for display, making the package size extremely compact.
Rust itself has consistently been ranked as the #1 most loved language by developers for many years, and there is no doubt it will continue to be a language to watch.
Setting up Tauri and Next.js
Using Next.js for the Tauri frontend is officially recommended and is clearly described in the documentation, but I'll briefly introduce it here as well.
First, create a Next.js project.
Creating a Next.js Project
npx create-next-app@14.2.3
Next, since you need to statically export Next.js to run it on Tauri, configure the following setting:
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "export",
images: {
unoptimized: true,
},
};
export default nextConfig;
Next, add the tauri command to package.json.
"scripts": {
"tauri": "tauri"
}
This completes the initial setup on the Next.js side.
While the latest version of Next.js is currently 15.x, the official Tauri documentation states that version 14 works correctly.
Although new features have been added in Next.js 15 and React 19, support from peripheral libraries hasn't caught up yet, and there may be unexpected pitfalls due to dependency issues.
For now, it would be safer to stick with version 14.
In fact, I didn't think too deeply about this and used Next 15, which I would regret later.
Setting up Tauri
I'm assuming you've already set up your Rust environment.
Since the environment setup for Tauri varies depending on the target platform, I will only introduce the setup for Windows, which is what I used for development.
Tauri uses Microsoft C++ Build Tools and Microsoft Edge WebView2 for development. Both are required for Windows development.
Installing Microsoft C++ Build Tools
- Download and open the Microsoft C++ Build Tools installer to begin installation.
- During installation, check the "Desktop development with C++" option.

Regarding WebView2, it is installed by default in Windows 11 and later, so you don't need to install it specifically.
Creating a Tauri Project
Create a Tauri project using the npm command.
npm create tauri-app@latest
You will be asked about your development environment configuration; if you are using Next.js, choose TypeScript and React.
Now that the environment is ready, let's start it in dev mode.
npm install
npm run tauri dev
This completes the setup for Tauri.
Backend Implementation
Since I'm a Rust beginner, everything would be pointless if I couldn't build the business logic, so I started by building that first.
This app doesn't use a database, so the business logic is very simple.
(It's so simple that in the end, Rust code only makes up about 10% of the source code...)
It's a bit embarrassing, but let me introduce it briefly.
Receiving Image Binaries and Parallel Processing
The purpose of this app is to convert image files such as PNG, JPG, and WebP into AVIF and WebP.
I built it with a structure where image files selected by the user on the frontend are converted to binary and passed to the backend.
First, you need to define the types of the values received as arguments from TypeScript on the frontend as structs.
Be aware that if these struct definitions differ from the types passed from TypeScript, it will result in an error.
#[derive(Debug, Serialize, Deserialize)]
struct FileInfo {
file_name: String,
file_name_with_extension: String,
mime_type: String,
}
#[derive(Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
enum ExtensionType {
Webp,
Avif,
}
Next, implement the logic to receive the binary data of the image file groups passed from the frontend and perform the conversion in parallel.
To call a Rust function from the frontend, use the #[tauri::command] macro.
impl ExtensionType {
fn get_extension_str(&self) -> &'static str {
match self {
ExtensionType::Webp => "webp",
ExtensionType::Avif => "avif",
}
}
}
#[tauri::command]
fn convert(
files_binary: Vec<Vec<u8>>,
file_infos: Vec<FileInfo>,
extension_type: ExtensionType,
quality: u8,
) -> Result<(Vec<PathBuf>, String), Error> {
// Get the temporary directory
let temp_dir = std::env::temp_dir();
// Generate a unique directory name
let unique_dir_name = uuid::Uuid::new_v4().to_string();
// Create a temporary directory to save temporary files
let output_dir = temp_dir.join(format!("tavif_{}", unique_dir_name));
std::fs::create_dir_all(&output_dir)?;
let output_paths = files_binary
.par_iter()
.zip(file_infos)
.filter_map(|(file_binary, file_info)| {
let output_path = output_dir.join(format!(
"{}.{}",
file_info.file_name,
extension_type.get_extension_str()
));
match extension_type {
ExtensionType::Webp => {
encode_to_webp(file_binary, &output_path, quality).ok()?;
}
ExtensionType::Avif => {
encode_to_avif(file_binary, &output_path, quality).ok()?;
}
};
Some(output_path)
})
.collect::<Vec<_>>();
Ok((output_paths, output_dir.to_str().unwrap_or("").to_string()))
}
Rust has a library called rayon for easily performing parallel processing.
In this code, the par_iter() method is used to process file binary data in parallel.
This makes it possible to perform image conversion at high speed.
File Compression and Conversion Logic
It is difficult for someone who has only touched interpreted languages to handle binaries manually in Rust.
Rust has a library management site called crates.io, where many useful libraries are published.
From the image conversion libraries available on crates.io, I implemented the conversion logic using the image and webp crates, which offer excellent conversion efficiency.
fn encode_to_avif(img_binary: Vec<u8>, output_path: &str, quality: u8) -> Result<()> {
let img = image::load_from_memory(&img_binary)?;
let img = img.to_rgb8();
let file = File::create(output_path)?;
let writer = BufWriter::new(file);
let encoder = AvifEncoder::new_with_speed_quality(writer, 5, quality);
encoder.write_image(
img.as_raw(),
img.width(),
img.height(),
image::ColorType::Rgb8,
)?;
Ok(())
}
fn encode_to_webp(img_binary: Vec<u8>, output_path: &str, quality: u8) -> Result<()> {
// Since the quality argument is unified as u8, convert it here to f32
let quality = quality as f32;
let img = image::load_from_memory(&img_binary)?;
let img = img.to_rgba8();
let (width, height) = img.dimensions();
let encoded = webp::Encoder::new(&*img, webp::PixelLayout::Rgba, width, height)
.encode(quality)
.to_vec();
// Write encoded data to a file
std::fs::write(output_path, encoded)?;
Ok(())
}
Yes, that's all there is to the business logic.
While there are a few other minor logics, these are the main ones.
You might have noticed, but this has become more like learning Tauri than Rust (I intended to write more Rust code).
Let's keep implementation going without worrying about it.
Frontend Implementation
As mentioned earlier, I used Next.js for the frontend.
The general role of the frontend is as follows:
- Convert image files selected by the user into binary and pass them to the backend.
- Receive the results and paths of the converted images from the backend and display them to the user.
Tauri comes with many built-in Rust APIs that allow high-speed access to the file system from frontend JS. By utilizing these, you can easily create an app that feels like a native application.
Selecting Image Files via Drag and Drop
When implementing a drag-and-drop file selection feature in React, it is common to use libraries like react-dropzone.
However, react-dropzone and similar libraries do not work well in Tauri.
Instead, Tauri provides a drag-and-drop feature in its API, so we will use that.
For simplicity, I will only introduce the core part here. With just the following code, you can detect file drop operations and retrieve file paths.
import { listen } from "@tauri-apps/api/event";
listen("tauri://drag-drop", (event) => {
const paths = (event.payload as { paths: string[] }).paths;
});
One thing to note is that this drop detection range covers the entire app window. If you want to enable it only within a specific range, the official documentation does not provide such a setting, so you will need to implement it yourself.
File Selection Dialog
Since D&D alone isn't enough for a good UX, it's necessary to display a file selection dialog.
Tauri, of course, provides an API for file selection dialogs, so we'll use that.
import { open } from "@tauri-apps/plugin-dialog";
export async function openDialog(
setFilePaths: (paths: string[]) => void,
prevPaths: string[]
): Promise<void> {
const paths: string[] | null = await open({
title: "Select Files",
multiple: true,
directory: false,
filters: [
{
name: "Image Files",
extensions: ["jpg", "jpeg", "png", "webp"],
},
],
});
if (!paths) return;
const allPaths = [...prevPaths, ...paths]; // Combine existing paths and new paths
const uniquePaths = Array.from(new Set(allPaths)); // Remove duplicates
setFilePaths(uniquePaths); // Pass the calculated array directly
}
By using the open function like this, you can easily specify everything from the dialog title to the valid file formats. 🦀
Calling Functions Created on the Rust Side
In the backend implementation section, we will execute the convert function created in Rust from the frontend side.
In Tauri, you use a function called invoke to call Rust functions from the frontend.
import { invoke } from "@tauri-apps/api/core";
import { readFile } from "@tauri-apps/plugin-fs";
export async function convert(
setIsProcessing: (isProcessing: boolean) => void,
filePaths: string[],
quality: number,
extensionType: string,
fileInfos: FileInfo[],
setProcessedFilePaths: (processedFilePaths: string[]) => void,
setTabSelected: (tabSelected: "output" | "input") => void,
setDialog: (dialog: React.ReactNode) => void,
setOutputTempDir: (outputTempDir: string) => void
): Promise<React.ReactNode | null> {
setIsProcessing(true);
// Get binary data from the selected file paths
const binarys = await readFileAsync(filePaths);
// Create data to pass binary data to the backend
const sendData = await createSendData(binarys);
// Call the convert function on the backend
const [result, outputTempDir] = await invoke<[string[], string]>("convert", {
filesBinary: sendData,
fileInfos: fileInfos,
extensionType: extensionType,
quality: quality,
});
setOutputTempDir(outputTempDir); // Save the path of the temporary directory
setProcessedFilePaths(result); // Save the paths of the converted files
setIsProcessing(false); // Set the processing flag to false
setTabSelected("output"); // Set the tab to output
}
async function readFileAsync(filePaths: string[]): Promise<Uint8Array[]> {
const binarys = await Promise.all(filePaths.map(async (filePath) => {
const res = await readFile(filePath);
return res;
}));
return binarys;
}
async function createSendData(binarys: Uint8Array[]) {
return binarys.map((uint8Array) => {
return Array.from(uint8Array);
});
}
I have omitted error handling and other processing parts, but this is how we call the convert function created in the backend implementation section.
Basically, this is how communication happens between Rust and TS.
Of course, it is also possible to call TS functions from Rust, but since I didn't use it in this app, I'll omit it here.
Points I Personally Got Stuck On
Security Issues When Previewing Local Image Files
In this app, there is a feature to preview selected local image files, for example, when an image is selected.

However, in Tauri, there are security restrictions due to CSP (Content Security Policy), so you cannot directly specify local image files in the src attribute of an img tag.
To solve this problem, you need to use a function called convertFileSrc provided by Tauri to convert the path you want to specify in the src attribute.
import { convertFileSrc } from "@tauri-apps/api/path";
const src = convertFileSrc(filePath);
Additionally, configuration for CSP and the asset protocol is required. Set it in tauri.conf.json as follows. In scope, specify the directory where you allow local file reading permissions.
"security": {
"csp": "default-src 'self' ipc: http://ipc.localhost; img-src 'self' asset: http://asset.localhost",
"assetProtocol": {
"enable": true,
"scope": ["$TEMP", "$HOME"]
}
}
In this way, you can specify the local image file path in the src attribute of an img tag.
React Libraries Not Yet Supporting React 19
As mentioned at the beginning of the article, I encountered this problem because I thoughtlessly used Next.js 15.
Specifically, Ant Design, which I'm using as a UI library, does not yet fully support React 19, so some UI components started behaving strangely.
While a patch to support React 19 has been released on the official Ant Design website, it doesn't seem to be a complete fix.
In the end, I had to implement the parts of the components that were behaving strangely myself using Tailwind CSS (such as modal dialogs).
This applies to more than just React, but you should always carefully examine whether dependencies and operating environments are okay before using a library. (A lesson learned.)
It's Hard to Build with GitHub Actions!
Tauri claims that by building with GitHub Actions, it is possible to support multiple platforms from a single environment.
Initially, I planned to build this app for Windows, Mac, and Linux using GitHub Actions to make it cross-platform. However, in reality, configuring the GitHub workflow was difficult, and I struggled just to get the build to pass.
Furthermore, when I checked it on a real Mac, it didn't work properly (I didn't even check Linux). In the end, I decided to release it as a Windows-only build.
It's certainly true that a large part of this is due to my own lack of knowledge, but I've seen many people struggling with this online, so it might be a fairly difficult issue.
I'd love to hear opinions or advice from experts on this. If I ever build another app with Tauri, I'd like to try this part again.
Looking Back on This App Development
As a beginner in low-level languages, I tried making an app using Rust/Tauri.
While there were various things I struggled with, my overall assessment is that creating apps with Rust/Tauri is very comfortable for people who are used to web technologies.
Tauri already has many APIs provided officially, and I spent less time writing Rust code from scratch than I expected.
For simple apps, you could probably make them almost entirely with just frontend knowledge.
However, the difficulty of Rust as a language is indeed very high, just as the rumors say. I still don't feel like I've left the "Rust beginner" stage yet 😇
However, the feeling of controlling the lower layers is very fun. I'm also interested in the Go language, so I want to continue catching up with technology!
This turned out to be quite long, but thank you for reading until the end!
See you again! 👋
Discussion
追記についてですが、
convert関数の修正で_idxが不要になったなら、変数名の前にアンダースコアを付けて無視するのではなくその前の.enumerateを削除して良さそうですね。ちなみにget_extension_strが参照&ExtensionTypeを受け取って参照&strを返しているのはなぜでしょうか、2 つのライフタイムに関連は無いように見えます。ご指摘ありがとうごさいます!確かに
.enumerateは不要でした。修正します!get_extension_strが&selfを受け取り&strを返している点についてですが、拡張子の判定をmatch selfを使用して行うのが個人的に分かりやすかったのと、戻り値が文字列リテラルでstaticライフタイムであることからメモリ効率が良いと思い実装しております。素人で返答になっていなかったらすいません。勉強になりますm(__)m
ExtensionTypeは小さな 1 整数へとコンパイルされ、コピーコストがかからないので、#[derive(Clone, Copy)]を付ければ、.get_extension_str()は&selfではなく単にselfを受け取ることができます。また、「
&selfを受け取って&strを返す」というのは「受け取った&selfのライフタイムと、返す&strのライフタイムが同じである」という宣言なので、今の返り値は static になっていません。理解に乏しく大変申し訳無いのですが、&selfを受け取ってしまうとライフタイムが同期されるということでしょうか。。。?
試しに戻り値を
&strのもの、&'static strのもの、selfを受け取り&'static strを返すものを同時にconvert関数内で出力したところ、全て同じメモリ領域に格納されていることを確認しました。また、staticライフタイム境界を検証する関数に値を渡したところ、1つめの&self受け取り&str返しの関数のみコンパイルエラーが出ることは分かりました。
そう、アドレス値は同じなのです。1 つめの &self 受け取り &str 返しでは型強制(
&'static str→&'a strの変換)が起こっているのですが、コンパイル時の借用検査におけるライフタイムが変わるだけなので、実行時には何の変換も起こりません。静的領域に置かれた文字列のアドレス値がそのまま使われ続けます。しかし借用検査では static として扱われなくなるので、コンパイルエラーが出ることになります。私もうまく説明できなくて申し訳ないのですが、返り値のライフタイムは 2 つめ(アドレス値を受け取る)か 3 つめ(ほぼ bool 値を 1 つ受け取るのと同じ)のどちらかが自然と考えます。
返り値に
'staticを付けずにただ&strと書くと、コンパイラは省略されたライフタイムをどこかから補う必要があり、このとき「引数&selfのライフタイムと同じ」という推論を働かせます。この省略ルールは、たとえば「スライス&[T]を受け取り、条件を満たす要素を探してその参照&Tを返す」みたいな関数を書くときはライフタイムを書く必要が無く楽で良いのですが、今回のようなケースでは混乱の元ですね。なるほど!Rustは何かコードに問題があればコンパイラが教えてくれるだろうという浅い考えでRustを書いていましたが、とても腑に落ちました。大変勉強になりました!
自分も以前Tauriを使用する際に、最初Next.jsのv15系入れてことごとく後悔しました...やっぱりまだv14系が無難ですよね。
まだ14系を使ったほうが良さそうですね。。tauriが対応しても自分が使いたいライブラリが対応してないとかもまだまだありそうです(^_^;)
encode_to_webpと類似の他のメソッドは、画像ファイルが大きい場合、CPUを大量に消費し、webviewページがフリーズしてしまいます。これらをasyncメソッドに改造して、別スレッドでファイル処理を行うようにすることができます。これにより、よりスムーズな体験が得られるでしょう。
これは私が書いたTauriデスクトップアプリケーションで、テキストタイプとPDFタイプのファイルを処理するコードです。
パフォーマンス指摘の件とても勉強になります!
ありがとうございますm(_ _)m