ビルドのログ、変換の進み具合、ping の応答のように、コマンドが出力した行をその場で画面に出したいときは、shell プラグインの spawn() で起動し、stdout / stderr の data イベントで受け取ります。execute() は終了するまで何も返さないので、この用途には向きません(まとめて受け取る方法は コマンドの標準出力・エラー出力を取得する)。ここでは 1 回の data に何が入るか、進捗表示の \r、文字コード、そして「リアルタイムにならない」原因を中心に説明します。プロセスを途中で止める方法は 時間のかかるプロセスを管理・強制終了する で扱います。
前提条件
shell プラグインを追加します。
npm run tauri add shell
spawn() には shell:allow-spawn が必要で、shell:default には含まれません。例では ping の回数指定(Windows は -n、macOS / Linux は -c)・回数・宛先だけを許可します。allow の書き方の基本は 外部コマンド(ls/dir等)を実行する を参照してください。
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Capability for the main window",
"windows": ["main"],
"permissions": [
"core:default",
"shell:default",
{
"identifier": "shell:allow-spawn",
"allow": [
{
"name": "ping",
"cmd": "ping",
"args": [{ "validator": "-[nc]" }, { "validator": "\\d{1,2}" }, { "validator": "[\\w.-]+" }]
}
]
}
]
}
1. フロントエンドから実装する (TypeScript)
届くイベントと、1 回の data に入るもの
| 登録先 | 届くもの |
|---|---|
cmd.stdout.on('data') | 標準出力の 1 行 |
cmd.stderr.on('data') | 標準エラー出力の 1 行 |
cmd.on('close') | 終了コード code とシグナル signal。最後に 1 回 |
cmd.on('error') | 読み取りの失敗(文字コードが合わない行など) |
- 1 回の
dataは、改行(\n)か復帰(\r)までの 1 行で、区切り文字が付いたまま届きます。Windows のコマンドなら末尾は\r\nです。そのままconsole.logすると空行が挟まるので、表示前に取り除きます。 - リスナーは
spawn()の前に登録します。届いた時点でリスナーが無いイベントは捨てられ、後から登録しても最初の数行は戻りません。 errorはその行を読めなかったことを表すだけで、プロセスは動き続けていることが多いです。終わったかどうかはcloseだけで判断します。stdoutとstderrは別々に読まれるので、両者の順番がプログラムの出力順どおりになるとは限りません。
ログを画面に流す
進捗を 1 行で書き換えるプログラムは、行末に改行ではなく \r を出して同じ行を上書きしています。\r で終わった行を次の行で置き換えれば、ターミナルと同じ見た目になります。出力が多いと 1 行ごとの DOM 更新が重くなるので、描画 1 回分にまとめています。
import { Command, type TerminatedPayload } from '@tauri-apps/plugin-shell';
const view = document.querySelector<HTMLPreElement>('#log')!;
let lines: string[] = [];
let overwriteLast = false; // 直前の行が \r で終わった(進捗表示)なら次の行で上書きする
let scheduled = false;
function push(chunk: string) {
const endsWithCR = chunk.endsWith('\r');
const text = chunk.replace(/\r?\n$|\r$/, ''); // 区切り文字を落とす
const latest = text.split('\r').pop() ?? ''; // "10%\r20%" とまとめて届いたら最後の状態だけ残す
if (overwriteLast && lines.length > 0) lines[lines.length - 1] = latest;
else lines.push(latest);
overwriteLast = endsWithCR;
if (scheduled) return;
scheduled = true;
requestAnimationFrame(() => {
scheduled = false;
if (lines.length > 2000) lines = lines.slice(-2000); // 古い行は捨てる
view.textContent = lines.join('\n');
view.scrollTop = view.scrollHeight;
});
}
export function runPing(host: string): Promise<TerminatedPayload> {
const isWindows = navigator.userAgent.includes('Windows');
const cmd = Command.create(
'ping',
[isWindows ? '-n' : '-c', '5', host],
isWindows ? { encoding: 'shift_jis' } : undefined, // 日本語版 Windows の ping は Shift_JIS で出力する
);
// spawn() より前に登録する
cmd.stdout.on('data', push);
cmd.stderr.on('data', (line) => push(`[stderr] ${line}`));
cmd.on('error', (message) => console.warn('読み取りエラー:', message));
return new Promise((resolve, reject) => {
cmd.on('close', (payload) => {
push(`--- 終了 (code=${payload.code})\n`);
resolve(payload); // 終わるまで await で待てるようにする
});
cmd.spawn().catch(reject); // 権限や範囲のエラーはここに来る
});
}
文字コードを合わせる
data の文字列は、既定では出力を UTF-8 として読んだものです。日本語版の Windows では ping などの OS 付属コマンドや PowerShell が Shift_JIS で出力するため、日本語を含む行は data ではなく error に「invalid utf-8 sequence of 1 bytes from index 10」のような文面で届き、画面に出ません。Command.create() の 3 番目の引数に encoding を渡すと、その文字コードで読みます。指定できるのは shift_jis や windows-31j、utf-16le のような Web 標準の名前で、cp932 は通りません。PowerShell 側の出力を UTF-8 に変える方法は PowerShell スクリプトを実行する で扱います。
encoding: 'raw' にすると文字列にせずバイト列のまま受け取れますが、行では区切られず、届いたかたまりごとの data になります。
リアルタイムにならないとき
ターミナルでは 1 行ずつ出るのに、アプリでは終了時にまとめて届くことがあります。多くのプログラムは、出力先がターミナルでないと出力をため込み、終了時や一定量ごとにまとめて書き出すためです。Python なら python -u か環境変数 PYTHONUNBUFFERED=1、自作のプログラムなら行ごとにフラッシュするよう直します。
2. バックエンドから実装する (Rust)
Rust ではプラグインの app.shell().command() で起動すると、Stdout / Stderr / Terminated のイベントを受け取る Receiver が返ります。行の区切り方は JS と同じですが、中身は文字列ではなくバイト列なので、自分で文字列にします。例では UTF-8 として読めない行を Shift_JIS として読み直しています。
フロントエンドへは、コマンドの引数で受け取った Channel で送ります。Rust からのイベントを受信する (listen) で扱う emit() でも送れますが、Channel はその呼び出し専用で送った順に届くので、1 回の実行の出力を流す用途に向いています。
use serde::Serialize;
use tauri::ipc::Channel;
use tauri_plugin_shell::process::{CommandEvent, Encoding};
use tauri_plugin_shell::ShellExt;
/// フロントエンドへ送る 1 行分
#[derive(Clone, Serialize)]
#[serde(tag = "kind", content = "data", rename_all = "camelCase")]
enum LogEvent {
Stdout(String),
Stderr(String),
}
/// UTF-8 として読めない行は Shift_JIS(日本語版 Windows の既定)として読む
fn decode_line(bytes: &[u8]) -> String {
let text = match std::str::from_utf8(bytes) {
Ok(s) => s.to_string(),
Err(_) => match Encoding::for_label(b"shift_jis") {
Some(enc) => enc.decode(bytes).0.into_owned(),
None => String::from_utf8_lossy(bytes).into_owned(),
},
};
text.trim_end_matches(['\r', '\n']).to_string()
}
#[tauri::command]
async fn stream_ping(
app: tauri::AppHandle,
host: String,
on_event: Channel<LogEvent>,
) -> Result<Option<i32>, String> {
// Rust からの起動は capability の範囲に縛られないので、値はここで確かめる
if host.is_empty() || !host.chars().all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-') {
return Err("invalid host".into());
}
let count_flag = if cfg!(windows) { "-n" } else { "-c" };
let (mut rx, _child) = app
.shell()
.command("ping")
.args([count_flag, "5", host.as_str()])
.spawn()
.map_err(|e| e.to_string())?;
let mut code = None;
// 出力をすべて読み終えると None が返ってループが終わる
while let Some(event) = rx.recv().await {
match event {
CommandEvent::Stdout(bytes) => on_event
.send(LogEvent::Stdout(decode_line(&bytes)))
.map_err(|e| e.to_string())?,
CommandEvent::Stderr(bytes) => on_event
.send(LogEvent::Stderr(decode_line(&bytes)))
.map_err(|e| e.to_string())?,
CommandEvent::Terminated(payload) => code = payload.code,
_ => {}
}
}
Ok(code) // 終了コードは invoke の戻り値にする
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_shell::init())
.invoke_handler(tauri::generate_handler![stream_ping])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
フロントエンドでは Channel を作って引数に渡します。Rust の on_event は JS では onEvent になります。invoke() の Promise はプロセスが終わった後に解決します。
import { Channel, invoke } from '@tauri-apps/api/core';
type LogEvent = { kind: 'stdout' | 'stderr'; data: string };
const onEvent = new Channel<LogEvent>();
onEvent.onmessage = ({ kind, data }) => console.log(`[${kind}] ${data}`);
const code = await invoke<number | null>('stream_ping', { host: '127.0.0.1', onEvent });
console.log('exit code:', code);
動作確認
index.html に <pre id="log"></pre> を置き、npm run tauri dev で起動して runPing('127.0.0.1') を呼びます。日本語版 Windows では、応答の行が 1 秒ごとに増えていきます。
127.0.0.1 に ping を送信しています 32 バイトのデータ:
127.0.0.1 からの応答: バイト数 =32 時間 <1ms TTL=128
127.0.0.1 からの応答: バイト数 =32 時間 <1ms TTL=128
...
--- 終了 (code=0)
試しに encoding の指定を外すと、応答の行は表示されず(空行と終了の行だけになり)、DevTools のコンソールに「読み取りエラー: invalid utf-8 sequence of 1 bytes from index 10」が出ます。
よくあるエラーと対処法
- 最初の数行だけ表示されない:
spawn()の後でリスナーを登録しています。on()を先に並べ、最後にspawn()を呼びます。 - data が来ず、error に「invalid utf-8 sequence of 1 bytes from index …」が届く: 出力の文字コードが UTF-8 ではありません。
encodingを指定するか、コマンド側の出力を UTF-8 にします。 - spawn() が「unknown encoding cp932」で失敗する:
encodingの名前が認識されていません。shift_jisかwindows-31jを使います。 - 終了するまで何も届かない: 起動したプログラムが出力をため込んでいます(上の「リアルタイムにならないとき」)。
- 「shell.spawn not allowed. Permissions associated with this command: shell:allow-spawn」: 権限の追加漏れです(リリースビルドでは「Command plugin:shell|spawn not allowed by ACL」)。
OS ごとの違いと注意点
- Windows: 日本語版では OS 付属のコマンドの多くが Shift_JIS で出力しますが、Node.js のように UTF-8 で出力するものもあるので、コマンドごとに確かめて
encodingを決めます。 - macOS / Linux:
pingは回数を-cで指定しないと止まりません。止まらないコマンドを止める方法は 時間のかかるプロセスを管理・強制終了する を参照してください。 - 共通: 1 行の区切りは改行か
\rなので、改行を出さずに同じ行へ書き足していくプログラムは、次の改行まで何も届きません。
