[Rust 実践] #14 ホットキー常駐アプリの構造 — shotkey を分解する

[Rust 実践] #14 ホットキー常駐アプリの構造 — shotkey を分解する

はじめに

今回は、自作のスクリーンショットアプリ shotkey(fixtan/shotkey、Tauri v2 + Rust、Windows 用)を分解します。見るのは、次の 4 つです。

見るものshotkey での役割
グローバルホットキー他のアプリを使っている間も、Ctrl+Alt+1 などに反応する
トレイ窓がなくても、アプリが動いていることを示し、終了の入口になる
窓を閉じても終わらない設定の窓を閉じても、ホットキーは効き続ける
多重起動の防止2 つ目を起動しても、動くのは 1 つだけ(2 つ目は、1 つ目の設定窓を出す)

分解した版は、コミット 0fe93c1(2026-10-12、v0.3.1)です。

画面の撮影と録画は Windows の機能を使うので、この環境(Linux)では動きません。そこで、次の 3 つに分けて確かめました。

  1. 同じ構造の最小アプリ minikey を作って、Linux(仮想ディスプレイ)で動かす
  2. shotkey の、OS に依存しない部分(core)のテストを動かす
  3. shotkey 本体を Linux でビルドして、ホットキーが Rust に届くところまで動かす

shotkey の構成

shotkey/
├── Cargo.toml          ← ワークスペース(core と src-tauri をまとめる)
├── core/               ← OS に依存しない部分(Rust だけ)
│   └── src/ geom.rs(座標), naming.rs(ファイル名), pixels.rs(画素)
├── src-tauri/          ← Tauri のアプリ本体
│   ├── Cargo.toml
│   ├── capabilities/default.json
│   ├── tauri.conf.json
│   └── src/ lib.rs(652 行), capture.rs(撮影), record.rs(録画), config.rs(設定), main.rs
└── src/                ← 画面(HTML と JavaScript)
    └── index.html, settings.js, overlay.html, overlay.js, rec.html, styles.css

第9回の「ファイルを分ける」が、ここでは 3 段になっています。

  • core: 座標の計算、ファイル名の連番、画素の切り出し。Rust だけで書かれていて、OS に依存しません。
  • capture.rs と record.rs: Windows の機能を使う部分。
  • lib.rs: Tauri の部分(ホットキー、トレイ、窓、コマンド)。上の 2 つをつなぎます。

src-tauri/Cargo.toml では、core を次のように取り込んでいます。

[dependencies]
shotkey-core = { path = "../core" }
tauri = { version = "2", features = ["tray-icon", "image-png"] }
tauri-plugin-global-shortcut = "2"
tauri-plugin-single-instance = "2"

[target.'cfg(windows)'.dependencies]
windows-capture = "2"
wasapi = "0.25"
windows = { version = "0.58", features = [ ... ] }

[target.'cfg(windows)'.dependencies] に書いた依存は、Windows でビルドするときだけ使われます。

OS ごとに、中身を差し替える

capture.rs は、Windows の版と、それ以外の版を、mod imp として 2 つ持ち、最後に pub use imp::*; で、どちらかを外に出しています。

#[cfg(not(windows))]
mod imp {
    use super::*;
    pub fn monitors() -> Vec<Rect> { Vec::new() }
    pub fn grab_desktop() -> Result<Bgra, String> { Err("Windows 専用".into()) }
    pub fn monitor_handle_at(_x: i32, _y: i32) -> Option<usize> { None }
    pub fn cursor_pos() -> Option<(i32, i32)> { None }
    pub fn foreground_window() -> Option<Rect> { None }
    pub fn play_shutter() {}
}

pub use imp::*;

Windows 以外では、同じ名前の関数が「何もしない」か「エラーを返す」だけの版に置き換わります。呼ぶ側(lib.rs)は、OS を気にせず書けます。record.rs も、同じ形です。このおかげで、shotkey は Linux でもビルドできます(あとで確かめます)。

