2023-04-05
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:
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
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",
}
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"}
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>
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
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.