Using Rust, Tauri, and SvelteKit to Build a Note Taking App

2023-04-05

In this blog post, I'll be guiding you through the process of building a note taking app using Tauri. Move over Electron :)

Alt text

Tauri allows us to build fast, cross-platform, and small sized apps using HTML, CSS, and JavaScript.

It accomplishes this by using WebViews. A WebView lets you embed web content (HTML,CSS, JavaScript) into an application without needing a full-fledged web browser.

Rust is used for the backend logic and SvelteKit for the frontend.

Each OS uses a different WebView rendering engine:

Setting up the project

Make sure Rust and the Tauri dependencies are installed as described here.

SvelteKit requires Node.js. I install it using Fedora's package manager.

sudo dnf install nodejs

Instead of npm, I'll install pnpm as the Node.js package manager

sudo npm install -g pnpm

Now we can initialize a new svelte project.

$ mkdir notes && cd notes
$ pnpm create svelte 

// hit enter to create the project in the current directory

// use down arrow key to select Skeleton project

// use down arrow key to select Yes, using Typescript syntax

// use space bar to select 
// * Add ESLint for code linting
// * Add Prettier for code formatting

// @next gets latest version
$ pnpm add -D @sveltejs/adapter-static@next

edit svelte.config.js

// change adapter-auto to adapter-static 
import adapter from '@sveltejs/adapter-static';

...

// add prerender entries 
kit: {
		adapter: adapter(),
		prerender: {
			entries: ['*', '/edit/*']
		}
}

Disable SSR by creating src/routes/+layout.ts

export const prerender = true;
export const ssr = false;

Check your node.js version and make sure pnpm uses the correct one

$ node -v 
v18.15.0 

// edit .npmrc 
$ vi .npmrc 

// add your version number as so
use-node-version=18.15.0

Setup Tauri

$ pnpm add -D @tauri-apps/cli
$ pnpm tauri init

// What is your app name? notes

// What should the window title be? notes

// Where are your web assets ..? ../build

// What is the URL of your dev server? http://localhost:5173

// What is your frontend dev command? pnpm run dev

// What is your frontend build command? pnpm run build

Run the app

// will be slow the first time running but after much faster
$ pnpm tauri dev

Setting up components

I recommend for beginners to go through the official Svelte tutorial here to grasp its fundamentals.

This is an excerpt of what a component is:

In Svelte, an application is composed from one or more components. A component is a reusable self-contained block of code that encapsulates HTML, CSS and JavaScript that belong together, written into a .svelte file. The 'hello world' example in the code editor is a simple component.

I'll be creating two components inside of src/lib. One called Notes.svelte will display all notes created. The other called CreateNote.svelte will be a text box where we can add new notes.

Create src/lib/Notes.svelte

<script lang="ts">
	let title = "First Note";
</script>

<div id="notes">
	<p> {title} </p>
</div>

Create src/lib/CreateNote.svelte

<script lang="ts">
	let newNote;
	let newTitle;
</script>

<div id="new-note">
	<h1> Create a Note </h1>
	<textarea bind:value={newTitle} id="new-note-title" placeholder="Note title"></textarea>
	<textarea bind:value={newNote} id="new-note-box" placeholder="Note body"></textarea>
</div>

A page is a route to a certain path. src/routes/+page.svelte will be the homepage, for instance.

Import the components into the page by editing src/routes/+page.svelte

<script lang="ts">
	import Notes from '$lib/Notes.svelte'
	import CreateNote from '$lib/CreateNote.svelte'
</script>

<div id="container">
	<CreateNote/>
	<Notes/>
</div>

You could place CSS style tags into each Page or Component, but I prefer a global CSS file.

Create static/global.css

/* CSS reset */
*, *::before, *::after {
  box-sizing: border-box;
}

* {
  margin: 0;
}

html, body {
  height: 100%;
}

body {
  line-height: 1.5;
  -webkit-font-smoothing: antialiased;
}

img, picture, video, canvas, svg {
  display: block;
  max-width: 100%;
}

input, button, textarea, select {
  font: inherit;
}

p, h1, h2, h3, h4, h5, h6 {
  overflow-wrap: break-word;
}

#root, #__next {
  isolation: isolate;
}

/* Component and Page CSS */