ホットキーを登録する

lib.rs の apply_hotkeys です(src-tauri/src/lib.rs の 470〜523 行。設定の項目 10 個の並びは、一部省略)。

use tauri_plugin_global_shortcut::{GlobalShortcutExt, Shortcut, ShortcutState};

struct AppState {
    cfg: Mutex<Config>,
    // ...
    keys: Mutex<HashMap<u32, Action>>, // ホットキーの id → 何をするか
    busy: AtomicBool,                  // 撮影中 (範囲選択中も含む) は、次のホットキーを受けない
    key_errors: Mutex<Vec<String>>,    // 登録できなかったホットキーの説明
    // ...
}

/// 設定のとおりにホットキーを登録し直す。登録できなかったものを、理由つきで返す。
fn apply_hotkeys(app: &AppHandle, cfg: &Config) -> Vec<String> {
    let gs = app.global_shortcut();
    let _ = gs.unregister_all();
    let mut map = HashMap::new();
    let mut errors = Vec::new();
    // ...
    for (action, label, text) in [ (Action::All, "全体", &hk.all), /* ... 全部で 10 個 */ ] {
        let text = text.trim();
        if text.is_empty() {
            continue;
        }
        match text.parse::<Shortcut>() {
            Err(e) => errors.push(format!("{label} ({text}): 読み取れません — {e}")),
            Ok(sc) => match gs.register(sc) {
                Ok(()) => {
                    map.insert(sc.id(), action);
                }
                Err(e) => errors.push(format!("{label} ({text}): 登録できません (ほかのアプリが使っている可能性があります) — {e}")),
            },
        }
    }
    *app.state::<AppState>().keys.lock().unwrap() = map;
    errors
}

流れは、次の 4 つです。

  1. unregister_all() で、いったん全部外す(設定を変えたときに、登録し直せる)
  2. 文字列("Ctrl+Alt+1")を Shortcut に変える(parse)。失敗したら、読み取れなかった理由を記録
  3. register で、OS に登録する。失敗したら、理由を記録
  4. 登録できたものだけ、「ホットキーの id → やること(Action)」の表(HashMap)に入れる

エラーは、途中で止めずに、集めて返します。 1 つ登録できなくても、残りは使えます。集めたエラーは、設定画面に見せます。

押されたときの処理(ハンドラ)は、アプリの組み立ての中で渡しています。

tauri::Builder::default()
    .plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| show_main(app)))
    .plugin(
        tauri_plugin_global_shortcut::Builder::new()
            .with_handler(|app, shortcut, event| {
                if event.state() != ShortcutState::Pressed {
                    return;
                }
                let action = app.state::<AppState>().keys.lock().unwrap().get(&shortcut.id()).copied();
                if let Some(a) = action {
                    run_action(app, a);
                }
            })
            .build(),
    )
  • event.state() != ShortcutState::Pressed で、「押した」だけを受けます(あとで、離したときも届くことを確かめます)。
  • shortcut.id() で、表を引いて、Action を取り出します。.copied() は、Option<&Action> を Option<Action> にします(Action は Copy を付けた enum です)。
  • 押されたあとの処理は、run_action に渡します。

動作中は、次のキーを受けない

fn run_action(app: &AppHandle, action: Action) {
    let state = app.state::<AppState>();
    // 録画のホットキーは、録画中なら「停止」になる
    if matches!(action, Action::RecWindow | Action::RecMonitor | Action::RecRegion) && state.rec.lock().unwrap().is_some() {
        stop_recording(app);
        return;
    }
    if state.busy.swap(true, Ordering::SeqCst) {
        return; // 撮影中
    }
    let app = app.clone();
    std::thread::spawn(move || {
        let result = do_action(&app, action);
        // ...
        if !keep_busy {
            app.state::<AppState>().busy.store(false, Ordering::SeqCst);
        }
    });
}
  • busy.swap(true, ...): 「true に書き換えて、前の値を返す」を、一度にやります。前の値が true なら、すでに動作中なので、何もしません。「確かめてから書く」を 2 回に分けないので、ほぼ同時に 2 回押されても、1 つしか通りません。
  • std::thread::spawn: 撮影は、別のスレッドで動かします。ハンドラの中で時間のかかることをしないためです。

