同梱したバイナリファイルを実行する

Rust や Go で作った CLI を bundle.externalBin で同梱し、Command.sidecar() と Rust の sidecar() で実行する。ファイル名の規則、権限のスコープ、終了時の後始末も示す。

外部プロセス 対象: Tauri 2.x 更新日: 読了目安: 約10分 shell-012
目次
  1. 前提条件
  2. サイドカーを配置して登録する
  3. ファイル名にターゲットトリプルを付ける
  4. tauri.conf.json に登録する
  5. 1. フロントエンドから実装する (TypeScript)
  6. 2. バックエンドから実装する (Rust)
  7. 動作確認
  8. よくあるエラーと対処法
  9. OS ごとの違いと注意点
  10. 関連レシピ

Rust や Go で書いた CLI ツールなど、アプリと一緒に配る実行ファイルを Tauri では「サイドカー(sidecar)」と呼びます。tauri.conf.json の bundle.externalBin に登録するとインストーラーに同梱され、Shell プラグインの Command.sidecar()(JS)や sidecar()(Rust)で起動できます。つまずきやすいのは、ファイル名・設定・権限・呼び出しで書く名前が少しずつ違う点です。Python や Node.js も実行ファイルにすれば同じ仕組みで動きます(Python、Node.js)。

前提条件

npm run tauri add shell

JS から起動するには、権限と「どのサイドカーを、どの引数で」というスコープを capability に書きます。shell:default は URL を開く shell:allow-open だけで、core:default にも Shell の権限はありません。execute() には shell:allow-execute、spawn() には shell:allow-spawn が要り、それぞれの name に externalBin と同じ文字列を書いて "sidecar": true を付けます。

{
  "$schema": "../gen/schemas/desktop-schema.json",
  "identifier": "default",
  "description": "Capability for the main window",
  "windows": ["main"],
  "permissions": [
    "core:default",
    {
      "identifier": "shell:allow-execute",
      "allow": [
        { "name": "binaries/mytool", "sidecar": true, "args": ["--json", { "validator": ".+" }] }
      ]
    },
    {
      "identifier": "shell:allow-spawn",
      "allow": [
        { "name": "binaries/mytool", "sidecar": true, "args": ["serve", "--port", { "validator": "\\d{1,5}" }] }
      ]
    },
    "shell:allow-kill"
  ]
}

args が true なら引数は何でも通ります。配列にすると先頭から順に、文字列はその位置の固定値、validator はその位置の値を検査する正規表現(前後に ^ と $ が自動で付く)です。args を省略すると、JS から渡した引数はエラーにならずに捨てられます。Rust から起動するだけなら capability は不要です。

サイドカーを配置して登録する

ファイル名にターゲットトリプルを付ける

externalBin に "binaries/mytool" と書くと、ビルド時に src-tauri/binaries/mytool-<ターゲットトリプル>(Windows は末尾に .exe)が探されます。手元のトリプルは rustc -Vv の host: 行で分かり、置くのはビルドする対象の分だけで足ります。

実行する環境置くファイル名
Windows (x64)mytool-x86_64-pc-windows-msvc.exe
macOS (Apple Silicon)mytool-aarch64-apple-darwin
macOS (Intel)mytool-x86_64-apple-darwin
Linux (x64)mytool-x86_64-unknown-linux-gnu

Rust 製のツールなら、ビルドしてトリプル付きの名前でコピーします。

# Windows (PowerShell)
cargo build --release --manifest-path tools/mytool/Cargo.toml
$triple = (rustc -Vv | Select-String "host:").Line.Split(" ")[1]
Copy-Item tools/mytool/target/release/mytool.exe "src-tauri/binaries/mytool-$triple.exe"
# macOS / Linux(実行権限も付ける)
cargo build --release --manifest-path tools/mytool/Cargo.toml
cp tools/mytool/target/release/mytool "src-tauri/binaries/mytool-$(rustc -Vv | grep host | cut -f2 -d' ')"
chmod +x src-tauri/binaries/mytool-*

tauri.conf.json に登録する

{
  "bundle": {
    "externalBin": ["binaries/mytool"]
  }
}

