[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 つに分けて確かめました。
- 同じ構造の最小アプリ
minikeyを作って、Linux(仮想ディスプレイ)で動かす - shotkey の、OS に依存しない部分(
core)のテストを動かす - 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 つです。
unregister_all()で、いったん全部外す(設定を変えたときに、登録し直せる)- 文字列(
"Ctrl+Alt+1")をShortcutに変える(parse)。失敗したら、読み取れなかった理由を記録 registerで、OS に登録する。失敗したら、理由を記録- 登録できたものだけ、「ホットキーの 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 は
tauri2.12.5、tauri-plugin-global-shortcut2.4.1、tauri-plugin-single-instance2.5.5、global-hotkey0.8.0。shotkey 本体はtauri2.12.1、tauri-plugin-global-shortcut2.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回・完結)
- Rust とは / 環境構築 / Hello World
- 変数・型・関数(Java との対比)
- 所有権(ムーブ)
- 借用と参照
- 構造体・enum・match
- Result / Option とエラー処理
- トレイトとジェネリクス(Java の interface との違い)
- ライフタイム入門と、総まとめの CLI
実践編(8回・予定。内容は、書きながら変わる可能性があります)
- Cargo・クレート・モジュール構成
- ファイル操作・パス・文字コード
- 小さな CLI を作り込む(簡易アーカイバ)
- Tauri v2 の構造(フロントと Rust の役割分担)
- コマンドとイベントの橋渡し
- グローバルホットキー常駐(shotkey を分解)(今回・連載の第14回)
- 設定の保存・トレイ・自動起動
- ビルド・署名・GitHub リリース