この 2 つの効果を、あとで最小アプリで比べます。

窓を閉じても終わらない

.on_window_event(|window, event| {
    // 設定の窓を閉じても、終わらない (トレイに残る)。終わるのは、トレイの「終了」
    if let WindowEvent::CloseRequested { api, .. } = event {
        if window.label() == "main" {
            api.prevent_close();
            let _ = window.hide();
        }
    }
})

CloseRequested(閉じる要求)が来たら、api.prevent_close() で閉じるのをやめて、代わりに hide() で隠します。tauri.conf.json では、設定の窓(main)を、最初から "visible": false にしています。窓がなくても、アプリは動いている、という状態です。

終わらせるのは、トレイのメニューの「終了」です。

let open = MenuItem::with_id(app, "open", "設定を開く", true, None::<&str>)?;
// ...
let quit = MenuItem::with_id(app, "quit", "終了", true, None::<&str>)?;
let menu = Menu::with_items(app, &[&open, &folder, &stop, &quit])?;
TrayIconBuilder::with_id("main")
    .icon(app.default_window_icon().cloned().ok_or("アイコンがありません")?)
    .tooltip("shotkey")
    .menu(&menu)
    .show_menu_on_left_click(false)
    .on_menu_event(|app, ev| match ev.id.as_ref() {
        "open" => show_main(app),
        // ...
        "quit" => {
            stop_recording_blocking(app); // 録画中に終わっても、MP4 を壊さない
            app.exit(0)
        }
        _ => {}
    })
    .build(app)?;

トレイのアイコンを左クリックしたときは、設定の窓を出します(show_main)。

多重起動を防ぐ

.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| show_main(app)))

この 1 行です。2 つ目のプロセスを起動すると、2 つ目は終わり、1 つ目に知らせが届きます。知らせを受け取る処理(この中身)が、show_main(設定の窓を出す)です。

最小アプリ minikey

上の仕組みを、機能を抜いて、1 つの lib.rs にしました。撮影の代わりに、2 つの動作(すぐ終わる Quick、400 ミリ秒かかる Slow)と、終了(Quit)を持ちます。tauri 2.12.5、tauri-plugin-global-shortcut 2.4.1、tauri-plugin-single-instance 2.5.5 です。

use std::collections::HashMap;
use std::sync::Mutex;
use std::sync::atomic::{AtomicBool, Ordering};
use std::thread;
use std::time::Duration;

use tauri::menu::{Menu, MenuItem};
use tauri::tray::TrayIconBuilder;
use tauri::{AppHandle, Manager, WindowEvent};
use tauri_plugin_global_shortcut::{GlobalShortcutExt, Shortcut, ShortcutState};

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Action {
    Quick,
    Slow,
    Quit,
}

struct AppState {
    keys: Mutex<HashMap<u32, Action>>, // ホットキーの id → 何をするか
    busy: AtomicBool,                  // 動作中は、次のホットキーを受けない
}

fn name() -> String {
    thread::current().name().unwrap_or("?").to_string()
}

/// 設定のとおりに登録する。登録できなかったものを、理由つきで返す。
fn apply_hotkeys(app: &AppHandle, list: &[(Action, &str)]) -> Vec<String> {
    let gs = app.global_shortcut();
    let _ = gs.unregister_all();
    let mut map = HashMap::new();
    let mut errors = Vec::new();
    for (action, text) in list {
        match text.parse::<Shortcut>() {
            Err(e) => errors.push(format!("{action:?} ({text}): 読み取れません — {e}")),
            Ok(sc) => match gs.register(sc) {
                Ok(()) => {
                    map.insert(sc.id(), *action);
                }
                Err(e) => errors.push(format!("{action:?} ({text}): 登録できません — {e}")),
            },
        }
    }
    *app.state::<AppState>().keys.lock().unwrap() = map;
    errors
}

