👋 はじめに
この記事では、前回の記事(【1】最小構成)に「ロック解除・スリープ復帰のログ出力」を追加します。
Windowsサービスとして常駐し、PCがロック解除されたタイミングやスリープから復帰したタイミングを自動検知してログファイルに記録する仕組みを作ります。勤怠の自動記録やセキュリティ監査ログの収集など、「PCが使われ始めたタイミング」を知りたい場面で活用できます。
🎯 やりたいこと
Win + L でのロック解除や、スリープからの復帰タイミングを検知してログに記録します。
📁 プロジェクト構成
Project2_SessionEvents/
├── Project2_SessionEvents.csproj
├── Program.cs ← エントリポイント(DI設定のみ、実装は省略)
├── Worker.cs ← サービスのメインロジック
├── Services/
│ └── FileLogService.cs ← ファイルへのログ書き込み(単純なファイル追記、実装は省略)
└── scripts/
├── install-service.bat ← サービス登録スクリプト(sc create コマンドのラッパー)
└── uninstall-service.bat ← サービス削除スクリプト
Note:
Program.csは標準的な Worker Service のテンプレート通りの DI 設定、FileLogService.csは日付別ファイルへの1行追記を行うだけの単純な実装のため、本記事ではコア部分(Worker.cs / NativeMessageWindow)に絞って解説します。
💡 解決策:Win32 API で直接受け取る
最初に思いつくのは SystemEvents.SessionSwitch や SystemEvents.PowerModeChanged ですが、BackgroundService のスレッドプールにはメッセージループがないため機能しません。さらに、モダンスタンバイ(S0 Low Power Idle)環境では PowerModeChanged 自体が発火しないという制約もあります(詳しくは後述の「ハマりポイント」を参照)。
そこで、専用の非表示ウィンドウを STA スレッド上で作成し、Win32 メッセージを直接受け取る方式を採用しました。
| Win32 メッセージ | 用途 |
|---|---|
WM_WTSSESSION_CHANGE |
ログイン・ロック解除を検出 |
WM_POWERBROADCAST |
従来の S3 スリープ復帰を検出 |
モダンスタンバイ環境ではスリープ復帰も
WM_WTSSESSION_CHANGE (SessionUnlock)として届きます。Win32 API レベルでの厳密な判別は困難なため、本実装では両者をSESSION_CHANGEとして記録します。
🔧 実装
📦 csproj の設定
<Project Sdk="Microsoft.NET.Sdk.Worker">
<PropertyGroup>
<TargetFramework>net8.0-windows</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<AssemblyName>WindowsServiceDemo_2</AssemblyName>
<!-- NativeWindow / Application.Run() のために WinForms を有効化(UI は使わない) -->
<UseWindowsForms>true</UseWindowsForms>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.1" />
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="8.0.1" />
</ItemGroup>
</Project>
UseWindowsForms を true にするのは NativeWindow と Application.Run() を使うためです。UI は一切表示しません。
⚙️ メインワーカー(Worker.cs)
using System.Runtime.InteropServices;
using System.Windows.Forms;
using Project2_SessionEvents.Services;
namespace Project2_SessionEvents;
public class Worker : BackgroundService
{
private readonly ILogger<Worker> _logger;
private readonly FileLogService _fileLogService;
public Worker(ILogger<Worker> logger, FileLogService fileLogService)
{
_logger = logger;
_fileLogService = fileLogService;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
LogEvent("STARTUP", "Windows Service が起動しました。");
// STA スレッド上で Win32 メッセージループを起動
var messageLoopThread = new Thread(RunMessageLoop)
{
IsBackground = true,
Name = "Win32MessageLoop"
};
messageLoopThread.SetApartmentState(ApartmentState.STA); // ← STA 必須
messageLoopThread.Start();
try
{
await Task.Delay(Timeout.Infinite, stoppingToken);
}
catch (OperationCanceledException) { }
finally
{
Application.Exit(); // メッセージループを終了
LogEvent("SHUTDOWN", "Windows Service が停止しました。");
}
}
private void RunMessageLoop()
{
using var window = new NativeMessageWindow(
onSessionChange: reason =>
LogEvent("SESSION_CHANGE", $"セッションが切り替わりました。理由: {reason}"),
logger: _logger
);
Application.Run(); // ← ここでメッセージループが始まる
}
private void LogEvent(string eventType, string message)
{
_fileLogService.WriteLog(eventType, message);
_logger.LogInformation("[{EventType}] {Message}", eventType, message);
}
}
🪟 非表示ウィンドウ(NativeMessageWindow)
internal sealed class NativeMessageWindow : NativeWindow, IDisposable
{
private const int WM_WTSSESSION_CHANGE = 0x02B1;
private const int WM_POWERBROADCAST = 0x0218;
private const int PBT_APMRESUMEAUTOMATIC = 0x0012;
private const int PBT_APMRESUMESUSPEND = 0x0007;
private const int WTS_SESSION_LOGON = 0x5;
private const int WTS_SESSION_UNLOCK = 0x8;
private const int NOTIFY_FOR_ALL_SESSIONS = 1;
[DllImport("Wtsapi32.dll", SetLastError = true)]
private static extern bool WTSRegisterSessionNotification(IntPtr hWnd, int dwFlags);
[DllImport("Wtsapi32.dll", SetLastError = true)]
private static extern bool WTSUnRegisterSessionNotification(IntPtr hWnd);
private readonly Action<string> _onSessionChange;
private readonly ILogger _logger;
public NativeMessageWindow(Action<string> onSessionChange, ILogger logger)
{
_onSessionChange = onSessionChange;
_logger = logger;
// 非表示ウィンドウを作成
CreateHandle(new CreateParams { Caption = "Win32MessageWindow" });
// WTS セッション通知を登録
if (!WTSRegisterSessionNotification(Handle, NOTIFY_FOR_ALL_SESSIONS))
logger.LogWarning("WTSRegisterSessionNotification failed.");
}
protected override void WndProc(ref Message m)
{
switch (m.Msg)
{
// ログイン / ロック解除(モダンスタンバイではスリープ復帰もここに届く)
case WM_WTSSESSION_CHANGE:
var reason = m.WParam.ToInt32();
if (reason == WTS_SESSION_LOGON || reason == WTS_SESSION_UNLOCK)
{
var r = reason == WTS_SESSION_LOGON ? "SessionLogon" : "SessionUnlock";
_onSessionChange(r);
}
break;
// 従来の S3 スリープ環境(モダンスタンバイでは発火しない)
case WM_POWERBROADCAST:
var evt = m.WParam.ToInt32();
if (evt == PBT_APMRESUMEAUTOMATIC || evt == PBT_APMRESUMESUSPEND)
_onSessionChange("PowerResume");
break;
}
base.WndProc(ref m);
}
public void Dispose()
{
WTSUnRegisterSessionNotification(Handle);
DestroyHandle();
}
}
🏗️ 仕組みまとめ
[Win32 OS]
|
| WM_WTSSESSION_CHANGE (SessionUnlock) ← ロック解除、またはスリープ復帰(モダンスタンバイ)
| WM_POWERBROADCAST (PowerResume) ← スリープ復帰(従来 S3 スリープ)
↓
[NativeMessageWindow (非表示ウィンドウ)]
↓
[Worker.LogEvent()]
↓
[FileLogService] → service_yyyyMMdd.log
| イベント | 検出方法 | ログの eventType |
|---|---|---|
| 起動時 |
ExecuteAsync 開始時 |
STARTUP |
| ログイン | WM_WTSSESSION_CHANGE (SessionLogon) |
SESSION_CHANGE |
| ロック解除 | WM_WTSSESSION_CHANGE (SessionUnlock) |
SESSION_CHANGE |
| スリープ復帰(モダンスタンバイ) | WM_WTSSESSION_CHANGE (SessionUnlock) |
SESSION_CHANGE |
| スリープ復帰(S3) | WM_POWERBROADCAST (PowerResume) |
SESSION_CHANGE |
| 停止時 |
ExecuteAsync の finally |
SHUTDOWN |
✅ 動作確認
| 確認内容 | 手順 | 期待ログ |
|---|---|---|
| 起動ログ | サービス起動直後 | [STARTUP] |
| ロック解除 |
Win + L → ロック解除 |
[SESSION_CHANGE] ... SessionUnlock |
| スリープ復帰 | スリープ → 復帰 | [SESSION_CHANGE] ... SessionUnlock |
ログ出力例:
[2026-06-03 09:00:00] [STARTUP] Windows Service が起動しました。
[2026-06-03 09:15:32] [SESSION_CHANGE] セッションが切り替わりました。理由: SessionUnlock
[2026-06-03 14:22:11] [SESSION_CHANGE] セッションが切り替わりました。理由: SessionUnlock
モダンスタンバイ環境ではロック解除とスリープ復帰が両方
SessionUnlockとして記録されます。これは Win32 API レベルの制約です。
⚠️ ハマりポイントまとめ
1. BackgroundService にはメッセージループがない
最初に思いつく実装がこれです:
// ❌ BackgroundService では動作しない
SystemEvents.SessionSwitch += OnSessionSwitch;
SystemEvents.PowerModeChanged += OnPowerModeChanged;
SystemEvents は Windows メッセージループが動いているスレッド上でないとイベントが届きません。BackgroundService のスレッドプールにはメッセージループがないため、イベントが無視されます。
2. モダンスタンバイでは SystemEvents が機能しない
最近の PC の多くは モダンスタンバイ(S0 Low Power Idle) を採用しています。
REM 確認コマンド
powercfg /a
Standby (S0 Low Power Idle) Network Connected が Available であれば該当します。
SystemEvents.PowerModeChanged は S3 スリープを前提とした API です。モダンスタンバイ(S0)環境では発火しません。
3. STA スレッドを忘れない
Win32 ウィンドウには STA(Single Threaded Apartment)スレッドが必要です。SetApartmentState(ApartmentState.STA) を必ず設定してください。
4. IDisposable でリソースを確実に解放する
WTSUnRegisterSessionNotification と DestroyHandle を Dispose() で確実に呼ぶことでリソースリークを防げます。
📝 まとめ
BackgroundService 上で Windows のセッションイベントを受け取るには、STA スレッド上に非表示ウィンドウを立ててメッセージループを回す必要があります。SystemEvents では動かない理由を理解した上でこの構成にすると、モダンスタンバイ環境でも安定してロック解除・スリープ復帰を検知できます。
次回の【3】では、AC電源のイベント取得について触れる予定です。
📚 参考
- WTSRegisterSessionNotification - Microsoft Learn
- WM_POWERBROADCAST - Microsoft Learn
- モダン スタンバイ - Microsoft Learn
本記事に掲載している内容は、私個人の見解であり、所属する組織の立場や戦略、意見を代表するものではありません。