[Rust #03] ホットキーランチャー「HotRun」を作り、すでに使われているキーを洗い出すまで
はじめに
長く使ってきた、ホットキーランチャーがあります。「Ctrl+1 で、あのプログラムを起動」のように、キーに登録したものを開くだけの、小さなアプリです。ずっと使っていたのは、移すのが面倒だったからです。不満は2つありました。
- 編集の画面が使いにくい
- キーが重なったとき、エラーが出るだけで、どのキーなのか教えてくれない
PowerToysに同じ機能もありますが、私はコマンドラインで操作できて、設定がJSON1ファイルで、AIにまとめて書き換えてもらえるものがほしかったので、自作しました。名前は HotRun です。
- ホットキーで、プログラム / ファイル / フォルダを開く
- 設定はJSON1ファイル。画面からも、コマンドラインからも、同じファイルを使う
- タスクトレイに常駐する。Windows起動時の自動起動もできる
- 一覧、編集の画面、保存前の「テスト実行」
- 他のアプリがすでに使っているキーの一覧と、押して確かめる機能
言語は Rust です。画面だけ Tauri v2 と素のJavaScriptで、ホットキーの登録と実行はRustです。対象は Windowsだけです。
スクリーンショット
リポジトリ:
GitHub - fixtan/hotrun: Hotkey launcher for Windows
ホットキーでプログラム・ファイル・フォルダを開く、Rust + Tauri v2製のランチャー。設定はJSON1ファイル。
github.com前回の記事(Rustで作ったスクショ+録画アプリの話):
[Rust #02] スクショ+録画アプリ「shotkey」を作り、画面を固めて範囲選択、システム音つきの録画まで
自作のスクリーンショット&録画アプリ「shotkey」をRustで作りました。
lain-lab.com1. 構成:core、cli、app の3つ
core/ hotrun-core(ライブラリ)。設定の読み書きと検査、キーの解釈、使用中キーの分類。Windowsの呼び出しは win.rs だけ
cli/ hotrun(コマンドライン)。一覧、検査、HKLaunchのCSV取り込み、待ち受け、使用中キーの洗い出し
app/ hotrun-app(Tauri)。設定画面、トレイ、ホットキーのエンジン
設定ファイルは %APPDATA%\hotrun\config.json です(--config FILE で変更できます)。画面もコマンドラインも、同じ core を通して、同じファイルを読み書きします。
core の大半は、Windowsを呼ばない処理です。キーの文字列の解釈、設定の検査、使用中キーの候補と分類、といったものです。そのため、Linuxのサンドボックスでも cargo test が回ります。Windowsでしか確かめられないのは、RegisterHotKey の呼び出しと、画面と、トレイだけです。
Tauriの画面は、JavaScriptのDOMの動きも、Node(jsdom)でテストしています。「一覧が3つに分かれて出る」「絞り込む」といった動きは、実機の前に確認できます。
2. RegisterHotKey:登録したスレッドに結びつく
ホットキーの登録は、Win32の RegisterHotKey です。修飾キーは、Alt=1、Ctrl=2、Shift=4、Win=8 のビットで、押しっぱなしの連打を防ぐ MOD_NOREPEAT も付けます。すでに誰かが取っているキーを登録しようとすると、エラー 1409(ERROR_HOTKEY_ALREADY_REGISTERED)で断られます。
注意点は、登録したスレッドにホットキーが結びつくことです。押されたときの WM_HOTKEY は、そのスレッドのメッセージキューに届きます。画面のスレッドで登録すると、画面の処理と混ざってしまうので、ホットキー専用のエンジンスレッドを作りました。
エンジンスレッド
1. PeekMessageW を1回呼んで、メッセージキューを作る
2. 設定のキーを登録(取れなかったものは、理由つきで記録)
3. GetMessage のループ
WM_HOTKEY → そのキーの設定を実行(ShellExecuteW の "open")
WM_APP → 設定の読み直し(登録しなおす)
WM_QUIT → ループを抜けて、全部解除
設定を変えたとき、画面のスレッドからは、チャンネル(mpsc)に新しい設定を入れて、PostThreadMessageW で WM_APP を送ります。エンジンスレッドは、起こされたらチャンネルから受け取り、いったん全部解除してから、登録しなおします。終了は、Drop で WM_QUIT を送ります。
実行は、ShellExecuteW の "open" です。プログラムでも、ファイルでも、フォルダでも、同じ呼び方でWindowsが適切に開いてくれます。引数、作業フォルダ、ウィンドウの状態(通常・最大化・最小化・非表示)も、設定で指定できます。
取れなかったキーは、どのキーなのかを、画面に出します。古いランチャーの不満のひとつを、これで解消しました。
3. 設定JSONを、壊さない
設定は、人もAIも直接書き換えるファイルです。そこで、壊れた設定を、アプリが上書きしないことを決めました。
- 起動時にJSONが読めなかったら、画面はエラー状態になり、保存の操作を受け付けません。勝手に空の設定で上書きして、元の設定が消える事故を防ぎます。
- 保存は、一時ファイルに書いてから、名前を変える(
tmp→rename)形です。書き込み中に落ちても、半端なファイルが残りません。 - 項目の
idは自動で振ります(e01,e02…)。 - 保存するとき、編集中の項目だけを検査します。「ホットキーの書き方」「実行する対象が空でないか」「予約されたキー」「修飾キーなしの文字キー(
Aだけ、など)」「同じ設定内の重複」です。ほかの項目の問題で、いま編集している項目の保存が止まることはありません。
画面は、設定ファイルが外から書き換えられても、ファイルの再読み込みで追従します。AIにまとめて書き換えてもらったあと、ボタンひとつで反映できます。
4. キー入力は keyCode:shotkey とは逆にした
編集画面では、「欄をクリックして、キーを押す」だけでホットキーを入力できます。ここで、前回の shotkey とは逆の選択をしました。
shotkey は、キーの位置(e.code)で名前を作りました。日本語配列でも英語配列でも、同じキーは同じ名前になるからです。一方、RegisterHotKey が受け取るのは、仮想キーコード(VK)です。VKは配列に依存します。たとえば、記号のキーは、配列によって位置が違います。
// keyCode は Windows の仮想キーコードそのもの。RegisterHotKey に渡す値と同じ
let vk = e.keyCode || 0;
if (vk === 229) vk = vkFromCode(e.code); // IME の最中は 229 が来る。文字と数字だけ code から戻す
HotRunは、押したキーのVKを、そのまま登録するので、keyCode が正解でした。IMEが動いているときは 229 が来るので、そのときだけ、文字と数字を code から戻しています。
キーの名前の表は、JavaScript側(NAMED)と、Rust側(NAMED_KEYS)の2か所にあります。ずれるとバグになるので、テストでRustのソースを読んで、JavaScriptの表と突き合わせています。
5. Tauriの窓とトレイ
画面は、Tauri v2です。つまずいた所を、書いておきます。
- 窓はRustで作る。ラベルは
main、edit-N(編集、項目ごと)、scan(使用中のキー一覧)。権限(capabilities)に"edit-*"のように書いておかないと、あとから作る窓が、コマンドを呼べません。 - リリースビルドには
custom-protocolが要る。付け忘れると、開発サーバーを探しに行って、真っ白な画面になります。 - メイン窓の×は「隠す」。トレイに残るアプリなので、窓を閉じても終了しません。終わるのは、トレイの「終了」からです。トレイは、左クリックでメニューを出さず(
show_menu_on_left_click(false))、左クリックで設定を開きます。 - 確認ダイアログは、ページの中の重ね表示。
alertやconfirmではなく、HTMLで作りました。見た目がそろうのと、DOMのテストで確かめられるからです。 - 二重起動は防ぐ(
tauri-plugin-single-instance)。トレイにもう居るのに2つ目を起動すると、既存の窓が前に出ます。 - 自動起動は、レジストリ。
HKCU\...\Runに"exe" --hiddenを書きます。--hiddenは、窓を出さずにトレイだけで起動する指定です。
6. 使用中のキーを洗い出す
ここからが、メインの話です。ホットキーを登録しようとして、他のアプリに取られていたとき、誰が取っているのかを知りたくなります。Windowsには、「いま登録されているホットキーの一覧」を教えてくれるAPIが、私が調べた範囲では、見つかりませんでした。持ち主を教えるAPIも、同じです。
でも、取られているかどうかは、調べられます。
pub fn scan_taken(candidates: &[crate::Hotkey]) -> Vec<crate::Hotkey> {
let mut taken = vec![];
for h in candidates {
let mods = HOT_KEY_MODIFIERS(h.mods) | MOD_NOREPEAT;
match unsafe { RegisterHotKey(HWND::default(), SCAN_ID, mods, h.vk) } {
Ok(()) => unsafe {
let _ = UnregisterHotKey(HWND::default(), SCAN_ID); // 取れたら、すぐ返す
},
Err(err) => {
if err.code() == windows::core::HRESULT::from_win32(ALREADY_REGISTERED) {
taken.push(*h); // 断られたものを集める
}
}
}
}
taken
}
全部の組み合わせに、登録を試すだけです。取れたら、すぐ解除します。「すでに使用中」で断られたキーが、使われているキーです。キーは1度も押しません。
試すのは、修飾キーの16通り(Ctrl・Alt・Shift・Winの組み合わせ)と、キーの一覧(文字、数字、F1〜F24、矢印、テンキー、音量やブラウザのキーなど)の掛け算です。修飾キーそのもの、マウスのボタン、IME用のキーは外しています。「名前を持たないキーは、一覧に VK173 のような形で出てしまう」ので、テストで全候補に名前があることを確かめています。
登録は、呼んだスレッドに結びつくので、スキャンは専用のスレッドで走らせます。ほかのスレッドの登録と、ぶつからないようにするためです。
分け方:HotRun / Winキー / 他のアプリ
出てきた結果は、3つに分けて見せます。
pub fn classify(h: &Hotkey, own: &[Hotkey]) -> Class {
if own.contains(h) {
Class::HotRun // 自分で取っているキー
} else if h.mods & WIN != 0 {
Class::Win // Winキーを含む組み合わせ
} else {
Class::Other // それ以外
}
}
私のパソコンでは、240件でした。内訳は、HotRun自身が23件、Winキーを含む組み合わせが181件、他のアプリが36件です。Winキー系は、Windows標準のものと、PowerToysのようにWinキーを使うアプリのものが、混ざっています。「Winキーを含む」で分けているだけで、「Windows標準」だと言い切れる分け方では、ありません。
最初は、見づらかった
最初の画面は、キーを1つずつ並べただけで、Win+Shift+S、Win+Shift+D… と、同じ前置きが181個も並び、とても見づらいものでした。そこで、修飾キーごとに1行にして、右側に、キーだけを並べる形にしました。
Ctrl + Alt | Backspace Tab Delete 0 5 6 7 8 9 K M R
Alt + Shift | Tab R W F10
Winキーのグループは、181件もあるので、初期状態では閉じてあります。絞り込みの欄に入力したときだけ、開きます。
私のバグ:グループ名が、合っていなかった
使ってみると、HotRunのグループが、一覧に出ませんでした。合計は240件なのに、画面の3つの合計は217件で、23件が消えていました。
原因は、名前の食い違いです。Rustが Class::HotRun を、serde の rename_all = "snake_case" で書き出すと、"hot_run" になります。画面のJavaScriptは、"hotrun" を探していました。
pub enum Class {
#[serde(rename = "hotrun")] // snake_case のままだと "hot_run" になって、画面が探す名前と合わない
HotRun,
Win,
Other,
}
直すのは1行ですが、境界をまたぐ名前は、テストで固定することにしました。画面が見る3つの名前を、Rust側のテストで確かめています。
押して確認:届かないキーが、取られているキー
持ち主の名前は、Windowsからは分かりません。そこで、実際に押して確かめる道具を付けました。Hotkey Screener と同じやり方です。
登録されたホットキーは、押されると、Windowsが登録したアプリへ回してしまうので、ほかの窓には、キーが届きません。この性質を、そのまま使います。
- 「押して確認」を押して、5秒以内に調べたいキーを押す
- 画面にキーが届いた → そのキーは、誰も取っていない
- 何も届かなかった → 他のアプリが取っている(またはWindowsの予約)。押した瞬間に反応したアプリが持ち主
document.addEventListener('keydown', (ev) => {
if (!probing) return;
const k = window.HotRunCommon.readKey(ev);
if (!k.complete) return; // Ctrl だけ、などはまだ待つ
ev.preventDefault();
ev.stopImmediatePropagation(); // 確認中の Esc は、窓を閉じずに、キーとして調べる
// ... 一覧にあるキーなら、食い違いを知らせる。なければ「誰も取っていません」
}, true);
私のパソコンで試しました。A は届いて、「今は誰も取っていません」と出ました。F2 は届かず、ほかのキャプチャアプリが反応しました。取られているキーが、期待どおりに「届かない」側に入ったわけです。
弱点もあります。届かなかったとき、どのキーを押したのかは、画面には分かりません。HotRun自身のキーも、自分が取っているので、届かない側に入ります(一覧の「HotRun が使用中」で見分けます)。そして、持ち主のアプリが反応するときは、そのアプリの動作が実際に実行されます。
持ち主を特定するのは、やめた
持ち主の名前まで当てる方法も、考えました。
- キーボードフックで、押されたキーを先に捕まえて、止める。止めれば、持ち主は動かない。押したキーは正確に分かるが、止めると持ち主は目を覚まさないので、特定はできない。
- 押したあと、どのプロセスのスレッドが急に動いたかを比べる。これは推測で、外れもある。しかも、持ち主の動作は、実行される。管理者権限で動いているアプリは、通常権限からは見えないので、管理者での起動も要りそう。
どちらも、安全に、確実に、とはいきません。フックは、セキュリティソフトに警戒されるかもしれません。そこで、持ち主の特定は、今回は作りませんでした。リストと、押して確認だけでも、「空いているキーを探して使う」目的には、足りています。先客がいるキーを、使いたいときだけ、持ち主を自分で探すことになります。
7. リリース:GitHub Actions
配布は、タグをpushすると、GitHub Actionsが走る形です。windows-latest で、テストを回して(cargo test と、画面のテスト npm test)、ビルドして、HotRun-<タグ>-windows-x64.zip を作って、下書きのリリースに置きます。中身を確かめてから、自分で Publish を押します。
これは、[Rust #02] で、「下書きのリリースは、タグのページには出ない」と学んだ形そのままです。確認するのは、リリースの一覧です。
8. つまずいたこと
手元でビルドできないコードを、書いた
私は、Windowsのコードを、そのままコンパイルして確かめる環境が、手元にありませんでした。core の純粋な処理は、Linuxでテストが回ります。Tauriの画面の側は、Windows専用の部分を空の関数に置き換えて、型だけを検査しました。そのため、実機のビルドで見つかる誤りが、出ました。
- ZIPで更新したファイルを上書きしても、ファイルの更新時刻のせいで、
cargoが「変わっていない」と判断して、古いままビルドすることがありました。scanモジュールが「見つからない」と出たのが、これです。上書きしたあとは、更新時刻を更新してから、ビルドします。 - ZIPで上書きしても、古いファイルは消えません。コマンドライン側で
win.rsをcoreへ移したあと、古いcli/src/win.rsが残って、エラーの山になりました。消えると思い込んだのは、私の間違いでした。
アイコンが、すぐには変わらない
[Rust #02] と同じです。アイコンは、コンパイルのときにexeへ埋め込まれます。タイトルバーの小さなアイコンは新しくなっても、エクスプローラーやタスクバーは、古いアイコンを覚えていることがあります。
まとめ:スレッド、keyCode、壊さない保存、そして境界の名前
RegisterHotKeyは、登録したスレッドに結びつく。専用のエンジンスレッドを作り、メッセージループで受ける。設定の更新は、PostThreadMessageWで起こして、全部解除してから登録しなおす- キー入力は、
RegisterHotKeyに渡すVKに合わせて、keyCodeで読む([Rust #02] のe.codeとは逆)。IMEの229だけ、codeから戻す - 設定JSONは、壊れていたら上書きしない。保存は、一時ファイルに書いてから、名前を変える
- 使用中のキーは、全組み合わせに登録を試して、1409で断られたものを集める。キーは押さない
- 持ち主は、Windowsからは分からない。届かないキーが、取られているキー。押して確かめる
- Rustと画面の間で、名前をやり取りするものは、テストで固定する(
hot_runとhotrunの食い違い)
動作確認は、手元のWindows 11が中心です。ホットキーの登録、設定画面、トレイ、使用中のキー一覧、押して確認は、動いています。管理者権限で動いているウィンドウの前面で、ホットキーが効くかは、まだ確かめていません。実行する対象に、%USERPROFILE% のような環境変数が使えるかも、まだ確かめていません。持ち主の特定も、まだ作っていません。
Releases · fixtan/hotrun
Windows用zip(HotRun-vx.y.z-windows-x64.zip)。署名なし。
github.com