fn run_action(app: &AppHandle, action: Action) {
    if action == Action::Quit {
        println!("[rust] quit");
        app.exit(0);
        return;
    }
    if app.state::<AppState>().busy.swap(true, Ordering::SeqCst) {
        println!("[rust] {action:?}: ignored (busy)");
        return;
    }
    if std::env::var("MINIKEY_INLINE").is_ok() {
        // 比較用: ハンドラの中で、そのまま動かす
        println!("[rust] {action:?}: start inline on thread {}", name());
        if action == Action::Slow {
            thread::sleep(Duration::from_millis(400));
        }
        println!("[rust] {action:?}: end");
        app.state::<AppState>().busy.store(false, Ordering::SeqCst);
        return;
    }
    let app = app.clone();
    thread::spawn(move || {
        println!("[rust] {action:?}: start on thread {}", name());
        if action == Action::Slow {
            thread::sleep(Duration::from_millis(400));
        }
        println!("[rust] {action:?}: end");
        app.state::<AppState>().busy.store(false, Ordering::SeqCst);
    });
}

pub fn run() {
    let mut b = tauri::Builder::default();
    if std::env::var("MINIKEY_NO_SINGLE").is_err() {
        b = b.plugin(tauri_plugin_single_instance::init(|_app, args, _cwd| {
            println!("[rust] second instance tried to start, args={args:?}");
        }));
    }
    b.plugin(
        tauri_plugin_global_shortcut::Builder::new()
            .with_handler(|app, shortcut, event| {
                println!("[rust] key event {:?} on thread {}", event.state(), name());
                if event.state() != ShortcutState::Pressed {
                    return;
                }
                let action = app.state::<AppState>().keys.lock().unwrap().get(&shortcut.id()).copied();
                if let Some(a) = action {
                    run_action(app, a);
                }
            })
            .build(),
    )
    .setup(|app| {
        app.manage(AppState { keys: Mutex::new(HashMap::new()), busy: AtomicBool::new(false) });

        let quit = MenuItem::with_id(app, "quit", "終了", true, None::<&str>)?;
        let menu = Menu::with_items(app, &[&quit])?;
        let tray = TrayIconBuilder::with_id("main")
            .icon(app.default_window_icon().cloned().ok_or("no icon")?)
            .tooltip("minikey")
            .menu(&menu)
            .build(app);
        println!("[rust] tray: {}", if tray.is_ok() { "built" } else { "failed" });

        let list = [
            (Action::Quick, "Ctrl+Alt+1"),
            (Action::Slow, "Ctrl+Alt+2"),
            (Action::Quit, "Ctrl+Alt+Q"),
            (Action::Quick, "Ctrl+Alt+1"),   // わざと重複
            (Action::Slow, "Ctrl+Alt+"),     // わざと壊れた形
            (Action::Slow, "Foo+3"),         // わざと知らないキー
        ];
        for e in apply_hotkeys(app.handle(), &list) {
            println!("[rust] hotkey error: {e}");
        }
        println!("[rust] ready");

        if std::env::var("MINIKEY_CLOSE_TEST").is_ok() {
            let w = app.get_webview_window("main").unwrap();
            println!("[rust] main visible at start: {}", w.is_visible().unwrap());
            thread::spawn(move || {
                thread::sleep(Duration::from_millis(500));
                w.show().unwrap();
                println!("[rust] main visible after show: {}", w.is_visible().unwrap());
                w.close().unwrap();
                thread::sleep(Duration::from_millis(500));
                println!("[rust] main visible after close: {}", w.is_visible().unwrap());
            });
        }
        Ok(())
    })
    .on_window_event(|window, event| {
        if let WindowEvent::CloseRequested { api, .. } = event {
            if window.label() == "main" {
                println!("[rust] close requested -> hide");
                api.prevent_close();
                let _ = window.hide();
            }
        }
    })
    .run(tauri::generate_context!())
    .expect("error while running tauri application");
}

