Build a Cross-Platform Desktop Application With Rust Using Tauri
Time to read: 14 minutes
The plethora of options available for building software applications is a testament to how far the software development industry has come. The area of desktop application development is no exception. For every Javascript enthusiast, there's Electron. For every Gopher, there's Wails. And if you have risen to the challenge of learning Rust — despite its reputation for being difficult to master — then Tauri is the perfect tool.
I recently wrote about building a cross-platform desktop application using Go. This time, I will show you how to do it using Rust. To get familiar with Tauri, you will build a GitHub desktop client which will interact with the GitHub API, providing the following features:
- View public repositories and Gists
- View private repositories and Gists for an authenticated user
- Create a new Gist for the authenticated user.
Rust will be used for the backend. React, Typescript, and Vite will be used for the front-end. The UI components will be created using Ant Design (AntD).
Prerequisites
To follow this tutorial, you will need the following:
- A basic understanding of Rust, Typescript, and React.
- Rust ≥ 1.67 and Cargo
- Yarn
Check the Tauri prerequisites to ensure that your development environment is ready.
How it works
Tauri uses a command system to expose functions to the front-end. Commands are simply functions with the #[tauri::command]
annotation. On the front-end, Tauri provides a special function named invoke()
which allows you to invoke your command. If your function takes any arguments, the arguments can be passed as a JSON object. The invoke()
function returns a Promise, if your command returns data then the Promise resolves to the returned value.
The command can also return errors. While this may require some extra work, I will walk you through the process of doing that.
Getting started
Create a new Tauri project using the following command.
You will be prompted to answer a few questions. Respond as shown below (use the arrow keys to move)
This scaffolds a new project using Rust for the backend and React, TypeScript, and Vite for the frontend. Once the process is completed, navigate into the newly created folder and run the project, by running the commands below.
This command sequence will load dependencies for the front-end, download crates for the back-end, and build the binary for the back-end. Once the process is completed, the application will run as seen in the image below.
Close the application and open the project folder in your preferred editor or IDE, to get started with adding features to the application.
Build the backend
All the code for the backend is located in the src-tauri folder.
Add dependencies
For serialisation and deserialisation, Tauri comes with serde and serde_json out of the box. However, we need a crate to help with making API requests. For this, reqwest will be used. To install it, along with the `json` and `blocking` features, run the following command in the src-tauri directory.
Allowing for the speed of your network connection, they’ll be installed relatively quickly.
Declare custom error
One thing to remember about Tauri is that everything returned from commands must implement serde::Serialize, including errors.
As mentioned earlier, API requests in this application are handled by Reqwest. In the event of an error during the process, a reqwest::Error
will be returned in the result. This error can’t be serialised which means Rust will panic if a command returns this error. To fix this, a custom error will be written which will implement the <From reqwest::Error>
trait. In doing so, you will be able to convert a reqwest::Error
into your custom error. This error will also implement the serde::Serialize
trait which means that your command can safely return it.
In src-tauri/src, create a new file namederror.rs and add the following code to it.
TauriError
is a struct with one field named message
. In addition to implementing the <From reqwest::Error>
and serde::Serialize
traits, it also implements the Display
trait.
Declare models
The application will have the following models:
APIResult
: This is a generic wrapper type around Rust’s in-built Result enum. The error arm will be the earlier declaredTauriError.
Commit
: This corresponds to a GitHub commit response object.CommitNode
: This corresponds to the inner commit object found in a GitHubCommit
response object. It is important to note that, sometimes, this object may not be present in the response.Gist
: This corresponds to a GitHub Gist response object.GistContent
: A Gist is made up of one or more files (GistFile
in this application). This model corresponds to the string content of a singleGistFile
.GistInput
: This corresponds to the input required to create a new Gist.GistFile
: This corresponds to a file associated with aGist
.GithubUser
: This corresponds to a user on GitHub. In this application, this will contain only the user’s profile name and avatar URL.NewGistResponse
: This corresponds to the response when a new Gist is created.Repository
: This corresponds to a GitHub repository response object.URL
: This is an enum that captures the two variants of URLs you will encounter in this application. Some URLs need to be appended to the base GitHub API URL while others are fully-qualified URLs that require no extra formatting. This enum provides variants for both and a method to return the appropriate URL for a request.
To implement these models, create a new file in src-tauri/src named models.rs and add the following code to it.
The models that will be used to pass data between the frontend and API also implement serde’s Deserialize
and Serialize
traits. This allows for conversion of structs to/from JSON objects or strings as may be required.
An implementation function named value()
is provided for the URL
enum. Using this the actual URL to be called can be gotten.
Add functionality for sending API requests
Next, add the code to help with sending GET and POST requests. In the src-tauri/src folder, create a new file named api.rs and add the following code to it.
The construct_headers()
function is used to generate a HeaderMap
which contains the appropriate headers for every request. It is recommended that GitHub API requests have the Accept header set to application/vnd.github+json
. Additionally, requests without a User-Agent header will not be accepted. This function starts by adding both headers to the map, which will be returned.
Additionally, there will be a need to make authenticated requests to retrieve information on private resources. This will require an Authorization header. However, this is not required for public resources. The construct_headers()
takes one argument, token
, which is an Option. The Some
value of token
is a string sequence corresponding to the authentication token. If present, an additional header will be generated and added to the map which will be returned.
The make_get_request()
andmake_post_request()
functions both take a URL enum and a token Option
as arguments. Additionally, the make_post_request()
function takes a third argument which is the data to be sent. The argument data
is a generic type; however, the provided type must implement Serde’s Serialize trait.
Next, add the commands which will be invoked on the frontend. In the src-tauri/src folder, create a new file named command.rs and add the following code to it.
A function is created to handle each API interaction the application will be making. As mentioned earlier, each function is annotated with the #[tauri::command]
attribute, which makes it available for invocation on the frontend.
The general flow of each function is as follows:
- Make a GET or POST request.
- Deserialize the response into a matching struct. The only exception is the
get_gist_content
which directly returns the response as a string. - Return the response in a
Result::OK
variant.
Provide commands to the builder function
The last thing to do on the backend is to pass the earlier defined commands to the builder function in src-tauri/src/main.rs. To do this, open the file and update its code to match the following.
Build the frontend
All the code for the frontend is stored in the src folder. But before writing any code, add the JavaScript dependencies using the following commands.
The dependencies are as follows:
- Ant Design: This helps designers/developers build beautiful and flexible products with ease
- Ant-design icons: This gives you access to AntD’s SVG icon collection
- React-router: This will be used to implement client-side routing
- Prismjs: This will be used to implement syntax highlighting for the Gists. Additionally, the types for Prismjs are added.
Add types
Next, in the src folder, create a new file named types.ts and add the following code to it.
The types are quite similar to the ones declared earlier for the backend. There are, however, a few differences.
- A parent interface named
GithubItem
is declared. TheGist
andRepository
interfaces extend this to add their divergent fields. This is done because some generic components will be used to render only Gists or repositories. This interface will be used to ensure that the type provided to such a component extends theGithubItem
interface. - An interface named
CodeSnippet
is declared. This corresponds to the Gist code that is rendered for eachGistFile
. - A generic type named
Nullable
is declared. This will be used in situations where null is an acceptable value for a given type.
Add helper function
In the src folder, create a new file named helper.ts and add the following code to it.
This function will be used to get the appropriate error messages in catch blocks.
Add authentication
For authentication, the user will be required to provide a GitHub Personal Access Token. The token will be included in the header of requests to endpoints requiring authentication.
To create one, if you don’t have one, log in to your GitHub account. Then, click your profile icon, in the top right-hand corner, and in the popup that appears, click Settings. Next, click Developer settings at the bottom of the left-hand-side navigation menu. Then, click Personal access tokens > Tokens (classic). Finally, click Generate new token > Generate new token (classic)
In the "New personal access token (classic)" form, check repo and gist, as in the screenshot below.
For this project, the React Context API will be used to store the token for an hour, after which the user will have to re-authenticate by providing the token again.
In the src folder, create a new folder called components to hold your React components. In it, create a new folder named context. In that folder, create a new file named AuthModal.tsx and add the following code to it.
This component renders the authentication form. The form has a single field for the user to paste and save a token. The shouldShowModal
prop is used to conditionally render the form, while the onSubmit
and onCancel
props are used to respond to the user’s action.
Next, in the context folder again, create a new file named AuthContext.tsx and add the following code to it.
There are two exports in this file. The first is the useAuthContext
hook. This hook will be used to retrieve the token saved in Context
. The second is the AuthContextProvider
component. This component is responsible for rendering the authentication form (either on page load or when the token has “expired” after 1 hour).
It also renders an error page if the user clicks “Cancel” on the authentication form. This component takes a JSX element (named children
) as a prop and wraps it with a context provider — thus giving the child element access to the value of the token.
Add the Master-Detail layout
For displaying repositories and Gists, the master-detail layout will be used. A list of items will be rendered, and clicking on one item will display more information on the selected item beside the list. In the src/components folder, create a new file named ListItem.tsx and add the following code to it.
This component renders a single GithubItem
in the list using the AntD Card component. The title of the card is provided as a component prop. In addition to the title, this component receives three other props:
- The
onSelect
prop is used to notify the parent item that the card has been clicked item
corresponds to the Gist or repository which will be rendered on the cardselectedItem
is used by the component to determine if the rendered item was clicked by the user; in which case, a light blue background is added to the card styling.
Next, create a new file named MasterDetail.tsx in the components folder and add the following code to it.
This component is responsible for rendering the list of items in one column and the details of the selected item in another column. The items to be rendered are provided as a prop to the component.
In addition to that, the getItemDescription()
prop is a function to get what will be displayed under the user avatar; this is the repository name or the Gist description.
The detailLayout()
prop is a function provided by the parent component which returns the JSX content for the detail section based on the provided item. This allows Gists and repositories to have entirely different layouts while using the same child component for rendering.
Add repository-related components
Next, in the src/components folder, create a new folder named repository to hold components related to a repository.
Then, in the repository folder, create a new file named GithubUserGrid.tsx and add the following code to it.
This component will be used to render the list of stargazers and contributors to a repository.
Then, create a new file named RepositoryDetails.tsx in the repository folder and add the following code to it.
For the first time, you will use the invoke()
function provided by Tauri. For a given Repository, three calls are made to the backend to get the contributors, stargazers, and commits for the repository.
The first parameter of the invoke()
function is a string corresponding to the name of the backend function to be called. The second (optional) parameter is a JSON object which contains the arguments for the function. Idiomatically, Rust arguments are written in snake case, however, this function expects them to be provided in camel case hence a file_url
argument on the backend should be provided as fileUrl
to the function.
As mentioned earlier, the invoke()
function is asynchronous and returns a Promise. This is why the async
and await
keywords are used.
Next, create the component for rendering public repositories. In the src/components/repository folder, create a new file named PublicRepositories.tsx and add the following code to it.
Next, create another file named PrivateRepositories.tsx in the src/components/repository folder and add the following code to it.
This component is very similar to the PublicRepositories
component but for two key things. First, this component will be wrapped with an AuthContextProvider
, which makes it possible to retrieve the saved token via the useAuthContext
hook. Second, it invokes a different bound function (get_repositories_for_authenticated_user
) to get the repositories for the user whose token was provided.
Add Gist related components
Next, in the src/components folder, create a new folder named gist to hold components related to a Gist. Then, in that new folder, create a new file named GistDetails.tsx and add the following code to it.
This component renders the code in the file(s) for a given Gist. Every Gist response comes with a files
key. This is an object containing all the files for the Gist. Each file
object contains the URL to the file’s raw content and the language associated with the file. This component retrieves all the files using the get_gist_content()
function and renders them in a Carousel.
Next, in the gist folder, create a file named PublicGists.tsx and add the following code to it.
Just as was done for the rendering of public repositories, the get_public_gists
command is invoked to retrieve public Gists from the GitHub API and pass them to the MasterDetail
component, along with the functions to get the Gist description and display more information on the Gist when selected.
Next, create a new file named PrivateGists.tsx in the gist folder and add the following code to it.
This component will be wrapped with an AuthContextProvider
component, thus giving it access to the provided token. Using the token, an asynchronous call is made to the GitHub API via the get_gists_for_authenticated_user()
command. The results are then passed to the MasterDetail
component along with the other required props for appropriate rendering.
The last Gist-related component to be built is the form to create a new Gist. To do this, create a new file named CreateGist.tsx in the src/component/gist folder and add the following code to it.
The request to create a new Gist has three fields:
description
: Where provided, this will describe what the code in the Gist aims to achieve. This field is optional and is represented in the form by an input field.public
: This is a required field and determines whether or not the Gist has public access. In the form you created, this is represented by a switch which is set tooff
by default. This means that unless otherwise specified by the user, the created Gist will be secret and only available to users who have its link.files
: This is another required field. It is an object and for each entry in the object, the key is the name of the file (with the extension included) and the value is the content of the file.
This is represented in the form you created as a dynamic list with each list item consisting of a text field for the file name and a text area for the file content.
By clicking the Add File button, you have the ability to add multiple files. You also have the ability to delete a file. Note that you will be required to have at least one file and if you do not, an error message will be displayed.
When the form is properly filled out and submitted, the onFinish()
function is used to create an object conforming to the GistInput
struct declared in src-tauri/src/models.rs and the create_new_gist
command is invoked with the appropriate arguments.
Because this component is wrapped with the AuthContextProvider
, the saved token can be retrieved and passed alongside the GistInput
as required by the command. Once a successful response is received, the app displays a success message and redirects to the list of Gists for the authenticated user.
Put the pieces together
Add navigation
With all the individual components in place, the next thing to add is navigation, a means by which the user can move around the application. To add this, create a new file in the src/components folder named NavBar.tsx and add the following code to it.
This component renders a navigation bar at the top of the window with two main items: Public Actions and Private Actions. Each item then has sub-items, which are links that will eventually render the component associated with the sub-item. With this in place, you can add routing to your application.
Add routing
In the src folder, create a new file named routes.tsx and add the following code to it.
Here, you specified the routes in the application as well as the component to be rendered for each path. In addition to that, you have wrapped the components which require the user to provide a token with the AuthContextProvider
component.
Next, open src/App.tsx and update the file's code to match the following.
Here, you have included the NavBar
component you declared earlier. You also declared an Outlet
component which is provided by react-router-dom to render child route elements.
Finally update the code in main.tsx to match the following.
Test that the application works
You’ve successfully built your first app with Tauri. Run the application again and take it for a spin by running the following command from the project's top-level folder.
By default, when the app loads, you will be greeted with a list of public repositories. Using the navigation menu, you can view public (and private) repositories and Gists by clicking the corresponding menu item.
When you select a menu item for a private repository or private Gist, a pop-up will be displayed asking for your GitHub token as shown below.
Paste your Personal Access Token (PAT) and click Save. Your repositories (or Gists as the case may be) will then be rendered. You will be able to navigate around the private section of the app without having to re-enter your token for a few minutes.
And that's how to build a cross-platform desktop application with Rust and Tauri
This is just the tip of the iceberg. Tauri helps you take advantage of Rust’s strengths - memory safety at compile-time, fearless concurrency, and improved performance due to the absence of a garbage collector; in the process of building next-generation desktop applications.
What other features do you think you can add to the application? Did you know that you could make further customizations to the app such as the width and height, or even start off in full-screen mode? Have a look at the Configuration documentation to see how you can further configure your app.
In case you get stuck at any point, feel free to access the codebase here.
I’m excited to see what more you come up with. Until next time, make peace, not war ✌🏾
Joseph Udonsak is a software engineer with a passion for solving challenges – be it building applications or conquering new frontiers on Candy Crush. When he’s not staring at his screens, he enjoys a cold beer and laughs with his family and friends.
Related Posts
Related Resources
Twilio Docs
From APIs to SDKs to sample apps
API reference documentation, SDKs, helper libraries, quickstarts, and tutorials for your language and platform.
Resource Center
The latest ebooks, industry reports, and webinars
Learn from customer engagement experts to improve your own communication.
Ahoy
Twilio's developer community hub
Best practices, code samples, and inspiration to build communications and digital engagement experiences.