「対応機器がつながっているか確かめる」「機器を抜き差ししたら画面を更新する」といった場面では、接続中の USB デバイスの一覧が必要です。Tauri 自体にも WebView にも、どの OS でも使える USB の API はないので、Rust の rusb クレートで取得してコマンドで JS に返します。VID/PID(メーカーと製品の番号)はどの機器でも取れますが、製品名を読むには機器を開く必要があり、そこで OS とドライバーの差が出ます。
前提条件
src-tauri で rusb を追加します。コードは rusb 0.9 系向けです。
cd src-tauri
cargo add rusb --features vendored
自作コマンドの invoke だけなので、capability の追加は不要です。rusb は libusb という C のライブラリを使います。システムに libusb が見つかればそれに動的にリンクし、見つからなければソースからビルドしてアプリに組み込みます。Homebrew の libusb と pkg-config が入った Mac でビルドすると前者になり、配布先の Mac に同じものが無いと起動に失敗します。vendored 機能を付けると常に組み込む形になり、この心配がなくなります。Linux でシステムの libusb を使うなら、vendored を付けずに libusb-1.0-0-dev と pkg-config(Debian / Ubuntu の場合)を入れます。
取れる情報は次のように分かれます。
| 情報 | 機器を開かずに取れるか |
|---|---|
| VID/PID、クラス、差し込み口の位置 | 取れる(すべての OS) |
| メーカー名・製品名・シリアル番号 | 開く必要がある。Windows はドライバー次第、Linux は権限次第 |
1. バックエンドから実装する (Rust)
押さえる点は 3 つです。
- 開けなくても一覧から外さない: 名前が読めなかった機器も VID/PID は正しいので、理由を添えて返します。
- メインスレッドで動かさない: 機器を開いて文字列を読むと、1 台で数十ミリ秒かかることがあります。
#[tauri::command(async)]にして画面を止めないようにします(重い処理を別スレッド・非同期で実行する)。 - bus と address で機器を覚えない: address は挿し直すたびに変わります。抜き差しの判定には差し込み口の位置(bus とポート番号の並び)を、特定の機器を探すには VID/PID(あればシリアル番号も)を使います。
use std::time::Duration;
use rusb::{Device, DeviceDescriptor, UsbContext};
use serde::Serialize;
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct UsbDeviceInfo {
port: String, // 差し込み口の位置(例: "1-7.1")
vendor_id: u16,
product_id: u16,
class: u8, // 0x09 はハブ
manufacturer: Option<String>,
product: Option<String>,
note: Option<String>, // 名前が読めなかった理由
}
/// bus とポート番号の並び。同じ口に挿している限り変わらない
fn port_of<T: UsbContext>(device: &Device<T>) -> String {
let ports: Vec<String> = device
.port_numbers()
.unwrap_or_default()
.iter()
.map(|p| p.to_string())
.collect();
format!("{}-{}", device.bus_number(), ports.join("."))
}
/// 機器を開いてメーカー名と製品名を読む。開けなければ理由を返す
fn read_names<T: UsbContext>(
device: &Device<T>,
desc: &DeviceDescriptor,
) -> Result<(Option<String>, Option<String>), String> {
let handle = device.open().map_err(|e| match e {
rusb::Error::NotSupported => "ドライバーが対応していないため開けません".to_string(),
rusb::Error::Access => "権限がありません(udev の設定が必要)".to_string(),
other => other.to_string(),
})?;
let timeout = Duration::from_millis(300);
let lang = handle
.read_languages(timeout)
.map_err(|e| e.to_string())?
.into_iter()
.next()
.ok_or("文字列を持たない機器です")?;
// 持っていない項目は「Invalid parameter」で失敗するので None にする
let manufacturer = handle.read_manufacturer_string(lang, desc, timeout).ok();
let product = handle.read_product_string(lang, desc, timeout).ok();
Ok((manufacturer, product))
}
#[tauri::command(async)] // 同期関数のまま、メインスレッドとは別のスレッドで実行される
fn list_usb_devices(with_names: bool, include_hubs: bool) -> Result<Vec<UsbDeviceInfo>, String> {
let devices = rusb::devices().map_err(|e| e.to_string())?;
let mut list = Vec::new();
for device in devices.iter() {
let Ok(desc) = device.device_descriptor() else { continue };
if desc.class_code() == 0x09 && !include_hubs {
continue;
}
let (manufacturer, product, note) = if with_names {
match read_names(&device, &desc) {
Ok((m, p)) => (m, p, None),
Err(reason) => (None, None, Some(reason)),
}
} else {
(None, None, None)
};
list.push(UsbDeviceInfo {
port: port_of(&device),
vendor_id: desc.vendor_id(),
product_id: desc.product_id(),
class: desc.class_code(),
manufacturer,
product,
note,
});
}
list.sort_by(|a, b| a.port.cmp(&b.port));
Ok(list)
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.invoke_handler(tauri::generate_handler![list_usb_devices])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
シリアル番号も read_serial_number_string() で読めますが、機器を特定できる情報なので、必要なときだけ読み、外部に送らないようにします。
Linux で開く権限を与える(udev)
Linux では一覧と VID/PID は一般ユーザーでも取れますが、機器を開くには書き込み権限が要ります。sudo でアプリを動かすのではなく、対象の機器だけに udev ルールで権限を与えます。uaccess はログイン中のユーザーに権限を与える指定で、効かせるにはファイル名の番号を 73 より小さくします。置いたら機器を挿し直します。
# /etc/udev/rules.d/70-my-device.rules
SUBSYSTEM=="usb", ATTR{idVendor}=="1234", ATTR{idProduct}=="5678", TAG+="uaccess"
アプリと一緒に配るなら、tauri.conf.json の bundle.linux.deb.files でルールを /usr/lib/udev/rules.d/ に置きます。システムの libusb にリンクした場合は depends に libusb-1.0-0 も足します(Linux 用 Deb パッケージを作る)。
{
"bundle": {
"linux": {
"deb": {
"depends": ["libusb-1.0-0"],
"files": {
"/usr/lib/udev/rules.d/70-my-device.rules": "udev/70-my-device.rules"
}
}
}
}
}
2. フロントエンドから呼び出す (TypeScript)
rusb の抜き差しの通知は Windows では使えない(rusb::has_hotplug() が false)ので、どの OS でも動く方法として一覧を定期的に取り直して比べます。比べるときは名前を読まない軽い呼び出しにし、変化があったときだけ名前付きで取り直します。
import { invoke } from '@tauri-apps/api/core';
type UsbDeviceInfo = {
port: string;
vendorId: number;
productId: number;
class: number;
manufacturer: string | null;
product: string | null;
note: string | null;
};
const hex4 = (n: number) => n.toString(16).padStart(4, '0');
const idOf = (d: UsbDeviceInfo) => `${d.port}/${hex4(d.vendorId)}:${hex4(d.productId)}`;
export function label(d: UsbDeviceInfo): string {
const name = [d.manufacturer?.trim(), d.product?.trim()].filter(Boolean).join(' ');
return `${name || '(名前なし)'} [${hex4(d.vendorId)}:${hex4(d.productId)}]`;
}
const list = (withNames: boolean) =>
invoke<UsbDeviceInfo[]>('list_usb_devices', { withNames, includeHubs: false });
// 2 秒ごとに比べ、増えた機器と減った機器を知らせる。戻り値の関数で止める
export function watchUsb(onChange: (added: UsbDeviceInfo[], removed: UsbDeviceInfo[]) => void) {
let known = new Map<string, UsbDeviceInfo>();
let busy = false;
const tick = async () => {
if (busy) return;
busy = true;
try {
const quick = await list(false);
if (quick.length === known.size && quick.every((d) => known.has(idOf(d)))) return;
const next = new Map((await list(true)).map((d) => [idOf(d), d]));
const added = [...next.values()].filter((d) => !known.has(idOf(d)));
const removed = [...known.values()].filter((d) => !next.has(idOf(d)));
known = next;
onChange(added, removed);
} finally {
busy = false;
}
};
void tick();
const timer = window.setInterval(() => void tick(), 2000);
return () => window.clearInterval(timer);
}
watchUsb((added, removed) => {
for (const d of added) console.log('接続:', label(d), d.note ?? '');
for (const d of removed) console.log('切断:', label(d));
});
特定の機器を待つなら、vendorId と productId が一致するものが added に来たかを見ます。USB シリアル変換器(Arduino など)が目的なら、COM ポート名と VID/PID を一緒に取れる シリアルポート通信 の一覧の方が便利です。
動作確認
npm run tauri dev で起動すると、最初の比較で接続中の機器がすべて「接続」として出ます。Windows では次のように(一部を抜粋。機器名と ID は例)、名前が読める機器と読めない機器が混ざります。名前が読めたのは、HID の機器(キーボードやマウスのレシーバー)と、HID を含む複合機器(Web カメラやスピーカー)です。
接続: Example USB Speaker [1234:0001]
接続: (名前なし) [1234:0002] ドライバーが対応していないため開けません
接続: Example USB Receiver [1234:0003]
接続: Example Web Camera [1234:0004]
接続: (名前なし) [1234:0005] ドライバーが対応していないため開けません
機器を挿すと 2 秒ほどで「接続」、抜くと「切断」が出ます。
よくあるエラーと対処法
- 「Operation not supported or unimplemented on this platform」: Windows で、機器のドライバーが rusb から開けない種類です(例では「ドライバーが対応していないため開けません」に置き換えています)。名前は諦めて VID/PID で扱います。自社の機器と通信までするなら、ドライバーを WinUSB にします。開発中は Zadig などで入れ替え、配布する機器ではファームウェアに Microsoft OS ディスクリプタを持たせると WinUSB が自動で割り当てられます。
- 「Access denied (insufficient permissions)」: Linux で機器を開く権限がありません。上の udev ルールを置きます。
- 製品名はあるのにメーカー名が null: メーカー名の文字列を持たない機器です。読み出しは「Invalid parameter」で失敗するので、例では None として扱っています。
- 「Operation timed out」「Pipe error」: 機器が文字列の要求に応えていません。一覧全体は失敗させず、その機器だけ名前なしにします(例の
note)。 - 配布先の Mac で起動直後に落ちる: Homebrew の libusb に動的にリンクされています。
vendored機能を付けてビルドし直します。
OS ごとの違いと注意点
- Windows: 名前まで読めるのは、HID(キーボード・マウス・それらのレシーバー)や HID を含む複合機器、WinUSB の機器です。ハブ、LAN アダプター、Bluetooth アダプターなどは VID/PID だけになり、USB メモリも多くの場合同じです。
- Linux: 開く権限は udev で与えます(1 章)。一部の機器は、ディストリビューションの既定のルールで最初から開けることがあります。
- macOS: 一覧は特別な設定なしで取れます。App Sandbox を有効にする場合(Mac App Store 向け)は、エンタイトルメント
com.apple.security.device.usbが必要です。 - ライセンス: libusb をアプリに組み込む場合(
vendoredや Windows の既定)、libusb のライセンスは LGPL なので、配布前に条件を確認します。 - キーボードやマウスの入力そのものを読みたいなら、USB として開くのではなく キーボードやマウス (HID) の入力を取る の方法を使います。Bluetooth でつないだ機器は USB の一覧に出ないので、Bluetooth (BLE) デバイスをスキャンする を参照してください。
