TCP ソケット通信を行う

Recipe ID: net-016

アプリケーションから TCP サーバーに対して生のソケット接続を行い、メッセージの送受信を行う方法を解説します。
Web ブラウザのサンドボックス内(JavaScript)からは、セキュリティ上の理由で生の TCP ソケット(net.Socket 等)を直接扱うことができません。
そのため、Tauri では Rust 側の Custom Command を作成し、Rust の標準ライブラリまたは非同期ランタイムを使用して TCP 通信を実装し、その結果をフロントエンドに返却するというアーキテクチャを取ります。

実装パターン 1: 同期(ブロッキング)による実装

最もシンプルな実装方法は、Rust の標準ライブラリ std::net::TcpStream を使用することです。
この方法は実装が容易ですが、大量の同時接続を捌く場合や、長時間接続を維持する(ブロッキングする)場合には向きません。
単発のコマンド送信(例:ステータス確認、簡単な制御コマンド送信)に適しています。

Rust 側 (src-tauri/src/lib.rs)

use tauri::command;
use std::io::{Read, Write};
use std::net::TcpStream;
use std::time::Duration;

#[command]
fn tcp_send_sync(address: String, message: String) -> Result<String, String> {
    // 1. 接続
    // タイムアウト未指定だと接続できない場合に長時間ブロックする可能性があるため推奨されませんが、
    // ここではシンプルさを優先して直接 connect しています。
    let mut stream = TcpStream::connect(&address)
        .map_err(|e| format!("Connection failed: {}", e))?;

    // 2. タイムアウト設定 (重要)
    // 読み書きが永久にブロックしないようにタイムアウトを設定します
    stream.set_read_timeout(Some(Duration::from_secs(5)))
        .map_err(|e| format!("Set read timeout failed: {}", e))?;
    stream.set_write_timeout(Some(Duration::from_secs(5)))
        .map_err(|e| format!("Set write timeout failed: {}", e))?;

    // 3. 送信
    stream.write_all(message.as_bytes())
        .map_err(|e| format!("Write failed: {}", e))?;

    // 4. 受信
    // 固定サイズのバッファを用意
    let mut buffer = [0; 1024];
    let n = stream.read(&mut buffer)
        .map_err(|e| format!("Read failed: {}", e))?;

    if n == 0 {
        return Ok("Server closed connection".to_string());
    }

    // 文字列として返す (バイナリの場合は Base64 エンコードするか Vec<u8> を返す)
    let response = String::from_utf8_lossy(&buffer[..n]).to_string();
    Ok(response)
}

#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    tauri::Builder::default()
        .invoke_handler(tauri::generate_handler![
            tcp_send_sync
        ])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

JavaScript 側

import { invoke } from '@tauri-apps/api/core';

async function sendCommand(): Promise<void> {
    try {
        const response = await invoke<string>('tcp_send_sync', {
            address: '127.0.0.1:8080',
            message: 'STATUS\n'
        });
        console.log('Response:', response);
    } catch (err) {
        console.error('TCP Error:', err);
    }
}

---

実装パターン 2: 非同期 (Tokio) による実装

UI スレッドをブロックせず、より効率的にネットワーク操作を行うには tokio を使用した非同期実装が推奨されます。
Tauri v2 はデフォルトで Tokio ランタイム上で動作しているため、async なコマンドを定義するだけで利用可能です。

依存関係の追加 (src-tauri/Cargo.toml)

tokio の機能を使用するため、full feature または net feature を有効にします。

[dependencies]
# tokio は tauri の依存関係として既に入っている場合が多いですが、
# 明示的に機能を使う場合は以下のように記述します
tokio = { version = "1", features = ["net", "io-util", "time"] }

Rust 側 (src-tauri/src/lib.rs)

use tauri::command;
use tokio::net::TcpStream;
use tokio::io::{AsyncReadExt, AsyncWriteExt};

