Building and Distributing a Desktop Pet with Tauri

November 7, 2024

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

Preview of the pet

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:

Preview of the pet

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

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.

Example of the solid starter template

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:

Image of a type hint

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:

Pixelated koi pond Github reference: src/assets/background.png

The koi fish animation was kindly donated by Tim Needham. Koi 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 inside App.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:

https://discord.com/invite/tauri