空いているポート番号を探して使う

ポート 0 で待ち受けて OS に空き番号を選ばせ、local_addr() で読む。その番号を画面へは State とコマンドで、サイドカーへは標準出力で、別のプログラムへはファイルで伝える。

通信 対象: Tauri 2.x 更新日: 読了目安: 約9分 net-013
目次
  1. 前提条件
  2. 1. バックエンドから実装する (Rust)
  3. ポート 0 で待ち受け、画面とファイルに伝える
  4. サイドカーに番号を選ばせる
  5. 2. フロントエンドから呼び出す (TypeScript)
  6. 動作確認
  7. よくあるエラーと対処法
  8. OS ごとの違いと注意点
  9. 関連レシピ

ローカルサーバーやサイドカーのポートを 3000 や 8080 に固定すると、他のアプリや 2 つ目の起動と重なったときに起動できません。待ち受けるときのポート番号を 0 にすると、OS がその時点で空いている番号を選び、local_addr() でその番号を読めます。つまずくのは番号の伝え方で、自分の画面(WebView)、自分で起動する子プロセス、無関係の別のプログラムで方法が変わります。サーバーそのものの作り方は アプリ内にローカル Web サーバーを立てる を参照してください。

前提条件

tokio 1(full)、axum 0.8、Tauri 2.x で書いています。画面からの呼び出しに http プラグイン、サイドカーの起動に Shell プラグインを使います。

npm run tauri add http
npm run tauri add shell
cd src-tauri
cargo add axum@0.8
cargo add tokio --features full

番号は起動のたびに変わるので、http プラグインの許可はポートを * にします(書き方は HTTP GET リクエストを送る(Rust経由))。サイドカーを Rust から起動するだけなら Shell の権限は要りません。

{
  "$schema": "../gen/schemas/desktop-schema.json",
  "identifier": "default",
  "description": "Capability for the main window",
  "windows": ["main"],
  "permissions": [
    "core:default",
    {
      "identifier": "http:default",
      "allow": [{ "url": "http://127.0.0.1:*" }]
    }
  ]
}

伝える相手ごとの方法は次のとおりです。

伝える相手方法
自分の画面State に置き、コマンドで返す
自分で起動する子プロセス子に 0 で待ち受けさせ、選ばれた番号を標準出力で受け取る
番号を引数で受け取るしかない子プロセス空き番号を調べて引数で渡す(まれに横取りされる)
無関係の別のプログラム決まった場所のファイルに書く
LAN の他の機器mDNS で公開する

1. バックエンドから実装する (Rust)

ポート 0 で待ち受け、画面とファイルに伝える

use std::net::Ipv4Addr;
use std::sync::Mutex;
use std::time::Duration;

use axum::routing::get;
use axum::Router;
use tauri::{Manager, RunEvent};
use tauri_plugin_shell::process::{CommandChild, CommandEvent};
use tauri_plugin_shell::ShellExt;

/// OS が選んだ番号(アプリ内のサーバー)
struct ServerPort(u16);

/// 起動したサイドカー(終了時に止める)
struct Backend(Mutex<Option<CommandChild>>);

#[tauri::command]
fn server_port(port: tauri::State<'_, ServerPort>) -> u16 {
    port.0
}

#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    // 1. ウィンドウを作る前に、ポート 0 で待ち受けを開いて番号を読む
    let listener = tauri::async_runtime::block_on(tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0)))
        .expect("failed to open a local port");
    let port = listener.local_addr().expect("failed to read the port").port();

    tauri::Builder::default()
        .plugin(tauri_plugin_http::init())
        .plugin(tauri_plugin_shell::init())
        .manage(ServerPort(port)) // 2. ページが読み込まれる前に番号が決まっている
        .manage(Backend(Mutex::new(None)))
        .setup(move |app| {
            // 3. 開いた listener をそのまま使う(閉じて開き直さないので横取りされない)
            let router = Router::new().route("/health", get(|| async { "ok" }));
            tauri::async_runtime::spawn(async move {
                let _ = axum::serve(listener, router).await;
            });
            // 4. 無関係の別のプログラム向けに、番号をファイルに書く
            let dir = app.path().app_local_data_dir()?;
            std::fs::create_dir_all(&dir)?;
            std::fs::write(dir.join("server.port"), port.to_string())?;
            println!("listening on 127.0.0.1:{port}");
            Ok(())
        })
        .invoke_handler(tauri::generate_handler![server_port, start_backend])
        .build(tauri::generate_context!())
        .expect("error while building tauri application")
        .run(|app, event| {
            if let RunEvent::Exit = event {
                // 番号のファイルを消し、サイドカーを止める
                if let Ok(dir) = app.path().app_local_data_dir() {
                    let _ = std::fs::remove_file(dir.join("server.port"));
                }
                if let Some(child) = app.state::<Backend>().0.lock().unwrap().take() {
                    let _ = child.kill();
                }
            }
        });
}

Builder より前に bind しているので、manage() の時点で番号が決まっており、画面からの server_port はいつ呼ばれても待たずに答えられます。setup で emit() して知らせる方法は、ページがまだ listen() していないと取りこぼします。また、番号を読んだ listener をそのままサーバーに渡すので、調べてから待ち受けるまでの間に他のプロセスに取られることもありません。

ファイルは CLI やブラウザ拡張など、アプリと無関係に動くプログラム向けです。強制終了すると古いファイルが残るので、読む側は「接続できなければアプリは起動していない」とみなします。複数起動すると上書きし合うので、Single Instance プラグイン と組み合わせます。

