コマンドの出力をリアルタイムで受け取る

spawn() の stdout / stderr の data イベントで出力を 1 行ずつ受け取り、close で終了を知る。行末と \r の進捗表示の扱い、文字コード、Rust から Channel で送る方法も示す。

外部プロセス 対象: Tauri 2.x 更新日: 読了目安: 約10分 shell-009
目次
  1. 前提条件
  2. 1. フロントエンドから実装する (TypeScript)
  3. 届くイベントと、1 回の data に入るもの
  4. ログを画面に流す
  5. 文字コードを合わせる
  6. リアルタイムにならないとき
  7. 2. バックエンドから実装する (Rust)
  8. 動作確認
  9. よくあるエラーと対処法
  10. OS ごとの違いと注意点
  11. 関連レシピ

ビルドのログ、変換の進み具合、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 なので、改行を出さずに同じ行へ書き足していくプログラムは、次の改行まで何も届きません。

関連レシピ

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

Web Ninja

この記事を書いた人

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

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

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

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