tauri.conf.json の窓は、shotkey と同じく、"label": "main"、"visible": false です。Cargo.toml の依存は、tauri(tray-icon と image-png つき)、tauri-plugin-global-shortcut、tauri-plugin-single-instance です。

動かして確かめる

画面のない環境なので、仮想ディスプレイ(Xvfb、X11)の上で起動して、xdotool でキーを押しました。起動の方法は、前回までと同じです(3 つの環境変数つき)。

登録エラー

起動時の出力です(minikey の list に、わざと 3 つ悪いものを入れています)。

[rust] tray: built
[rust] hotkey error: Quick (Ctrl+Alt+1): 登録できません — HotKey already registered: HotKey { mods: Modifiers(ALT | CONTROL), key: Digit1, id: 589830 }
[rust] hotkey error: Slow (Ctrl+Alt+): 読み取れません — Found empty token while parsing hotkey: Ctrl+Alt+
[rust] hotkey error: Slow (Foo+3): 読み取れません — Couldn't recognize "Foo" as a valid key for hotkey, if you feel like it should be, please report this to https://github.com/tauri-apps/muda
[rust] ready
  • 同じキーを 2 回登録すると、2 回目は already registered で失敗しました。
  • 壊れた形(Ctrl+Alt+)と、知らないキー名(Foo+3)は、登録の前の、読み取り(parse)で失敗しました。

3 つとも止まらず、残り(Ctrl+Alt+1、2、Q)は登録されて、[rust] ready まで進みました。

押す、離す

xdotool key ctrl+alt+1 を 1 回押した結果です。

[rust] key event Pressed on thread ?
[rust] key event Released on thread ?
[rust] Quick: start on thread ?
[rust] Quick: end

押したときと離したときの、2 回、ハンドラが呼ばれました。 Pressed だけを処理するのは、そのためです。スレッドの名前は ?(名前のないスレッド)で、main ではありませんでした。

動作中のキーは、無視される

Ctrl+Alt+2(400 ミリ秒かかる Slow)を押した直後に、Ctrl+Alt+1(Quick)を押しました。

[rust] key event Pressed on thread ?
[rust] Slow: start on thread ?
[rust] key event Released on thread ?
[rust] key event Pressed on thread ?
[rust] Quick: ignored (busy)
[rust] key event Released on thread ?
[rust] Slow: end

Slow が動いている間に来た Quick は、ignored (busy) で無視されました。Slow の終わり(Slow: end)より前に、2 つ目のキーのイベントが、処理されています。

ハンドラの中で動かすと

比較のために、MINIKEY_INLINE を付けて、別スレッドを使わず、ハンドラの中でそのまま動かしました。同じ操作です。

[rust] key event Pressed on thread ?
[rust] Slow: start inline on thread ?
[rust] Slow: end
[rust] key event Released on thread ?
[rust] key event Pressed on thread ?
[rust] Quick: start inline on thread ?
[rust] Quick: end
[rust] key event Released on thread ?
  • 2 つ目のキー(Quick)の Pressed が、Slow: end の後に処理されています。ハンドラが塞がっている間、後のイベントが待たされました。
  • イベントは、なくなりませんでした(待たされて、あとで処理されました)。
  • その結果、Quick は、busy に引っかからず、実行されました。

別のスレッドで動かす版(上)は、「動作中のキーを捨てる」、ハンドラの中で動かす版は、「順番に全部やる」という違いになりました。shotkey は、前者です。

窓を閉じても、終わらない

MINIKEY_CLOSE_TEST で、起動の 0.5 秒後に、窓を出して、close() を呼びました。