#container {
	box-sizing: border-box;
	width: 100%;
	height: 100%;
	display: flex;
	white-space: nowrap;
}

#notes {
	background: #eee;
}

Add it to src/app.html inside of the head tag

<link rel="stylesheet" type="text/css" href="%sveltekit.assets%/global.css">

I'll be saving the notes in the frontend. For this we require Tauri's frontend API.

$ pnpm add -D @tauri-apps/api

We must tell Tauri which paths are available to our app. In this case I'll be writing to a file called db.bson in the user's home/notes-db directory.

Edit src-tauri/tauri.conf.json

"tauri": {
   "allowlist": {
      "all": false,
	  "fs": {
	  	"scope": ["$HOME/notes-db/*", "$HOME/notes-db"],
		"all": true
	  },
	  "path": {
	  	"all": true
	  }
    },

Also scroll down in the conf.json file and find "identifier" . It should be unique to your app. I'll set it to com.random.random.

"bundle": {
	"identifier": "com.random.random",
}

Handling data in the backend

I've chosen to store the data as bson (Binary JSON). Read more about bson here. Basically it's how MongoDB stores JSON data on disk as binary.

Tauri lets the frontend pass data back and forth to the backend (Rust) using Tauri commands.

For the sake of brevity I'll just show the complete Rust backend code.

Edit src-tauri/src/main.rs with:

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

use bson::{Document};

use serde::{Serialize, Deserialize};
use std::io::{Cursor};

use serde_json;

#[derive(Serialize, Deserialize, Debug)]
struct Note {
    bson_uuid: String,
    date_time: bson::DateTime,
    title: String,
    body: String,
}

// builds a new Note object for the froteend
// we then convert it to a bson document 
// lastly we convert it into a vec of bytes to store on disk (frontend handles appending then saving this to disk)
#[tauri::command]
fn saveNote(title: &str, body: &str) -> Vec<u8> {
    let note = Note { bson_uuid: bson::Uuid::new().to_string(), date_time: bson::DateTime::now(), title: title.to_string(), body: body.to_string() };
    let note_doc = bson::to_document(&note).unwrap();

    return bson::to_vec(&note_doc).unwrap();
}

// after the frontend edits or deletes a note 
// it must be saved back to db.bson
#[tauri::command]
fn editNote(data: &str) -> Vec<u8> {
    let vecNotes: Vec<Note> = serde_json::from_str(data).unwrap(); 
    let vecDocs: Vec<Document> = vecNotes.iter().map(|e| bson::to_document(&e).unwrap() ).collect();
    let docsArray: Vec<u8> = vecDocs.clone().into_iter().flat_map(|e| bson::to_vec(&e.clone()).unwrap()).collect();

    return docsArray; 
}

// loading the raw data from db.bson requires us to convert it to JSON
// for the frontend to interact with
#[tauri::command]
fn loadNotes(data: &str) -> String{

   // check if database is empty. 
   // Return if it is otherwise the program will crash 
   if data.chars().count() == 0 {
        return String::from("no data");
   }

   // frontend passes the database as a string array of bytes 
   // parse it into bytes 
   let mybytes: Vec<u8> = data
       .trim_matches(|c| c == '[' || c== ']')
       .split(',')
       .map(|s| s.parse().unwrap())
       .collect();
  
   // now we iterate through the bytes and convert it 
   // to a Vec of bson Document 
   let mut curs = Cursor::new(mybytes);
   curs.set_position(0);

   let array_len = curs.get_ref().len() as u64;

   let mut docs = Vec::new();

   for _ in 0..array_len {
        match Document::from_reader(&mut curs) {
            Ok(doc) => {println!("{} \n\n", doc); docs.push(doc);},
            Err(e) => {
                println!("Error {:?}", e);
                break;
            }
        }
   }

   // return to the frontend an array of bson documents as JSON
   return serde_json::to_string(&docs).unwrap();
}

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

I create a Tauri app with three functions available to the frontend: saveNote, editNote, loadNotes

saveNote will be called from the frontend and be passed two values: title, body. I then create a new Note struct with those values then convert it to a bson::Document. Lastly I convert the document to a bson::Array (Vec of bytes) and return it to the frontend to handle storing it to disk.

editNote receives from the frontend an updated/modified version of the data stored on disk. The frontend requires this function to rebuild the bson database. We then return the binary bson back to the frontend to store to disk.

loadNotes takes what's stored on disk "[123],[100],etc.." and converts it to JSON for the frontend.

Also, edit src-tauri/Cargo.toml to include bson as a dependency

[dependencies]
bson = {version = "2.6.0"}

Handling data in the frontend

Svelte's writable store allows each component or page to individually modify/read a global state. Whenever it changes, all components get the newly changed value.

Create and edit src/lib/store.js

import { writable } from 'svelte/store';

import { homeDir, join } from '@tauri-apps/api/path';
import { exists, BaseDirectory, createDir, writeBinaryFile, readBinaryFile } from '@tauri-apps/api/fs';

import {invoke} from '@tauri-apps/api/tauri';

// This value gets initialized when loadStore() is called
// contains all bson stored on disk but as JSON
export const myStore = writable({});

// initialize myStore with the contents in the database
// let Rust convert the binary to an array of JSON
export async function loadStore() {
		let binData = await readBinaryFile('./notes-db/db.bson', {dir: BaseDirectory.Home});
		invoke('loadNotes', {data: binData.toString()}).then((dat) => {
			myStore.set(JSON.parse(dat));
		});
}

// send the updated JSON to the backend as a string
// the backend converts it to an array of bson documents as bytes and we store it to db.bson 
export async function editStore(newVal) {
	let jsonToString = JSON.stringify(newVal);
	// send the updated store to the backend
	invoke('editNote', {data: jsonToString}).then((dat) => {
		// store the updated bson to disk
		let data = new Uint8Array(dat);
		writeBinaryFile('./notes-db/db.bson', data, {dir: BaseDirectory.Home});
		loadStore();
	});
}

Edit src/routes/+pages.svelte to call loadStore from above. Also to pass the store to the components.

<script>
	import { myStore, loadStore } from '$lib/store.js';

  	import { homeDir, join } from '@tauri-apps/api/path';
  	import { exists, BaseDirectory, createDir, writeBinaryFile, readBinaryFile } from '@tauri-apps/api/fs';

  	import {invoke} from '@tauri-apps/api/tauri';
  	import { onMount } from 'svelte';

	import Notes from '$lib/Notes.svelte'
	import CreateNote from '$lib/CreateNote.svelte'

	var db;

	// gets called whenever the page/component gets mounted
	onMount(async () => {
		// get the user's home directory
 		let home = await homeDir();	
 		// append the directory we'll create
		db = await join(home, 'notes-db');

		// check if notes-db directory exists. If not then create it
		let checkDB = await exists('notes-db', {dir: BaseDirectory.Home});
		if (!checkDB) {
			// if the directory doesn't exist then create it
			await createDir('notes-db', {dir: BaseDirectory.Home, recursive: true });
		}

		// check if db.bson exists. If not then create it. 
		let checkFile = await exists('./notes-db/db.bson', {dir: BaseDirectory.Home});
		if (!checkFile) {
			await writeBinaryFile('./notes-db/db.bson', new Uint8Array([]), {dir: BaseDirectory.Home});		
		}

		// load myStore with what's on disk
		loadStore();
  });
</script>

<div id="container">
	<CreateNote/>
	/** Pass myStore to the component **/
	<Notes allNotes={$myStore} />
</div>

Edit src/lib/Notes.svelte

<script lang="ts">
	// This value gets bound to myStore 
	// <Notes allNotes={$myStore}>
	export let allNotes;
	
	// bson stores Date as milliseconds
	// Convert the date from milliseconds to human readbale
	function numToDate(num) {
		let toInt = parseInt(num, 10);
		let date = new Date(toInt);
		let options = {
			year: 'numeric',
			month: 'long',
			day: 'numeric',
			hour: 'numeric',
			minute: 'numeric',
		};
		return date.toLocaleString(undefined, options);
	}
</script>

// Loop through each note and render it
<div id="notes">
	{#if allNotes.length > 0}
	{#each allNotes as note }
		<div id="note">
			<a href="/edit/{note.bson_uuid}">Edit</a>
			<p> {note.title} </p>
			<p> { numToDate(note.date_time.$date.$numberLong) } </p>
		</div>
	{/each}
	/** either still loading or no data exists **/
	{:else}
		<p>Try saving a note</p>
	{/if}
</div>

Edit src/lib/CreateNote.svelte

<script>
	import { invoke } from '@tauri-apps/api/tauri'
	import { loadStore } from '$lib/store.js'
	import {BaseDirectory, writeBinaryFile, readBinaryFile} from '@tauri-apps/api/fs'

	// bound to value of title textarea
	let newNote;
	// bound to value of body textarea
	let newTitle;

	// This isn't bound to $myStore as above
	// This gets assigned the raw binary stored on disk
	let allNotes;

	async function save(){
			// sets allNotes to contain the binary stored on disk
			await load();

			// Let the backend handle creating a new binary document
			invoke('saveNote', {title: newTitle, body: newNote} ).then((response) => {
				// Here I simply merge the returned data with allNotes
				let loaded = new Uint8Array(allNotes);
				response = new Uint8Array(response);
				let mergeArray = new Uint8Array(loaded.length + response.length);
				mergeArray.set(loaded);
				mergeArray.set(response, loaded.length);
				// and save it to disk
				writeBinaryFile('./notes-db/db.bson', mergeArray, {dir: BaseDirectory.Home});	

				// after saving, reload writable myStore with saved data on disk
				loadStore();

				// empty textarea contents after save	
				newNote = "";
				newTitle = "";
			});
	}

	async function load() {
		allNotes = await readBinaryFile('./notes-db/db.bson', { dir: BaseDirectory.Home});	
	}
</script>

<div id = "new-note">
	<h1> Create a note </h1>
	<button on:click={save}>Save</button>
	<textarea bind:value={newTitle} id="new-note-title" placeholder="Note title"></textarea>
	<textarea bind:value={newNote} id="new-note-box" placeholder="Note body"></textarea>
</div>

Lastly, I'll create another page that will be used to let the user edit a note. [slug] will be the note's UUID.

Create src/routes/edit/[slug]/+page.svelte

<script>
	import { myStore, editStore } from '$lib/store.js';
	import { page } from '$app/stores';
	import { onMount} from 'svelte';

	let oneNote;
	let oneNoteBody;
	let oneNoteTitle;

	let slug;

	function save(){
		let currentStore = $myStore;
		// get the index of the current note that we're editing
		let index = currentStore.findIndex(item => item.bson_uuid === slug); 
		// grab the values for editing
		let updatedObject = {...currentStore[index]};
		// edit the values with what's in the textareas
		updatedObject.title = oneNoteTitle;
		updatedObject.body = oneNoteBody;

		// update the store
		myStore.update(store => {
			let updatedStore = [...store];
			updatedStore[index] = updatedObject;

			return updatedStore;
		});

		// save to disk
		editStore($myStore);
	}
	
	// deletes this note 
	function del() {
		// filter out this note in myStore
		myStore.update(objects => objects.filter(obj => obj.bson_uuid !== slug));
		// give the updated store to the backend
		editStore($myStore);
		// redirect to /
		window.location.href="/";
	}

	// check the slug to match a UUID in myStore
	onMount(async () => {
		slug = $page.params.slug.toString();
		$myStore.forEach(element => {
			if (element.bson_uuid === slug) {
				// grab the values and render them to the DOM
				oneNote = element;
				oneNoteTitle = element.title;
				oneNoteBody = element.body;
				return;
			}
		});
	});

</script>

<div id = "new-note">
	<h1> Edit note </h1>
	<a href="/" id="home-button">BACK</a>
	<button on:click={save}>Save</button>
	<button on:click={del}>Delete</button>
	<textarea bind:value={oneNoteTitle} id="edit-note-title"></textarea>
	<textarea bind:value={oneNoteBody} id="edit-note-box"></textarea>
</div>

Done

Running the following command will build the app into a binary located at src-tauri/target/release/

pnpm tauri build

My binary says it's at 10M, but I get it down to 3.0M by using UPX

$ upx notes

For cross platform compilation check out the official Tauri docs.

Here is the complete code on Github

Final notes

My initial goal was to have drag and drop functionality of images or videos. This would have made this tutorial way longer which is not my goal.

I chose to store the data as bson (binary JSON) as I was planning to store the images/videos as blobs. I'm not sure that this would even work as MongoDB's docs mention that a bson document can only store 16MB. I guess something like IndexedDB would serve my goals better.