#[command]
async fn tcp_send_async(address: String, message: String) -> Result<String, String> {
    // 非同期接続
    let mut stream = TcpStream::connect(&address).await
        .map_err(|e| format!("Async Connect failed: {}", e))?;

    // 送信
    stream.write_all(message.as_bytes()).await
        .map_err(|e| format!("Async Write failed: {}", e))?;

    // 受信
    let mut buffer = vec![0; 1024];
    let n = stream.read(&mut buffer).await
        .map_err(|e| format!("Async Read failed: {}", e))?;

    if n == 0 {
        return Ok("".to_string());
    }

    Ok(String::from_utf8_lossy(&buffer[..n]).into_owned())
}

// invoke_handler への登録をお忘れなく: tcp_send_async

実装パターン 3: バイナリデータの送受信

画像データや独自のバイナリプロトコルを扱う場合、String ではなく Vec<u8> を使用します。
Tauri は Vec<u8> を自動的に Uint8Array (JavaScript) に変換してくれます。

Rust 側

#[command]
async fn tcp_send_binary(address: String, data: Vec<u8>) -> Result<Vec<u8>, String> {
    let mut stream = TcpStream::connect(&address).await
        .map_err(|e| e.to_string())?;

    stream.write_all(&data).await.map_err(|e| e.to_string())?;

    let mut buffer = Vec::new();
    // 相手がデータを送り終えるまで全て読み込む (EOFまで)
    // ※ プロトコルによっては read_exact などを使う必要があります
    stream.read_to_end(&mut buffer).await.map_err(|e| e.to_string())?;

    Ok(buffer)
}

JavaScript 側

import { invoke } from '@tauri-apps/api/core';

async function sendBinary(): Promise<void> {
    // 送信データ (例: [0x01, 0x02, 0x03])
    const dataToSend = new Uint8Array([1, 2, 3]);

    try {
        // Rust の Vec<u8> は JS の Uint8Array と互換性があります
        // 戻り値も自動的に Uint8Array (または number[]) になります
        const responseBuffer = await invoke<Uint8Array>('tcp_send_binary', {
            address: '127.0.0.1:9000',
            data: Array.from(dataToSend) // Tauri v1/v2 のバージョンによっては Array.from が必要な場合がある
        });
        
        console.log('Received bytes:', responseBuffer);
    } catch (err) {
        console.error(err);
    }
}

注意点とトラブルシューティング

1. ファイアウォール

開発中の接続テストで失敗する場合、OS のファイアウォールが通信をブロックしていないか確認してください。特に Windows では外部への接続や、ローカルサーバーへの接続がブロックされることがあります。

2. localhost の解決

localhost というホスト名が IPv4 (127.0.0.1) ではなく IPv6 (::1) に解決され、サーバー側が IPv4 でしか待機していない場合に接続エラーになることがあります。接続テストを行う際は、明示的に 127.0.0.1 を指定すると確実です。

3. 持続的な接続 (Keep-Alive)

チャットアプリのように接続を維持し続ける場合、command を呼び出して終了するモデル(リクエスト・レスポンス型)では不十分です。 その場合、Rust 側で以下のようなアーキテクチャを検討してください。

1. 接続を開始する Command を呼ぶ (stateUnboundedSender などを保持)。
2. バックグラウンドタスク (tauri::async_runtime::spawn) で受信ループを回す。
3. 受信したデータを Event (app_handle.emit) を使ってフロントエンドにプッシュする。

// 概念図
use tauri::{Emitter, Listener};

#[command]
fn connect_and_listen(app: tauri::AppHandle, address: String) {
    tauri::async_runtime::spawn(async move {
        let mut stream = TcpStream::connect(address).await.unwrap();
        let mut buf = [0; 1024];
        loop {
            let n = stream.read(&mut buf).await.unwrap();
            if n == 0 { break; }
            let data = &buf[..n];
            // イベントとしてフロントエンドに通知
            app.emit("tcp-data", data).unwrap();
        }
    });
}