[rust] main visible at start: false
[rust] main visible after show: true
[rust] close requested -> hide
[rust] main visible after close: false

close() で CloseRequested が来て、prevent_close で閉じず、hide で隠れました。起動の 6 秒後も、プロセスは動いたままでした(そのあとに Ctrl+Alt+Q を送って、終了コード 0 で終わりました)。

多重起動

1 つ目を動かしたまま、--foo bar を付けて、2 つ目を起動しました。

second exit=0
-- 1st log:
[rust] second instance tried to start, args=["./target/debug/minikey", "--foo", "bar"]

2 つ目は、何も出力せず、終了コード 0 で終わりました。1 つ目には、2 つ目の起動引数が届きました。

2 つのプロセスが、同じキーを登録すると

single-instance を外して(MINIKEY_NO_SINGLE)、2 つのプロセスを動かしました。2 つ目のプロセスでは、Ctrl+Alt+1、2、Q のすべてが、次のように失敗しました。

[rust] hotkey error: Quick (Ctrl+Alt+1): 登録できません — HotKey already registered: HotKey { mods: Modifiers(ALT | CONTROL), key: Digit1, id: 589830 }

そのあとで Ctrl+Alt+1 を押すと、Quick が動いたのは 1 つ目のプロセスだけ(2 つ目は 0 回)でした。同じキーは、システム全体で 1 つのプロセスしか登録できません。 shotkey が、エラーを集めて設定画面に見せるのは、このためです。

shotkey 本体を、Linux で動かす

shotkey 本体を、同じ環境(Xvfb)で、cargo build -p shotkey でビルドして、動かしました。

  • ビルドは通りました。 capture.rs と record.rs が、上で見た #[cfg(not(windows))] の版になるためです。
  • 起動すると、窓は出ません(xdotool search --onlyvisible --name shotkey の件数が 0)。
  • Ctrl+Alt+1、Ctrl+Alt+4、Ctrl+Alt+8 を押すと、Rust に届いて、それぞれ次のエラーが出ました。
Ctrl+Alt+1 ->
shotkey: Windows 専用
Ctrl+Alt+4 ->
shotkey: Windows 専用
Ctrl+Alt+8 ->
shotkey: 録画は Windows 専用です
  • 2 つ目を起動すると、2 つ目は終了コード 0 で終わり、1 つ目の設定の窓が出ました(窓の件数が 1)。

ホットキー → run_action → do_action → 撮影(このスタブでエラー)→ report_error、という流れが、Linux でも動いたことになります。

core のテスト

OS に依存しない core は、Linux でテストできます。

running 15 tests
test geom::tests::bounding_with_negative_origin ... ok
...
test naming::tests::next_is_max_plus_one_not_count ... ok
...
test result: ok. 15 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

15 件が成功しました(geom 6 件、naming 5 件、pixels 4 件)。たとえば naming のテストは、「2 番を消してあっても、連番は詰めない(最大 + 1)」を、保存先のファイル名の一覧だけで確かめています(ファイルの読み書きは、テストに入っていません)。

Java と並べる

多重起動の防止を、Java の標準ライブラリだけで書いて、動かしました。固定のポートを待ち受け、先に取れたほうが「1 つ目」になります。

try (ServerSocket server = new ServerSocket(PORT, 1, InetAddress.getLoopbackAddress())) {
    System.out.println("first instance: listening");
    // ... 2 つ目からの接続を待つ
} catch (BindException e) {
    System.out.println("second instance: " + e.getMessage());
    // 1 つ目に、引数を送って、終わる
}
second instance: Address already in use
second instance: told the first, exiting
first instance: second instance said args=--foo bar

1 つ目に、2 つ目の起動引数(--foo bar)が届きました。Tauri の single-instance が、(app, args, cwd) を渡してくれるのと、同じ形です。ただし、この Java の版は、自分で書いたもので、tauri-plugin-single-instance の中身(どうやって 1 つ目を見つけるか)は、確かめていません。

