キーボードやマウス (HID) の入力を取る

ウィンドウ内のキーやマウスは DOM のイベントで取り、ほかのアプリへの入力まで要るなら rdev の listen で受けて JS に送る。macOS の許可、Wayland 非対応、プライバシーへの配慮も示す。

ハードウェア連携 対象: Tauri 2.x 更新日: 読了目安: 約9分 hw-008
目次
  1. 前提条件
  2. 1. フロントエンドから実装する (TypeScript)
  3. 2. バックエンドから実装する (Rust)
  4. 利用者への説明とプライバシー
  5. 動作確認
  6. よくあるエラーと対処法
  7. OS ごとの違いと注意点
  8. 関連レシピ

キーボードやマウスの入力を取る方法は、「どこへの入力が必要か」で決まります。アプリのウィンドウ内の入力なら、WebView の DOM イベントで足り、クレートも権限も要りません。ほかのアプリを操作している間の入力まで必要なとき(押したキーを画面に表示する、ほかのアプリでの操作回数を記録するなど)に限り、Rust の rdev クレートで OS 全体の入力を受け取ります。こちらはキーロガーと同じ仕組みなので、OS の許可とプライバシーへの配慮が欠かせません。

やりたいこと使うもの
ウィンドウ内のキー・マウスDOM の keydown / pointerdown(1 章)
背面にあっても決まったキーの組み合わせで反応するGlobal Shortcut プラグイン
ウィンドウ外のマウスカーソルの位置cursorPosition()(sys-020)
ほかのアプリへの入力も含めて受け取るrdev(2 章)

ショートカットが目的なら Global Shortcut プラグインを使います。登録した組み合わせだけを受け取るので、ほかの入力を見ずに済みます。

前提条件

1 章は追加の準備なしで動きます。2 章では src-tauri で rdev を追加します。コードは rdev 0.5 系向けです。

cd src-tauri
cargo add rdev

自作コマンドの invoke と、core:default に含まれる listen だけなので、capability の追加は不要です。Linux でのビルドには X11 の開発用ファイル(Debian / Ubuntu は libx11-dev・libxi-dev・libxtst-dev)が要ります。macOS では利用者の許可が要ります(「OS ごとの違い」を参照)。

1. フロントエンドから実装する (TypeScript)

ウィンドウにフォーカスがある間の入力は、DOM のイベントで受け取ります。ショートカットの判定には、キーボードの配列に左右されない e.code(キーの位置)を使い、入力される文字が必要なときだけ e.key を見ます。日本語入力の変換中のキーは isComposing で除きます。

const pressed = new Set<string>();

window.addEventListener('keydown', (e) => {
  if (e.isComposing) return; // IME で変換中のキーは扱わない
  if (!e.repeat) pressed.add(e.code); // 押しっぱなしのリピートは数えない
  if ((e.ctrlKey || e.metaKey) && e.code === 'KeyK') {
    e.preventDefault(); // WebView の既定の動作を止める
    console.log('コマンドパレットを開く');
  }
});
window.addEventListener('keyup', (e) => pressed.delete(e.code));
// フォーカスが外れると keyup が届かないので、押下中の記録を捨てる
window.addEventListener('blur', () => pressed.clear());

window.addEventListener('pointerdown', (e) => {
  console.log(`${e.pointerType} button=${e.button} at (${e.clientX}, ${e.clientY})`);
});

Alt+Tab や Windows キーの組み合わせのように OS が先に処理するキーは、ページに届きません。

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

rdev::listen() は OS 全体の入力をコールバックで受け取り続け、戻ってきません。押さえる点は 4 つです。

  • 専用のスレッドで 1 回だけ呼ぶ: 2 回呼ぶと先のコールバックが置き換わります。止める関数も無いので、オン・オフはフラグで切り替え、オフの間は捨てます。
  • コールバックはすぐ返す: Windows では、OS がコールバックの終わりを待ってから入力を処理するため、重い処理をすると PC 全体の入力が引っかかります。どの OS でもチャンネルで別スレッドへ渡し、そこから JS に emit します。
  • 必要な情報だけを送る: event.name には実際に入力された文字が入ります。例ではこれを使わず、修飾キーとファンクションキーの名前、マウスのボタンだけを送ります。
  • 自分のウィンドウへの入力も届く: アプリにフォーカスがある間は、DOM のイベントと両方で受け取ります。
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{mpsc, Arc, OnceLock};
use rdev::{EventType, Key};
use serde::Serialize;
use tauri::{AppHandle, Emitter, State};

/// JS に送る内容。どの文字を打ったかは含めない
#[derive(Clone, Serialize)]
struct GlobalInput {
    kind: &'static str,  // "key-down" / "key-up" / "mouse-down" / "wheel"
    key: Option<String>, // 許可したキーとマウスのボタンの名前だけ
}

#[derive(Default)]
struct InputMonitor {
    enabled: Arc<AtomicBool>,
    started: OnceLock<()>,
}

/// 名前を送ってよいキーだけを通す。文字・数字・記号のキーは None
fn allowed_key(key: Key) -> Option<String> {
    let allowed = matches!(
        key,
        Key::ControlLeft | Key::ControlRight | Key::ShiftLeft | Key::ShiftRight
            | Key::Alt | Key::AltGr | Key::MetaLeft | Key::MetaRight
            | Key::F1 | Key::F2 | Key::F3 | Key::F4 | Key::F5 | Key::F6
            | Key::F7 | Key::F8 | Key::F9 | Key::F10 | Key::F11 | Key::F12
    );
    allowed.then(|| format!("{key:?}"))
}

