連載Index(読む順・公開済リンクはここが最新): S00_門前の誓い_総合Index
ホットキーは「開発機では動くが、配布後に落ち方が増える」種類の機能になりやすい。
キーの占有は端末ごと・常駐ツールごとに変わり、終了経路の増加で解除漏れも混ざる。
WinFormsのメッセージループへ安全に載せ、失敗時の縮退(無効化・キー変更)まで含めて組む。
そのための最短テンプレと、Win32エラーの読み方(GetLastWin32Error)を整理する。
1. このページで手に入るもの
- RegisterHotKey/UnregisterHotKey(Win32 API)をWinFormsのライフサイクルへ載せる最短テンプレ(コピペ可)
- 競合(登録失敗)と解除漏れ(次回起動崩れ)を同時に潰す境目(Handle生成・破棄、例外経路、終了経路)
- GetLastWin32Error の意味・前提・落とし穴・ログに残す粒度(使いどころが迷わない)
- WndProcを軽く保ち、UI詰まりを作らない受け方(通知→実処理の分離)
- 失敗パターン別の直し方(悪い例 → 直す → ポイント)と、テスト観点の整理
2. 先に逆引き(症状 → 原因 → 対策)
| 症状(見えていること) | 主な原因(混入点) | まず打つ手(最短) | 次の一手(運用まで) |
|---|---|---|---|
| 登録できない(起動直後から効かない) | 他アプリ/常駐ツールが同じキーを占有、セッション条件差 |
TryRegisterで失敗検知し、無効化・キー変更へ落とす |
設定画面に「競合時の復旧」を用意し、Win32エラーをログへ残す |
| 次回起動で効かない | 解除漏れ(例外/多経路終了/Dispose未実施) |
finally と Dispose で解除を必ず通す |
Handle再作成も含めて「登録済み状態」を管理する |
| 押しても反応しない | WM_HOTKEY未処理、base.WndProc を呼んでいない |
WM_HOTKEYだけ拾い、他は base.WndProc 維持 |
受信ログを残し、メッセージループ前提を崩さない |
| 押すとUIが固まる | WndProc内で重い処理、.Result 等で同期ブロック |
WndProcは通知のみ、処理は BeginInvoke 等へ |
async/await の戻り先(UI復帰)を前提に設計する |
| 多重起動で奪い合う | 複数インスタンスが同じキーを取り合う | 多重起動を抑止、既存インスタンスへ誘導 | 多重起動の仕様を決め、テスト観点へ組み込む |
3. 最短テンプレ(コピペ): WinFormsへ「Win32ホットキー」を安全に載せる型
Win32 API の RegisterHotKey/UnregisterHotKey を「Handleの生存期間」に結び、失敗時は縮退へ落とす。
このテンプレが狙うことは次の3点。
-
登録・解除の境目を決める:
HandleCreated/HandleDestroyedとDisposeに寄せ、解除漏れを減らす - 登録失敗を仕様として扱う: 競合・権限差が混ざる前提で、失敗検知→縮退へ落とす
- 受け口を軽くする: WndProcは通知だけに留め、実処理は別メソッドへ移す
3.1 Win32 API 定義(P/Invoke)
using System;
using System.ComponentModel;
using System.Runtime.InteropServices;
using System.Windows.Forms;
internal static class HotKeyNative
{
// Win32: RegisterHotKey が押下通知として投げるメッセージ
// https://learn.microsoft.com/windows/win32/inputdev/wm-hotkey
public const int WM_HOTKEY = 0x0312;
// 競合の典型: ERROR_HOTKEY_ALREADY_REGISTERED (1409)
// 既に同じホットキーが登録されている、の意味になる(別プロセス/同一プロセスを含む)
public const int ERROR_HOTKEY_ALREADY_REGISTERED = 1409;
[Flags]
public enum Modifiers : uint
{
None = 0x0000,
Alt = 0x0001,
Control = 0x0002,
Shift = 0x0004,
Win = 0x0008,
NoRepeat = 0x4000, // 必要ならキーリピートを抑止
}
// Win32: user32.dll
// https://learn.microsoft.com/windows/win32/api/winuser/nf-winuser-registerhotkey
[DllImport("user32.dll", SetLastError = true)]
public static extern bool RegisterHotKey(IntPtr hWnd, int id, Modifiers fsModifiers, uint vk);
// Win32: user32.dll
// https://learn.microsoft.com/windows/win32/api/winuser/nf-winuser-unregisterhotkey
[DllImport("user32.dll", SetLastError = true)]
public static extern bool UnregisterHotKey(IntPtr hWnd, int id);
}
/// <summary>
/// Win32ホットキー登録を「登録→解除」まで1つの塊にするヘルパ。
/// 貼る場所: Formのフィールドとして保持する。
/// 境目: Handleが生きている間だけ登録する(Handle再作成に追従しやすい)。
/// 戻り値: TryRegister は成功/失敗を bool で返し、失敗理由は win32Error に出す。
/// 例外方針: 競合は運用で起きる前提のため例外化しない。縮退(無効化・キー変更)へ落とす。
/// </summary>
internal sealed class HotKeyRegistration : IDisposable
{
private readonly IntPtr _handle;
private readonly int _id;
// 解除は「登録に成功した場合のみ」行う。状態を持たないと解除の多重呼び出しが増えやすい。
private bool _registered;
public HotKeyRegistration(IntPtr handle, int id)
{
_handle = handle;
_id = id;
}
public bool TryRegister(HotKeyNative.Modifiers modifiers, Keys key, out int win32Error)
{
// SetLastError=true の P/Invoke なので、失敗時は直後に GetLastWin32Error を読むのが前提になる。
_registered = HotKeyNative.RegisterHotKey(_handle, _id, modifiers, (uint)key);
// 成功時は 0 として扱う(ログが見やすい)
win32Error = _registered ? 0 : Marshal.GetLastWin32Error();
return _registered;
}
public void Dispose()
{
if (!_registered) return;
// 解除失敗も運用で起きるため、戻り値は拾ってログに残せる形へする。
// ここでは「解除を試みた」事実を優先し、例外は投げない方針に寄せる。
var ok = HotKeyNative.UnregisterHotKey(_handle, _id);
if (!ok)
{
// UnregisterHotKey も SetLastError=true。失敗理由を残す余地を確保する。
var err = Marshal.GetLastWin32Error();
// _logger.LogWarning($"HotKey unregister failed. id={_id} win32={err}");
}
_registered = false;
}
}
3.2 Form側(ライフサイクル・受信・縮退)
using System;
using System.Windows.Forms;
public partial class MainForm : Form
{
// HotKeyRegistration は「登録→解除」までをまとめるため、Formの生存期間に合わせて保持する。
private HotKeyRegistration? _hotKey;
// WM_HOTKEY の識別子。複数登録する場合はIDを増やし、WParamで区別する。
private const int HotKeyId = 1;
public MainForm()
{
InitializeComponent();
// Handleの生成・破棄に寄せると、Handle再作成にも追従しやすい。
this.HandleCreated += (sender, e) => RegisterHotKeyOrFallback();
this.HandleDestroyed += (sender, e) => UnregisterHotKeySafe();
}
private void RegisterHotKeyOrFallback()
{
// 再登録前に解除を通し、二重登録の揺れを避ける。
UnregisterHotKeySafe();
_hotKey = new HotKeyRegistration(this.Handle, HotKeyId);
// 例: Ctrl + Space(必要なら NoRepeat を混ぜる)
var ok = _hotKey.TryRegister(
modifiers: HotKeyNative.Modifiers.Control | HotKeyNative.Modifiers.NoRepeat,
key: Keys.Space,
win32Error: out var err);
if (ok)
{
// _logger.LogInformation("HotKey registered. id=1 key=Ctrl+Space");
return;
}
// 失敗は「何も起きない」にしない。縮退へ落とす。
// err の代表例:
// - 1409: 既に登録済み(競合)の可能性が濃い
// - 5: アクセス拒否(環境条件/権限条件が絡む場合がある)
// _logger.LogWarning($"HotKey register failed. id=1 win32={err}");
// 縮退案(要件で選ぶ):
// - ホットキー機能を無効化し、代替操作(メニュー/ボタン)へ寄せる
// - 設定画面でキー変更を促す
// - 既定キーを複数候補で試す(衝突率の低い組合せへ)
_hotKey.Dispose();
_hotKey = null;
}
private void UnregisterHotKeySafe()
{
try
{
_hotKey?.Dispose();
}
finally
{
_hotKey = null;
}
}
protected override void WndProc(ref Message m)
{
// WM_HOTKEY のみ拾い、他メッセージは base.WndProc に流す。
if (m.Msg == HotKeyNative.WM_HOTKEY && m.WParam.ToInt32() == HotKeyId)
{
// WndProcは通知だけに留める。重い処理はUIキューへ積む。
this.BeginInvoke((Action)(() => OnHotKeyPressed()));
return;
}
base.WndProc(ref m);
}
private void OnHotKeyPressed()
{
// 実処理を置く場所。
// - ここでUI操作や非同期呼び出しを行う
// - 長い処理はさらに別スレッドへ逃がし、戻りはUIへ戻す設計にする
}
protected override void Dispose(bool disposing)
{
if (disposing)
{
// Form破棄経路でも解除を通す(終了経路が増えても漏れにくい)
UnregisterHotKeySafe();
}
base.Dispose(disposing);
}
}
4. 解説: 定義 → 評価規則 → 状態の変化 → 落とし穴
Win32 API を「成功前提で呼ぶ」と壊れ方が増える。失敗検知と境目を先に揃える。
4.1 用語と前提(Win32 APIである点を明示)
-
RegisterHotKey / UnregisterHotKey
Windowsの user32.dll が提供するWin32 API。C#からはP/Invoke(DllImport)で呼ぶ。
返り値はboolで、falseの場合は失敗。失敗理由は Win32 の「最後のエラー(Last Error)」として別に保持される。 -
WM_HOTKEY
RegisterHotKey で登録したキーが押されたときに、登録先ウィンドウへ投げられるメッセージ。 -
Last Error(最後のエラー)
Win32 APIの多くは「返り値で成功/失敗」を返し、「失敗理由は別スロット(スレッド単位)へ格納する」。
その別スロットを読むAPIが GetLastError。C#ではMarshal.GetLastWin32Error()がそれに相当する。
4.2 評価規則(境目: どこで呼ぶか)
-
登録はHandleが生きている間に行う
WinFormsではHandleが再作成される可能性があるため、HandleCreatedを登録の境目にする。 -
解除は複数経路から必ず通す
HandleDestroyedとDisposeを解除の境目にし、例外・多経路終了でも漏れにくい形にする。 -
WndProcは通知だけ
WM_HOTKEY受信後に実処理を呼び出すだけに留めると、UI詰まりが減る。
4.3 状態の変化(登録済みを状態として扱う)
- 登録に成功したかどうかを状態として持ち、解除は「成功した場合のみ」行う
- 再登録(設定変更・再初期化・Handle再作成)では「解除→登録」の順へ揃える
4.4 落とし穴(壊れ方が増える混入点)
- 競合は避け切れない(常駐ツール、IME、配列系ユーティリティ、OS機能、別プロセス、同一プロセスの二重登録)
- 解除漏れは「その場で動く」ため、発火が遅れて追いづらい
- WndProcに重い処理や同期待ち(
.Result等)が混ざると、入力系の詰まりが拡大する
5. GetLastWin32Error を深掘り(何者か・前提・落とし穴・使い方)
Marshal.GetLastWin32Error() は「直前のWin32 API失敗理由」を読む手段で、読むタイミングと条件が命。
5.1 何者か(Win32の Last Error を読む)
Win32 API には、次のような設計の関数が多い。
- 返り値: 成功/失敗(
BOOLなど) - 失敗理由: スレッド単位の Last Error に格納(GetLastErrorで取得)
C# ではそれを Marshal.GetLastWin32Error() で読める。
ポイントは、「失敗した直後に読む」 こと。Last Error は後続のWin32呼び出しで上書きされうる。
5.2 使える条件(SetLastError=true が前提)
P/Invoke(DllImport)側で SetLastError = true を付けると、CLRが「Last Error を取得できる形で呼び出す」経路になる。
付けない場合、Marshal.GetLastWin32Error() を読んでも意味のある値にならないことがある。
このページのテンプレは SetLastError = true を付けているため、RegisterHotKey/UnregisterHotKey 失敗直後に読む前提が成立する。
5.3 落とし穴(読み違いが起きる典型)
-
成功時に読んでしまう
成功時のLast Errorは未定義になりやすい。成功時は0扱いへ寄せ、失敗時だけ読むのが安全。 -
読むまでに別のWin32 APIが混ざる
例: 失敗後にログ出力や別API呼び出しが入ると、Last Error が上書きされる可能性がある。
失敗直後にvar err = Marshal.GetLastWin32Error();を確保してから周辺処理へ移る。 -
別スレッドで読んでしまう
Last Error はスレッド単位のため、失敗したスレッドと別スレッドで読むと意味が変わる。
WndProc受信後に別スレッドへ逃がす場合でも、「失敗理由の取得」は失敗直後・同一スレッドで行う。
5.4 実務での使い方(ログ粒度と復旧へ繋ぐ)
-
ログに残す最小要素
id(ホットキー識別子)、修飾キー、仮想キー、win32(エラー番号)
例:HotKey register failed. id=1 key=Ctrl+Space win32=1409 -
代表例: 1409(競合)を縮退へ繋ぐ
1409 は「既に登録済み」。別アプリ占有の可能性が濃い。
その場合は「機能を無効化」「キー変更」へ落とすのが最短の復旧になる。 -
例外化しない理由
競合は運用で起きる前提が強く、例外で落とすと復旧動線が詰まる。
bool + エラー取得で「縮退へ落とす」を仕様として扱う方が安定する。
6. 判例: 混入点が多い順に潰す(持ち帰れる観点を増やす)
失敗を「起きない前提」にせず、混入点を設計に畳み込むと詰まりが減る。
6.1 解除漏れ(終了経路の増加で壊れ方が増える)
解除は Dispose に集約し、「呼び忘れ」を構造で減らす。
悪い例(解除が通らない経路が増えやすい):
public void StartHotKey()
{
// 登録だけ行い、例外・終了経路・再初期化での解除が増殖しやすい
HotKeyNative.RegisterHotKey(this.Handle, 1, HotKeyNative.Modifiers.Control, (uint)Keys.Space);
}
直す(登録済み状態を持ち、解除を必ず通す):
private HotKeyRegistration? _hotKey;
public void StartHotKey()
{
// 再初期化・設定変更経路で二重登録にならないよう、先に解除を通す
_hotKey?.Dispose();
_hotKey = new HotKeyRegistration(this.Handle, 1);
var ok = _hotKey.TryRegister(
modifiers: HotKeyNative.Modifiers.Control,
key: Keys.Space,
win32Error: out var err);
if (ok) return;
// 失敗理由は直後に確保し、縮退へ繋ぐ(競合が多い領域)
// _logger.LogWarning($"HotKey register failed. win32={err}");
_hotKey.Dispose();
_hotKey = null;
}
ポイント:
- 終了経路が増えても解除漏れが増えないよう、解除は1箇所へ寄せる
- 再初期化(設定変更など)のたびに「解除→登録」の順へ揃える
- 失敗理由は直後に確保し、縮退へ繋ぐ材料にする
6.2 Handle再作成(登録したつもりが混ざる)
登録は HandleCreated を境目にし、再作成へ追従させる。
悪い例(Load/コンストラクタで1回だけ登録し、再作成で崩れる):
public MainForm()
{
InitializeComponent();
HotKeyNative.RegisterHotKey(this.Handle, 1, HotKeyNative.Modifiers.Control, (uint)Keys.Space);
}
直す(Handleの境目で揃える):
public MainForm()
{
InitializeComponent();
// Handle生成のたびに登録し、破棄のたびに解除する
this.HandleCreated += (sender, e) => RegisterHotKeyOrFallback();
this.HandleDestroyed += (sender, e) => UnregisterHotKeySafe();
}
ポイント:
- 「登録したが効かない」はHandle再作成が混入点になりやすい
- Handleの生存期間に登録・解除を結び付けると、壊れ方が減る
- 再登録前に解除を通すと、二重登録の揺れも減る
6.3 競合(登録失敗)を縮退へ繋げないと運用が詰まる
競合は起きる前提で「失敗検知→縮退」を先に用意する。
悪い例(失敗を握りつぶし、何も起きない状態になる):
var ok = HotKeyNative.RegisterHotKey(this.Handle, 1, HotKeyNative.Modifiers.Control, (uint)Keys.Space);
// ok が false でも放置
直す(失敗直後にWin32エラーを確保し、縮退へ落とす):
var ok = HotKeyNative.RegisterHotKey(this.Handle, 1, HotKeyNative.Modifiers.Control, (uint)Keys.Space);
if (!ok)
{
// 失敗直後・同一スレッドで読む(ここがズレると値が上書きされうる)
var err = Marshal.GetLastWin32Error();
// 1409 は競合の可能性が濃い。縮退へ落とす判断材料になる。
// _logger.LogWarning($"HotKey register failed. win32={err}");
// 縮退:
// - 無効化して代替操作へ
// - 設定画面でキー変更
}
ポイント:
- 失敗理由の取得タイミングが命(直後に確保)
- 競合(1409)はよく出るため、縮退の仕様を先に持つ
- 「何も起きない」を避けると、現場での詰まりが減る
6.4 WndProc重化(入力系の詰まりが拡大する)
WndProcは通知だけ。実処理は別メソッドへ逃がす。
悪い例(受信口で同期待ち・重処理が混ざる):
protected override void WndProc(ref Message m)
{
if (m.Msg == HotKeyNative.WM_HOTKEY)
{
var result = SomeAsync().Result; // UIスレッドで同期待ちになりやすい
DoHeavy(result);
return;
}
base.WndProc(ref m);
}
直す(通知→UIキューへ積む):
protected override void WndProc(ref Message m)
{
if (m.Msg == HotKeyNative.WM_HOTKEY)
{
// 受信口は軽く保つ。実処理は別へ。
this.BeginInvoke((Action)(() => OnHotKeyPressed()));
return;
}
base.WndProc(ref m);
}
private async void OnHotKeyPressed()
{
// 実処理側で非同期を扱う(戻り先がUIになる前提を意識する)
var result = await SomeAsync();
DoHeavy(result);
}
ポイント:
- 受信口に重い処理が入ると、入力系の詰まりが連鎖する
-
.Resultは同期待ちを作りやすい - 通知と実処理を分離すると、設計意図が説明しやすくなる
6.5 多重起動(自分自身が競合の混入点になる)
多重起動の仕様が未確定だと、ホットキーの奪い合いが起きる。
悪い例(何も決めずに2起動でき、片方だけ効く):
// 2インスタンスが同じホットキーを取り合う前提のまま
直す(多重起動の仕様を決め、挙動を揃える):
// 例: Mutexで抑止し、既存インスタンスへ誘導する等
// 実装詳細は要件次第。重要なのは「仕様として決める」こと。
ポイント:
- 競合の原因が「別アプリ」だけでなく「自分の多重起動」になる
- 仕様(抑止/既存へ誘導/並行許可)を先に決めると説明が通る
- テスト観点に「多重起動」を入れると崩れ方の再現が進む
7. チェックリスト(確認観点)
「境目」「縮退」「Last Error」「受信口軽量化」が揃うと品質が上がる。
| 観点 | チェック内容 | 具体の確認ポイント |
|---|---|---|
| Win32前提 | Win32 APIである点が明記されている | user32.dll / P/Invoke / SetLastError=true が揃う |
| 境目 | 登録・解除の境目が揃っている |
HandleCreated で登録、HandleDestroyed/Dispose で解除 |
| 状態 | 登録済みを状態として扱っている | 成功時のみ解除、再登録は「解除→登録」 |
| Last Error |
GetLastWin32Error の読み方が成立している |
失敗直後・同一スレッドで確保、成功時は読まない |
| 縮退 | 登録失敗時に復旧できる | 無効化/キー変更/代替操作などへ落とせる |
| 受信口 | WndProcが軽い | WM_HOTKEYのみ拾い、他は base.WndProc 維持 |
| 入力詰まり | 同期待ちが混ざらない |
.Result 等が受信口に入らない |
8. セルフチェック(5問)
全て「はい」になると、配布後の詰まりが減る。
- RegisterHotKey/UnregisterHotKey が Win32 API(user32.dll)である点を前提に設計できているか
- 登録・解除が Handle の生存期間に結び付いているか(Handle再作成でも追従するか)
- 登録失敗時に
GetLastWin32Errorを失敗直後に確保し、縮退へ繋げられるか - 解除が
Dispose/finally経由で必ず通り、解除漏れが増えにくいか - WndProc が通知だけで、実処理や同期待ちが混ざっていないか
回答の目安
- 1: user32.dll / DllImport / SetLastError=true が揃っているなら「はい」
- 2: HandleCreated/HandleDestroyed と Dispose で管理しているなら「はい」
- 3: 失敗直後に
var err = Marshal.GetLastWin32Error();を確保し、縮退へ繋げられるなら「はい」 - 4: 解除が1箇所へ寄っており、例外や終了経路増加に引きずられないなら「はい」
- 5: WndProcは通知のみで、実処理は別メソッドなら「はい」
9. 関連トピック
- 止血の作法と計測の型: E04
- メッセージループの構造: G11
- 非同期の実戦整理: G13
- ルール化の落とし込み: R06
- 連載Index: S00_門前の誓い_総合Index
連載Index(読む順・公開済リンクはここが最新): S00_門前の誓い_総合Index