もう 1 つ、busy の swap を、Java の AtomicBoolean.getAndSet と並べました。

1st press: was busy=false
2nd press: was busy=true

Rust の AtomicBool::swap(true, ...) と同じで、「書き換えて、前の値を返す」ものです。

グローバルホットキーそのものは、Java の標準ライブラリで書く方法を、確認していません。

初めて出てきた書き方

書き方意味
Cargo.toml の [workspace]、path = "../core"複数のクレートを 1 つのリポジトリにまとめ、パスで取り込む
[target.'cfg(windows)'.dependencies]Windows でビルドするときだけ使う依存
#[cfg(windows)] / #[cfg(not(windows))]OS によって、コードを入れ替える
mod imp { ... } と pub use imp::*;中の関数を、外に同じ名前で出す(入れ替えの仕組みに使う)
tauri_plugin_global_shortcut::Builder::new().with_handler(...)ホットキーが押されたときの処理を渡す
app.global_shortcut().register(sc) / unregister_all()ホットキーを登録する / 全部外す
"Ctrl+Alt+1".parse::<Shortcut>()文字列を、ホットキーに変える(Result)
event.state() と ShortcutState::Pressed押した / 離した
AtomicBool / .swap(true, Ordering::SeqCst)書き換えて、前の値を返す(ロックなしで使える bool)。Ordering の意味は、今回は説明しません
.copied()Option<&T> を Option<T> にする(T が Copy のとき)
WindowEvent::CloseRequested { api, .. } と api.prevent_close()窓を閉じる要求を受けて、閉じるのをやめる
TrayIconBuilder::with_id("main")...build(app)トレイのアイコンを作る
MenuItem::with_id(...) / Menu::with_items(...)メニューの項目と、メニュー
tauri_plugin_single_instance::init(|app, args, cwd| ...)2 つ目の起動のとき、1 つ目で呼ばれる処理を渡す

歴史

Windows には、グローバルホットキーのための API RegisterHotKey があります。

項目Microsoft の文書の記述
何をするかシステム全体のホットキーを定義する。押されると、WM_HOTKEY メッセージが、窓かスレッドに届く
登録できないとき「指定したキーの組み合わせが、すでに別のホットキーとして登録されている」と、失敗する
スレッド窓を指定しない(NULL)と、呼んだスレッドに結び付き、そのスレッドのメッセージループで処理する
自動リピートMOD_NOREPEAT を付けると、押し続けても、通知が 1 回になる(文書の表には「Windows Vista では、このフラグは対応していない」という注記がある)
上書きPrintScreen など、OS の標準のホットキーは、アプリの窓が手前にあるとき、上書きできることがある
対応最小の対応クライアントは Windows Vista(文書の表記)

「同じキーは、1 つしか登録できない」という性質は、Windows の文書にも書かれています。minikey の実験(2 つのプロセスのうち、2 つ目だけが失敗する)は、Linux(X11)での結果ですが、同じ性質でした。

Linux については、global-hotkey クレートを、X11 だけに対応と書いているページがありました(別のクレート hotkey-listener の比較表。Wayland では、X11 の仕組みを使うクレートは動かない、と説明されています)。今回の実験は、X11(Xvfb)の上だけです。

動作確認

