How to write and package desktop apps with Tauri + Vue + Python

First of all, why desktop app with web technologies ?

Historical an current context :

As hinted in the introduction, writing desktop apps adds a layer of complexity to software editors, due to the fact that they don’t control the environment their apps are running on.

Due to this complexity Java got extremely popular in the 90s and early 2000s because with the JVM (java virtual machine) the leitmotiv “write once, run anywhere” was somehow achieved. JVM gives you the guarantee (at a certain extent) that you don’t have to care about your user’s environment, since your code will compile to java byte code, and once compiled to byte code, the JVM take it from there and runs it on the system it is installed on. Thus, back then, many applications got built using a popular java library Swing / AWT.

JVM was really a big step in desktop applications, still one last friction remaining for businesses, they were requiring both desktop-grade performance and web-like reach, thus, the definition of run everywhere got extended from desktop only to desktop and web, hence browsers. This was something, that Swing couldn’t do, and the set of skills to rewrite a comparable Swing UI app to its web version was completely different, so unlikely to be the same team, the same constraints etc…

This additional extension of the definition of everywhere emphasized the need to have an unified way to write apps for both worlds, thus using web technologies to write desktop apps was a good approach, that way the same team who wrote the web version could write/adapt the desktop version and vice versa, which makes total sens from business standpoint, this is what made Electron very popular and gave birth to apps like Atom or Vscode

Why Tauri then ?

Electron is great but :

While Electron revolutionized cross-platform desktop development by allowing developers to use familiar web technologies (HTML, CSS, JavaScript), it came at a cost that have drawn criticism over the years. One of the most prominent issues is application size: even a minimal “Hello World” app can exceed 100MB because Electron bundles an entire Chromium engine and Node.js runtime within each application. This self-contained model ensures consistency across platforms but at the cost of bloated binaries, also from a backend perspective, Electron is tightly coupled to JavaScript/TypeScript and Node.js. While it’s technically possible to integrate other languages via custom native modules or local servers, doing so is complex and outside the comfort zone of most frontend teams.

Tauri’s emergence :

Because of these limitations, newer frameworks like Tauri have emerged to offer a more lightweight and flexible alternative. Tauri isn’t a browser bundled with your app — it wraps the native WebView provided by the operating system (WebKit on macOS/Linux, WebView2 on Windows), resulting in dramatically smaller binary sizes. Under the hood, Tauri uses Rust to manage communication between the frontend and backend, but it’s intentionally unopinionated about how you structure your backend logic. While Rust is the default and most integrated choice, you’re not locked into it — Tauri can communicate with any backend over a secure bridge. In our case, we’ll be using Python to implement the backend, structured like a local API the frontend can talk to, giving us access to the rich Python ecosystem while keeping the UI layer entirely web-based.

Let’s Build a To-do App

Steps :

App’s Spec

App’s architecture

App’s Setup

App’s packaging

App’s Spec :

App’s spec is very simple, a Todo List is for GUI what’s hello world is for a programming language, it’s basically an app with a GUI where you can add tasks and the added tasks get displayed, you’re also expected to be able to delete the tasks, that’s it.

App’s Architecture :

Schema :

Schema’s explanation :

Tauri is implemented in rust, thus very well integrated with it, Tauri exposes a method “invoke” that you can call from within your .vue or .js files which call a rust command which are functions decorated by #[tauri::command] containing the logic implementation, and in our case, it’s a function that calls a python api which takes the command and then either create/delete/list tasks from a local SQLite database manipulated using SQLAlchemy core

Why not using rust directly without python :

We could have, although it depends on the use case, you might be wanting to move faster than rust would allows you to, you might have a team that is not rust proficient, your app might be using some libs that are more mature in python ecosystem etc… so it depends

Technical discussion on binding Tauri — rust — python

When deciding how to make rust communicate with python, we have many solutions with pros and cons, Http protocol offers a good trade-off, let’s say a word or 2 on some solutions :

Calling python directly from rust :

Pros :

– More control remains with rust

– More control remains with rust Cons :

– hard to maintain

– overhead of python runtime starting each time you call python you have to acquire the gil and start an interpreter

Using ZeroMQ protocol :

