WindowsサービスをC#で作る

#はじめに
Visual Studio のテンプレートで基本部分はすぐ作れるものの、その後に色々あるのでメモ。

#プロジェクトの作成
ここでは Visual Studio 2019 で作ってみる。
まず、新しいプロジェクトの作成で、「Windows サービス（.NET Framework）」を作る。
.NET Frameworkのバージョンは多分どれでも良いが、とりあえずここでは4.5にする。少なくとも4.5なら、今のWindows10では初期状態でも動くっぽい。（.NET Frameworkの手動インストール不要）

#名前をつける
デフォルトでService1.csというファイルが作られる。
この名前がサービス名になってしまうので、まずはこれを変える。
ファイル名を変更すると、関連するところを修正するか聞かれるので、OKで自動修正する。

ファイル名の変更では変わらないところが１箇所（<サービス名>.cs[デザイン]タブのプロパティにある ServiceName）あるので、これも変更しておく。

#インストーラの追加
サービスとして登録できるようにするために、インストーラを追加する必要がある。
<サービス名>.cs[デザイン]タブ　で右クリックし、メニューから「インストーラーの追加」を選択する。

ProjectInstaller.cs ファイルが追加される。
ProjectInstaller.cs[デザイン] タブに「ServiceInstaller1」の項目があるので、右クリックのプロパティにある「Description」にサービスの説明を書いておく。（services.mscで表示される説明文）

「DisplayName」にはサービス名（services.mscで表示されるサービス名）を書いておく。

また、StartTypeがデフォルトで「Manual」なので、「Automatic」にするとWindows起動時に自動起動するようになる。


#サービス実行アカウントの指定
ProjectInstaller.cs[デザイン] タブに「serviceProcessInstaller1」の項目があるので、右クリックのプロパティを開き、「Account」を変えることでサービス実行アカウントを選択する。
一般的には「LocalSystem」にすることが多い（イベントログの書き込み権限など、許可されていることが多いので）ようだが、必要な権限で決めるべき。
いわゆる管理者権限が必要ならば「LocalSystem」にする。
必要ない場合は「LocalService」で良いかもしれない。

#サービスのインストールについて
サービスはインストールすることでWindowsに登録され、services.mscに表示されるようになる。

インストール方法は、

* installutil を使う
* scを使う
* InstallShieldやmsiの指定でインストールする
* サービスのプログラムを実行することでインストールする

といった方法がある。
ここでは、サービスのプログラムを実行することでインストールする方法を説明する。

やり方は簡単で、ManagedInstallerClass.InstallHelper を呼び出せば良い。
具体的には以下のようになる。

```
using System.Configuration.Install;
using System.Reflection;

    .
    .
    .

try
{
　　　　ManagedInstallerClass.InstallHelper(new string[] { Assembly.GetExecutingAssembly().Location });
}
catch (Exception e)
{
　　　　Log.Write("install error: " + e.ToString());
}
```

これは毎回呼ぶわけにいかないので、コマンドライン引数で指定されたときに呼び出すようにする。
コマンドラインで実行しても、Console.Write ではなにも出力されないようなので注意。

```
static class Program
{
　　　　static void Main(string[] args)
　　　　{
    if (0 < args.Length) {
      string arg = args[0].ToLower().Trim();
      if (arg.Equals("/i")) {
        InstallService();
      }
      else if (arg.Equals("/u")) {
        UninstallService();
      }
      return;
    }

    // 以下に元々の Main
        ・
        ・
        ・
  }

　　　　private static void InstallService()
　　　　{
　　　　　　　　try
　　　　　　　　{
　　　　　　　　　　　　ManagedInstallerClass.InstallHelper(new string[] { Assembly.GetExecutingAssembly().Location });
　　　　　　　　}
　　　　　　　　catch (Exception e)
　　　　　　　　{
    　　　　Log.Write("install error: " + e.ToString());
　　　　　　　　}
　　　　}

　　　　private static void UninstallService()
　　　　{
　　　　　　　　try
　　　　　　　　{
　　　　　　　　　　　　ManagedInstallerClass.InstallHelper(new string[] { "/u", Assembly.GetExecutingAssembly().Location });
　　　　　　　　}
　　　　　　　　catch (Exception e)
　　　　　　　　{
    　　　　Log.Write("uninstall error: " + e.ToString());
　　　　　　　　}
　　　　}
}
```

これで、管理者権限で起動したコマンドプロンプトで、<サービス名>.exe /i と実行するとサービスが登録される。
アンインストールは　/i の代わりに /u を使う。

#サービスの実行
<サービス名>.exe /i で登録しておけば、あとは通常のサービスと同様に実行できる。
service.msc を起動して「サービスの開始」か、コマンドプロンプトで net start "<サービス名>"で実行する。

:::note info
net start で指定するサービス名は、ServiceInstaller1 の DisplayName で指定したものにする
:::


#サービス本体の処理
サービス開始時に呼び出される OnStart と停止時に呼び出される OnStop を使う。OnStart, OnStopはテンプレートに含まれているので、自動的に作られる。

OnStartで別スレッドを開始するか、System.Timers.Timer を使って処理を実装する。
OnStopでスレッドやタイマーを終了する。

