[サンプルコード]TauriでReact×Rustなデスクトップアプリを作った

Tauriに関する日本語の記事がまだまだ少ないので、自作したアプリを元にTauriの簡単な解説とサンプルコードの紹介をします。


## 0. Tauriとは

バックエンドにRustを使用してデスクトップアプリを作成するためのツールキットです。

※現在バックエンドに使用できるのはRustだけですが、将来的にGo, Nim, Python, Csharpなどの他の言語もサポート予定とのこと。

フレームワークの選択肢が多いのが1つの特徴です。

現在は下記フレームワークが使用できます。

- Vanilla.js
- React
- Vite
- Vue.js
- Angular
- Svelte
- Dominator

参考1：[What is Tauri? | Tauri Studio](https://tauri.studio/docs/about/intro)

参考2：[Tauriで使えるWebフレームワークたち（VSCodeのプラグインも添えて）](https://zenn.dev/sakai_nako/articles/tauri-and-web-framework#vanilla.js%EF%BC%88%E3%83%95%E3%83%AC%E3%83%BC%E3%83%A0%E3%83%AF%E3%83%BC%E3%82%AF%E3%81%AA%E3%81%97%EF%BC%89)

ユーザーの各システムにインストール済みのWebViewを使って描画を行うので、bundleサイズが小さいのがもう1つの特徴です。

現在、ver1.0のリリースに向けたリリース候補版の開発が進められています。

https://tauri.studio/release-notes

## 1. 今回作成したアプリ

![tauri_github_analyzer screen_shot](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2634053/15f3b008-2338-0779-bfbe-144c75b76660.png)

GitHubリポジトリ：[tauri_github_analyzer](https://github.com/sakelog/tauri_github_analyzer)

### 1-1. 概要

GitHubのTraffic情報のビューアーです。

画面の操作だけでGitHubのAPIからTraffic情報を取得して一覧化します。

初回起動時にpersonal access token入力用のウィンドウを表示して、設定用ファイルとして、jsonファイルをアプリデータフォルダに出力します。

次回起動以降は保存したtokenを元に表示します。

※APIリクエストに失敗した場合は5回までtokenの再入力を受け付けて、超えたら強制終了。


### 1-2. READMEと仕様書

- [README.md(日本語版)](https://github.com/sakelog/tauri_github_analyzer/blob/main/README_ja.md)
- [シーケンス図(日本語版)](https://github.com/sakelog/tauri_github_analyzer/blob/v1.1.2/docs/sequence%20diagram_ja.md)

特にRust側の処理が分からなくなりそうだったので、シーケンス図を作成しています。

※余談ですが、シーケンス図の作成にはMermaid表記を使用しています。

https://github.blog/2022-02-14-include-diagrams-markdown-files-mermaid/


## 2.サンプルコード


処理の中心となる箇所を抜き出して解説します。


### 2-1. 前提条件


#### 2-1-1. フォルダ構成

```
  /
  ├─ build
  ├─ node_modules
  ├─ src
  | ├── @types
  | │   ├── github.d.ts
  | │   └── global.d.ts
  | ├── components
  | │   └── ...
  | ├── lib
  | │   └── ...
  | ├── App.tsx
  | ├── index.tsx
  | ├── react-app-env.d.ts
  | └── reportWebVitals.ts
  └─ src-tauri
    ├── icons
    │   └── ...
    ├── src
    │   ├── fetch_repo_info
    │   │   ├── get_reqwest.rs
    │   │   └── struct_type.rs
    │   ├── fetch_repo_info.rs
    │   ├── file_io.rs
    │   ├── lib.rs
    │   └── main.rs
    ├── Cargo.lock
    ├── Cargo.toml
    ├── build.rs
    └── tauri.conf.json
```

| ディレクトリ  | 説明                                        |
|:------------ |:--------------------------------------------|
| build        | Reactのbuild結果を格納                       |
| node_modules | Node.jsのpackageを格納                       |
| src          | Reactg側のソースを格納                       |
| src-tauri    | Rust側のソースとTauri全体の設定ファイルを格納 |

#### 2-1-2. Rust crate

```toml:Cargo.toml
[build-dependencies]
tauri-build = { version = "1.0.0-rc.7", features = [] }

[dependencies]
serde_json = "1.0"
serde = { version = "1.0", features = ["derive"] }
tauri = { version = "1.0.0-rc.8", features = ["path-all"] }
anyhow = "1.0"
reqwest = { version = "0.11", features = ["json"] }
tokio = { version = "1", features = ["full"] }
futures = "0.3"
```


#### 2-1-3. package.json

```json:package.json
{
  "name": "tauri_github_analyzer",
  "version": "1.1.3",
  "description": "Viewer of GitHub traffic built in Tauri",
  "author": "sake <sakelog.website@gmail.com>",
  "license": "MIT",
  "scripts": {
    "start": "cross-env BROWSER=none vite",
    "prebuild": "tsc --noEmit && yarn lint",
    "build": "vite build",
    "test": "",
    "lint": "eslint --ext .ts,.tsx src/",
    "tauri": "tauri",
    "dev": "tauri dev",
    "release": "tauri build"
  },
  "dependencies": {
    "@chakra-ui/react": "^1.8.8",
    "@emotion/react": "^11.9.0",
    "@emotion/styled": "^11.8.1",
    "@reduxjs/toolkit": "^1.8.1",
    "@tauri-apps/api": "^1.0.0-rc.3",
    "chart.js": "^3.7.1",
    "date-fns": "^2.28.0",
    "date-fns-tz": "^1.3.3",
    "framer-motion": "6",
    "react": "^18.0.0",
    "react-chartjs-2": "^4.1.0",
    "react-dom": "^18.0.0",
    "react-redux": "^8.0.1",
    "web-vitals": "^2.1.4"
  },
  "devDependencies": {
    "@tauri-apps/cli": "1.0.0-rc.9",
    "@tsconfig/vite-react": "^1.0.0",
    "@types/jest": "^27.4.1",
    "@types/node": "^17.0.25",
    "@types/react": "^18.0.6",
    "@types/react-dom": "^18.0.2",
    "@typescript-eslint/eslint-plugin": "^5.20.0",
    "@typescript-eslint/parser": "^5.22.0",
    "@vitejs/plugin-react": "^1.3.2",
    "cross-env": "^7.0.3",
    "eslint": "^8.14.0",
    "eslint-config-airbnb": "^19.0.4",
    "eslint-config-airbnb-typescript": "^17.0.0",
    "eslint-config-prettier": "^8.5.0",
    "eslint-plugin-import": "^2.26.0",
    "eslint-plugin-jsx-a11y": "^6.5.1",
    "eslint-plugin-react": "^7.29.4",
    "eslint-plugin-react-hooks": "^4.4.0",
    "prettier": "^2.6.2",
    "typescript": "^4.6.3",
    "vite": "^2.9.7"
  },
  "browserslist": {
    "production": [
      ">0.2%",
      "not dead",
      "not op_mini all"
    ],
    "development": [
      "last 1 chrome version",
      "last 1 firefox version",
      "last 1 safari version"
    ]
  }
}

```


### 2-2. jsonファイルを読み書きする(Rust)

```rust:file_io.rs
use anyhow::{anyhow, Result};
use std::fs;
use std::fs::OpenOptions;
use std::io::Write;
use std::path::PathBuf;

#[derive(Debug, serde::Deserialize, serde::Serialize)]
struct TokenJson {
    personal_token: String,
}

pub fn load_env(file_path: PathBuf) -> Result<String> {
    let input_str = fs::read_to_string(file_path);

    let input_str = match input_str {
        Ok(str) => str,
        Err(e) => return Err(anyhow!(e)),
    };

    let input_json: TokenJson = serde_json::from_str(&input_str)?;

    Ok(input_json.personal_token.into())
}

pub fn create_env_file(file_path: PathBuf, personal_token: String) -> Result<()> {
    let personal_token_json = serde_json::json!(TokenJson {
        personal_token: personal_token.clone()
    });

    let dir_path = &file_path.parent().unwrap();

    if dir_path.exists() {
    } else {
        let dir_created = fs::create_dir(dir_path);

        match dir_created {
            Ok(dir) => dir,
            Err(e) => return Err(anyhow!(e)),
        };
    }

    let output_file = OpenOptions::new().create(true).write(true).open(file_path);

    let mut result_output_file = match output_file {
        Ok(file) => file,
        Err(e) => return Err(anyhow!(e)),
    };

    let out_str = serde_json::to_string(&personal_token_json).expect("json toStr error");

    let out_buf = out_str.as_bytes();
    result_output_file.write_all(out_buf)?;
    result_output_file.flush()?;

    Ok(())
}

```

#### 2-2-1. 扱うファイル

下記の形式のjsonファイルを読み書きします。

```json:token.json
{"personal_token":"YOUR_TOKEN"}
```

#### 2-2-2. ファイル読み込み処理

```rust:file_io.rs
use anyhow::{anyhow, Result};
use std::fs;
.
.
.
use std::path::PathBuf;

#[derive(Debug, serde::Deserialize, serde::Serialize)]
struct TokenJson {
    personal_token: String,
}

pub fn load_env(file_path: PathBuf) -> Result<String> {
    let input_str = fs::read_to_string(file_path);

    let input_str = match input_str {
        Ok(str) => str,
        Err(e) => return Err(anyhow!(e)),
    };

    let input_json: TokenJson = serde_json::from_str(&input_str)?;

    Ok(input_json.personal_token.into())
}
.
.
.
```

| 項目   | 内容                                                                               |
|:------:|:---------------------------------------------------------------------------------- |
|関数名  |load_env(public)                                                                    |
|引数    |file_path(PathBuf型)                                                                |
|戻り値  |Result<String,anyhow::Error> ※anyhow::Resultを使用のため、エラーの型はanyhow～になる |

1. 引数で指定したファイルパスから、中の値をStringとして読み込む。
2. 読込結果が問題なかったらそのまま処理、問題があればエラーを返す。
3. 読み込んだStringを構造体TokenJson形式に変換する。
4. 最終的にinput_json内の"personal_token"の値を返す。

#### 2-2-3. ファイル出力処理

```rust:file_io.rs
use anyhow::{anyhow, Result};
use std::fs;
use std::fs::OpenOptions;
use std::io::Write;
use std::path::PathBuf;

#[derive(Debug, serde::Deserialize, serde::Serialize)]
struct TokenJson {
    personal_token: String,
}
.
.
.
pub fn create_env_file(file_path: PathBuf, personal_token: String) -> Result<()> {
    let personal_token_json = serde_json::json!(TokenJson {
        personal_token: personal_token.clone()
    });

    let dir_path = &file_path.parent().unwrap();

    if dir_path.exists() {
    } else {
        let dir_created = fs::create_dir(dir_path);

        match dir_created {
            Ok(dir) => dir,
            Err(e) => return Err(anyhow!(e)),
        };
    }

    let output_file = OpenOptions::new().create(true).write(true).open(file_path);

    let mut result_output_file = match output_file {
        Ok(file) => file,
        Err(e) => return Err(anyhow!(e)),
    };

    let out_str = serde_json::to_string(&personal_token_json).expect("json toStr error");

    let out_buf = out_str.as_bytes();
    result_output_file.write_all(out_buf)?;
    result_output_file.flush()?;

    Ok(())
}

```

| 項目   | 内容                                                                               |
|:------:|:---------------------------------------------------------------------------------- |
|関数名  |create_env_file(public)                                                             |
|引数    |file_path(PathBuf型), personal_token(String型)                                      |
|戻り値  |Result<(),anyhow::Error> ※anyhow::Resultを使用のため、エラーの型はanyhow～になる     |

1. 引数で指定したファイルパスから、ディレクトリのパスを取得。
2. ディレクトリが存在しなければ新規で作成する。
3. 出力ファイルを作成（すでにファイルが存在したら上書き）。
4. 引数のpersonal access tokenをjson形式に変換して書き込み。

※戻り値をResultにしているのは、エラーが発生したときにpanicさせずに呼び出し元に返して、最終的にReact側でエラー処理するため。

### 2-2. GitHubのAPIをリクエストしてTraffic情報を取得する(Rust)

```rust:fetch_repo_info > get_reqwest.rs
use super::struct_type::{ClonesItem, Repository, TrafficInfo, TrafficItem, ViewsItem};
use anyhow::{anyhow, Result};
use futures::future;
use reqwest::{header, Client};
use serde_json::Value;

async fn get_login_user(github: Client) -> Result<String> {
    let users = github.get("https://api.github.com/user").send().await;

    let users_text = match users {
        Ok(resp) => resp.text().await,
        Err(e) => return Err(anyhow!(e)),
    };

    let users_json: Value = match users_text {
        Ok(text) => serde_json::from_str(&text)?,
        Err(e) => return Err(anyhow!(e)),
    };

    let login = users_json["login"].as_str();

    let login = match login {
        Some(login) => login,
        None => return Err(anyhow!("param:login not found.")),
    };

    Ok(login.into())
}

async fn get_repos(github: Client, login: &str) -> Result<Vec<Repository>> {
    let username = login;
    let repos = github
        .get(format!("https://api.github.com/users/{username}/repos"))
        .send()
        .await;

    let repos_text = match repos {
        Ok(resp) => resp.text().await,
        Err(e) => return Err(anyhow!(e)),
    };

    let repos_json: Vec<Repository> = match repos_text {
        Ok(text) => serde_json::from_str(&text)?,
        Err(e) => return Err(anyhow!(e)),
    };

    Ok(repos_json.into())
}

pub async fn get_traffic_info(
    github: Client,
    login: &str,
    repos: Vec<Repository>,
) -> Result<Vec<TrafficInfo>> {
    let mut traffic_list: Vec<TrafficInfo> = vec![];

    let owner = login;
    for repo in repos {
        let repo_name = repo.name;

        let views_url = format!("https://api.github.com/repos/{owner}/{repo_name}/traffic/views");
        let clones_url = format!("https://api.github.com/repos/{owner}/{repo_name}/traffic/clones");

        let views = github.clone().get(views_url).send();
        let clones = github.clone().get(clones_url).send();

        let (result_views, result_clones) = future::join(views, clones).await;

        let views_text = match result_views {
            Ok(resp) => resp.text(),
            Err(e) => return Err(anyhow!(e)),
        };
        let clones_text = match result_clones {
            Ok(resp) => resp.text(),
            Err(e) => return Err(anyhow!(e)),
        };

        let (result_views_text, result_clones_text) = future::join(views_text, clones_text).await;

        let views_json: ViewsItem = match result_views_text {
            Ok(text) => serde_json::from_str(&text)?,
            Err(e) => return Err(anyhow!(e)),
        };

        let clones_json: ClonesItem = match result_clones_text {
            Ok(text) => serde_json::from_str(&text)?,
            Err(e) => return Err(anyhow!(e)),
        };

        let traffic_result = TrafficInfo {
            name: repo_name,
            url: repo.html_url,
            views_info: TrafficItem {
                count: views_json.count,
                uniques: views_json.uniques,
                items: views_json.views,
            },
            clones_info: TrafficItem {
                count: clones_json.count,
                uniques: clones_json.uniques,
                items: clones_json.clones,
            },
        };

        traffic_list.push(traffic_result);
    }

    Ok(traffic_list.into())
}

pub async fn main(personal_token: String) -> Result<Vec<TrafficInfo>> {
    const USER_AGENT: &str = "user-agent-name";

    let mut headers = header::HeaderMap::new();
    headers.insert("Accept", "application/vnd.github.v3+json".parse().unwrap());
    headers.insert(
        "Authorization",
        format!("token {personal_token}").parse().unwrap(),
    );

    let github = reqwest::Client::builder()
        .user_agent(USER_AGENT)
        .default_headers(headers)
        .build();

    let github = match github {
        Ok(client) => client,
        Err(e) => return Err(anyhow!(e)),
    };

    let login = get_login_user(github.clone()).await;

    let login = match login {
        Ok(login) => login,
        Err(e) => return Err(e),
    };

    let repos = get_repos(github.clone(), &login).await;

    let repos = match repos {
        Ok(repos) => repos,
        Err(e) => return Err(e),
    };

    let traffic_info = get_traffic_info(github.clone(), &login, repos).await;

    let traffic_info = match traffic_info {
        Ok(info) => info,
        Err(e) => return Err(e),
    };

    Ok(traffic_info.into())
}

```

```rust:fetch_repo_info > struct_type.rs
#[derive(Debug, serde::Deserialize)]
pub struct Repository {
    pub html_url: String,
    pub name: String,
}

#[derive(Debug)]
#[allow(dead_code)]
#[derive(serde::Deserialize, serde::Serialize)]
pub struct TrafficInfo {
    pub name: String,
    pub url: String,
    pub views_info: TrafficItem,
    pub clones_info: TrafficItem,
}

#[derive(Debug, serde::Deserialize, serde::Serialize)]
pub struct TrafficItem {
    pub count: i64,
    pub uniques: i64,
    pub items: Vec<Traffic>,
}
#[derive(Debug, serde::Deserialize, serde::Serialize)]
pub struct ViewsItem {
    pub count: i64,
    pub uniques: i64,
    pub views: Vec<Traffic>,
}
#[derive(Debug, serde::Deserialize, serde::Serialize)]
pub struct ClonesItem {
    pub count: i64,
    pub uniques: i64,
    pub clones: Vec<Traffic>,
}

#[derive(Debug, serde::Deserialize, serde::Serialize)]
pub struct Traffic {
    pub count: i64,
    pub timestamp: String,
    pub uniques: i64,
}

```


#### 2-2-1. reqwestのClientを作る

```rust:fetch_repo_info > get_reqwest.rs
.
.
.
use anyhow::{anyhow, Result};
.
.
.
use reqwest::{header, Client};
.
.
.
pub async fn main(personal_token: String) -> Result<Vec<TrafficInfo>> {
pub async fn main(personal_token: String) -> Result<Vec<TrafficInfo>> {
    const USER_AGENT: &str = "user-agent-name";

    let mut headers = header::HeaderMap::new();
    headers.insert("Accept", "application/vnd.github.v3+json".parse().unwrap());
    headers.insert(
        "Authorization",
        format!("token {personal_token}").parse().unwrap(),
    );

    let github = reqwest::Client::builder()
        .user_agent(USER_AGENT)
        .default_headers(headers)
        .build();

    let github = match github {
        Ok(client) => client,
        Err(e) => return Err(anyhow!(e)),
    };

.
.
.
}
```

AcceptヘッダーとAuthorizationヘッダーを設定したreqwestのClientを作成します。

このClientを使用することで、都度ヘッダーを設定する必要がなくなります。


#### 2-2-2. 処理の流れ

Repository trafficはGitHub GraphQL APIにはまだ対応していないので、
GitHub REST APIの組み合わせで情報を取得しています。

流れとしては下記の通りです。

1. tokenに紐づいたログインユーザーを取得
2. ユーザーのrepositoryをすべて取得
3. 各repositoryのviewsとclonesを取得して、json形式で返す


#### 2-2-3. 処理を並行して走らせる

```rust:fetch_repo_info > get_reqwest.rs
.
.
.
use anyhow::{anyhow, Result};
use futures::future;
.
.
.
pub async fn get_traffic_info(
    github: Client,
    login: &str,
    repos: Vec<Repository>,
) -> Result<Vec<TrafficInfo>> {
    let mut traffic_list: Vec<TrafficInfo> = vec![];

    let owner = login;
    for repo in repos {
        let repo_name = repo.name;

        let views_url = format!("https://api.github.com/repos/{owner}/{repo_name}/traffic/views");
        let clones_url = format!("https://api.github.com/repos/{owner}/{repo_name}/traffic/clones");

        let views = github.clone().get(views_url).send();
        let clones = github.clone().get(clones_url).send();

        let (result_views, result_clones) = future::join(views, clones).await;
        .
        .
        .
}

.
.
.

```

`let (result_views, result_clones) = future::join(views, clones).await`というように、`future::join`を使用して、viewsの取得とclonesの取得のawaitを同時に走らせています。

こうすることで、並行して処理が可能です。

※並行処理についてはまだまだ改善の余地ありです……

### 2-3. 起動したタイミングで非同期でRustの処理を実行する(React)

フロントエンドとRsutの連携は公式ドキュメントが詳しいです。

[Calling Rust from the frontend | Tauri Studio](https://tauri.studio/docs/guides/command)

#### 2-3-1. React側

```tsx:App.tsx
import { useState, useEffect } from 'react';

// lib
import { invoke } from '@tauri-apps/api/tauri';

// component
.
.
.

// redux
import { useDispatch, useSelector } from 'react-redux';
import { AppDispatch, RootState } from 'lib/redux/store';
import {
  setTmpPersonalToken,
  setTokenSubmitted,
  setErrorMessage,
  incrementCountTokenSend,
} from 'lib/redux/lib/slice';

// Main
const App = () => {
  const [trafficResults, setTrafficResults] = useState<
    Array<GitHub.RepoInfo>
  >([]);

  const { isOpen, onOpen, onClose } = useDisclosure();

  // redux
  const tmpToken = useSelector<RootState>(
    (state) => state.mainState.tmpPersonalToken
  ) as string;
  const tokenSubmitted = useSelector<RootState>(
    (state) => state.mainState.tokenSubmitted
  ) as boolean;
  const countTokenSend = useSelector<RootState>(
    (state) => state.mainState.countTokenSend
  ) as number;

  const dispatch = useDispatch<AppDispatch>();

  useEffect(() => {
    const onCheckExistFile = async () => {
      const checkResult = await invoke<boolean>(
        'check_exist_file'
      );
      let resultJson: string | null = null;
      if (checkResult) {
        resultJson = await invoke<string>(
          'fetch_repo_info'
        ).catch(() => null);
        dispatch(incrementCountTokenSend());
      } else {
        if (tokenSubmitted) {
          resultJson = await invoke<string>(
            'fetch_repo_info',
            { newPersonalToken: tmpToken }
          ).catch(() => null);
          dispatch(incrementCountTokenSend());
        } else {
          onOpen();
        }
        dispatch(setTokenSubmitted(false));
        dispatch(setTmpPersonalToken(''));
      }

      let result: GitHub.RepoInfo[] = [];
      if (typeof resultJson === 'string') {
        result = JSON.parse(resultJson);
        setTrafficResults(result);
      } else {
        if (countTokenSend > 0) {
          dispatch(
            setErrorMessage(
              `token set error! : [ ${countTokenSend} ]`
            )
          );
        }
        await invoke<void>('delete_file');
      }
    };
    onCheckExistFile();
  }, [tokenSubmitted]);

  return (
  .
  .
  .
  );
};

export default App;

```

`import { invoke } from '@tauri-apps/api/tauri';`で、Rust側との連携に使用する`invoke`をimportしておきます。

invokeはPromiseを返すので、awaitで結果を待つ必要があります。

非同期処理は、useEffectの中でasync処理を定義して、実行します。

`tokenSubmitted`の値の変更を監視したいので、useEffectの第2引数に`tokenSubmitted`を指定しています。

```tsx
  useEffect(() => {
  .
  .
  .
  }, [tokenSubmitted])
```

読み込み時の1回だけ実行すれば良い場合は、下記のように第2引数を空配列にします。

```tsx
  useEffect(() => {
  .
  .
  .
  }, [])
```

#### 2-3-2. Rust側

invokeでRustの処理を実行するためには、Rust側で`#[tauri::command]`を指定した処理の追加と、`invoke_handler`の設定が必要です。

```rust:main.rs
#![cfg_attr(
    all(not(debug_assertions), target_os = "windows"),
    windows_subsystem = "windows"
)]

use anyhow::Result;
.
.
.
use tauri_github_analyzer::{create_env_file, get_reqwest, load_env};

struct PathState(PathBuf);
.
.
.
#[tauri::command]
async fn fetch_repo_info(
    new_personal_token: Option<String>,
    state: tauri::State<'_, PathState>,
) -> Result<String, String> {
    match new_personal_token {
        Some(token) => create_env_file(state.0.clone(), token).unwrap(),
        None => (),
    };

    let exist_personal_token = load_env(state.0.clone());

    let exist_personal_token = match exist_personal_token {
        Ok(token) => token,
        Err(_) => return Err("personal token load error".to_string()),
    };

    let fetch_result = get_reqwest::main(exist_personal_token).await;

    let result_json = match fetch_result {
        Ok(fetch_result) => serde_json::json!(fetch_result).to_string(),
        Err(_) => return Err("fetch repo info error".to_string()),
    };

    Ok(result_json)
}
.
.
.
fn main() {
    tauri::Builder::default()
        .manage(PathState(get_target_path().into()))
        .invoke_handler(tauri::generate_handler![
            check_exist_file,
            fetch_repo_info,
            delete_file,
            abnormal_end
        ])
        .run(tauri::generate_context!("tauri.conf.json"))
        .expect("error while running tauri application");
}
```

1. `#[tauri::command]`を指定して、実際に動かすコマンドを記述
2. main関数内で`invoke_handler`を使用して、コマンドをinvokeに登録

:::note warn
コマンドの引数はRust内ではsnake_caseで記載、
React側から呼び出すときはcamelCaseで記載なので注意
:::


## 3. 触ってみた感想

- bundleサイズが小さいのがうれしい
- 書き慣れたReactでフロントエンドを実装できるのはうれしい
- Rust難しい……
  - 一応公式のAPIも用意されているので、Rustに触らず開発をすることも可能ではある
- リリース候補版のため、破壊的な変更が入ることも多い