パスは src-tauri/ からの相対で、トリプルと拡張子は書きません。ビルドするとトリプルを除いた mytool(mytool.exe)がアプリ本体の実行ファイルと同じフォルダにコピーされ、実行時はそこから探されます。読むだけのデータは bundle.resources でリソースとして同梱します。書く場所ごとの名前の形は次のとおりです。

書く場所書く値
bundle.externalBinbinaries/mytool
capability の namebinaries/mytool
JS の Command.sidecar()binaries/mytool
Rust の sidecar()mytool(ファイル名だけ)

1. フロントエンドから実装する (TypeScript)

以下の mytool は、--json <ファイル> で JSON を出力し、serve --port <番号> で待ち受けを始めると ready で始まる行を出す自作ツールという想定です。

import { Command } from '@tauri-apps/plugin-shell';

// mytool --json <file> を実行し、標準出力の JSON を受け取る
export async function inspectFile(path: string): Promise<unknown> {
  const output = await Command.sidecar('binaries/mytool', ['--json', path]).execute();
  if (output.code !== 0) {
    throw new Error(output.stderr.trim() || `mytool exited with code ${output.code}`);
  }
  return JSON.parse(output.stdout);
}

execute() は終了を待って出力をまとめて返し、終了コードが 0 以外でも例外にはならないので code で判定します(詳細は コマンドの標準出力を取得する)。終わらないサーバーは spawn() で起動し、準備完了の行を待ちます。

import { Command, Child } from '@tauri-apps/plugin-shell';

// mytool serve --port <port> を起動し、"ready" の行が出るまで待つ
export async function startServer(port: number): Promise<Child> {
  const cmd = Command.sidecar('binaries/mytool', ['serve', '--port', String(port)]);
  const ready = new Promise<void>((resolve, reject) => {
    cmd.stdout.on('data', (line) => {
      if (line.startsWith('ready')) resolve();
    });
    cmd.on('close', ({ code }) => reject(new Error(`mytool exited early (code ${code})`)));
    cmd.on('error', (e) => reject(new Error(e)));
  });
  const child = await cmd.spawn(); // shell:allow-spawn が必要
  await ready;
  return child; // 止めるときは child.kill()(shell:allow-kill が必要)
}

JS から spawn() したプロセスは、アプリの終了時にプラグインが止めます。行ごとの受け取りは 出力をリアルタイムで受け取る、止め方は 時間のかかるプロセスを管理する を参照してください。

2. バックエンドから実装する (Rust)

Rust の sidecar() は capability の検査を受けず、ファイル名だけを渡します。spawn() が返すチャンネル(rx)は読み続けてください。読まずに持っていると出力が詰まり、サイドカーが止まります。また、Rust から spawn() したプロセスは、アプリ終了時にプラグインが止めてくれません。State に持っておき、RunEvent::Exit で kill() します。

use std::sync::Mutex;
use tauri::{Manager, RunEvent};
use tauri_plugin_shell::process::{CommandChild, CommandEvent};
use tauri_plugin_shell::ShellExt;

/// 常駐させたサイドカーを覚えておく
struct Server(Mutex<Option<CommandChild>>);

#[tauri::command]
async fn inspect_file(app: tauri::AppHandle, path: String) -> Result<String, String> {
    let output = app
        .shell()
        .sidecar("mytool") // "binaries/mytool" ではなくファイル名だけ
        .map_err(|e| e.to_string())?
        .args(["--json", path.as_str()])
        .output()
        .await
        .map_err(|e| e.to_string())?;
    if output.status.success() {
        Ok(String::from_utf8_lossy(&output.stdout).into_owned())
    } else {
        Err(String::from_utf8_lossy(&output.stderr).trim().to_string())
    }
}

fn start_server(app: &tauri::AppHandle) -> Result<(), Box<dyn std::error::Error>> {
    let (mut rx, child) = app
        .shell()
        .sidecar("mytool")?
        .args(["serve", "--port", "8765"])
        .spawn()?;
    app.state::<Server>().0.lock().unwrap().replace(child);
    tauri::async_runtime::spawn(async move {
        while let Some(event) = rx.recv().await {
            match event {
                CommandEvent::Stdout(line) => println!("[mytool] {}", String::from_utf8_lossy(&line).trim_end()),
                CommandEvent::Stderr(line) => eprintln!("[mytool] {}", String::from_utf8_lossy(&line).trim_end()),
                CommandEvent::Terminated(p) => println!("[mytool] exited with {:?}", p.code),
                _ => {}
            }
        }
    });
    Ok(())
}

