キーボードやマウスの入力を取る方法は、「どこへの入力が必要か」で決まります。アプリのウィンドウ内の入力なら、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
xtstrequired by cratex11was 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 デバイス一覧を取得する の方法で足ります。
