はじめに
TauriはRust+WebViewで作成できるマルチプラットフォームのGUIアプリが作成できるFWです。
https://tauri.app
TauriとDieselを利用したサンプルがあまりなかったので今回作成してみました。
アプリはTauriが1.0になったついでにRustの勉強しようと思いアプリを作成して自分用に利用していたものです。
今回にあわせてDieselを利用するように作り替えてみました。
Tauriについて
TauriはメインプロセスをRustで行い、フロントエンドはWebView上で描画します。
環境構築やアーキテクチャ、その他の詳細は公式ドキュメントや日本語記事も多く出ているのでそちらを参照してください。
Electronと違ってWevViewをアプリに内蔵しないので、配布アプリが軽量になるのは良いですが、
OSのWevViewに依存するので実行環境によって若干差異が生まれる可能性がようです。
アプリについて
Google AuthenticatorやAuthyと同じ類のものです。
アプリ起動からTOTP取得までの使い勝手が好きではなかったので自作して使っています。
それでもやっぱりめんどくさいので世の中全部FIDO認証になってほしいですね。
作ったもの
画面イメージ
そういえばTOTPってなんだっけ
RFC6238:Time-based One-time Password(TOTP)です。
認証したいサービスからSecretKey(シード)が発行され、そのKeyの値とエポックタイムでコードを生成します。
言い方を変えればKeyとエポックタイムの値を合わせれば毎回同じコードが取得されることになります。
SecretKeyってどこで発行されてるの?という件に関しては、
利用するサービスから発行されるQRコードをTOTPのアプリで読み込ませる場合が多いかと思います。
このQRコードにSecretKey、その他の情報が入っています。
よくみると直接SecretKeyを確認する方法が画面上に存在すると思うので機会があれば確認してみてください。
TOTPは内部的にはRFC4226:An HMAC-Based One-Time(HOTP)を利用しています。
HOTPが利用するカウンター値をエポックタイムにしたものがTOTPですね。
技術スタック
趣味なのでProduction Readyではないけれども利用してみたかったライブラリ等も使えたので満足です。
主なFW/ライブラリ
フロントエンド
フロントエンドは特に目新しいものはないので詳しいことは省きます。
FWは慣れているのでNext.jsを利用しました。
SSGするだけでNext.js過剰だと思いますが今回はRustの勉強がメインなのでまぁいいでしょう。
Recoilは手軽でいい感じですよね。
- Next.js
- Tailwind CSS
- React Hook Form
- daisyUI
- Recoil
バックエンド(Rust)
- libreauth
- ユーザ認証処理をまとめたクレート
HOTP/TOTP、パスフレーズ認証等も対応しています。
- ユーザ認証処理をまとめたクレート
- aes-gcm
- AESで暗号化/復号化をするクレート
暗号処理で困ったらRust Cryptoリポジトリを見に行けば良いと思います。
- AESで暗号化/復号化をするクレート
- diesel
- O/Rマッパークレート
今回のアプリでO/Rマッパーまで必要ないのですが、勉強も兼ねているので利用してみました。
マイグレーション機能も付いていて便利ですね。
今回はSQLiteで利用しています。
- O/Rマッパークレート
- tracing
- スタックトレースをログに記載するためのクレート
関数に付与することでログのトレースができる
- スタックトレースをログに記載するためのクレート
- serde
- シリアライズ/デシリアライズするためのクレート
アイコン作成
TauriがCLIを用意してくれているのでそれを利用しましょう。
アイコンにしたい1240x1240pxの透過PNGファイルの画像をTauriCLIに食わせるとアイコンを自動で生成してくれます。
現状Macの最小アイコンの表示に不具合がありそうです。(Tauriの問題ではない?)
Tauri と Diesel の連携
本題です。Tauriの状態管理にコネクションをいれる方法は、Actix-Webを参考しています。
Actix-Webではコンテキストにデータとして追加していたので同じような方法で実現しています。
他にも良い方法があれば検討したいです。
コネクション管理とDBマイグレーション
コネクション確立、状態管理の保存
tauri::Builder::default()
...
.setup(|app| {
...
let config = app.config();
// DBのコネクションを確立しています
// SQLiteの場合は接続確率時にDBファイルが生成されます
// 今回はあまり関係ないのですがコネクションプールはr2d2クレートで管理しています
// Dieselクレートに含まれているので利用しておきましょう
let conn = db::manager::establish_connection(&config);
// SQLiteを利用するので組み込みしているマイグレーションを実行します
db::manager::run_migration(&conn);
// Tauriの状態管理にコネクション情報を保持します
// 注意:状態管理は型をキーとするので、同じ型設定することはできません。
app.manage::<ConnPool>(conn);
...
})
上記のそれぞれの処理内容
// コネクションの確率
pub fn establish_connection(config: &Arc<Config>) -> ConnPool {
// アプリケーションで利用するデータディレクトリを取得します
// Linux: /home/alice/.config/${bundle_identifier}
// Windows: C:\Users\Alice\AppData\Roaming\${bundle_identifier}
// macOS: /Users/Alice/Library/Application Support\${bundle_identifier}
let mut path = store::get_app_path(config);
path.push(DB_FILE_NAME);
info!(
"{}",
&path
.to_str()
.expect_or_log("Failed to open database file path.")
.to_string()
);
let manager = ConnectionManager::<SqliteConnection>::new(path.to_str().unwrap());
Pool::builder()
.build(manager)
.expect("Failed to create a connection pool.")
}
// 組み込みのマイグレーション
use diesel_migrations::{embed_migrations, EmbeddedMigrations, MigrationHarness};
pub const MIGRATIONS: EmbeddedMigrations = embed_migrations!();
/// マイグレーションの実行
pub fn run_migration(conn: &ConnPool) {
// コネクションプールからコネクションを取得
conn.get()
.expect("Failed to get a connection from the connection pool.")
// まだ実行されていないマイグレーションを実行します
// マイグレーションしているかどうかは__diesel_schema_migrationsテーブルのレコードで判断します
.run_pending_migrations(MIGRATIONS)
.expect("Failed to migrations.");
}
状態管理の取得
#[tauri::command]
// app.manageに設定した型を引数にすると、Tautiが判断して引数に値を設定します。
// 設定されていない場合は、実行時にエラーになります。
pub fn load_accounts(connection: tauri::State<'_, ConnPool>) -> Vec<AccountDisplay> {
let conn = &mut connection.get().expect("Connection not found.");
AccountService::find_all_display(conn)
}
状態保存と取得を抜粋するとこんな感じです
// エイリアス
pub type ConnPool = Pool<ConnectionManager<SqliteConnection>>;
// ↓ここに設定した型が
app.manage::<ConnPool>(conn);
// ↓ここと同じに型になる必要がある
pub fn load_accounts(connection: tauri::State<'_, ConnPool>) -> Vec<AccountDisplay> {
Dieselの挙動
データ取得
/// Find by account id.
pub fn find_by_id(conn: &mut SqliteConnection, account_id: &String) -> Result<Account, Error> {
accounts.filter(id.eq(account_id)).first::<Account>(conn)
}
データ削除
/// Detele from account table.
pub fn delete(conn: &mut SqliteConnection, account_id: &String) -> bool {
match delete(accounts).filter(id.eq(account_id)).execute(conn) {
Ok(_r) => true,
Err(e) => {
error!("{}", e.to_string());
false
}
}
}
データ登録-トランザクションの扱い
// Insertableはデータ挿入用の構造体であることを示す
// 更新用の構造体の場合はAsChangesetを付与する、Insertableと同じ構造体にも付与できます
#[derive(Insertable)]
#[table_name = "accounts"]
pub struct AccountData<'a> {
pub id: &'a String,
pub secret_key: &'a String,
pub account_name: &'a String,
pub issuer: &'a String,
pub created_at: &'a String,
pub updated_at: &'a String,
}
// コネクションからトランザクションを呼び出します
// 以下のクロージャ内でエラーが発生するとロールバックされます
conn.immediate_transaction(|conn| {
// 以下の書き方はUpsertになります
insert_into(accounts)
.values(&data) // ここにInsertableのアトリビュートのついた構造体を設定
.on_conflict(id) // IDが競合した場合、複数の場合はタプルでかきます
.do_update() // アップデートを実行する .do_nothing() というのもある
.set(( // 更新する対象の値をセットする、AsChangesetを付与してある構造体を指定することも可能
account_name.eq(data.account_name),
issuer.eq(data.issuer),
updated_at.eq(&now),
))
.execute(conn) // 実行
})
.expect("Transaction Error.");
Tauri と Diesel を合わせた感想
Tauriの状態管理にコネクション情報を保存しておくところさえやってしまえばあとは特に難しい部分はないかと思います。
作業を始めた段階でDieselがメジャーアップデートをおこなったので情報が少なくこっちで詰まりました。
既存の不明点
知ってる方がいれば教えてください。
- Macの仮想デスクトップでの挙動がおかしい
- 起動した仮想デスクトップ以外で再表示すると起動した仮想デスクトップに強制移動してしまいます。
- 非表示の方法が悪い?
- システムトレイの位置の取得
- システムトレイピッタリに表示したいのですがシステムトレイの高さの取得がうまくいきません。
- 別のアプローチを考えた方がいいのかな。
- 暗号化したデータの保存方法
- TOTPのシークレットをDBに保存する時に暗号化したのですがうまくできず検証中
最後に
アプリ作成時の話なので雑多は内容になってしまいました。
ライブラリとFWの機能を利用するだけでTraitやマクロ、DBのテーブルも1つしかなかったので非常に簡易的なアプリになっています。
今後は開発でもっと深くRust理解して使っていきたいですね。
次は低レイヤーの実装をしてみるか、WebFWを使った実装をしてみようかと思っています。
本アプリのソースはGithubで公開しているので使ったりしてみてください。