enpitsulin

enpitsulin

这个人很懒,没有留下什么🤡
twitter
github
bilibili
nintendo switch
mastodon

Building Desktop Applications with Tauri - A Case Study of TodoMVC (Part 1)

Confused about learning Rust? Wrong, it's just too difficult to learn, so let's dive straight into practice. We'll use the classic practical project TodoMVC to implement a rust+sqlite backend and a react frontend for a cross-platform desktop app.

Creating a Tauri Project#

Although it's simple to create a new project according to the official documentation.

However, I use pnpm for package management, and to create a project with pnpm, run the following command:

pnpm create tauri-app

We choose to use the create-vite and then the react-ts template.

Create Project|1481x785

Then wait for the CLI to install the dependencies, open the project with VSCode. Here, I recommend installing rust-analyzer, but I assume you have already been advised to install it while learning Rust. Our project directory looks like this:

Directory|311x225

The src folder contains the frontend project content and the src-tauri folder contains the Rust backend. Developing the web interface is just like developing with React, but writing the Rust backend is different from Electron since Rust and Node.js are completely different.

Building the Page#

First, we directly use the CSS provided by the TodoMVC project and install todomvc-app-css.

pnpm add todomvc-app-css

Then import it in the entry file and delete the previously imported style file.

import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
+ import 'todomvc-app-css/index.css'
- import './index.css'

ReactDOM.createRoot(document.getElementById('root')!).render(
  <React.StrictMode>
    <App />
  </React.StrictMode>
)

Next, create a components directory and create the TodoItem component.

const TodoItem = () => {
  return (
    <li>
      <div className="view">
        <input type="checkbox" className="toggle" autoFocus />
        <label>some todo item</label>
        <button className="destroy"></button>
      </div>
    </li>
  )
}
export default TodoItem

And the TodoList component.

import TodoItem from './TodoItem'
const TodoList = () => {
  return (
    <>
      <header className="header">
        <h1>todos</h1>
        <input type="text" className="new-todo" placeholder="What needs to be done?" />
      </header>
      <section className="main">
        <input type="checkbox" className="toggle-all" />
        <label htmlFor="togle-all"></label>
        <ul className="todo-list">
          <TodoItem />
        </ul>
      </section>
      <footer className="footer">
        <span className="todo-count">
          <strong>1</strong> items left
        </span>
        <ul className="filters">
          <li>
            <a>All</a>
          </li>
          <li>
            <a>Active</a>
          </li>
          <li>
            <a>Completed</a>
          </li>
        </ul>
      </footer>
    </>
  )
}
export default TodoList

Then in App.tsx, delete the original template code and import TodoList to display it.

import TodoList from './component/TodoList'

function App() {
  return (
    <div className="todoapp">
      <TodoList />
    </div>
  )
}

export default App

Starting Tauri#

Then start Tauri, and you can see the effect.

pnpm tauri dev

Effect|1002x789

However, this part only displays a simple HTML structure and corresponding CSS styles without any functionality.

Developing the Backend#

Let's set aside implementing the web interface functionality for now and consider the Rust backend operations. First, let's understand how Tauri communicates.

According to the official documentation, we can mount invoke to the window.__TAURI__ object by using the TauriAPI package or by setting tauri.conf.json > build > withGlobalTauri to true. It's recommended to enable withGlobalTauri to simplify debugging later, although Tauri has official tests, I find it easier to test directly in the console.

Then we can use invoke to call the methods provided by the Rust backend.

The following operations are all under the src-tauri/ directory.

Using SQLite#

First, add the rusqlite dependency to gain the ability to operate SQLite.

[dependencies]
# ...
rusqlite = { version = "0.27.0", features = ["bundled"] }

Operations on the SQLite Database#

Referring to the usage of rusqlite, we create a method to establish a database connection.

fn connect() -> Result<()>{
    let db_path = "db.sqlite";
    let db = Connection::open(&db_path)?;
    println!("{}", db.is_autocommit());
    Ok(())
}

Then we can implement more methods to perform CRUD operations on the database, but writing too many methods requires creating and closing the database connection each time, which is cumbersome. Therefore, we can implement a struct TodoApp to encapsulate common methods.