サイドカーに番号を選ばせる

/// 番号を引数で受け取るしかない子のために、空き番号を調べる(閉じてから渡すので横取りされうる)
fn find_free_port() -> std::io::Result<u16> {
    let probe = std::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))?;
    Ok(probe.local_addr()?.port()) // probe はここで閉じる
}

/// サイドカーに `--port 0` で待ち受けさせ、「ready on 127.0.0.1:<番号>」の行から番号を読む
async fn spawn_backend(app: &tauri::AppHandle) -> Result<(u16, CommandChild), String> {
    let (mut rx, child) = app
        .shell()
        .sidecar("mytool")
        .map_err(|e| e.to_string())?
        .args(["serve", "--port", "0"])
        .spawn()
        .map_err(|e| e.to_string())?;
    let found = tokio::time::timeout(Duration::from_secs(10), async {
        while let Some(event) = rx.recv().await {
            if let CommandEvent::Stdout(bytes) = event {
                let line = String::from_utf8_lossy(&bytes);
                if let Some(addr) = line.trim().strip_prefix("ready on ") {
                    return addr.rsplit(':').next().and_then(|p| p.parse::<u16>().ok());
                }
            }
        }
        None // 番号を出す前に終了した
    })
    .await;
    match found {
        Ok(Some(port)) => {
            // 以降の出力も読み続ける(読まないと子が止まる)
            tauri::async_runtime::spawn(async move { while rx.recv().await.is_some() {} });
            Ok((port, child))
        }
        _ => {
            let _ = child.kill();
            Err("mytool did not report its port".into())
        }
    }
}

#[tauri::command]
async fn start_backend(app: tauri::AppHandle) -> Result<u16, String> {
    let (port, child) = spawn_backend(&app).await?;
    if let Some(old) = app.state::<Backend>().0.lock().unwrap().replace(child) {
        let _ = old.kill(); // 2 回呼ばれたら古いほうを止める
    }
    Ok(port)
}

親が find_free_port() で調べて渡す方法は、調べるために開いたポートを閉じてから子が開くまでの間に、別のプロセスが同じ番号を使う可能性が残ります。子が自分で 0 を指定して待ち受け、選ばれた番号を 1 行出力し、親がそれを読む方法なら競合は起きません。自作のサーバーならこちらにします。サイドカーの配置と後始末は 同梱したバイナリファイルを実行する を参照してください。

2. フロントエンドから呼び出す (TypeScript)

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

// Rust が起動時に選んだ番号を受け取る(起動のたびに変わるので保存しない)
const port = await invoke<number>('server_port');
// localhost ではなく 127.0.0.1 で組み立てる
const base = `http://127.0.0.1:${port}`;
const res = await fetch(`${base}/health`); // 許可は http://127.0.0.1:*
console.log(base, await res.text()); // http://127.0.0.1:54698 ok

// サイドカーの番号も同じようにコマンドで受け取る
const backendPort = await invoke<number>('start_backend');
console.log('backend', backendPort);

localhost は環境によって IPv6 の ::1 に解決され、127.0.0.1 だけで待ち受けているサーバーにつながらないことがあります。子プロセスに渡すアドレスも同じ理由で 127.0.0.1 と書きます。http プラグインではなくブラウザの fetch で呼ぶなら、サーバー側で CORS に答える必要があります(net-011)。

動作確認

npm run tauri dev で起動すると、ターミナルに listening on 127.0.0.1:54698 のように出て、画面のコンソールに http://127.0.0.1:54698 ok と出ます。起動し直すたびに番号が変わります。アプリのデータフォルダ(Windows なら %LOCALAPPDATA%\<identifier>)の server.port に同じ番号が書かれ、ウィンドウを閉じて終了すると消えます。

よくあるエラーと対処法

  • サイドカーの番号が届かず 10 秒で失敗する: 子の標準出力がためられています。Python はパイプへの出力をまとめて送るので、print(..., flush=True) にします(Python スクリプトの同梱)。
  • 「Registering a blocking socket with the tokio runtime is unsupported.」で始まるパニック: std の TcpListener を tokio::net::TcpListener::from_std() に渡す前に set_nonblocking(true) を呼んでいません。macOS・Linux の開発ビルドで検出され、Windows では検出されないまま動作がおかしくなることがあります。上の例のように最初から tokio で bind すれば不要です。
  • tauri dev が「Port 1420 is already in use」で止まる: Vite の開発サーバーはわざと 1420 に固定されています(strictPort: true)。使っているプロセスを止めるか、vite.config.ts の port と tauri.conf.json の devUrl を両方変えます。
  • 「url not allowed on the configured scope: …」: http プラグインの許可が固定ポートのままです。http://127.0.0.1:* にします。

OS ごとの違いと注意点

  • 番号の範囲: 0 で選ばれるのは OS が一時的な通信に使う範囲の番号で、起動のたびに変わります。保存したり、利用者に入力させたりする前提にしません。
  • オリジン: その番号の URL でページを開くと起動のたびに別のオリジンになり、localStorage なども引き継がれません。
  • IPv4 と IPv6: 127.0.0.1 で選ばれた番号は IPv4 のものです。同じ番号の ::1 は別のプロセスが使っていることがあります。
  • UDP: tokio::net::UdpSocket::bind("0.0.0.0:0") のように UDP でも同じ方法で選ばせられます(UDP パケット通信を行う)。

関連レシピ

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

Web Ninja

この記事を書いた人

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

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

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

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