5
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

👋 はじめに

この記事では、前回の記事(【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.SessionSwitchSystemEvents.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>

UseWindowsFormstrue にするのは NativeWindowApplication.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 ConnectedAvailable であれば該当します。

SystemEvents.PowerModeChanged は S3 スリープを前提とした API です。モダンスタンバイ(S0)環境では発火しません。

3. STA スレッドを忘れない

Win32 ウィンドウには STA(Single Threaded Apartment)スレッドが必要です。SetApartmentState(ApartmentState.STA) を必ず設定してください。

4. IDisposable でリソースを確実に解放する

WTSUnRegisterSessionNotificationDestroyHandleDispose() で確実に呼ぶことでリソースリークを防げます。

📝 まとめ

BackgroundService 上で Windows のセッションイベントを受け取るには、STA スレッド上に非表示ウィンドウを立ててメッセージループを回す必要があります。SystemEvents では動かない理由を理解した上でこの構成にすると、モダンスタンバイ環境でも安定してロック解除・スリープ復帰を検知できます。

次回の【3】では、AC電源のイベント取得について触れる予定です。

📚 参考

本記事に掲載している内容は、私個人の見解であり、所属する組織の立場や戦略、意見を代表するものではありません。

5
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?