ZeroMq protocol it what Jupyter seems to be using to make the notebook communicate with the kernel, check out this episode of developer’s voice for more info

Pros :

– High performance communication

– High performance communication Cons :

– Hard to maintain

– Non human readable

Unix domain socket :

Pros :

– High performance communication

– High performance communication Cons :

– Platform specific won’t work on Windows

Web Socket (TCP):

Pros :

– Ok performance

– Human readable

– Ok performance – Human readable Cons :

– More complex to maintain than http

Http :

Pros :

– Web friendly

– Human readable

– very maintainable for testing (you can use curl/postman)

– Web friendly – Human readable – very maintainable for testing (you can use curl/postman) Cons :

– More complex to maintain than http

So we went with http protocol, for us, it offers a good trade-off, but keep in mind, that this choice is context dependent

App’s screenshot :

This is how our little Todo app looks like

Project Setup :

Step 1 : Create a minimal Tauri project layout

#You're expected to have cargo installed https://doc.rust-lang.org/cargo/getting-started/installation.html

mkdir demo && cd demo

cargo install create-tauri-app

cargo create-tauri-app

# you will be answering some questions see img below

# Then install node dependencies

npm install

# you can display your UI with

npm run tauri dev

# add a dependency we will be needing later for rust

cd src-tauri && cargo add reqwest --features json

You should have a layout more and less like this one :

demo/

┣ public/

┃ ┣ tauri.svg

┃ ┗ vite.svg

┣ src/

┃ ┣ assets/

┃ ┃ ┗ vue.svg

┃ ┣ App.vue┃

┃ ┗ main.js

┣ src-tauri/

┃ ┣ capabilities/

┃ ┃ ┗ default.json

┃ ┣ src/

┃ ┃ ┣ lib.rs

┃ ┃ ┗ main.rs

┃ ┣ .gitignore

┃ ┣ Cargo.lock

┃ ┣ Cargo.toml

┃ ┣ build.rs

┃ ┗ tauri.conf.json

┣ .gitignore

┣ Makefile

┣ README.md

┣ index.html

┣ package-lock.json

┣ package.json

┗ vite.config.js

Step 2: Add python src-backend layout

# since we have created the layout for the UI with tauri, now we have to do it

# for the backend

# You're expected to have uv the python package manager installed for the following (https://docs.astral.sh/uv/getting-started/installation/)

# init a minimal python project layout

uv init --no-workspace src-backend --python 3.10

#create .venv + add dependencies

uv --directory src-backend add sqlalchemy sanic

Step 3 : Replace some files content

Let’s start with src-backend/main.py

# src-backend/main.py

from sanic import Sanic

from sanic.response import json

from sqlalchemy import (

create_engine,

Table,

Column,

Integer,

String,

MetaData,

)

from sqlalchemy import insert, delete, select

from datetime import datetime

# from platformdirs import user_data_dir

engine = create_engine("sqlite:///./tasks.db", future=True)

metadata = MetaData()

task_table = Table(

"tasks",

metadata,

Column("id", Integer, primary_key=True),

Column("name", String, nullable=False),

Column("created_at", String), ## we keep it as a string to avoid serialization pbs for demo purposes

)

metadata.create_all(engine)

app = Sanic("TauriPythonBackend")

@app.route("/")

async def index(request):

return json({"message": "Hello world !"})

@app.get("/tasks")

async def tasks_get(request):

with engine.begin() as conn:

tasks_result = [dict(el) for el in conn.execute(select(task_table)).mappings()]

return json(

{

"message": "Getting tasks for client",

"data": tasks_result,

}

)

@app.post("/tasks")

async def tasks_post(request):

data = {

"name": request.json.get("taskName", "no-name"),

"created_at": request.json.get("createdAt"),

"id": request.json.get("taskId"),

}

with engine.begin() as conn:

conn.execute(insert(task_table).values(**data))

print(f"Added task: {data['name']}")

return json({"message": f"Created task name {data.get('name')}"})

@app.delete("/tasks")

async def tasks_delete(request):

task_id = request.json.get("taskId", "no-id")

with engine.begin() as conn:

conn.execute(delete(task_table).where(task_table.c.id == task_id))

return json({"message": f"Deleted task of id {task_id}"})

if __name__ == "__main__":

