ローカルネットワーク内の機器を検索する (mDNS)

mdns-sd 0.21 で LAN 内のサービスを検索して Channel で画面へ送り、自分のアプリも公開する。サービス名の 15 バイト制限、終了時の取り消し、OS のファイアウォールの注意も示す。

通信 対象: Tauri 2.x 更新日: 読了目安: 約8分 net-015
目次
  1. 前提条件
  2. 1. バックエンドから実装する (Rust)
  3. 検索する
  4. 自分のアプリを公開する
  5. 2. フロントエンドから呼び出す (TypeScript)
  6. 動作確認
  7. よくあるエラーと対処法
  8. OS ごとの違いと注意点
  9. 関連レシピ

プリンターや NAS、Chromecast、同じアプリを入れた別の PC などは、mDNS(マルチキャスト DNS)で「_ipp._tcp.local. のサービスがこのアドレスのこのポートにある」と LAN に知らせています。これを検索すれば、利用者に IP アドレスを入力させずに接続先を一覧にできます。Tauri には mDNS の公式プラグインが無いので、Rust の mdns-sd クレートで検索と公開を行い、結果を Channel で画面へ送ります。

前提条件

mdns-sd 0.21、Tauri 2.x で書いています。公開するときのホスト名の取得に os プラグインのクレートを使いますが、Rust から関数を呼ぶだけなので .plugin() の登録も権限も要りません(ホスト名の取得)。自作のコマンドだけなので capability の追加もありません。

cd src-tauri
cargo add mdns-sd@0.21
cargo add tauri-plugin-os

サービスの種類は _<名前>._tcp.local.(UDP なら ._udp.local.)の形で、末尾の local. まで書きます。_ と ._tcp の間の名前は 15 バイトまでです。

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

use std::collections::HashMap;
use std::time::Duration;

use mdns_sd::{DaemonEvent, ServiceDaemon, ServiceEvent, ServiceInfo, VERIFY_TIMEOUT_DEFAULT};
use serde::Serialize;
use tauri::ipc::Channel;
use tauri::{Manager, RunEvent, State};

/// 公開するサービスの種類(_ と ._tcp の間は 15 バイトまで)
const SERVICE_TYPE: &str = "_tauri-demo._tcp.local.";

/// アプリ全体で 1 つだけ作るデーモン
struct Mdns(ServiceDaemon);

#[derive(Clone, Serialize)]
#[serde(tag = "kind", rename_all = "lowercase")]
enum MdnsEvent {
    Found { fullname: String },
    Resolved {
        fullname: String,
        host: String,
        port: u16,
        addresses: Vec<String>,
        txt: HashMap<String, String>,
    },
    Removed { fullname: String },
}

#[tauri::command]
fn mdns_browse(mdns: State<'_, Mdns>, service_type: String, on_event: Channel<MdnsEvent>) -> Result<(), String> {
    // 同じ種類の検索が残っていれば止める(ページを再読み込みしたときなど)
    let _ = mdns.0.stop_browse(&service_type);
    let rx = mdns.0.browse(&service_type).map_err(|e| e.to_string())?;
    tauri::async_runtime::spawn(async move {
        while let Ok(event) = rx.recv_async().await {
            let msg = match event {
                ServiceEvent::ServiceFound(_, fullname) => MdnsEvent::Found { fullname },
                ServiceEvent::ServiceResolved(info) => MdnsEvent::Resolved {
                    fullname: info.get_fullname().to_string(),
                    host: info.get_hostname().to_string(),
                    port: info.get_port(),
                    // IPv4 だけを渡す(IPv6 のリンクローカルは URL にそのまま書けない)
                    addresses: info.get_addresses_v4().iter().map(|ip| ip.to_string()).collect(),
                    txt: info.get_properties().clone().into_property_map_str(),
                },
                ServiceEvent::ServiceRemoved(_, fullname) => MdnsEvent::Removed { fullname },
                ServiceEvent::SearchStopped(_) => break,
                _ => continue,
            };
            if on_event.send(msg).is_err() {
                break;
            }
        }
    });
    Ok(())
}

#[tauri::command]
fn mdns_stop(mdns: State<'_, Mdns>, service_type: String) -> Result<(), String> {
    mdns.0.stop_browse(&service_type).map_err(|e| e.to_string())
}

