プリンターや 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>
