[Rust #03] ホットキーランチャー「HotRun」を作り、すでに使われているキーを洗い出すまで

[Rust #03] ホットキーランチャー「HotRun」を作り、すでに使われているキーを洗い出すまで

はじめに

長く使ってきた、ホットキーランチャーがあります。「Ctrl+1 で、あのプログラムを起動」のように、キーに登録したものを開くだけの、小さなアプリです。ずっと使っていたのは、移すのが面倒だったからです。不満は2つありました。

  • 編集の画面が使いにくい
  • キーが重なったとき、エラーが出るだけで、どのキーなのか教えてくれない

PowerToysに同じ機能もありますが、私はコマンドラインで操作できて、設定がJSON1ファイルで、AIにまとめて書き換えてもらえるものがほしかったので、自作しました。名前は HotRun です。

  • ホットキーで、プログラム / ファイル / フォルダを開く
  • 設定はJSON1ファイル。画面からも、コマンドラインからも、同じファイルを使う
  • タスクトレイに常駐する。Windows起動時の自動起動もできる
  • 一覧、編集の画面、保存前の「テスト実行」
  • 他のアプリがすでに使っているキーの一覧と、押して確かめる機能

言語は Rust です。画面だけ Tauri v2 と素のJavaScriptで、ホットキーの登録と実行はRustです。対象は Windowsだけです。

スクリーンショット

[Rust #03] HotRun 一覧 [Rust #03] HotRun 編集ウィンドウ [Rust #03] HotRun 使用中のキー一覧

リポジトリ:

前回の記事(Rustで作ったスクショ+録画アプリの話):

1. 構成: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% のような環境変数が使えるかも、まだ確かめていません。持ち主の特定も、まだ作っていません。