app.run(host="127.0.0.1", port=8000)

Make sure you’re able to run the sanic backend :

uv run --directory src-backend sanic main:app --debug --single-process

Let’s continue with src-tauri/src/lib.rs

This implements a command able to invoke our python backend from rust with http

use reqwest::Client;

use serde_json::Value;

#[tauri::command]

async fn py_api(method: String, endpoint: String, payload: Option) -> Result {

let client = Client::new();

let url = format!("http://127.0.0.1:8000/{}", endpoint);

let request = match method.as_str() {

"GET" => client.get(&url),

"POST" => {

let req = client.post(&url);

if let Some(data) = &payload {

req.json(data)

} else {

req

}

}

"PUT" => client.put(&url),

"DELETE" => client.delete(&url),

_ => return Err(format!("Unsupported HTTP method: {}", method)),

};

let request = if let Some(data) = payload {

request.json(&data)

} else {

request

};

let response = request

.send()

.await

.map_err(|e| e.to_string())?

.json::()

.await

.map_err(|e| e.to_string())?;

Ok(response)

}

#[cfg_attr(mobile, tauri::mobile_entry_point)]

pub fn run() {

tauri::Builder::default()

.plugin(tauri_plugin_opener::init())

.invoke_handler(tauri::generate_handler![py_api])

.run(tauri::generate_context!())

.expect("error while running tauri application");

}

Then finally, let’s change the content of src/App.vue

Step 4 : Let’s test our App and see how the front is glued to the backend

# At the root of your project :

#In one terminal run the backend with the previously seen command:

uv run --directory src-backend sanic main:app --debug --single-process

# In another terminal

npm run tauri dev

# you will be able to add tasks to your backend in a persistant manner,

# if you shut down

Now that we have successfully built a To-do List app using Tauri + Vue + Python, let’s see how we can package it

Packaging your app for production :

This section has caused me some pain to make the code work, but it was worth it. To make our app packageable and production ready, we should :

Bundle the python code to an exec binary (we will use pyinstaller)

Add this binary location to tauri’s config

Add some code logic to spawn the backend binary at startup time, and shut it down when the GUI exits

Build your app and generate a final binary desktop app

Bundle python code with pyinstaller :

Pyinstaller allows us to “compile” python to an exec, that can be executed without a python interpreter.

# we should install pyinstaller as a dev dependency

# At the root folder, run :

uv --directory src-backend add pyinstaller --group dev

# once pyinstaller installed, at the root folder run :

uv --directory src-backend run pyinstaller run.py \

--onefile \

--name python_backend \

--clean \

--log-level=DEBUG \

--collect-all sqlalchemy \

--collect-all sanic \

--collect-all sanic_routing \

--collect-all tracerite

You should then have generated a “src-backend/dist/python_backend” that you can run as such “./src-backend/dist/python_backend” :

Add the binary to tauri’s config :

1st, consider creating a folder src-tauri/binaries that will hold all the binaries your app might rely on.

Then copy the generated binary like this :

cp src-backend/dist/python_backend src-tauri/binaries/python_backend-$(rustc -vV | grep host | awk '{print $2}')

Notice that we’re adding ${rustc -vV | grep host | awk ‘{print $2}’} in my case it’s gonna be mapped to this path src-tauri/binaries/python_backend-aarch64-apple-darwin , Tauri expec the platform-specific triple (-aarch64-apple-darwin in my case) to be have cross-platform compilation but in tauri.conf.json, no need to add this suffix, just add the entry :

{ "bundle" :

{

"externalBin": [

"binaries/python_backend"

]

}

}

Once done, Tauri would be able to include the python binary in it final binary, but it doesn’t mean that it will be able to launch it, this what we will see in the next step

Spawn and shut down the backend with startup and app’s shutdown :

To have this bheaviour where it is Tauri that handle the lifecycle of the python_backend, we have to make some modification on our rust files, 1st, let’s create a src-tauri/src/api.rs that will hold the py_api cmd implementation. :

# src-tauri/src/api.rs

use reqwest::Client;

use serde_json::Value;

#[tauri::command]