Designing the TodoApp Class#

Designing the Table Structure#

First, let's design the database table structure.

The Todo table has a relatively simple structure, and the create table statement is:

CREATE TABLE IF NOT EXISTS Todo (
    id          varchar(64)     PRIMARY KEY,
    label       text            NOT NULL,
    done        numeric         DEFAULT 0,
    is_delete   numeric         DEFAULT 0
)

Todo Module#

Next, we create a todo.rs module and establish a Todo struct as the type for database rows for future use. Here, we use pub because we may need to access these properties in main.

pub struct Todo {
    pub id: String,
    pub label: String,
    pub done: bool,
    pub is_delete: bool,
}

And the TodoApp struct, although its internal methods are not yet implemented.

pub struct TodoApp {
    pub conn: Connection,
}
impl TodoApp {
    //To be implemented
}

Next, we abstract the CRUD operations into several member methods. Since Rust does not have a new keyword, we generally define a pub fn new() to construct the corresponding class, which essentially returns a struct that has an impl.

So we add the following implementation:

impl TodoApp{
    pub fn new()->Result<TodoApp>{
        let db_path = "db.sqlite";
        let conn = Connection::open(db_path)?;
        conn.execute(
            "CREATE TABLE IF NOT EXISTS Todo (
                id          varchar(64)     PRIMARY KEY,
                label       text            NOT NULL,
                done        numeric         DEFAULT 0,
                is_delete   numeric         DEFAULT 0
            )",
            [],
        )?;
        Ok(TodoApp { conn })
    }
}

Using the Result enum type is because Connection::open also returns a Result, and we want to simplify error handling using ? for error propagation (although this method does not have Err, it is just to simplify the process).

Now we can construct a TodoApp class using TodoApp::new().unwrap(), using unwrap() to unwrap the Ok from the enum type, which is our returned TodoApp.

Implementing Various Methods for TodoApp#

Now that we can construct the class, we want to perform CRUD operations on SQLite, so we need corresponding methods like get_todos, get_todo(id), new_todo(todo), update_todo(todo). There is no delete method because the design of the table already includes the is_delete field, which determines that our deletion will be a soft delete; hard deletion will not be implemented for now.

Querying All Todos#

Using the Connection.prepare() method, this method returns several methods of Statement such as query_map, execute, etc., which can accept parameters and pass them to the SQL statement during the query and return results.

Here, we use the query_map method to get an iterator, and by iterating through the iterator, we obtain a Vec<Todo>, which is an array of Todo objects, and then wrap it with the Result enum and return it.

Note that methods with generics but not controlled by parameters, such as the row.get method on line 6, are called with the generic parameter in the form of row.get::<I, T>().

Since SQLite does not have a boolean type, we use numeric values to represent true or false with 1 or 0, and we need to handle these two fields accordingly.

impl TodoApp {
    pub fn get_todos(&self) -> Result<Vec<Todo>> {
        let mut stmt = self.conn.prepare("SELECT * FROM Todo").unwrap();
        let todos_iter = stmt.query_map([], |row| {
            let done = row.get::<usize, i32>(2).unwrap() == 1;
            let is_delete = row.get::<usize, i32>(3).unwrap() == 1;

            Ok(Todo {
                id: row.get(0)?,
                label: row.get(1)?,
                done,
                is_delete,
            })
        })?;
        let mut todos: Vec<Todo> = Vec::new();

        for todo in todos_iter {
            todos.push(todo?);
        }

        Ok(todos)
    }
}

Thus, we can obtain data from SQLite, but we still need to provide commands for the frontend to call. We return to main.rs, first import the module and import Todo and TodoApp.

#![cfg_attr(
    all(not(debug_assertions), target_os = "windows"),
    windows_subsystem = "windows"
)]
mod todo;
use todo::{Todo, TodoApp};

