28
22

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

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

Posted at

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

参考2:Tauriで使えるWebフレームワークたち(VSCodeのプラグインも添えて)

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

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

1. 今回作成したアプリ

tauri_github_analyzer screen_shot

GitHubリポジトリ:tauri_github_analyzer

1-1. 概要

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

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

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

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

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

1-2. READMEと仕様書

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

※余談ですが、シーケンス図の作成には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

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

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)

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ファイルを読み書きします。

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

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

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 ※anyhow::Resultを使用のため、エラーの型はanyhow~になる
  1. 引数で指定したファイルパスから、中の値をStringとして読み込む。
  2. 読込結果が問題なかったらそのまま処理、問題があればエラーを返す。
  3. 読み込んだStringを構造体TokenJson形式に変換する。
  4. 最終的にinput_json内の"personal_token"の値を返す。

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

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)

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())
}

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を作る

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. 処理を並行して走らせる

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

2-3-1. React側

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を指定しています。

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

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

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

2-3-2. Rust側

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

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に登録

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

3. 触ってみた感想

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

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
28
22

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?