#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    tauri::Builder::default()
        .plugin(tauri_plugin_shell::init())
        .manage(Server(Mutex::new(None)))
        .setup(|app| {
            start_server(app.handle())?;
            Ok(())
        })
        .invoke_handler(tauri::generate_handler![inspect_file])
        .build(tauri::generate_context!())
        .expect("error while building tauri application")
        .run(|app, event| {
            // Rust から起動したサイドカーはプラグインが止めないので、ここで止める
            if let RunEvent::Exit = event {
                if let Some(child) = app.state::<Server>().0.lock().unwrap().take() {
                    let _ = child.kill();
                }
            }
        });
}
import { invoke } from '@tauri-apps/api/core';

const json = await invoke<string>('inspect_file', { path: 'C:\\data\\sample.png' });
console.log(JSON.parse(json));

動作確認

npm run tauri dev で起動すると、ビルド中にサイドカーが src-tauri/target/debug/mytool.exe(macOS / Linux は mytool)へコピーされます。inspectFile() を呼ぶと mytool の JSON がオブジェクトで表示され、Rust 版ではターミナルにサイドカーの出力が流れます。

[mytool] ready on 127.0.0.1:8765

アプリを閉じたあと、タスクマネージャー(macOS はアクティビティモニタ)に mytool が残っていなければ後始末もできています。

よくあるエラーと対処法

  • ビルドが「resource path … doesn't exist」で止まる: トリプル付きのファイルがありません。綴りと、Windows では .exe の有無を確かめます。
  • 「sidecar not configured under tauri.conf.json > bundle > externalBin: mytool」: JS にファイル名だけを渡しています。binaries/mytool にします(逆に Rust へパスを渡すと、ファイルが見つからない趣旨の OS エラー)。
  • 「Scoped command binaries/mytool not found」: 使った権限のスコープにその name がありません(権限を文字列だけで書いた場合も同じ)。"sidecar": true を書き忘れると、スコープを読めずに「The shell scope command value is required.」を含むエラーになります。
  • 「shell.execute not allowed. Permissions associated with this command: shell:allow-execute」: 権限そのものがありません。リリースビルドでは「Command plugin:shell|execute not allowed by ACL」だけになります。
  • 「Cannot define a sidecar with the same name as the Cargo package name」: サイドカー名が src-tauri/Cargo.toml のパッケージ名と同じです。ファイル名と externalBin を変えます。
  • Windows で再ビルドが「アクセスが拒否されました」という趣旨のエラーで止まる: 前回のサイドカーが残っていて、コピー先を置き換えられません。タスクマネージャーで終了させます。

OS ごとの違いと注意点

  • Windows: .exe は自動で補われ、サイドカーはコンソールウィンドウを出さずに起動します。
  • macOS / Linux: 元ファイルの実行権限がそのまま引き継がれます。chmod +x を忘れると、実行権限がないという趣旨のエラー(os error 13)になります。
  • iOS / Android: Shell プラグインがモバイルで提供するのは URL を開く機能だけで、サイドカーは使えません。
  • 作業ディレクトリ: アプリ本体のものを引き継ぎ、起動方法によって変わります。ファイルは絶対パスで渡すか、第 3 引数の { cwd } で指定します。
  • 後始末: 強制終了や tauri dev の再ビルドでは終了時の処理が走らず、サイドカーが残ることがあります。常駐させるものは、標準入力が閉じたら自分で終了する作りにしておくと確実です。

関連レシピ

参考リンク(公式ドキュメント)

Web Ninja

この記事を書いた人

Web Ninja ウェブエンジニア (Web Engineer)

会社員ネットワークエンジニアから独立してかれこれ 25 年以上 Web エンジニアとして活動中。普段は JavaScript と Node.js を自在に操り、時には C++ や Perl といった古流の技も嗜みます。近年は Tauri × Rust という新たな武器を手に、デスクトップアプリ開発の最前線を駆け抜けています。「作りたい」を「作れる」に変えるための、実践的な「技」をお届けします。

お問い合わせ: tauri.ninja@gmail.com

内容の誤り・動かないコードを報告する