本記事は上司「オンプレでチャットAI作って」 - Qiitaの続きにあたる記事です!
同記事では、PythonでRinna社提供のLLM rinna/japanese-gpt-neox-3.6b-instruction-sft を動かすことで、ローカルで動作する対話AIソフトを作成し、PyInstallerによって実行ファイル化した話までをしました。ここまではCLI (コマンドラインインターフェース)1 のお話でした。
本記事では下記GIFのように、CLIアプリをTauriから呼び出して使用することで、GUI (グラフィカルユーザーインターフェース) アプリへと変化させる方法を解説します!
リポジトリ: https://github.com/anotherhollow1125/rinna_gpt
リリース: https://github.com/anotherhollow1125/rinna_gpt/releases
なお、TauriやTS、Rustの知識が前提なのでニッチ記事と位置づけました!色々とご容赦ください...
???「Tauriはニッチ技術だ...私がそう判断した」
~ 機動言語Rust 連星の魔女 第2話より ~
GIF画像でTauriが気になった方へ、入門用ハンズオンは↓↓こちら↓↓!テマエミソデスガ
TL;DR フローチャート
- 入出力が簡単※なCLIを使うよ!という方 or CLIはこれから作るよ!という方
- Tauriのsidecar機能を使いましょう!その上で
- フロントエンド(TypeScript)だけで完結する
-
@tauri-apps/api/shell/Command
を使いましょう!
-
- バックエンド(Rust)で管理したい
-
tauri::api::process::Command
を使いましょう!
-
- フロントエンド(TypeScript)だけで完結する
- Tauriのsidecar機能を使いましょう!その上で
※具体的には、入出力が行単位で行われるCLIアプリです。
CLIの出力が複雑になってしまうなら以下の方法のほうが良いと思います。
- (手を加えるのが難しい)動かしたいCLIアプリはもうあるよ!という方
-
resources機能と
tokio::process::Command
を使いましょう!-
tauri::api::process::Command
よりも細かい調整が効きます。
-
-
resources機能と
本記事で扱うCLIアプリ
解説のため今回扱うCLIアプリの用意をします。直接的なコンテンツではありませんがお付き合いください。前回記事最後に作成したRinna GPT CLIアプリの代替として、オウム返しするダミー(スタブ)アプリを使います。
返答であることがわかりやすいように、受け取った入力を逆順で返すことにします。bashに標準で存在するrev
コマンドと同じ仕様です。
仕様
- 入力:
- 方法1: 文字列を標準入力で1行ずつ受け取る (例:
abcd
) - 方法2: コマンドラインで指定する (例:
$ rev abcd
)
- 方法1: 文字列を標準入力で1行ずつ受け取る (例:
- 出力: 入力を逆順にしたものを標準出力し改行する (例:
dcba
)- 方法1で入力する場合、Ctrl+Cが押されるまで(
EOF
まで)ループ - 方法2で入力する場合、1度で終了
- 方法1で入力する場合、Ctrl+Cが押されるまで(
以下にRustプログラムを置いておきます。本題ではないので折りたたんでおきます。
ダミー用アプリ rev
use std::env;
use std::io::stdin;
fn rev(input: &str) -> String {
input.chars().rev().collect::<String>()
}
fn cl_arg_mode(arg: &str) {
println!("{}", rev(&arg));
}
fn interactive_mode() {
loop {
let mut buffer = String::new();
let n = stdin().read_line(&mut buffer).unwrap();
if n == 0 {
break;
}
let buffer = buffer.trim();
println!("{}", rev(&buffer));
}
}
fn main() {
let args: Vec<String> = env::args().collect();
if args.len() > 1 {
cl_arg_mode(&args[1]);
} else {
interactive_mode();
}
}
今後記事ではこちらをコンパイルした実行ファイルrev
(Winではrev.exe
) を使います!
なお検証はWin11で行っています(おそらくですが今回は他のOSでも共通の内容だと思います)。本記事に関係するアプリケーションのバージョンは以下とします。
- tauri: 1.4
- cargo: 1.69.0
- yarn: 1.22.19
- tauri-cli: 1.4.0
バージョン確認詳細
> rustup --version
rustup 1.26.0 (5af9b9484 2023-04-05)
info: This is the version for the rustup toolchain manager, not the rustc compiler.
info: The currently active `rustc` version is `rustc 1.69.0 (84c898d65 2023-04-16)`
> cargo --version
cargo 1.69.0 (6e9a83356 2023-04-12)
> node -v
v18.4.0
> npm -v
8.13.1
> yarn -v
1.22.19
> cd tauri-project
> yarn tauri --version
yarn run v1.22.19
$ tauri --version
tauri-cli 1.4.0
Done in 0.15s.
[dependencies]
tauri = { version = "1.4", features = [ "shell-sidecar", "shell-open"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
tokio = { version = "1.28.2", features = ["full"] } # 使う場合
TauriからCLIアプリを呼び出す
Tauriフレームワーク自体が兼ね備える実行ファイル埋め込み(sidecar)機能を使うことで、Tauriから簡単にCLIアプリを呼び出すことが可能です!
Tauriのsidecar機能
CLIアプリを呼び出すには、最初にTauriプロジェクトの設定ファイルであるtauri.conf.json
に2つの記述を追加します。
{
"tauri": {
"allowlist": {
"shell": {
"sidecar": true,
"scope": [
{
"name": "rev",
"sidecar": true,
"args": true
}
]
}
},
"bundle": {
"externalBin": [
"rev"
]
},
}
}
tauri.conf.json 全体
テンプレートのデフォルト設定に項目を追加した場合以下のような感じになります。余剰な部分は適宜読み替えてください。{
"build": {
"beforeDevCommand": "yarn dev",
"beforeBuildCommand": "yarn build",
"devPath": "http://localhost:1420",
"distDir": "../dist",
"withGlobalTauri": false
},
"package": {
"productName": "your_product_name",
"version": "0.0.0"
},
"tauri": {
"allowlist": {
"all": false,
"shell": {
"all": false,
"open": true,
"sidecar": true,
"scope": [
{
"name": "rev",
"sidecar": true,
"args": true
}
]
}
},
"bundle": {
"active": true,
"targets": "all",
"identifier": "com.tauri.dev",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
],
"externalBin": [
"rev"
]
},
"security": {
"csp": null
},
"windows": [
{
"fullscreen": false,
"resizable": true,
"title": "your_product_name",
"width": 800,
"height": 600
}
]
}
}
1つはallowlist
で、こちらはTauriの機能に関するホワイトリストになっています。今回はsidecar
機能の有効化と有効にする実行ファイルに関する記述を行っています。項目args
はコマンドライン引数に関する設定です。今回はコマンドライン引数は無条件で受け取れるようにtrue
に設定しています。
args
を設定しないとコマンドライン引数を受け取ってくれない仕様なのは、インジェクション攻撃を避けるためのバカよけであると考えられます。晒すCLIアプリケーションに合わせて適切な設定をすることが大切です。
2つ目はbundle
で、(おそらくですが)ビルド時にバンドルするファイルを列挙するための項目になっています。
今回相対パスとしてrev
を指定しています。この場合、設定ファイルがあるtauri.conf.json
をカレントディレクトリとみなすので、src-tauri
ディレクトリに実行ファイルを置いておけば良いです。
...が!本機能を使うには実行ファイルの名前を少し細工する必要があります。
マルチプラットフォームビルドをスムーズにするためか、ビルドするターゲットの環境を表す"Target Triple"をファイル名と拡張子の間(suffix)に入れる必要があります。
rev.exe
↓
rev-[TARGET TRIPLE].exe
なおこの細工を施すのは実行ファイル名だけです。tauri.conf.json
に記述したrev
はそのままで大丈夫です。
rustc -Vv
コマンドの出力中のhost
がTarget Tripleです。
> rustc -Vv | Select-String "host:" | ForEach-Object {$_.Line.split(" ")[1]}
x86_64-pc-windows-msvc
$ rustc -Vv | grep host | cut -f2 -d' '
x86_64-unknown-linux-gnu
総括すると、今回でWindowsの場合は、src-tauri
フォルダ直下にrev-x86_64-pc-windows-msvc.exe
という名前の実行ファイルを置いておくとうまくrev
を認識してくれるようになります。
TypeScriptから呼び出す: @tauri-apps/api/shell/Command
sidecarを登録してしまえばフロント側から簡単にコマンドを呼び出せます!今回はReactテンプレートで始めた場合にデフォルトで記述されているgreet
関数に細工をする形で使い方を示そうと思います。
デフォルトテンプレートの内容と実行スクショ
import { useState } from "react";
import reactLogo from "./assets/react.svg";
import { invoke } from "@tauri-apps/api/tauri";
import "./App.css";
function App() {
const [greetMsg, setGreetMsg] = useState("");
const [name, setName] = useState("");
async function greet() {
// Learn more about Tauri commands at https://tauri.app/v1/guides/features/command
setGreetMsg(await invoke("greet", { name }));
}
return (
<div className="container">
<h1>Welcome to Tauri!</h1>
<div className="row">
<a href="https://vitejs.dev" target="_blank">
<img src="/vite.svg" className="logo vite" alt="Vite logo" />
</a>
<a href="https://tauri.app" target="_blank">
<img src="/tauri.svg" className="logo tauri" alt="Tauri logo" />
</a>
<a href="https://reactjs.org" target="_blank">
<img src={reactLogo} className="logo react" alt="React logo" />
</a>
</div>
<p>Click on the Tauri, Vite, and React logos to learn more.</p>
<form
className="row"
onSubmit={(e) => {
e.preventDefault();
greet();
}}
>
<input
id="greet-input"
onChange={(e) => setName(e.currentTarget.value)}
placeholder="Enter a name..."
/>
<button type="submit">Greet</button>
</form>
<p>{greetMsg}</p>
</div>
);
}
export default App;
コマンドライン引数で渡して実行する場合
今回紹介する方法の中では最もシンプルな方法になります。コマンドライン引数で反転させたい文字列を渡して実行する場合、次のようになります。
import { Command } from "@tauri-apps/api/shell";
async function greet() {
// Learn more about Tauri commands at https://tauri.app/v1/guides/features/command
const greetMsg = await invoke<string>("greet", { name });
// コマンドの実行
const output = await Command.sidecar("rev", greetMsg).execute();
setGreetMsg(output.stdout);
}
Command
のsidecar
メソッドの第一引数にCLIアプリ名を、第二引数にコマンドライン引数を渡し、execute
メソッドを実行して出力を待つだけです。簡単にCLIアプリを組み込めましたね!
標準入出力でやり取りする場合
コマンドライン引数だけでは完結しない対話型アプリもあるでしょう。筆者が用意したRinna GPTのCLIアプリも、最初の立ち上げに時間がかかるためコマンドライン引数ではなく標準入力で入力を受け取るように設計しています。
import { useState, useEffect, useRef } from "react";
import { Command, Child } from "@tauri-apps/api/shell";
function App() {
const [greetMsg, setGreetMsg] = useState("");
const [name, setName] = useState("");
const command = useRef<Command | undefined>(undefined);
const child = useRef<Child | undefined>(undefined);
useEffect(() => {
(async () => {
if (command.current !== undefined) {
return;
}
command.current = Command.sidecar("rev");
child.current = await command.current.spawn();
})();
}, []);
async function greet() {
// Learn more about Tauri commands at https://tauri.app/v1/guides/features/command
const greetMsg = await invoke<string>("greet", { name });
// 入力
await child.current?.write(`${greetMsg}\n`);
// 出力
command.current?.stdout.on("data", (line) => setGreetMsg(line));
}
// 省略...
}
Command
のspawn
メソッドを呼び出して起動し、Child
を取得しています。この時CLIアプリは子プロセスとして呼び出されています。CLIアプリ子プロセスは1度起動したら使い回したいので、Reactを使っている本構成ではuseRef
やuseEffect
を使い最初だけ初期化されるようにしています。
Child
のwrite
メソッドで文字列を標準入力でき、command.stdout.on
メソッドで1行標準出力があった時のイベントハンドラ内で、得られた標準出力を結果として用いることができます。
TS、Rust関係なく、標準出力が行単位ではないアプリで細かい出力取得ができる方法は調べた感じ見つかりませんでした。標準出力が複雑な場合は、後述しているtokio::process::Command
を使用する方法を検討してみてください。
Rustから呼び出す: tauri::api::process::Command
#[tauri::command]
なRust関数からCLIアプリを呼び出したい時などもあるでしょう。標準入出力の方はTypeScriptより少し記述が増えますが、こちらも比較的簡単に(当社比)記述することが可能です!
TypeScriptの時同様、今回はテンプレートデフォルトで付いてくるgreet
関数を書き換える形で示します。
デフォルトテンプレートの内容
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
// Learn more about Tauri commands at https://tauri.app/v1/guides/features/command
#[tauri::command]
fn greet(name: &str) -> String {
format!("Hello, {}! You've been greeted from Rust!", name)
}
fn main() {
tauri::Builder::default()
.invoke_handler(tauri::generate_handler![greet])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
コマンドライン引数で渡して実行する場合
TypeScriptとほぼ同じ記述量で楽に呼び出せます!
use tauri::api::process::Command;
#[tauri::command]
fn greet(name: &str) -> String {
let rev_name = Command::new_sidecar("rev")
.expect("failed to create `rev.exe` binary command")
.args([name])
.output()
.unwrap();
format!("Hello, {}! You've been greeted from Rust!", rev_name.stdout)
}
Command
構造体をnew_sidecar
で生成し、args
でコマンドライン引数を指定した後、output
メソッドでコマンドの完了を待ち、結果をその後に利用しています。シンプル!
標準入出力でやり取りする場合
一方標準入出力版については、申し訳ないのですが、ここまでが比較的シンプルだったので少し驚くかもしれないぐらいの分量を書く必要があります。greet
関数以外にも細工が要るので全体を載せます。いくぞ!
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
use tauri::api::process::{Command, CommandEvent::*};
use tauri::async_runtime::{channel, Receiver, Sender};
// 魔改造greetコマンド
// Learn more about Tauri commands at https://tauri.app/v1/guides/features/command
#[tauri::command]
async fn greet(
name: &str, // 反転する文字列
rev_input_tx: tauri::State<'_, Sender<(String, Sender<String>)>>, // manageで管理されているSenderリソース
) -> Result<String, String> {
// 返送用チャンネル
let (rev_output_tx, mut rev_output_rx) = channel(32);
// revに送信
rev_input_tx
.send((name.to_string(), rev_output_tx))
.await
.map_err(|e| e.to_string())?;
// revの返送を受信
let Some(rev_name) = rev_output_rx.recv().await else {
return Err("Failed to receive reversed name".to_string());
};
// greet本来の処理
let res = format!("Hello, {}! You've been greeted from Rust!", rev_name);
Ok(res)
}
fn setup_rev_command(mut rev_input_rx: Receiver<(String, Sender<String>)>) {
// コマンド子プロセス生成
let (mut rev_event_rx, mut child) = Command::new_sidecar("rev")
.expect("failed to create `rev.exe` binary command")
.spawn()
.expect("Failed to spawn sidecar");
// revコマンドに対するクエリを受け付ける非同期タスク
tauri::async_runtime::spawn(async move {
// rev_input_rxが閉じられるまでループ
// s: 反転対象
// reply_tx: 反転結果を送信するチャンネル
while let Some((s, reply_tx)) = rev_input_rx.recv().await {
let s = format!("{}\n", s);
// rev.exeの標準入力に書き込み
child
.write(s.as_bytes())
.expect("failed to send input to `rev.exe` binary command");
let event = rev_event_rx.recv().await.unwrap();
match event {
// rev.exeの標準出力を取得してクエリ元に返送
Stdout(output) => reply_tx.send(output).await.unwrap(),
Stderr(output) | Error(output) => {
eprintln!("{}", output);
break;
}
_ => break,
}
}
});
}
fn main() {
// クエリ用チャネル
let (rev_input_tx, rev_input_rx) = channel(32);
setup_rev_command(rev_input_rx);
tauri::Builder::default()
.invoke_handler(tauri::generate_handler![greet])
// リソース登録
.manage(rev_input_tx)
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
TypeScriptの時同様、一度立ち上げた子プロセスを使い回せるように書いています。
長すぎて見るところが多いかもしれません。これはTauriが裏で非同期ランタイム(tokio
)を動かしていることが影響しています。
とりあえずtauri::api::Command::spawn
が返している値を抑えておけば大丈夫です。
-
rev_event_rx
: 出力イベントを受け取るためのmpsc
(マルチプロデューサシングルコンシューマ) チャンネルのレシーバ- TypeScriptで
stdout.on
イベントハンドラを設けた辺りの処理に近いです。
- TypeScriptで
-
child
: 子プロセスのハンドラ- TypeScriptの場合とほぼ同じです。標準入力に使用します。
mpsc
を使用しているのは、非同期プログラミングにおいて、リソースを使いまわしながらの入出力の操作に都合が良いためです。Go言語のチャネルと同じ役割のものと言えば伝わりやすいでしょうか?
Command
やChild
の詳細や、spawn
の返り値の一つがレシーバなことは、別解として示している次節以降の「Tauri抜きの、Rust単体でのCLIアプリの呼び出し方」を見ていくとより理解が深まると思います。
次節以降を読んでから本節のTauriが用意している方法を振り返ると、これでも洗練されたインターフェースであることがわかります。
RustからCLIアプリを呼び出す【別解】
本節では別な方法として、Tauriとは無関係に、一般的なアプローチであるRustからコマンドを子プロセスとして呼び出す方法を解説します!
筆者は元々TauriがCLIアプリを呼び出す機能を提供してくれていることを知らず、Rinna GPT GUIアプリも本節の方法で実装を行いました()
tokio::process::Command
での書き方はtarui::api::process::Command
に通じる部分があります。本節はその点で理解の助けになるでしょう。
Command
構造体関連の話について、呼び出しに欲しい機能は紹介しますが、それ以外の詳細なオプション等は省くので、気になった方は適宜公式ドキュメント等を参照してください。
呼び出し方1: std::process::Command
Rustが子プロセスを呼ぶ最も基本的な方法で、真っ先に思い浮かぶのがこのstd::process::Command
になります。
なおとりあえず実行ファイルはカレントディレクトリ(実行ディレクトリ)にあるものとして解説しますが、Tauriで利用する場合細工が必要です。後述
使い方 例1: コマンドライン引数で渡し、結果を受け取る
コマンドライン引数を与え、それに対応する出力を得て終了する場合はとてもシンプルです。
use std::process::Command;
fn main() {
// newでプログラム・コマンド指定
let output = Command::new("./rev")
// コマンドライン引数指定
.arg("Hello, world!")
// 処理完了を待ち出力を取得
.output()
.unwrap();
// output.stdoutはVec<u8>型(バイト列)なので、文字列に変換して出力
println!("{}", String::from_utf8_lossy(&output.stdout));
}
実行結果
!dlrow ,olleH
コマンドライン引数は、arg
やargs
メソッドで指定します。その後output
メソッドを呼び出すことで、処理の完了を待ち、Output
を得ます。stdout
にVec<u8>
で標準出力が格納されています。
tauri::api::process::Command
とほぼ同じ方法で呼び出しています!Tauriの方がstd::process::Command
等に合わせて設計しているのだと考えられます。
使い方 例2: 入力をパイプする
標準入出力をパイプすることで、それぞれstd::io::Read
やstd::io::Write
を実装する入出力ストリームを介して、標準入出力の読み書きが可能になります!
とりあえず入力ストリームを取り出す場合は次のようになります。
use std::io::Write;
use std::process::{Command, Stdio};
fn main() {
let mut child = Command::new("./rev")
// stdin, stdoutを後で取り出せるように、Stdio::piped()を指定する
.stdin(Stdio::piped())
.stdout(Stdio::piped())
// 子プロセスとして起動
.spawn()
.expect("Failed to spawn child process");
{
// パイプの取り出し
let mut stdin = child.stdin.take().expect("Failed to open stdin");
// ChildStdinはstd::io::Writeを実装しているので、write_allが使える
stdin
.write_all("Hello, world!\n".as_bytes())
.expect("Failed to write to stdin");
stdin
.write_all("こんにちわ世界!".as_bytes())
.expect("Failed to write to stdin");
}
// 処理完了を待ち出力を取得
let output = child.wait_with_output().expect("Failed to read stdout");
// output.stdoutはVec<u8>型(バイト列)なので、文字列に変換して出力
println!("{}", String::from_utf8_lossy(&output.stdout));
}
実行結果
!dlrow ,olleH
!界世わちにんこ
output
メソッドで出力を待つのではなく、spawn
メソッドで立ち上げている点に注意してください。こうすることでパイプを取り出せ(child.stdin.take
でき)ます。
stdin
がドロップする際にCLI側にEOF
が送信されます(Rust製CLIの場合、例えばread_line
メソッドが返す読み取り長さが0
であればEOF
と判断できます)。
EOF
を受け取ったCLIアプリが閉じるのを、Child::wait_with_output
メソッドで待ち、Output
を得ています。
使い方 例3: 出力もパイプする
出力ストリームも取り出す場合は次のようになります。
use std::io::{BufRead, BufReader, Write};
use std::process::{Command, Stdio};
fn main() {
let mut child = Command::new("./rev")
// stdin, stdoutを後で取り出せるように、Stdio::piped()を指定する
.stdin(Stdio::piped())
.stdout(Stdio::piped())
// 子プロセスとして起動
.spawn()
.expect("Failed to spawn child process");
{
// パイプの取り出し
// ChildStdinはstd::io::Writeを実装しているので、write_allが使える
let mut stdin = child.stdin.take().expect("Failed to open stdin");
// ChildStdoutはstd::io::Readを実装しているので、(BufReader等色々経て)read_lineが使える
let stdout = child.stdout.take().expect("Failed to open stdout");
let mut reader = BufReader::new(stdout);
// やり取り1回分
let mut func = |s: &str| {
// 改行付与
let s = format!("{}\n", s);
// CLIへ入力
stdin
.write_all(s.as_bytes())
.expect("Failed to write to stdin");
// 出力受け取り
let mut buf = String::new();
let _ = reader.read_line(&mut buf).unwrap();
// 改行削除
let buf = buf.trim();
println!("res: {:?}", buf);
};
func("Hello, world!");
func("こんにちわ世界!");
}
// 完了を待つ
child.wait().expect("Failed to read stdout");
}
実行結果
res: "!dlrow ,olleH"
res: "!界世わちにんこ"
std::io::Read
トレイトを実装していればstd::io::BufReader
で包むことでread_line
メソッドを呼び出せるようになり、行単位で出力を得ることができます。
なお、行単位ではなく出力を都度取得したい場合はstd::io::Read::read
メソッドを呼べば良いだけです。入出力ストリームのインターフェースを使えるのでtauri::api::process::Command
の時よりもとても自由度が高いです!
メリット
- 非同期処理不要です。
- 前述の通り、 入出力の記述自由度が高いです!
デメリット
- メリットを裏を返すと、非同期処理がある(
tokio
ランタイムを動かしている)場合には非推奨です。
Tauriではtokio
ランタイムが動いているので、もちろん非推奨です。そこで次節の tokio::process::Command
を使用します。
呼び出し方2: tokio::process::Command
Rustの非同期デファクトスタンダードライブラリtokio
にあるtokio::process::Command
を使うことで、std::process::Command
にあったデメリットを克服できます!
以下std::process::Command
の方の最後の例をtokio::process::Command
を使うように変更したものになります。先程と大きく異なる注目してほしい箇所にコメントを付けています。
use std::process::Stdio;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::process::Command;
// 非同期の入出力やり取りは大体mpscを使うと上手く記述可能
use tokio::sync::mpsc::channel;
#[tokio::main]
async fn main() {
let mut child = Command::new("./rev")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.spawn()
.expect("Failed to spawn child process");
{
// tokio::process::ChildStdinはtokio::io::AsyncWriteを実装しているので、write_allが使える
let mut stdin = child.stdin.take().expect("Failed to open stdin");
// tokio::process::ChildStdoutはtokio::io::AsyncReadを実装しているので、(BufReader等色々経て)read_lineが使える
let stdout = child.stdout.take().expect("Failed to open stdout");
let mut reader = BufReader::new(stdout);
// やり取り用チャンネル
let (input_tx, mut input_rx) = channel(32);
let (output_tx, mut output_rx) = channel(32);
// やり取り1回分
// として非同期ではasyncブロックを作る方が見通しが良い
let _handle = tokio::spawn(async move {
while let Some(s) = input_rx.recv().await {
let s = format!("{}\n", s);
stdin
.write_all(s.as_bytes())
.await
.expect("Failed to write to stdin");
let mut buf = String::new();
let _ = reader.read_line(&mut buf).await.unwrap();
let buf = buf.trim();
output_tx.send(buf.to_string()).await.unwrap();
}
});
input_tx.send("Hello, world!").await.unwrap();
let res = output_rx.recv().await.unwrap();
println!("res: {}", res);
input_tx.send("こんにちわ世界!").await.unwrap();
let res = output_rx.recv().await.unwrap();
println!("res: {}", res);
} // drop input_tx, output_tx
// 完了を待つ
child.wait().await.expect("Failed to read stdout");
}
サンプルのCargo.tomlと実行結果
[package]
name = "example"
version = "0.1.0"
edition = "2021"
[dependencies]
tokio = { version="1.28.2", features = ["full"]}
res: !dlrow ,olleH
res: !界世わちにんこ
大きく異なるのは、先程の入出力ストリームが非同期となっている点と、func
を作らずtokio::spawn
でタスクを生成しその中でwhile let
によるループを行っている点です。
この例だけを見た場合は、外側で(クロージャではなく)非同期関数を定義してそこに&mut stdin
等を渡すような書き方も考えられますが、リソースの使いまわし等を考えて実装していくと、先程も少し触れたtokio::sync::mpsc
を使ったほうが見通しが良くなることが多いです。
メリット
- 入出力の記述自由度が高い!
デメリット
- 入出力の記述がある程度煩雑になります。
Tauriで活用する
最後に、tokio::process::Command
を使用してTauriでコマンドを呼び出してみたいと思います!
resources機能
sidecar機能でCLIアプリをtauri.conf.json
に登録する必要があったのと同様、tauriアプリとしてビルドするには、実行ファイルをバンドルするための登録作業が必要になります。
今回、実行ファイルはリソース扱いなため、tauri.bundle.resources
への登録をします。具体的にはfs
の$RESOURCE
スコープの追加とbundle
へのファイルパス登録になります。
{
"tauri": {
"allowlist": {
"fs": {
"scope": ["$RESOURCE/*"]
}
},
"bundle": {
"resources": [
"rev.exe"
]
}
}
}
tauri.conf.json 全体
テンプレートのデフォルト設定に項目を追加した場合以下のような感じになります。余剰な部分は適宜読み替えてください。{
"build": {
"beforeDevCommand": "yarn dev",
"beforeBuildCommand": "yarn build",
"devPath": "http://localhost:1420",
"distDir": "../dist",
"withGlobalTauri": false
},
"package": {
"productName": "your_product_name",
"version": "0.0.0"
},
"tauri": {
"allowlist": {
"all": false,
"shell": {
"all": false,
"open": true
},
"fs": {
"scope": ["$RESOURCE/*"]
}
},
"bundle": {
"active": true,
"targets": "all",
"identifier": "com.tauri.dev",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
],
"resources": [
"rev.exe"
]
},
"security": {
"csp": null
},
"windows": [
{
"fullscreen": false,
"resizable": true,
"title": "your_product_name",
"width": 800,
"height": 600
}
]
}
}
sidecar同様、resources
に指定するファイルのパスはtauri.conf.json
があるディレクトリが起点、すなわちsrc-tauri
フォルダが起点になります。今回の場合、rev.exe
はsrc-tauri
フォルダに置いています。
実行ファイル名を変更しなくて良い分シンプルですが、ファイルパスの取得に&mut App
が必要になるためsidecar機能とは一長一短なところがあります。
tauri::command
のコマンド内でCLIアプリを挟んでみる
tauri::api::process::Command
同様、mpsc
を介した書き方になるため直感的な見た目をしていないです。
ここまでと同じように、greet
サンプルを魔改造して、入力された名前の逆順をrev
アプリ経由で取得するように書くと以下のようになります!
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
use std::process::Stdio;
use tauri::App;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::process::Command;
use tokio::sync::mpsc::{channel, Receiver, Sender};
// 魔改造greetコマンド
// Learn more about Tauri commands at https://tauri.app/v1/guides/features/command
#[tauri::command]
async fn greet(
name: &str, // 反転する文字列
rev_input_tx: tauri::State<'_, Sender<(String, Sender<String>)>>, // manageで管理されているSenderリソース
) -> Result<String, String> {
// 返送用チャンネル
let (rev_output_tx, mut rev_output_rx) = channel(32);
// revに送信
rev_input_tx
.send((name.to_string(), rev_output_tx))
.await
.map_err(|e| e.to_string())?;
// revの返送を受信
let Some(rev_name) = rev_output_rx.recv().await else {
return Err("Failed to receive reversed name".to_string());
};
// greet本来の処理
let res = format!("Hello, {}! You've been greeted from Rust!", rev_name);
Ok(res)
}
// revコマンドを起動する
// app: &mut Appが必要なのでsetupメソッド内で呼び出す
fn setup_rev_command(
app: &mut App, // Appハンドル
mut rev_input_rx: Receiver<(String, Sender<String>)>, // revクエリを受け付ける受信機
) {
// resourcesで管理されているrev.exeのパスを取得
let rev_path = app
.path_resolver()
.resolve_resource("rev.exe")
.expect("failed to resolve resource");
// コマンド起動
let mut child = Command::new(&rev_path)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.spawn()
.expect("Failed to spawn child process");
// パイプ取得
let mut stdin = child.stdin.take().expect("Failed to open stdin");
let stdout = child.stdout.take().expect("Failed to open stdout");
let mut reader = BufReader::new(stdout);
// 処理用タスク
tokio::spawn(async move {
// s: 反転対象
// tx: 反転結果を送信するチャンネル
while let Some((s, tx)) = rev_input_rx.recv().await {
let s = format!("{}\n", s);
// rev.exeに送信
stdin
.write_all(s.as_bytes())
.await
.expect("Failed to write to stdin");
let mut buf = String::new();
// rev.exeからの出力を受信
let _ = reader.read_line(&mut buf).await.unwrap();
let buf = buf.trim();
// 反転結果を送信
tx.send(buf.to_string()).await.unwrap();
}
});
}
#[tokio::main]
async fn main() {
// 今回全体としてtokioを使用するので
// tauri内で使う非同期ランタイムを設定
tauri::async_runtime::set(tokio::runtime::Handle::current());
// revコマンドに送信するチャンネル
let (rev_input_tx, rev_input_rx) = channel(32);
tauri::Builder::default()
.invoke_handler(tauri::generate_handler![greet])
// リソースの追加
.manage(rev_input_tx)
// setupメソッドでrevコマンドを起動
.setup(|app| {
setup_rev_command(app, rev_input_rx);
Ok(())
})
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
長い!!! 本来はこれぐらいの記述量が必要なのが、tauri::api::process::Command::new_sidecar
のお陰である程度削減できているのだと考えると、Tauriが提供してくれているAPIは洗練されていることがわかります。
sidecarの時との違いは色々ありますが、中でもリソース(rev.exe
)のファイルパスを得るためtauri::Builder::setup
メソッド内で子プロセスを起動する必要がある点に注意してください。ビルドのことを考えず、デバッグ時に参照できるからと適当なファイルパス取得を&mut App
がない場所で行うように書いていると後で泣くことになります(1敗)。
こちらで紹介した方法はCLIアプリの出力が行単位ではない場合に使えるので、抑えておいて損はないと思います。
まとめ・所感
今回は、TauriやRustからCLIアプリを呼び出すイロハを紹介しました!
実装当初Pythonアプリの組み込みにtokio::process::Command
を使っていた筆者はtauri::api::process::Command
を知った時に椅子から転げ落ちたのですが、起き上がって記事のネタにできて現在は満足しています()
今回、本機能からTauri開発チームの 野望【全CLIアプリGUI化計画】 を感じたのが本記事執筆のモチベーションだったりします。
というのも、TauriのロードマップにOther Bindingsという、別な言語をバックエンドにできるような機能があるのですが、本機能は直接は関係ないものの、そのアプローチの一つだと見ることもできそうです。実際、本機能を使うことで(筆者は使いませんでしたが...) PythonをTauriバックエンドで実行することができています2。
Tauriには今回紹介した機能以外にも面白い野望を持った機能が沢山あります。筆者は、Tauriがいつかニッチ技術ではなくなり流行る日が来ると信じております。
ここまで読んでいただき誠にありがとうございました!