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.externalBin | binaries/mytool |
capability の name | binaries/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 scopecommandvalue 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の再ビルドでは終了時の処理が走らず、サイドカーが残ることがあります。常駐させるものは、標準入力が閉じたら自分で終了する作りにしておくと確実です。
