LoginSignup
40
38
この記事誰得? 私しか得しないニッチな技術で記事投稿!

【全CLIアプリGUI化計画】TauriでTypeScriptやRustからCLIアプリを呼び出す

Posted at

本記事は上司「オンプレでチャットAI作って」 - Qiitaの続きにあたる記事です!

同記事では、PythonでRinna社提供のLLM rinna/japanese-gpt-neox-3.6b-instruction-sft を動かすことで、ローカルで動作する対話AIソフトを作成し、PyInstallerによって実行ファイル化した話までをしました。ここまではCLI (コマンドラインインターフェース)1 のお話でした。

本記事では下記GIFのように、CLIアプリをTauriから呼び出して使用することで、GUI (グラフィカルユーザーインターフェース) アプリへと変化させる方法を解説します!

rinna3.gif

リポジトリ: 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はこれから作るよ!という方

※具体的には、入出力が行単位で行われるCLIアプリです。

CLIの出力が複雑になってしまうなら以下の方法のほうが良いと思います。

  • (手を加えるのが難しい)動かしたいCLIアプリはもうあるよ!という方

本記事で扱うCLIアプリ

解説のため今回扱うCLIアプリの用意をします。直接的なコンテンツではありませんがお付き合いください。前回記事最後に作成したRinna GPT CLIアプリの代替として、オウム返しするダミー(スタブ)アプリを使います。

返答であることがわかりやすいように、受け取った入力を逆順で返すことにします。bashに標準で存在するrevコマンドと同じ仕様です。

仕様

  • 入力:
    • 方法1: 文字列を標準入力で1行ずつ受け取る (例: abcd)
    • 方法2: コマンドラインで指定する (例: $ rev abcd)
  • 出力: 入力を逆順にしたものを標準出力し改行する (例: dcba)
    • 方法1で入力する場合、Ctrl+Cが押されるまで(EOFまで)ループ
    • 方法2で入力する場合、1度で終了

以下にRustプログラムを置いておきます。本題ではないので折りたたんでおきます。

ダミー用アプリ rev
rev/src/main.rs
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.
Cargo.toml(一部抜粋)
[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.conf.json (一部抜粋)
{
  "tauri": {
    "allowlist": {
      "shell": {
        "sidecar": true,
        "scope": [
          {
            "name": "rev",
            "sidecar": true,
            "args": true
          }
        ]
      }
    },
    "bundle": {
      "externalBin": [
        "rev"
      ]
    },
  }
}
tauri.conf.json 全体 テンプレートのデフォルト設定に項目を追加した場合以下のような感じになります。余剰な部分は適宜読み替えてください。
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です。

Windowsの場合の出力例
> rustc -Vv | Select-String "host:" | ForEach-Object {$_.Line.split(" ")[1]}
x86_64-pc-windows-msvc
Linux等bashの場合の出力例
$ 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関数に細工をする形で使い方を示そうと思います。

デフォルトテンプレートの内容と実行スクショ
src/App.tsx
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;

image.png

コマンドライン引数で渡して実行する場合

今回紹介する方法の中では最もシンプルな方法になります。コマンドライン引数で反転させたい文字列を渡して実行する場合、次のようになります。

TypeScript
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);
}

Commandsidecarメソッドの第一引数にCLIアプリ名を、第二引数にコマンドライン引数を渡し、executeメソッドを実行して出力を待つだけです。簡単にCLIアプリを組み込めましたね!

標準入出力でやり取りする場合

コマンドライン引数だけでは完結しない対話型アプリもあるでしょう。筆者が用意したRinna GPTのCLIアプリも、最初の立ち上げに時間がかかるためコマンドライン引数ではなく標準入力で入力を受け取るように設計しています。

TypeScript
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));
  }

  // 省略...
}

Commandspawnメソッドを呼び出して起動し、Childを取得しています。この時CLIアプリは子プロセスとして呼び出されています。CLIアプリ子プロセスは1度起動したら使い回したいので、Reactを使っている本構成ではuseRefuseEffectを使い最初だけ初期化されるようにしています。