pub async fn py_api(

method: String,

endpoint: String,

payload: Option,

) -> Result {

let client = Client::new();

let url = format!("http://127.0.0.1:8000/{}", endpoint);

let request = match method.as_str() {

"GET" => client.get(&url),

"POST" => {

let req = client.post(&url);

if let Some(data) = &payload {

req.json(data)

} else {

req

}

}

"PUT" => client.put(&url),

"DELETE" => client.delete(&url),

_ => return Err(format!("Unsupported HTTP method: {}", method)),

};

let request = if let Some(data) = payload {

request.json(&data)

} else {

request

};

let response = request

.send()

.await

.map_err(|e| e.to_string())?

.json::()

.await

.map_err(|e| e.to_string())?;

Ok(response)

}

Then src-tauri/src/lib.rs will just expose py_api to other modules :

//! src-tauri/src/lib.rs

pub mod api; // brings the module in

// Re-export the command to make it available to main.rs

pub use api::py_api;

Then it’s src-tauri/src/main.rs that will contain the logic to start tauri and handle the backend lifecycle :

//! src-tauri/src/main.rs

#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]

use std::{

env,

io::{BufRead, BufReader},

process::{Child, Command, Stdio},

sync::{Arc, Mutex},

thread,

};

use tauri::RunEvent;

/// “python_backend.exe” on Windows, “python_backend” elsewhere.

fn backend_filename() -> &'static str {

if cfg!(windows) {

"python_backend.exe"

} else {

"python_backend"

}

}

/// Spawns the side-car in the same folder as this executable.

fn spawn_backend() -> std::io::Result {

// Locate the Tauri executable, then its parent folder

let exe_path = env::current_exe().expect("failed to get current exe path");

let exe_dir = exe_path

.parent()

.expect("failed to get parent directory of exe");

// Build the full path to python_backend[-].exe

// Tauri build will have renamed the suffixed file to plain name next to the exe.

let backend_path = exe_dir.join(backend_filename());

println!("▶ looking for side-car at {:?}", backend_path);

let mut child = Command::new(&backend_path)

.stdout(Stdio::piped())

.stderr(Stdio::piped())

.spawn()?;

// Pipe stdout

if let Some(out) = child.stdout.take() {

thread::spawn(move || {

for line in BufReader::new(out).lines().flatten() {

println!("[backend] {line}");

}

});

}

// Pipe stderr

if let Some(err) = child.stderr.take() {

thread::spawn(move || {

for line in BufReader::new(err).lines().flatten() {

eprintln!("[backend-err] {line}");

}

});

}

println!("▶ spawned backend: {:?}", backend_path);

Ok(child)

}

fn main() {

// Shared handle so we can kill it on exit

let child_handle = Arc::new(Mutex::new(None));

// Build the app

let app = tauri::Builder::default()

.setup({

let child_handle = child_handle.clone();

move |_app_handle| {

let child = spawn_backend().expect("failed to spawn python backend");

*child_handle.lock().unwrap() = Some(child);

Ok(())

}

})

.invoke_handler(tauri::generate_handler![demo_todo_ru_py_lib::py_api])

.build(tauri::generate_context!())

.expect("error building Tauri");

// Run and on Exit make sure to kill the backend

let exit_handle = child_handle.clone();

app.run(move |_app_handle, event| {

if let RunEvent::Exit = event {

if let Some(mut child) = exit_handle.lock().unwrap().take() {

let _ = child.kill();

println!("⛔ backend terminated");

}

}

});

}

Once you have all of that in place, you can finally, build your app to a binary desktop application

Build your app and generate a final binary desktop app :

Finally the final step :

# At the root of the folder run :

cd src-tauri && npm run tauri build

If all the previous steps went good, you should optain a binary “src-tauri/target/release/bundle/dmg/demo-todo-ru-py_0.1.0_aarch64.dmg”

For mac OS just run the cmd :

open ./src-tauri/target/release/bundle/dmg/demo-todo-ru-py_0.1.0_aarch64.dmg

And you will have a screen like that and then you can use your app as any other one

Conclusion :

That’s it we’re finally at the end, it was dense, but i think it worths it, it’s a pattern that might be very useful in some context where you don’ want to use Tkinter, or your team already deeply invested in python ecosystem and web technologies.

You can get the final repo of the project here it’s slightly different than the code shown here to bring some robustness

Reference :