/// 接続できなかった機器がまだいるか確かめる(応答が無ければ removed が届く)
#[tauri::command]
fn mdns_verify(mdns: State<'_, Mdns>, fullname: String) -> Result<(), String> {
    mdns.0.verify(fullname, VERIFY_TIMEOUT_DEFAULT).map_err(|e| e.to_string())
}

/// この PC のホスト名を「xxx.local.」の形にする(PC ごとに違う名前が要る)
fn local_host_name() -> String {
    let raw = tauri_plugin_os::hostname();
    let label: String = raw
        .trim_end_matches(".local") // 「xxx.local」の形で返る環境もある
        .chars()
        .map(|c| if c.is_ascii_alphanumeric() || c == '-' { c } else { '-' })
        .take(63)
        .collect();
    if label.is_empty() {
        "tauri-app.local.".to_string()
    } else {
        format!("{label}.local.")
    }
}

/// このアプリを LAN に公開する(port は LAN から接続できる待ち受けの番号)
#[tauri::command]
fn mdns_advertise(mdns: State<'_, Mdns>, name: String, port: u16) -> Result<String, String> {
    let info = ServiceInfo::new(SERVICE_TYPE, &name, &local_host_name(), "", port, &[("v", "1")][..])
        .map_err(|e| e.to_string())?
        .enable_addr_auto(); // アドレスを自動で入れ、変わったら追従させる
    let fullname = info.get_fullname().to_string();
    mdns.0.register(info).map_err(|e| e.to_string())?;
    Ok(fullname)
}

#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    tauri::Builder::default()
        .setup(|app| {
            let daemon = ServiceDaemon::new()?;
            // register() が受け付けた後の失敗は、ここにしか届かない
            let monitor = daemon.monitor()?;
            tauri::async_runtime::spawn(async move {
                while let Ok(event) = monitor.recv_async().await {
                    if let DaemonEvent::Error(e) = event {
                        eprintln!("mDNS: {e}");
                    }
                }
            });
            app.manage(Mdns(daemon));
            Ok(())
        })
        .invoke_handler(tauri::generate_handler![mdns_browse, mdns_stop, mdns_verify, mdns_advertise])
        .build(tauri::generate_context!())
        .expect("error while building tauri application")
        .run(|app, event| {
            if let RunEvent::Exit = event {
                // 公開中のサービスの取り消しを送ってから止める
                if let Ok(done) = app.state::<Mdns>().0.shutdown() {
                    let _ = done.recv_timeout(Duration::from_secs(1));
                }
            }
        });
}

検索する

ServiceDaemon::new() は専用のスレッドを起こして UDP の 5353 番を開きます。値を捨ててもスレッドは止まらないので、検索のたびに作らず、State に 1 つだけ置いて使い回し、終了時に shutdown() します。browse() の結果は、名前だけが分かった ServiceFound、ホスト名・ポート・アドレス・TXT がそろった ServiceResolved、いなくなった ServiceRemoved の順に届きます。ServiceResolved は同じ機器について何度か届くことがある(アドレスが増えたときなど)ので、画面では fullname をキーに上書きします。同じ種類で browse() を呼び直すと受け取り先が置き換わるので、先に stop_browse() しています。

アドレスは Wi-Fi・有線・仮想アダプターの分が複数返ることがあります。例では IPv4 だけを渡し、画面側で順に接続を試す前提にしています。

自分のアプリを公開する

ServiceInfo::new() には種類、インスタンス名(一覧に出る名前)、ホスト名、アドレス、ポート、TXT(v=1 のような付加情報)を渡します。アドレスを空にするなら enable_addr_auto() が必須で、付けないと相手が接続先を得られません。ホスト名は .local. で終わり、PC ごとに違う名前にします。同じ名前だと、別の PC のアドレスが混ざって解決されることがあります。インスタンス名が他の機器と重なったときは「名前 (2)」のように自動で変えられます。

公開するポートは LAN から接続できるものにします。127.0.0.1 だけで待ち受けたサーバー は他の機器から届かないので、0.0.0.0 で待ち受けて認証を付け、番号は OS に選ばせたもの を渡します。インスタンス名と TXT は LAN の誰からも見えるので、利用者名や秘密の値は入れません。

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

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

type Resolved = {
  kind: 'resolved';
  fullname: string;
  host: string;
  port: number;
  addresses: string[];
  txt: Record<string, string>;
};
type MdnsEvent = { kind: 'found'; fullname: string } | Resolved | { kind: 'removed'; fullname: string };