fn main() {
    tauri::Builder::default()
        .invoke_handler(tauri::generate_handler![
            get_todos,
        ])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

#[tauri::command]
fn get_todos() -> Vec<Todo> {
    todo!()
}

Encapsulating Tauri State

We need to use the Mutex from the Rust standard library and Tauri's state to allow our encapsulated TodoApp object to be called across processes. First, we create an AppState struct for state management.

Then we manage this state through the manage method of tauri::Builder. We can then encapsulate the command to use the methods of this object to retrieve data.

#![cfg_attr(
    all(not(debug_assertions), target_os = "windows"),
    windows_subsystem = "windows"
)]
mod todo;
use todo::{Todo, TodoApp};

struct AppState {
    app: Mutex<TodoApp>,
}

fn main() {
    let app = TodoApp::new().unwrap();
    tauri::Builder::default()
        .invoke_handler(tauri::generate_handler![
            get_todos,
        ])
        .manage(AppState {
            app: Mutex::from(app),
        })
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

#[tauri::command]
fn get_todos() -> Vec<Todo> {
    let app = state.app.lock().unwrap();
    let todos = app.get_todos().unwrap();
    todos
}

Returning Data Serialization

After writing the command, I found an error in the annotation.

Error|658x358

This is because the returned Vec<Todo> is not a serializable type and cannot be returned to the frontend through the command. Returning to todo.rs, we add annotations to give the struct serialization capabilities.

use serde::{Serialize};

#[derive(Serialize)]
pub struct Todo {
    pub id: String,
    pub label: String,
    pub done: bool,
    pub is_delete: bool,
}

Then we can right-click on the interface, open the console, and input await __TAURI__.invoke("get_todos"), and we should see an empty array returned.

get_todos|789x797

Invoke Parameter Deserialization

The need for serialization and deserialization is similar to that of web applications with separated front and back ends, where JSON format is used in the transport layer, but the application needs real objects. Therefore, we need to add annotations to give the objects Serialize and Deserialize interfaces.

At the same time, the invoke method can also accept a second parameter as the argument for the command call, but the parameters also need to have the ability to deserialize data from JSON format, so we add annotations.

use serde::{Deserialize};
use serde::{Serialize, Deserialize};

#[derive(Serialize)]
#[derive(Serialize, Deserialize)]
pub struct Todo {
    pub id: String,
    pub label: String,
    pub done: bool,
    pub is_delete: bool,
}

Improving CRUD#

In addition to using the methods of Statement returned by Connection::prepare, we can also directly execute SQL statements from Connection. For example, to add a new todo, we get the todo parameter from invoke and deserialize it into a Todo object, then extract the id and label and pass them to the SQL statement's parameters to complete the INSERT.

pub fn new_todo(&self, todo: Todo) -> bool {
    let Todo { id, label, .. } = todo;
    match self
        .conn
        .execute("INSERT INTO Todo (id, label) VALUES (?, ?)", [id, label])
    {
        Ok(insert) => {
            println!("{} row inserted", insert);
            true
        }
        Err(err) => {
            println!("some error: {}", err);
            false
        }
    }
}

Similarly, there are update_todo, get_todo, and I won't list the code here, just provide function signatures. Whether to return values wrapped in Result or not is actually a matter of personal preference.

pub fn update_todo(&self, todo: Todo) -> bool {
  // more code
}
pub fn get_todo(&self, id: String) -> Result<Todo> {
  // also more code
}

Similarly, we need to add corresponding commands.

#[tauri::command]
fn new_todo(todo: Todo) -> bool {
    let app = TodoApp::new().unwrap();
    let result = app.new_todo(todo);
    app.conn.close();
    result
}

#[tauri::command]
fn update_todo(todo: Todo) -> bool {
    //to be implemented
}

#[tauri::command]
fn toggle_done(id: String) -> bool {
    //to be implemented
}

And don't forget to add them in generate_handler.

fn main() {
    tauri::Builder::default()
        .invoke_handler(tauri::generate_handler![
            get_todos,
            new_todo,
            toggle_done,
            update_todo
        ])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

Thus, we have basically completed the backend for TodoMVC. Next, in the next article, we will use React + Jotai + some packages to complete the frontend of this application and the communication with the Rust backend. This part will be much simpler, basically just basic React.

Next Article Link#

Building Desktop Applications with Tauri - A TodoMVC Example (Part 2)

Loading...
Ownership of this post data is guaranteed by blockchain and smart contracts to the creator alone.