工作機械や PLC、社内の独自サーバー、テキストでやり取りする各種サービスなど、HTTP ではなく生の TCP で話す相手とつなぐ場面があります。Webview の JavaScript には TCP ソケットを開く API がないので、Rust 側で tokio の TcpStream を使って通信し、結果を invoke の戻り値かイベントで画面に渡します。1 回の問い合わせで終わるならコマンドの中で接続を閉じ、相手からいつ届くか分からないデータを受けるなら接続を State に持って開きっぱなしにします。UDP は UDP パケット通信を行う、待ち受ける側(サーバー)は アプリ内にローカル Web サーバーを立てる で扱います。
前提条件
プラグインと capability の権限は不要です。自作のコマンドは、アプリ用の権限定義を作っていなければそのまま呼べます。受信の通知に使う listen の権限は core:default に含まれます。Tauri の非同期ランタイムは tokio なので、ランタイムを別に起動する必要はなく、ソケットなどの機能を有効にした tokio を依存に加えるだけです。
cd src-tauri
cargo add tokio --features net,io-util,time,sync
どちらの形にするかは、やり取りの性質で決めます。
| やり取り | 形 |
|---|---|
| 1 回送って 1 回応答を受ける(状態の問い合わせなど) | コマンドの中で接続して閉じる |
| 相手から任意のタイミングで届く(通知・ログ・計測値) | 接続を State に持ち、受信をイベントで送る |
| 短い要求を続けて何度も送る | 同上(毎回つなぎ直すと遅い) |
1. バックエンドから実装する (Rust)
1 往復だけならコマンドの中で閉じる
TcpStream::connect() には時間の上限がありません。相手の電源が切れているなど応答がない場合、少なくとも Windows では約 21 秒待たされてからエラーになるので、tokio::time::timeout() で包みます。応答の読み取りも同じです。
use std::time::Duration;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::TcpStream;
use tokio::time::timeout;
const CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
/// 接続して 1 行送り、1 行受け取って閉じる
#[tauri::command]
async fn tcp_request(addr: String, line: String) -> Result<String, String> {
let stream = timeout(CONNECT_TIMEOUT, TcpStream::connect(&addr))
.await
.map_err(|_| format!("{addr} への接続がタイムアウトしました"))?
.map_err(|e| format!("{addr} に接続できません: {e}"))?;
let (reader, mut writer) = stream.into_split();
writer.write_all(format!("{line}\n").as_bytes()).await.map_err(|e| e.to_string())?;
let mut reply = String::new();
timeout(Duration::from_secs(5), BufReader::new(reader).read_line(&mut reply))
.await
.map_err(|_| "応答がタイムアウトしました".to_string())?
.map_err(|e| e.to_string())?;
Ok(reply.trim_end().to_string()) // ここで stream の両側が破棄され、接続が閉じる
}
接続を State に持って開きっぱなしにする
into_split() で読み取り側と書き込み側に分け、書き込み側を State に、読み取り側を受信専用のタスクに渡します。タスクは tauri::async_runtime::spawn で起動し、待っている間はスレッドを占有しません。送信は .await をまたいでロックを持つので、Mutex は tokio のものにします(std との使い分けは State と Mutex でアプリの状態を管理する)。
切断は受信側で分かります。相手が正常に閉じると next_line() が None(EOF)を返し、相手のプロセスが落ちるなどしてリセットされるとエラーが返ります。困るのはケーブルが抜けた・相手の電源が落ちたときで、何も届かないまま読み取りが待ち続けます。そこで、一定時間何も届かなければ切れたとみなします。送信が成功しても相手が生きているとは限りません。相手が正常に閉じた直後でも 1 回目の書き込みは成功することがあり、失敗するのは 2 回目以降です。
use std::sync::atomic::{AtomicU64, Ordering};
use serde::Serialize;
use tauri::async_runtime::JoinHandle;
use tauri::{AppHandle, Emitter, Manager, State};
use tokio::net::tcp::{OwnedReadHalf, OwnedWriteHalf};
/// これだけ何も届かなければ切れたとみなす(相手が定期的に何か送ってくる前提)
const IDLE_TIMEOUT: Duration = Duration::from_secs(60);
struct Conn {
id: u64, // 接続ごとの番号。古い受信タスクが新しい接続を消さないようにする
writer: OwnedWriteHalf,
reader: JoinHandle<()>,
}
#[derive(Default)]
struct TcpState {
conn: tokio::sync::Mutex<Option<Conn>>,
next_id: AtomicU64,
}
#[derive(Clone, Serialize)]
struct Closed {
reason: String,
}
async fn read_loop(app: AppHandle, id: u64, read_half: OwnedReadHalf) {
let mut lines = BufReader::new(read_half).lines();
let reason = loop {
match timeout(IDLE_TIMEOUT, lines.next_line()).await {
Ok(Ok(Some(line))) => {
let _ = app.emit("tcp-line", line);
}
Ok(Ok(None)) => break "closed by peer".to_string(), // 相手が閉じた
Ok(Err(e)) => break e.to_string(), // リセットなど
Err(_) => break format!("{} 秒間何も届きませんでした", IDLE_TIMEOUT.as_secs()),
}
};
// 自分の接続がまだ State に残っていれば片付けてから知らせる
let state = app.state::<TcpState>();
let mut conn = state.conn.lock().await;
if conn.as_ref().is_some_and(|c| c.id == id) {
*conn = None;
}
let _ = app.emit("tcp-closed", Closed { reason });
}
#[tauri::command]
async fn tcp_connect(app: AppHandle, state: State<'_, TcpState>, addr: String) -> Result<(), String> {
let mut conn = state.conn.lock().await;
if let Some(old) = conn.take() {
old.reader.abort(); // 前の接続(再読み込み前のものなど)は閉じてから繋ぐ
}
let stream = timeout(CONNECT_TIMEOUT, TcpStream::connect(&addr))
.await
.map_err(|_| format!("{addr} への接続がタイムアウトしました"))?
.map_err(|e| format!("{addr} に接続できません: {e}"))?;
stream.set_nodelay(true).map_err(|e| e.to_string())?; // 短いメッセージをためずにすぐ送る
let (read_half, writer) = stream.into_split();
let id = state.next_id.fetch_add(1, Ordering::Relaxed);
let reader = tauri::async_runtime::spawn(read_loop(app.clone(), id, read_half));
*conn = Some(Conn { id, writer, reader });
Ok(())
}
#[tauri::command]
async fn tcp_send(state: State<'_, TcpState>, line: String) -> Result<(), String> {
let mut conn = state.conn.lock().await;
let c = conn.as_mut().ok_or("not connected")?;
c.writer.write_all(format!("{line}\n").as_bytes()).await.map_err(|e| e.to_string())
}
#[tauri::command]
async fn tcp_disconnect(state: State<'_, TcpState>) -> Result<(), String> {
let taken = state.conn.lock().await.take();
if let Some(mut c) = taken {
c.reader.abort(); // 受信をやめる
let _ = c.writer.shutdown().await; // 送信の終わりを伝えてから閉じる
}
Ok(())
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.manage(TcpState::default())
.invoke_handler(tauri::generate_handler![tcp_request, tcp_connect, tcp_send, tcp_disconnect])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
区切りを決めて読む
TCP はバイトの流れで、送った単位のまま届く保証はありません。「1 回の read() = 1 メッセージ」と考えると、2 件がくっついたり 1 件が途中で切れたりします。例では改行で区切っています。バイナリのプロトコルでは、先頭に本体の長さを付ける形がよく使われます。なお read_to_end() は相手が接続を閉じるまで戻らないので、接続を保つ通信には使えません。
use tokio::io::AsyncReadExt;
/// 先頭 4 バイト(ビッグエンディアン)の長さ + 本体、の形式で 1 件読む
async fn read_frame(reader: &mut OwnedReadHalf) -> std::io::Result<Vec<u8>> {
let len = reader.read_u32().await? as usize;
if len > 1024 * 1024 {
// 壊れたデータで巨大なメモリを確保しないよう上限を設ける
return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, "frame too large"));
}
let mut body = vec![0u8; len];
reader.read_exact(&mut body).await?; // 途中で切れると UnexpectedEof(これも切断)
Ok(body)
}
Vec<u8> はそのままイベントや戻り値にでき、JS には数値の配列で届きます。
2. フロントエンドから呼び出す (TypeScript)
受信と切断の通知は、接続する前に listen で登録しておきます。後から登録すると、接続直後に届いた行を取りこぼします。ページを再読み込みしても Rust 側の接続は残りますが、tcp_connect は前の接続を閉じてから繋ぐので二重にはなりません。例では 20 秒ごとに PING を送り、相手に応答させて 60 秒の無通信判定にかからないようにしています(相手がそう応答する約束が前提です)。
import { invoke } from '@tauri-apps/api/core';
import { listen } from '@tauri-apps/api/event';
type Closed = { reason: string };
let heartbeat: number | undefined;
await listen<string>('tcp-line', ({ payload }) => {
if (payload !== 'PING') console.log('受信:', payload); // ハートビートの応答は表示しない
});
await listen<Closed>('tcp-closed', ({ payload }) => {
window.clearInterval(heartbeat);
console.log('切断:', payload.reason);
});
export async function connect(addr: string) {
await invoke('tcp_connect', { addr });
window.clearInterval(heartbeat);
heartbeat = window.setInterval(() => {
invoke('tcp_send', { line: 'PING' }).catch(() => {}); // 切れていれば tcp-closed が届く
}, 20_000);
}
export const send = (line: string) => invoke('tcp_send', { line });
// 1 往復だけの問い合わせ
console.log('応答:', await invoke<string>('tcp_request', { addr: '127.0.0.1:9000', line: 'STATUS' }));
await connect('127.0.0.1:9000');
console.log('接続しました');
await send('hello');
切れたら繋ぎ直す場合は、tcp-closed を受けてから少し待って tcp_connect を呼び直します。待ち時間を 1、2、4 秒…と延ばしていく方法は WebSocket で接続してメッセージを送受信する と同じです。
動作確認
受け取ったものをそのまま返すエコーサーバーを、Node.js で別のターミナルに立てます。
node -e "require('net').createServer((s) => s.pipe(s)).listen(9000, '127.0.0.1')"
npm run tauri dev で起動して上のコードを実行すると、DevTools のコンソールに次のように出ます。
応答: STATUS
接続しました
受信: hello
続けてサーバーを Ctrl+C で止めると tcp-closed が届きます。相手が正常に閉じたときは closed by peer、プロセスごと終了したときはリセットのエラー文になり、Windows では「切断: 既存の接続はリモート ホストに強制的に切断されました。 (os error 10054)」と出ます。
よくあるエラーと対処法
- 「対象のコンピューターによって拒否されたため、接続できませんでした。 (os error 10061)」: 相手のポートで誰も待ち受けていません(Windows の文言。Linux / macOS では Connection refused)。アドレスとポート、サーバーが起動しているかを確かめます。
localhostで繋ぐと毎回 2 秒ほど遅れる:localhostは IPv6 の::1が先に試されることがあります。サーバーが127.0.0.1だけで待ち受けていると、少なくとも Windows では::1への接続が約 2 秒後に拒否されてから IPv4 で繋がります。127.0.0.1を直接指定します。- 「接続がタイムアウトしました」: 相手に届いていません。アドレスの誤り、相手の電源、途中のファイアウォールや VPN を疑います。
not connected:tcp_connectの前か、切断を検知して State から片付けた後にtcp_sendを呼んでいます。- メッセージがくっつく・途中で切れる: 区切りを決めずに
read()の単位で処理しています。行単位か長さ付きで読みます。
注意点
- ファイアウォール: 接続する側では、通常 OS のファイアウォールの確認は出ません。待ち受ける側(サーバー)を作るときの注意は net-011 を参照してください。社内ネットワークでは、途中の機器で特定のポートが閉じられていることがあります。
- 全ウィンドウに届く:
emit()はすべてのウィンドウに送ります。1 つに絞るならemit_to("main", ...)を使います(受け取り側は Rust からのイベントを受信する (listen))。 - 暗号化されない: 生の TCP は平文です。インターネット越しなら TLS を使うか、
wss://の WebSocket を検討します。 - アプリの終了: 終了すると受信タスクごと接続が閉じます。相手に終了を伝える必要があるプロトコルなら、終了前に
tcp_disconnectを呼びます。