// fullname をキーにした一覧(同じ機器の resolved は上書きする)
export const devices = new Map<string, Resolved>();

export async function browse(serviceType: string, onChange: () => void) {
  const onEvent = new Channel<MdnsEvent>();
  onEvent.onmessage = (e) => {
    if (e.kind === 'resolved') {
      devices.set(e.fullname, e);
      console.log('resolved', e.fullname, `${e.addresses[0] ?? '?'}:${e.port}`);
    } else if (e.kind === 'removed') {
      devices.delete(e.fullname);
    } else {
      console.log('found', e.fullname);
    }
    onChange();
  };
  await invoke('mdns_browse', { serviceType, onEvent });
  return () => invoke<void>('mdns_stop', { serviceType });
}
// (続き) 自分を公開してから、同じ種類を 30 秒探す
await invoke<string>('mdns_advertise', { name: 'デモ用の PC', port: 43210 });
const stop = await browse('_tauri-demo._tcp.local.', () => console.log(`${devices.size} 台`));
window.setTimeout(() => void stop(), 30_000);

動作確認

npm run tauri dev で起動して上のコードを実行すると、自分自身が見つかります(アドレスは環境によって違います)。

found デモ用の PC._tauri-demo._tcp.local.
resolved デモ用の PC._tauri-demo._tcp.local. 192.168.1.23:43210

同じアプリを別の PC でも起動すると、互いの一覧に出ます。片方を閉じると、終了時の shutdown() が取り消しを送るので、もう片方には removed がすぐ届きます。LAN にどんな種類があるかは、_services._dns-sd._udp.local. を検索すると found で種類の名前が届きます。

よくあるエラーと対処法

  • 「mDNS service _http._tcp must end with '._tcp.local.' or '._udp.local.'」: 末尾の .local. がありません。
  • 「Hostname must end with '.local.': …」: ServiceInfo::new() の第 3 引数が .local. で終わっていません。
  • 公開したのに見つからない: サービス名が 15 バイトを超えると、register() は成功を返しても公開されません。理由は monitor() にだけ「Service name length must be <= 15 bytes」と届くので、例ではこれを出力しています。
  • 1 件も見つからない: まずファイアウォール(次節)を疑います。ゲスト用 Wi-Fi など端末どうしの通信を遮るネットワーク、ルーターの先の別のネットワーク、VPN の接続中も届きません。
  • 電源を切った機器が一覧に残る: 取り消しを送らずに消えた機器は、記録の有効期限(長いものは 75 分)まで残ります。接続に失敗したら mdns_verify を呼ぶと、10 秒ほど応答が無ければ removed が届きます。

OS ごとの違いと注意点

mDNS は UDP の 5353 番でマルチキャストを送受信します。OS のファイアウォールで受信が止められても、エラーにはならず「見つからない」だけになります。

  • Windows: 初回起動時に Windows Defender ファイアウォールの確認画面が出ることがあります。許可しなかった場合や、許可したネットワークの種類(プライベート / パブリック)と接続中のネットワークの種類が違う場合は受信できません。確認画面を閉じるとブロックの規則が残り、次からは確認なしで遮られるので、ファイアウォールの設定で許可に変えます。規則は実行ファイルごとなので、tauri dev の実行ファイルとインストールしたアプリは別々に扱われます。
  • macOS: macOS 15 以降は、初回にローカルネットワーク上の機器へのアクセスの許可を求められ、拒否されると見つかりません(システム設定の「プライバシーとセキュリティ」→「ローカルネットワーク」で変更)。そのとき表示する説明は NSLocalNetworkUsageDescription に書き、src-tauri/Info.plist に置くとアプリの Info.plist に取り込まれます。
  • Linux: ufw や firewalld で受信を絞っている場合は、5353/udp の受信を許可します。
  • iOS / Android: マルチキャストの利用に OS 側の許可や設定が別に要るため、このレシピはデスクトップを対象にしています。

macOS 用の src-tauri/Info.plist には、追加したいキーだけを書きます(既定の内容と合わせられます)。

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
  <key>NSLocalNetworkUsageDescription</key>
  <string>同じネットワークにある機器を探すために使います</string>
</dict>
</plist>

関連レシピ

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

Web Ninja

この記事を書いた人

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

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

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

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