OnStart, OnStop に時間がかかる処理を入れる場合は、SetServiceStatus(https://docs.microsoft.com/en-us/windows/win32/api/winsvc/nf-winsvc-setservicestatus) を使って保留状態を追加すると良いが、ここでは省略。


#サービスから画面に通知メッセージを表示する
サービスでWindowやDialogの表示はそのままではできない。これはサービスを実行しているアカウントが、画面に表示する権限を持たないため。
このため、ログオン中のユーザ権限を使って CreateProcessAsUser し、通知用の別アプリを起動する。

そもそもサービスは誰もログオンしていなくても動いているので、OnSessionChangeを使って、ログオンしたら別アプリを起動、ログオフでアプリを終了し、プロセス間通信を使ってメッセージなどを表示させる。

#OnSessionChange
ユーザがログオン・ログオフ時に呼び出され、そこで session ID が得られるのでCreateProcessAsUserが実行できる。
OnSessionChange自体は普通にoverrideすれば良いが、CanHandleSessionChangeEvent = true; を実行しておく必要がある。
これはサービスのクラスのコンストラクタなどに書けばOK。

OnSessionChangeの例は以下の通り。

```
protected override void OnSessionChange(SessionChangeDescription changeDescription)
{
  switch (changeDescription.Reason)
  {
    case SessionChangeReason.SessionLogon:
      startApplication(changeDescription.SessionId);
      break;
    case SessionChangeReason.SessionLogoff:
      stopApplication();
      break;
  }
}
```

void startApplication(int sesid) で通知メッセージ表示のアプリを起動する。
stopApplicationで終了。

#アプリ起動
アプリの起動は CreateProcessAsUserで行うが、その前にトークンを取得する必要がある。
トークンは WTSQueryUserToken で取得する。
この辺りのAPIはNativeMethodsにまとめるが、構造体が必要なのでちょっと面倒。

とりあえずまとめるとこんな感じ。

```
class NativeMethods
{
    [StructLayout(LayoutKind.Sequential)]
    public struct STARTUPINFO
    {
            public int cb;
            public String lpReserved;
            public String lpDesktop;
            public String lpTitle;
            public uint dwX;
            public uint dwY;
            public uint dwXSize;
            public uint dwYSize;
            public uint dwXCountChars;
            public uint dwYCountChars;
            public uint dwFillAttribute;
            public uint dwFlags;
            public short wShowWindow;
            public short cbReserved2;
            public IntPtr lpReserved2;
            public IntPtr hStdInput;
            public IntPtr hStdOutput;
            public IntPtr hStdError;
    }
    [StructLayout(LayoutKind.Sequential)]
    public struct PROCESS_INFORMATION
    {
            public IntPtr hProcess;
            public IntPtr hThread;
            public uint dwProcessId;
            public uint dwThreadId;
    }
    [StructLayout(LayoutKind.Sequential)]
    public struct SECURITY_ATTRIBUTES
    {
            public int Length;
            public IntPtr lpSecurityDescriptor;
            public bool bInheritHandle;
    }

    [DllImport("wtsapi32.dll", SetLastError = true)]
    public static extern bool WTSQueryUserToken(UInt32 sessionId, out IntPtr Token);

    [DllImport("advapi32.dll", EntryPoint = "CreateProcessAsUser", SetLastError = true,
               CharSet = CharSet.Ansi, CallingConvention = CallingConvention.StdCall)]
    public extern static bool CreateProcessAsUser(IntPtr hToken, String lpApplicationName,
        String lpCommandLine, ref SECURITY_ATTRIBUTES lpProcessAttributes,
        ref SECURITY_ATTRIBUTES lpThreadAttributes, bool bInheritHandle, int dwCreationFlags,
        IntPtr lpEnvironment, String lpCurrentDirectory, ref STARTUPINFO lpStartupInfo,
        out PROCESS_INFORMATION lpProcessInformation);
    public static IntPtr WTS_CURRENT_SERVER_HANDLE = IntPtr.Zero;
    public static Int32 WTS_CURRENT_SESSION = -1;
}

```

この定義をした上で、startApplicationは以下のようになる。

```
    private void startApplication(int sesid)
    {
            IntPtr token = IntPtr.Zero;
            bool res = NativeMethods.WTSQueryUserToken((uint)sesid, out token);
            if (!res)
            {
                Log.Write("WTSQueryUserToken failed: " + Marshal.GetLastWin32Error());
                return;
            }
            string path = "C:\\temp\\servicemessage.exe"; // メッセージ表示の実行ファイル
            string dir = "C:\\temp"; // 実行時のディレクトリ
            var si = new NativeMethods.STARTUPINFO();
            si.lpDesktop = "winsta0\\default";
            si.cb = Marshal.SizeOf(si);
            var pi = new NativeMethods.PROCESS_INFORMATION();
            var sa = new NativeMethods.SECURITY_ATTRIBUTES();
            sa.bInheritHandle = false;
            sa.Length = Marshal.SizeOf(sa);
            sa.lpSecurityDescriptor = IntPtr.Zero;
            res = NativeMethods.CreateProcessAsUser(token, path, string.Empty, ref sa, ref sa,
                false, 0, IntPtr.Zero, dir, ref si, out pi);
            if (!res)
            {
                Log.Write("CreateProcessAsUser failed: " + Marshal.GetLastWin32Error());
            }
        }

```

ちゃんとやるなら実行ファイルのパスは Assembly.GetExecutingAssembly().Location とかから作るのが良さそう。