Skip to main content

4 posts tagged with "Rust"

View All Tags

Tauri Plugin System Design

· 8 min read
Huakun Shen
Website Owner

In Raycast Analysis and uTools Analysis I discussed the two successful app launchers and their plugin system designs. But both of them have big limitations. Raycast is mac-only. uTools is cross-platform (almost perfect), but it is built with Electron, thus large bundle size and memory consumption.

Tauri is a new framework that can build cross-platform desktop apps with Rust and Web. With much smaller bundle size and memory consumption. It’s a good choice for building a cross-platform app launcher.

Requirements

  • Plugins can be built with JS frontend framework, so it’s easier for develop to build
  • UI can be controlled by plugin
  • Sandbox preferred, never trust plugins not developed by official team, community plugin could be malicious. Neither of Raycast, Alfred, uTools used sandbox. So we can discuss this as well.

Solution

Plugins will be developed as regular single page application. They will be saved in a directory like the following.

plugins/
├── plugin-a/
│ └── dist/
│ ├── index.html
│ └── ...
└── plugin-b/
└── dist/
├── index.html
└── ...

Optionally use symbolic link to build the following structure (link dist of each plugin to the plugin name. You will see why this could be helpful later.

plugins-link/
├── plugin-a/
│ ├── index.html
│ └── ...
└── plugin-b/
├── index.html
└── ...

When a plugin is triggered, the main Tauri core process will start a new process running a http server serving the entire plugins or plugins-link folder as static asset. The http server can be actix-web.

Then open a new WebView process

const w = new WebviewWindow('plugin-a', {
url: 'http://localhost:8000/plugin-a'
});

If we didn’t do the dist folder symlink step, the url would be http://localhost:8000/plugin-a/dist

Do the linking could avoid some problem.

One problem is base url. A single page application like react and vue does routing with url, but the base url is / by default. i.e. index page is loaded on http://localhost:8000. If the plugin redirects to /login, it should redirect to http://localhost:8000/login instead of http://localhost:8000/plugin-a/login

In this case, https://vite-plugin-ssr.com/base-url, https://vitejs.dev/guide/build#public-base-path can be configured in vite config.

Another solution is to use proxy in the http server. Like proxy_pass in nginx config.

API

Now the plugin’s page can be loaded in a WebView window.

However, a plugin not only needs to display a UI, but also need to interact with system API to implement more features, such as interacting with file system. Now IPC is involved.

Tauri by default won’t allow WebView loaded from other sources to run commands or call Tauri APIs.

See this security config dangerousRemoteDomainIpcAccess

https://tauri.app/v1/api/config/#securityconfig.dangerousremotedomainipcaccess

"security": {
"csp": null,
"dangerousRemoteDomainIpcAccess": [
{
"domain": "localhost:8000",
"enableTauriAPI": true,
"windows": ["plugin-a"],
"plugins": []
}
]
},

enableTauriAPI determines whether the plugin will have access to the Tauri APIs. If you don’t want the plugin to have the same level of permission as the main app, then set it to false.

This not only work with localhost hosted plugins. The plugin can also be hosted on public web (but you won’t be able to access it if there is no internet). This will be very dangerous, as compromised plugin on public web will affect all users. In addition, it’s unstable. Local plugin is always safer.

There is another plugins attribute used to control which tauri plugin’s (The plugin here means plugin in rust for Tauri framework, not our plugin) command the plugin can call.

https://tauri.app/v1/api/config/#remotedomainaccessscope.plugins

plugins is The list of plugins that are allowed in this scope. The names should be without the tauri-plugin- prefix, for example "store" for tauri-plugin-store.

For example, Raycast has a list of APIs exposed to extensions (https://developers.raycast.com/api-reference/clipboard)

Raycast uses NodeJS runtime to run plugins, so plugins can access file system and more. This is dangerous. From their blog https://www.raycast.com/blog/how-raycast-api-extensions-work, their solution is to open source all plugins and let the community verify the plugins.

This gives plugins more freedom and introduces more risks. In our approach with Tauri, we can provide a Tauri plugin for app plugins with all APIs to expose to the extensions. For example, get list of all applications, access storage, clipboard, shell, and more. File system access can also be checked and limited to some folders (could be set by users with a whitelist/blacklist). Just don’t give plugin access to Tauri’s FS API, but our provided, limited, and censored API plugin.

How to give plugin full access to OS and FS?

Unlike Raycast where the plugin is run directly with NodeJS, and render the UI by turning React into Swift AppKit native components. The Tauri approach has its UI part in browser. There is no way to let the UI plugin access OS API (like FS) directly. The advantage of this approach is that the UI can be any shape, while Raycast’s UI is limited by its pre-defined UI components.

If a plugin needs to run some binary like ffmpeg to convert/compress files, the previous sandbox API method with a custom Tauri plugin won’t work. In this scenario, this will be more complicated. Here are some immature thoughts

  • The non-UI part of the plugin will need a JS runtime if written in JS, like NodeJS or bun.js
  • Include plugin script written in python, Lua, JS… and UI plugin runs them using a shell API command (like calling a CLI command)
  • If the plugin need a long running backend, a process must be run separately, but how can the UI plugin communicate with the backend plugin? The backend plugin will probably need to be an http server or TCP server.
    • And how to stop this long running process?

Implementation Design

User Interface

Raycast supports multiple user interfaces, such as list, detail, form.

To implement this in Jarvis, there are 2 options.

  1. The extension returns a json list, and Jarvis render it as a list view.
  2. Let the extension handles everything, including list rendering.

Option 1

Could be difficult in our case, as we need to call a JS function to get data, this requires importing JS from Tauri WebView or run the JS script with a JS runtime and get a json response.

To do this, we need a common API contract in JSON format on how to render the response.

  1. Write a command script cmd1.js

  2. Jarvis will call bun cmd1.js with argv, get response

    Example of a list view

    {
    "view": "list",
    "data": [
    {
    "title": "Title 1",
    "description": "Description 1"
    },
    {
    "title": "Title 2",
    "description": "Description 2"
    },
    {
    "title": "Title 3",
    "description": "Description 3"
    }
    ]
    }

This method requires shipping the app with a bun runtime (or download the runtime when app is first launched).

After some thinking, I believe this is similar to script command. Any other language can support this. One difference is, “script command” relies on users’ local dependency, custom libraries must be installed for special tasks, e.g. pandas in python. It’s fine for script command because users are coders who know what they are doing. In a plugin, we don’t expect users to know programming, and install libraries. So shipping a built JS dist with all dependencies is a better idea. e.g. bun build index.ts --target=node > index.js, then bun index.js to run it without installing node_modules.

https://bun.sh/docs/bundler

In the plugin’s package.json, list all commands available and their entrypoints (e.g. dist/cmd1.js, dist/cmd2.js).

{
"commands": [
{
"name": "list-translators",
"title": "List all translators",
"description": "List all available translators",
"mode": "cmd"
}
]
}

Option 2

If we let the extension handle everything, it’s more difficult to develop, but less UI to worry about.

e.g. translate input1 , press enter, open extension window, and pass the input1 to the WebView.

By default, load dist/index.html as the plugin’s UI. There is only one entrypoint to the plugin UI, but a single plugin can have multiple sub-commands with url path. e.g. http://localhost:8080/plugin-a/command1

i.e. Routes in single page app

All available sub-commands can be specified in package.json

{
"commands": [
{
"name": "list-translators",
"title": "List all translators",
"description": "List all available translators",
"mode": "cmd"
},
{
"name": "google-translate",
"title": "Google Translate",
"description": "Translate a text to another language with Google",
"mode": "view"
},
{
"name": "bing-translate",
"title": "Bing Translate",
"description": "Translate a text to another language with Bing",
"mode": "view"
}
]
}

If mode is view, render it. For example, bing-translate will try to load http://localhost:8080/translate-plugin/bing-translate

If mode is cmd, it will try to run bun dist/list-translators and render the response.

mode cmd can have an optional language field, to allow using Python or other languages.

Script Command

Script Command from Raycast is a simple way to implement a plugin. A script file is created, and can be run when triggered. The stdout is sent back to the main app process.

Supported languages by Raycast script command are

  • Bash
  • Apple Script
  • Swift
  • Python
  • Ruby
  • Node.js

In fact, let users specify an interpreter, any script can be run, even executable binaries.

Alfred has a similar feature in workflow. The difference is, Raycast saves the code in a separate file, and Alfred saves the code within the workflow/plugin (in fact also in a file in some hidden folder).

Reference

Rust SocketIO Event Handling with Channel

· 3 min read
Huakun Shen
Website Owner

rust-socketio is an open source Rust client for socket.io

It has an async version, but the event handling is a bit tricky. All callback functions (closures) have to use async move {} to handle events. To use variables outside the closure, you have to make clones of the variables and pass them to the closure. The regular sync version also needs to do this, but the async version is more complicated because of the async nature. The variables have to be cloned and moved twice.

#[tokio::main]
async fn main() {
let (tx, rx) = channel::<String>();
let tx2 = tx.clone();
let socket = ClientBuilder::new("http://localhost:9559")
.on("evt1", move |payload, socket| {
let tx = tx.clone();
async move {
tx.send(format!("identity: {:?}", payload)).unwrap();
}
.boxed()
})
// .on("evt1", callback2)
.on_any(move |evt, payload, socket| {
let tx = tx2.clone();
async move {
tx.send(format!("{:?}", payload)).unwrap();
}
.boxed()
})
.connect()
.await
.expect("Connection failed");
}

This makes things complicated and hard to read. I have to clone variables so many times. Rust's nature keeps me focusing on the language itself rather than the business logic. In JS I could finish this without even thinking about this problem.

See this issue https://github.com/1c3t3a/rust-socketio/issues/425

Solution

What I ended up doing is use on_any and channel to transfer all event handling to another loop outside the closures to avoid variables moving. It's much simpler.

Here is how I did it.

#[derive(Debug)]
pub struct EventMessage {
pub event: String,
pub payload: Payload,
}

let (done_tx, mut done_rx) = tokio::sync::mpsc::channel::<()>(1);
let (evt_tx, mut evt_rx) = tokio::sync::mpsc::channel::<EventMessage>(1);

let socket = ClientBuilder::new(SERVER_URL)
.on(Event::Connect, |_, _| async move {}.boxed())
.on_any(move |evt, payload, _| {
let evt_tx = evt_tx.clone();
async move {
evt_tx
.send(EventMessage {
event: evt.to_string(),
payload,
})
.await
.unwrap();
}
.boxed()
})
.on(Event::Error, |err, _: Client| {
async move {
eprintln!("Error: {:#?}", err);
}
.boxed()
})
.connect()
.await
.expect("Connection failed");


loop {
tokio::select! {
_ = done_rx.recv() => {
break;
}
Some(evt) = evt_rx.recv() => {
// Handle event received from evt_rx
match evt.event.as_str() {
"evt1" => {...}
"evt2" => {...}
}
}
_ = tokio::signal::ctrl_c() => {
break;
}
};
}

All events are caught by on_any and sent to evt_tx. Then I can handle all events in the loop outside the closures. This way I don't have to clone variables so many times and move them. It's much simpler and easier to read.

Not sure about the performance difference. It shouldn't matter as this is dealing with network I/O. The performance bottleneck is the network, not the CPU. So I think this is a good solution.

Cloning variables, locking and unlocking mutexes, and moving variables from stack to heap all cost time, and are harder to read. Unsure about the cost of using channels, but I think it's a good trade-off.

I am thinking about a new design for rust_socketio. The ClientBuilder can simply return a channel to the user, and the user can handle all events in the loop with select outside the closures. This way the user can handle events in a more natural way.

Tauri Deployment (Auto Update and CICD)

· 2 min read
Huakun Shen
Website Owner

Docs

Tauri Updater

Bundler Artifacts has sample CI and config script.

Cross-Platform Compilation has a sample GitHub Action CI script for cross-platform compilation (Windows, MacOS and Linux). Compiled files are stored as artifacts in a draft GitHub release. The release assets will be read by updater server for auto-update.

Sample tauri.config.json

Building

For updater to work, a public key is required.

tauri.config.json
"updater": {
"active": true,
"endpoints": [
"https://releases.myapp.com/{{target}}/{{current_version}}"
],
"dialog": true,
"pubkey": "YOUR_UPDATER_SIGNATURE_PUBKEY_HERE"
}

A pair of keys can be generated with tauri signer generate -w ~/.tauri/ezup.key.

If update is configured, then private key and password environment variables must be set.

The following script can automatically load the private key as environment variable. Assuming password is an empty string.

#!/usr/bin/env bash
PRIVATE_KEY_PATH="$HOME/.tauri/ezup.key";
if test -f "$PRIVATE_KEY_PATH"; then
export TAURI_PRIVATE_KEY=$(cat ~/.tauri/ezup.key); # if the private key is stored on disk
export TAURI_KEY_PASSWORD="";
else
echo "Warning: Private Key File Not Found";
fi

CICD (GitHub Action)

In GitHub Action, environment variables can be set like this in the top level of yml file.

env:
TAURI_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
TAURI_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}

I encountered a error during compilation on Ubuntu platform.

Error: thread '<unnamed>' panicked at 'Can't detect any appindicator library', src/build.rs:326:17

I found a solution in this issue.

Install libayatana-appindicator3-1-dev with apt for ubuntu.

Updater Server

vercel/hazel is a updater server for electron, can be deployed in a few clicks on vercel.

lemarier/tauri-update-server forks vercel/hazel.

I forked lemarier/tauri-update-server to be HuakunShen/tauri-update-server.

The reason I made a fork is that, new upates were made in vercel/hazel, and I merged the new commits to lemarier/tauri-update-server.

With one click, an update server can be deployed on Vercel.

See EzUp and HuakunShen/tauri-ezup-updater for and example.

The former is the actual Tauri app. The later is the corresponding update server.

S3 SDK with Rust (Manual Credential Configuration)

· 5 min read
Huakun Shen
Website Owner

First of all, I have to say, I am very disappointed with AWS's documentation. They do have many documentation and sample code, but I am still unable to find what I was looking for (easily).

Intro

I was working on a project that requires using Rust to upload files to AWS S3. I wanted to use Rest API to do this, but could not find enough information from the documentation. There is no sample code or something like a postman API doc that allows you to generate client code from a Rest API.

For example, in this API doc on PutObject, https://docs.aws.amazon.com/AmazonS3/latest/API/API_PutObject.html.

Authorization:authorization string doesn't mean anything to me. I have access key and secret, but there must be a way to get this authorization string. I am pretty sure it exists, and must be somewhere in the docs. I just couldn't find it. Put a link in the documentation isn't hard. It saves people time from looking through your entire documentation. The purpose of World Wide Web is to link things together, instead of look for things separately and try to assemble in clients' head.

Then I switched to Rust SDK. They have plenty of documentation and sample code; but I got stuck on one problem for a long time. Again, authorization. The documentation and sample code always assume you have the same scenario as they do. They assume you have a ~/.aws/credentials file with your access key id and secret. Sample code always loads credentials automatically from default locations or environment variables, which is fine for a server application. For client-side software, this doesn't hold. I need to explicitly pass credentials to a function to generate a client. This is possible and documented for both Python and Nodejs version of the doc, but not for Rust.

I had to go over so many documentation and sample code to figure out how to do this naive thing. Function from another Rust crate (package) has to be used. aws_types.

Basically, there are many different ways to produce credentials and client; but for someone without prior knowledge about your nasty design, there is no way to know which package I should find what the method needed. If you decide to put things in different packages, then at least provide an obvious link somewhere to indicate "You have the option to do blah blah, read the docs here".

Reading AWS docs (Rust) is like browse information everywhere and try to assemble in my head. Without enough prior knowledge, it's not easy to get things done quickly.

Details

Comparison

When I google "AWS s3 python client credential loading", the first link gives me what I need: Passing credentials as parameters . Took me 10 seconds to find the answer.

For Nodejs, it took me ~10 minutes. To find docs and examples everywhere. This is how I found the solution eventually.

  1. Google "aws s3 create nodejs client with credentials"
  2. Found S3 Client - AWS SDK for JavaScript v3, the JS package API docs
  3. S3Client class API
  4. Constructor API
  5. S3ClientConfig Interface API
  6. Properties -> Credentials Type
  7. AwsCredentialIdentity (Type of credentials property)
  8. Finally found that this is where to pass in accessKeyId, expiration, secretAccessKey, sessionToken.

This is no different from browsing source code. It's important developers has the ability to read source code and API docs. That doesn't mean the docs provider don't need to provide easy access to the most basic functionalities.

At least I could figure out Nodejs solution within 20 minutes. Took me a few hours to figure out the Rust solution.

Final Words

  • Also, why is documentation and examples everywhere? aws.amazon.com, github.com, and external websites like S3 Client - AWS SDK for JavaScript v3, (different for every language).
  • It's OK to have external API docs as each language have their own platforms. Like rust docs for rust crates.
  • But you should have a central place for links to everywhere and a easy-to-use search utility.
  • Could you put everything in one place and provide a search utility to search everything?
  • Like what you have in JS API docs
  • If your example is on GitHub, it's not that to search through the source code