確認できたこと

  • 上のコード、設定、出力は、すべて Linux(Rust 1.97.0、Cargo 1.97.0、edition 2024、Xvfb の上の X11)で、実際に実行した結果です。minikey は tauri 2.12.5、tauri-plugin-global-shortcut 2.4.1、tauri-plugin-single-instance 2.5.5、global-hotkey 0.8.0。shotkey 本体は tauri 2.12.1、tauri-plugin-global-shortcut 2.4.0(Cargo.lock の値)。Java は OpenJDK 21
  • 登録エラー(重複、壊れた形、知らないキー名)が、止まらずに集められること
  • 押したとき(Pressed)と離したとき(Released)の両方で、ハンドラが呼ばれること。ハンドラのスレッドが main ではないこと
  • 別スレッドで動かす版が、動作中のキーを ignored (busy) で捨てること。ハンドラの中で動かす版が、後のイベントを待たせて、順番に全部実行すること
  • CloseRequested → prevent_close → hide で、窓が隠れ、プロセスが残ること
  • single-instance で、2 つ目が終了コード 0 で終わり、1 つ目に起動引数が届くこと
  • single-instance なしの 2 つのプロセスで、2 つ目だけ、同じキーが already registered で失敗すること
  • shotkey 本体の、Linux でのビルド(cargo check と cargo build)、起動時に窓が出ないこと、ホットキー 3 つが Rust に届いて「Windows 専用」のエラーになること、2 つ目の起動で 1 つ目の窓が出ること
  • core のテスト 15 件の成功
  • Java の、ポートによる多重起動の防止と、AtomicBoolean.getAndSet

歴史の出典

  • RegisterHotKey の記述は、Microsoft Learn の RegisterHotKey の文書です。取得ツールが返した要約を通して確認しました。「すでに登録されているときに失敗する」は、文書の「also fails if the keystrokes … have already been registered」という趣旨の記述です。全文は読んでいません
  • Linux(X11 だけ)の記述は、hotkey-listener の docs.rs のページの比較表です。第三者のページで、1 つの出典だけです。global-hotkey 本体の README は、確認していません

確認できていないこと

  • Windows での動作(RegisterHotKey の実際の挙動、管理者権限の窓が手前のときにホットキーが効くか、トレイの表示、windows-capture による撮影と録画、実際のスクリーンショットの内容)。shotkey の DESIGN.md にも、Windows で確かめる項目が書かれています
  • macOS と Wayland
  • トレイのアイコンが、実際に画面に出ているか。minikey で build が成功したこと(tray: built)までで、この環境の Xvfb には、トレイを表示する仕組みがありません
  • キーを押し続けたとき(自動リピート)に、Pressed が何回来るか
  • 登録できなかったホットキーを、設定画面に見せる部分(get_hotkey_errors と画面側)の動作
  • tauri-plugin-single-instance が、1 つ目をどうやって見つけているか(今回は、Rust のコードを読んでいません)
  • shotkey の最初の動作確認で、起動の 6 秒後に押したキーには反応がなく、15 秒待つと反応しました。debug ビルドの初回の起動が遅いためかもしれませんが、原因は調べていません
  • 設定の保存(config.json)。set_config を呼んでいないので、設定のファイルは作られませんでした(この実行では、設定のディレクトリにファイルがありませんでした)
  • Java でグローバルホットキーを扱う方法

次回

次回は、設定の保存・トレイ・自動起動を扱います。今回は、トレイとホットキーの登録を見ました。次回は、設定をファイルに保存して読み込む部分(config.rs)と、ログイン時に自動で起動する仕組みを見ます。

連載の目次(予定)

入門編(8回・完結)

  1. Rust とは / 環境構築 / Hello World
  2. 変数・型・関数(Java との対比)
  3. 所有権(ムーブ)
  4. 借用と参照
  5. 構造体・enum・match
  6. Result / Option とエラー処理
  7. トレイトとジェネリクス(Java の interface との違い)
  8. ライフタイム入門と、総まとめの CLI

実践編(8回・予定。内容は、書きながら変わる可能性があります)

  1. Cargo・クレート・モジュール構成
  2. ファイル操作・パス・文字コード
  3. 小さな CLI を作り込む(簡易アーカイバ)
  4. Tauri v2 の構造(フロントと Rust の役割分担)
  5. コマンドとイベントの橋渡し
  6. グローバルホットキー常駐(shotkey を分解)(今回・連載の第14回)
  7. 設定の保存・トレイ・自動起動
  8. ビルド・署名・GitHub リリース