fn to_message(event: &EventType) -> Option<GlobalInput> {
    match event {
        EventType::KeyPress(k) => Some(GlobalInput { kind: "key-down", key: allowed_key(*k) }),
        EventType::KeyRelease(k) => Some(GlobalInput { kind: "key-up", key: allowed_key(*k) }),
        EventType::ButtonPress(b) => Some(GlobalInput { kind: "mouse-down", key: Some(format!("{b:?}")) }),
        EventType::Wheel { .. } => Some(GlobalInput { kind: "wheel", key: None }),
        _ => None, // マウスの移動は大量に届くので送らない
    }
}

#[tauri::command]
fn start_input_monitor(app: AppHandle, monitor: State<'_, InputMonitor>) {
    monitor.enabled.store(true, Ordering::Relaxed);
    let enabled = monitor.enabled.clone();
    // 2 回目以降はフラグを戻すだけ
    monitor.started.get_or_init(move || {
        let (tx, rx) = mpsc::channel::<GlobalInput>();
        let emitter = app.clone();
        std::thread::spawn(move || {
            for msg in rx {
                let _ = emitter.emit("global-input", msg);
            }
        });
        std::thread::spawn(move || {
            let result = rdev::listen(move |event| {
                if !enabled.load(Ordering::Relaxed) {
                    return; // オフの間は捨てる
                }
                if let Some(msg) = to_message(&event.event_type) {
                    let _ = tx.send(msg); // ここでは送るだけにして、すぐ返す
                }
            });
            if let Err(e) = result {
                let _ = app.emit("global-input-error", format!("{e:?}"));
            }
        });
    });
}

#[tauri::command]
fn stop_input_monitor(monitor: State<'_, InputMonitor>) {
    monitor.enabled.store(false, Ordering::Relaxed);
}

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

フロントエンドでは、押されているキーとクリックの回数を記録します(受け取り方は Rust からのイベントを受信する (listen))。

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

type GlobalInput = { kind: 'key-down' | 'key-up' | 'mouse-down' | 'wheel'; key: string | null };

const held = new Set<string>();
let clicks = 0;

await listen<GlobalInput>('global-input', ({ payload }) => {
  if (payload.kind === 'mouse-down') {
    clicks += 1;
    console.log(`クリック: ${payload.key}(${clicks} 回目)`);
  } else if (payload.kind === 'key-down' && payload.key && !held.has(payload.key)) {
    held.add(payload.key); // 押しっぱなしで繰り返し届く分は数えない
    console.log('押された:', payload.key);
  } else if (payload.kind === 'key-up' && payload.key) {
    held.delete(payload.key);
  }
});

await listen<string>('global-input-error', ({ payload }) => {
  console.error('入力の監視を開始できません:', payload);
});

// 設定画面で利用者が「オン」にしたときだけ呼ぶ
export const startMonitor = () => invoke('start_input_monitor');
export const stopMonitor = () => invoke('stop_input_monitor');

利用者への説明とプライバシー

rdev のコールバックには、パスワードを含む、ほかのアプリへの入力もすべて通ります。

  • 既定はオフにし、何のために何を取るかを画面で説明してから、利用者の操作でオンにします。macOS の許可を求める前にも同じ説明を出します。
  • 取る情報は用途に必要な分だけにします。離席の検知が目的なら、最後の入力からの経過時間だけを返す user-idle クレートのような方法で足り、入力の中身に触れずに済みます。キーの並びを保存したり、外部に送ったりはしません。
  • 監視している間はトレイアイコンなどで分かるようにし、いつでもオフにできるようにします。

動作確認

npm run tauri dev で起動して startMonitor() を呼び、ほかのアプリにフォーカスを移して左の Ctrl を押したりクリックしたりすると、開発者ツールのコンソールに次の形で出ます。文字のキーを押しても何も出ません。stopMonitor() の後は何も出なくなります。

押された: ControlLeft
クリック: Left(1 回目)

よくあるエラーと対処法

  • macOS でイベントが 1 つも届かない、または「EventTapError」: 許可がありません。多くの場合エラーにはならず、届かないだけです。許可したらアプリを起動し直します。
  • Linux で「MissingDisplayError」: X11 の画面に接続できません。環境変数 DISPLAY の無いところから起動していないか確かめます。
  • Linux のビルドで「The system library xtst required by crate x11 was not found.」: 前提条件の開発用ファイルを入れます(xtst の部分は足りないものによって変わります)。
  • PC 全体の入力が重くなる(Windows): コールバックの中で時間のかかる処理をしています。例のようにチャンネルで別スレッドへ渡します。
  • 2 回目の開始から前の処理が動かない: rdev::listen() を 2 回呼んでいます。例のように OnceLock で 1 回に限ります。

OS ごとの違いと注意点

  • Windows: 管理者として実行中のアプリが前面にあると、その入力は届かないことがあります(アプリが管理者権限で動いているか確認する)。日本語入力では押したキーが届くだけで、変換後の文字は分かりません。セキュリティソフトに警告されることもあります。
  • macOS: システム設定の「プライバシーとセキュリティ」の「アクセシビリティ」で許可します(バージョンによっては「入力監視」の欄も使います)。tauri dev の間は、起動元のターミナルや VS Code に許可を与えます。許可の確認や設定画面を開く処理には、コミュニティ製の tauri-plugin-macos-permissions などを使えます。
  • Linux: X11 のセッションだけで動きます。Wayland ではほかのアプリへの入力は取れません。
  • 保守: rdev の最新版は 2023 年の 0.5.3 です。新しい OS の版で使うなら、配布前に実機で十分に試します。
  • キーボードやマウスを USB 機器として一覧に出すだけなら、接続されている USB デバイス一覧を取得する の方法で足ります。

関連レシピ

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

Web Ninja

この記事を書いた人

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

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

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

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