Today we will show you how to build a cross-platform desktop pet application using Tauri! Follow along and get your hands on the code.
What We Are Going To Build
In this blog post we will discover how to use Tauri v2 to create a rudimentary desktop pet. We will setup a working desktop application for Windows, Linux and Mac and also distribute it with CrabNebula Cloud.
Desktop pets are a mid-2000s phenomenon that fall into the category of virtual pets. Most of you will know the names of Tamagotchi and the like. Check out the article on Wikipedia for a little trip down memory lane: https://en.wikipedia.org/wiki/Virtual_pet.
Features include:
- Pet is layered on top of the desktop (no full screen support)
- Pet follows mouse clicks
- Send pet to home location by clicking on it
- 3D animations and wiggle effect
- Koi pond background
Inspiration
This will be more of a personal anecdote, so feel free to skip to the beginning of the hands-on part: Setting Up.
A little while ago I came across a video of a small youtuber called RachelfTech. She was building a desktop pet with another open source project called Godot. Godot is a game engine and has it’s very own challenges to overcome. If you are interested, I recommend checking out her video on the topic: https://www.youtube.com/watch?v=x8BO9C6YtlE
After watching it, a little seed grew in my head. I spent the next few days thinking about what kind of theme/pet to choose.
I also happened to be in Japan for a few months at the time and came across this koi pond:
It struck me. Wouldn’t it be neat to have a koi as a desktop pet?! A timid little koi swimming across your screen. The perfect opportunity to see what it would take to do this with Tauri. The idea for this project was born.
Setting Up
Let’s get into the process. First, we need to make sure we have all the dependencies set up. To save time, I will not go into detail here. If you have successfully followed the instructions in the Tauri v2 docs. You will be mostly set up. We don’t need any of the mobile requirements.
https://tauri.app/start/prerequisites/
The last thing you will have to install on top is pnpm. The package manager of your choice is optional but this guide will use pnpm in the following.
At this point you should have this setup:
- Tauri Desktop Build Dependencies (as outlined in the docs)
- Rust
- PNPM
Technologies
- Tauri to build the app
- Vite to build the frontend
- SolidJS for frontend reactivity
- TypeScript because doughhh
Bootstrapping
Okay, it is time to roll up our sleeves. If you should lose track at any point you can always have a look at the finished code here: https://github.com/crabnebula-dev/koi-pond.
We will begin by creating a new project with the handy Tauri create-tauri-app
CLI.
pnpm create tauri-app
Choose the following:
- Project name · koi-pond`
- Identifier · Just entering through is fine
- Choose which language to use for your frontend · TypeScript / JavaScript
- Choose your package manager · pnpm
- Choose your UI template · Solid - (https://solidjs.com/)
- Choose your UI flavor · TypeScript
Now you can:
cd koi-pond
pnpm install
pnpm tauri dev
You should be greeted by a running Tauri application that looks something like this. If it takes a little longer to build the first time, that is perfectly normal.
Addressing The Obvious
Now we have an application 🎉 But there are still pretty obvious barriers to turning this into a desktop pet.
- Currently, our application does not stay on top of other windows
- Our application launches in windowed mode and does not fill the entire screen
- If our application filled the entire screen, how would we be able to use our desktop!?
Luckily the Tauri core team has already developed configuration options for a lot of our problems. Even when there is no configuration option we can always dive into the rust code and solve issues ourselves.
Always on top and fullscreen
We will start with one of the easier cases. Making the app window stay on top of any other desktop application is trivial. We just need to add a little bit of configuration to our /src-tauri/tauri.conf.json
.
Replace the app
key with the following configuration:
"app": {
"macOSPrivateApi": true,
"windows": [
{
"title": "koi-pond",
"alwaysOnTop": true,
"center": true,
"hiddenTitle": true,
"resizable": false,
"decorations": false,
"transparent": true,
"fullscreen": false,
"visibleOnAllWorkspaces": true,
"maximized": true
}
],
"security": {
"csp": null
}
},
Reference: /src-tauri/tauri.conf.json
You may be asking yourself what all of these do. I will just wrap this up by saying it will do the following:
- Make our app background transparent (need to make sure not to set a solid background with css)
- Always display the app on top of desktop applications (will not overlay the toolbar or dedicated full screens)
- Centre our app
- Remove decorations and borders
- Try to maximize the app
If you are using VSCode, the template we just installed should have recommended you to install the tauri-apps.tauri-vscode
VSCode Extension.
With this you can also check the hints in the IDE:
Alternatively you can also find all of those in the docs: https://tauri.app/reference/config/#windowconfig
Cleaning The Canvas
We have finished configuring the application. Before we start coding, let’s remove the template code that we don’t need.
Firstly replace src/App.css
:
body {
display: flex;
align-items: center;
justify-content: center;
height: 100vh;
border: none;
padding: 0;
margin: 0;
position: relative;
}
body:before {
content: ' ';
display: block;
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
background-image: url('assets/background.png');
opacity: 0.1;
background-size: cover;
background-position: bottom center;
}
.container {
margin: 0;
}
Github reference: src/App.css
This will simply add a fullscreen background to our app.
Now we will get our Assets. Namely I created a pixelated background with this tool from the original koi pond image:
Github reference: src/assets/background.png
The koi fish animation was kindly donated by Tim Needham.
Github reference: src/assets/koi-frames
Get the assets here: https://github.com/crabnebula-dev/koi-pond/tree/main/src/assets and put them into your src/assets
folder.
Now it is time to cleanup our src/App.tsx
. You can replace the file with the following code, that will provide us with a clean slate:
import './App.css';
function App() {
return <div class="container"></div>;
}
export default App;
Github reference: src/App.tsx
At last we also need to remove the unused commands from our Rust code:
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_shell::init())
.invoke_handler(tauri::generate_handler![])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
Github reference: src-tauri/src/lib.rs
Now we should have an empty pond as a background. You can confirm by running pnpm tauri dev
again.
Since we can’t yet click through and we don’t have controls you may have to use ALT + F4
or CMD + Q
to close the app again.
If you dislike the overlayed background you can easily get rid of it by removing this line in src/App.css
:
background-image: url('assets/background.png');
Implementing Click-Through
When you launch the application, you will notice that we now have an empty maximized application. The problem is that we can’t click on anything on the desktop. This sucks! There is no point in having a desktop pet if it prevents us from using the desktop. To solve this, we have to get a little creative. There are many ways to deal with this. We will enable click-through for the webview. This means that the webview will no longer react to clicks. Instead they will go directly to the underlying application. Currently there is no configuration option to do this. Instead we will add the following Rust code:
use tauri::Manager;
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_shell::init())
.invoke_handler(tauri::generate_handler![])
.setup(|app| {
let window = app.get_webview_window("main").expect("Window failed");
window
.set_ignore_cursor_events(true)
.expect("Failed to set ignore cursor events");
#[cfg(not(target_os = "linux"))]
window.maximize().expect("Could not maximize window");
Ok(())
})
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
Github reference: src-tauri/src/lib.rs
Now we can use our desktop again! Great. But how do we get the WebView to follow our clicks? The answer to that problem is: Today we use Rust! We will use the Rust backend to listen for clicks and send their positions to the frontend.
We will use two rust crates. One to listen for mouse clicks and one to retrieve the position of the mouse when clicking:
cd src-tauri
cargo add mouce
cargo add mouse_position
This can be done with the following Rust code:
use mouce::common::{MouseButton, MouseEvent};
use mouce::Mouse as OtherMouse;
use mouse_position::mouse_position::Mouse;
use tauri::{AppHandle, Emitter, Manager};
#[derive(Clone, serde::Serialize)]
struct Payload {
x: i32,
y: i32,
}
fn listen_for_mouse_events(app_handle: AppHandle) {
let mut mouse_manager = OtherMouse::new();
mouse_manager
.hook(Box::new(move |e| match e {
MouseEvent::Press(MouseButton::Left) => {
let position = Mouse::get_mouse_position();
match position {
Mouse::Position { x, y } => {
app_handle
.emit("mouse_click", Payload { x, y })
.expect("Failed to unwrap Mouse position");
}
Mouse::Error => println!("Error getting mouse position"),
}
}
_ => (),
}))
.expect("Failed to listen for Mouse Events");
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_shell::init())
.invoke_handler(tauri::generate_handler![])
.setup(|app| {
let window = app.get_webview_window("main").expect("Window failed");
window
.set_ignore_cursor_events(true)
.expect("Failed to set ignore cursor events");
#[cfg(not(target_os = "linux"))]
window.maximize().expect("Could not maximize window");
let app_handle = app.handle().clone();
listen_for_mouse_events(app_handle);
Ok(())
})
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
Github reference: src-tauri/src/lib.rs
Now we successfully added a data stream to our frontend. This is already all the Rust code we need for now 🎉
A little explanation. We use the mouce
crate to listen for mouse press events, namely left clicks, and bind it to the .emit
function of the Tauri AppHandle
. As payload we send the current mouse position. This is what we will be able to process in the frontend. You can read more about the communication between the Tauri frontend and the backend here: https://tauri.app/develop/calling-frontend/.
Adding Life To The Pond
It is time to implement our koi fish. We will need to:
- Show the image of the koi
- Listen to events from Rust and update the position of the koi accordingly
- Add animations and wiggle effect to our Koi
Displaying The Koi
To get our Koi on screen is trivial. Simple web tech:
import "./App.css";
import { createSignal } from "solid-js";
const images = import.meta.glob("./assets/koi-frames/*.png", { eager: true });
function App() {
const homePosition = { x: 0, y: 0 };
const [position, setPosition] = createSignal(homePosition);
const [direction, setDirection] = createSignal(0);
const [frame, setFrame] = createSignal(1);
const incrementNumber = () => setFrame((prev) => (prev % 12) + 1);
createEffect(() => {const interval = setInterval(incrementNumber, 50);
onCleanup(() => clearInterval(interval));
const calculateDirection = (oldPosition: Position, newPosition: Position) => {
const x = oldPosition.x - newPosition.x;
const y = oldPosition.y - newPosition.y;
let angle = (Math.atan2(y, x) * 180) / Math.PI;
if (angle < 0) angle = 360 + angle;
const roundToNearest15 = Math.round(angle / 15) * 15;
roundToNearest15 === 360 ? 0 : roundToNearest15;
setDirection(roundToNearest15);
};
const calculateStyle = (position: Position, direction: number) => {
return `position: absolute; left: ${position.x - 90}px; top: ${position.y - 80}px;
animation-timing-function: ease-in-out;
transition: top 1.5s, left 1.5s, transform 0.5s;
`;
};
return (
<div class="container">
<div style={calculateStyle(position())} ref={koiRef}>
<img class="koi" width={175} src={images[`./assets/koi-frames/koi-${direction().toString().padStart(3, '0')}-${frame().toString().padStart(2, '0')}.png`].default} />
</div>
</div>
);
}
export default App;
Github reference: src/App.tsx
We are using a Solid signal for the koi position, direction and frame number so we will be able to update it through an event listener later.
Read up on Solid signals in their docs: Solid Docs
The function incrementNumber
is used to increment the frame number, and createEffect
ensures that it is called after 50ms, so that our koi has nice wiggle effect.
The function calculateDirection
will calculate the direction the koi is going in to the nearest 15 degree, because we have koi frames in 15 degree rotation steps.
The function calculateStyle
will simply take our raw information and make a css style from it.
Our koi will now stay at the homePosition
which is currently set to {x: 0, y: 0}
.
Making it Move
Now we want to update our koi position based on the events from Rust. Yet again our friends from the Tauri core team have something prepared for us. We can just use the according Tauri JS API to listen for those.
Documentation for this can be found here: https://tauri.app/develop/calling-frontend/#listening-to-events-on-the-frontend
import './App.css';
import { createSignal, onCleanup, createEffect } from 'solid-js';
import { listen } from '@tauri-apps/api/event';
const images = import.meta.glob('./assets/koi-frames/*.png', { eager: true });
function App() {
let koiRef: HTMLDivElement;
type Position = {
x: number;
y: number;
};
const homePosition = { x: 0, y: 0 };
const [position, setPosition] = createSignal(homePosition);
const [direction, setDirection] = createSignal(0);
const [frame, setFrame] = createSignal(1);
const incrementNumber = () => setFrame((prev) => (prev % 12) + 1);
createEffect(() => {
const interval = setInterval(incrementNumber, 50);
onCleanup(() => clearInterval(interval));
});
listen<Position>('mouse_click', (event) => {
if (sendHome(event.payload)) return;
calculateDirection(position(), event.payload);
setPosition(event.payload);
});
const sendHome = (clickPosition: Position) => {
if (!koiRef) return false;
const boundingBox = koiRef.getBoundingClientRect();
if (!boundingBoxIsClicked(boundingBox, clickPosition)) return false;
calculateDirection(position(), homePosition);
setPosition(homePosition);
return true;
};
const boundingBoxIsClicked = (boundingBox: DOMRect, clickPosition: Position) => {
const { x, y } = clickPosition;
return (
x > boundingBox.left && x < boundingBox.right && y > boundingBox.top && y < boundingBox.bottom
);
};
const calculateDirection = (oldPosition: Position, newPosition: Position) => {
const x = oldPosition.x - newPosition.x;
const y = oldPosition.y - newPosition.y;
let angle = (Math.atan2(y, x) * 180) / Math.PI;
if (angle < 0) angle = 360 + angle;
const roundToNearest15 = Math.round(angle / 15) * 15;
roundToNearest15 === 360 ? 0 : roundToNearest15;
setDirection(roundToNearest15);
};
const calculateStyle = (position: Position) => {
return `position: absolute; left: ${position.x - 50}px; top: ${position.y - 80}px;
animation-timing-function: ease-in-out;
transition: top 1.5s, left 1.5s, transform 0.5s;
`;
};
return (
<div class="container">
<div style={calculateStyle(position())} ref={koiRef}>
<img
class="koi"
width={175}
src={
images[
`./assets/koi-frames/koi-${direction().toString().padStart(3, '0')}-${frame().toString().padStart(2, '0')}.png`
].default
}
/>
</div>
</div>
);
}
export default App;
Github reference: src/App.tsx
Okay, this was a big change so let’s break it down together.
We are:
- Listening to events from Rust named ‘mouse_click’ with the
Position
payload - Added the functionality to send the koi back to the
homePosition
- Implemented a rudimentary detection to see if we are clicking the bounding box of the koi
We have a moving koi!
Distributing with CrabNebula Cloud
Hooray 🎉! We just created an awesome Desktop Pet with Tauri. Now, we want to show it to our family, friends and the whole world.
We will be using Github Actions to build the application and CrabNebula Cloud to distribute it across the globe.
For this, you must have an account on CrabNebula Cloud
- After creating an organization, we will be creating an application in that organisation. We will name our application Koi Pond.
- Next, we will be creating an API key with Read and Write access and add it to the GiHub repository secrets to be able to access from Github Actions pipeline.
- Finally, we just have to add the the below YML file to our repository to
.github/workflows/release.yml
to build and publish the application automatically.
( You can learn more about the Workflow on CrabNebula Cloud CI docs )
name: Package and Build Koi-pond
on:
push:
branches:
- main
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env:
CN_APPLICATION: 'YOUR_ORG_NAME/YOUR_APP_NAME'
jobs:
draft:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
- name: create draft release
uses: crabnebula-dev/cloud-release@v0
with:
command: release draft ${{ env.CN_APPLICATION }} --framework tauri
api-key: ${{ secrets.CN_API_KEY }}
build:
needs: draft
strategy:
fail-fast: false
matrix:
os:
- ubuntu-22.04
- macos-latest
- windows-latest
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v3
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install stable toolchain
uses: actions-rust-lang/setup-rust-toolchain@v1
with:
toolchain: stable
cache: true
- name: install Linux dependencies
if: matrix.os == 'ubuntu-22.04'
run: |
sudo apt-get update
sudo apt-get install -y webkit2gtk-4.1
- name: build Tauri app for Windows, Linux
if: matrix.os != 'macos-latest'
run: |
pnpm install --frozen-lockfile
pnpm tauri build
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
- name: Setup api key file
if: matrix.os == 'macos-latest'
run: echo "${{ secrets.APPLE_API_KEY }}" > ./AuthKey.p8
- name: Install x86_64-apple-darwin for mac and build Tauri binaries
if: matrix.os == 'macos-latest'
run: |
rustup target add x86_64-apple-darwin
pnpm install --frozen-lockfile
export APPLE_API_KEY_PATH=$(realpath AuthKey.p8)
pnpm tauri build --target x86_64-apple-darwin
pnpm tauri build --target aarch64-apple-darwin
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE_BASE64 }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_API_KEY: ${{ secrets.APPLE_API_KEY_ID }}
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
- name: upload assets
uses: crabnebula-dev/cloud-release@v0
with:
command: release upload ${{ env.CN_APPLICATION }} --framework tauri
api-key: ${{ secrets.CN_API_KEY }}
path: ./src-tauri
publish:
needs: build
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
- name: publish release
uses: crabnebula-dev/cloud-release@v0
with:
command: release publish ${{ env.CN_APPLICATION }} --framework tauri
api-key: ${{ secrets.CN_API_KEY }}
After running the workflow, we can now see that our application has been published and can be shared from the org and app address you specified in the CN_APPLICATION environment variable.
You can download the application from here : https://web.crabnebula.cloud/crabnebula/koi-pond/releases
Adding Updater Plugin
Great work 👏! But suppose that we have a new version with awesome new features and new pets. How are we going to tell users about it? Don’t worry, Tauri Updater Plugin got us covered!
With the Updater Plugin and CrabNebula Cloud, the users will not only get notification as soon as a new update is available, but also they can update the application in just one click!
To add and use the updater plugin, we have to
- Add the following configuration to our
tauri.conf.json
file
"bundle": {
"createUpdaterArtifacts": true
},
"plugins": {
"updater": {
"endpoints": [
"https://cdn.crabnebula.app/update/crabnebula/koi-pond/{{target}}-{{arch}}/{{current_version}}"
],
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEQ0NUNEOEE3QThDOEYxRTYKUldUbThjaW9wOWhjMUUxVjA5WThMUmFEWkFhTHNUN0k2a2NBZGFqYjk0R0V2MURKRXBEVTBBclUK"
}
}
- Add Updater and Process Plugin to
lib.rs
tauri::Builder::default()
.plugin(tauri_plugin_updater::Builder::new().build())
.plugin(tauri_plugin_process::init())
- Create
updater.ts
and add the following code to check for the updates and send notification to the user.
import { check } from '@tauri-apps/plugin-updater';
import { ask, message } from '@tauri-apps/plugin-dialog';
import { relaunch } from '@tauri-apps/plugin-process';
export async function checkForAppUpdates() {
const update = await check();
if (update?.available) {
const yes = await ask(
`
Update to ${update.version} is available!
Release notes: ${update.body}
`,
{
title: 'Update Now!',
kind: 'info',
okLabel: 'Update',
cancelLabel: 'Cancel',
}
);
if (yes) {
await update.downloadAndInstall();
await relaunch();
}
}
}
- Finally, call the the updater function asynchronously from the
App()
function insideApp.tsx
import { checkForAppUpdates } from './updater';
function App() {
onMount(async () => {
await checkForAppUpdates();
});
}
Final Thoughts
This is it. You successfully build a cross-platform application with Tauri and also distributed it using CrabNebula Cloud! A lot of the concepts we looked at in this blog post are fairly rudimentary and I am sure I am not the only one thinking about the possible features that still could be implemented.
There are many topics that deserve more explanation or could be handled in a different way, but this blog post is already running pretty long. Depending on interest we might do another one of these in the future.
The source code for this application is available at https://github.com/crabnebula-dev/koi-pond
Also, you can download the latest version of the application at https://web.crabnebula.cloud/crabnebula/koi-pond/releases
If you still have questions you can always reach out on our discord:
https://discord.com/invite/JaMxSeceFU
Or on the official Tauri discord: