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. 今回作成したアプリ
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
[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
{
"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)
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ファイルを読み書きします。
{"personal_token":"YOUR_TOKEN"}
2-2-2. ファイル読み込み処理
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~になる |
- 引数で指定したファイルパスから、中の値をStringとして読み込む。
- 読込結果が問題なかったらそのまま処理、問題があればエラーを返す。
- 読み込んだStringを構造体TokenJson形式に変換する。
- 最終的にinput_json内の"personal_token"の値を返す。
2-2-3. ファイル出力処理
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~になる |
- 引数で指定したファイルパスから、ディレクトリのパスを取得。
- ディレクトリが存在しなければ新規で作成する。
- 出力ファイルを作成(すでにファイルが存在したら上書き)。
- 引数のpersonal access tokenをjson形式に変換して書き込み。
※戻り値をResultにしているのは、エラーが発生したときにpanicさせずに呼び出し元に返して、最終的にReact側でエラー処理するため。
2-2. GitHubのAPIをリクエストしてTraffic情報を取得する(Rust)
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())
}
#[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を作る
.
.
.
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の組み合わせで情報を取得しています。
流れとしては下記の通りです。
- tokenに紐づいたログインユーザーを取得
- ユーザーのrepositoryをすべて取得
- 各repositoryのviewsとclonesを取得して、json形式で返す
2-2-3. 処理を並行して走らせる
.
.
.
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側
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
の設定が必要です。
#![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");
}
-
#[tauri::command]
を指定して、実際に動かすコマンドを記述 - main関数内で
invoke_handler
を使用して、コマンドをinvokeに登録
コマンドの引数はRust内ではsnake_caseで記載、
React側から呼び出すときはcamelCaseで記載なので注意
3. 触ってみた感想
- bundleサイズが小さいのがうれしい
- 書き慣れたReactでフロントエンドを実装できるのはうれしい
- Rust難しい……
- 一応公式のAPIも用意されているので、Rustに触らず開発をすることも可能ではある
- リリース候補版のため、破壊的な変更が入ることも多い