Childwriteメソッドで文字列を標準入力でき、command.stdout.onメソッドで1行標準出力があった時のイベントハンドラ内で、得られた標準出力を結果として用いることができます。

TS、Rust関係なく、標準出力が行単位ではないアプリで細かい出力取得ができる方法は調べた感じ見つかりませんでした。標準出力が複雑な場合は、後述しているtokio::process::Commandを使用する方法を検討してみてください。

Rustから呼び出す: tauri::api::process::Command

#[tauri::command]なRust関数からCLIアプリを呼び出したい時などもあるでしょう。標準入出力の方はTypeScriptより少し記述が増えますが、こちらも比較的簡単に(当社比)記述することが可能です!

TypeScriptの時同様、今回はテンプレートデフォルトで付いてくるgreet関数を書き換える形で示します。

デフォルトテンプレートの内容
src-tauri/src/main.rs
// 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とほぼ同じ記述量で楽に呼び出せます!

src-tauri/src/main.rs (一部抜粋)
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関数以外にも細工が要るので全体を載せます。いくぞ!

src-tauri/src/main.rs
// 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イベントハンドラを設けた辺りの処理に近いです。
  • child: 子プロセスのハンドラ
    • TypeScriptの場合とほぼ同じです。標準入力に使用します。

mpscを使用しているのは、非同期プログラミングにおいて、リソースを使いまわしながらの入出力の操作に都合が良いためです。Go言語のチャネルと同じ役割のものと言えば伝わりやすいでしょうか?

CommandChildの詳細や、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: コマンドライン引数で渡し、結果を受け取る

コマンドライン引数を与え、それに対応する出力を得て終了する場合はとてもシンプルです。

Rust
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

コマンドライン引数は、argargsメソッドで指定します。その後outputメソッドを呼び出すことで、処理の完了を待ち、Outputを得ます。stdoutVec<u8>で標準出力が格納されています。

tauri::api::process::Commandとほぼ同じ方法で呼び出しています!Tauriの方がstd::process::Command等に合わせて設計しているのだと考えられます。

使い方 例2: 入力をパイプする

標準入出力をパイプすることで、それぞれstd::io::Readstd::io::Writeを実装する入出力ストリームを介して、標準入出力の読み書きが可能になります!

とりあえず入力ストリームを取り出す場合は次のようになります。

Rust
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: 出力もパイプする

出力ストリームも取り出す場合は次のようになります。

Rust
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を使うように変更したものになります。先程と大きく異なる注目してほしい箇所にコメントを付けています。

Rust
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と実行結果
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.conf.json (一部抜粋)
{
  "tauri": {
    "allowlist": {
      "fs": {
        "scope": ["$RESOURCE/*"]
      }
    },
    "bundle": {
      "resources": [
        "rev.exe"
      ]
    }
  }
}
tauri.conf.json 全体 テンプレートのデフォルト設定に項目を追加した場合以下のような感じになります。余剰な部分は適宜読み替えてください。
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.exesrc-tauriフォルダに置いています。

実行ファイル名を変更しなくて良い分シンプルですが、ファイルパスの取得に&mut Appが必要になるためsidecar機能とは一長一短なところがあります。

tauri::commandのコマンド内でCLIアプリを挟んでみる

tauri::api::process::Command同様、mpscを介した書き方になるため直感的な見た目をしていないです。

ここまでと同じように、greetサンプルを魔改造して、入力された名前の逆順をrevアプリ経由で取得するように書くと以下のようになります!

main.rs
// 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がいつかニッチ技術ではなくなり流行る日が来ると信じております。

ここまで読んでいただき誠にありがとうございました!

  1. CUI (キャラクタユーザインターフェース) とも言いますよね。どっちでも良いと思います(宗教)。本記事ではCLIで統一させていただいております。

  2. 「PyInstallerで実行ファイル化したものだろ嘘つき!」というご指摘はごもっともですが、これはTauriというよりは配布手段に乏しいPythonの問題だと思うので誇張表現ではないと考えています。

40
38
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
40
38