タスクトレイに常駐する PowerShell スクリプト「Task Tray App」を作った

ふと思い立って、タスクトレイに常駐する PowerShell スクリプトを作ってみた。GitHub でソースコード公開中。

この PowerShell スクリプトを起動すると、何のためかは分からないが3分おきに F15 キーを押下する定期処理が実行される。F15 キーというのは、一応定義はされているが通常は特に何の役割も与えられていないので、特に何も起こらない。しかしコレにより、実際にキーボードを押下したのと同じ動作が起こるので、しばらく無操作でもスリープ状態にならないことになる。まぁ、何のためにそんな定期処理を行うのかは分からないけど。(しらばっくれ)

PowerShell って Windows API や .NET API、そして C# のコードを混ぜ込んだ悪魔合体的なスクリプトになってしまうので、なかなかどうして、やりたいことを実現するのにこんなコードになってしまうのか、みたいなのが整理しづらいんだが、今回は割とその辺を理解して落とし込めたと思う。以降、トピックごとにメモ書きしていこうと思う。


今回書いたスクリプトは .NET Framework の System.Windows.Forms 名前空間にある API を多用するので、始めにこのアセンブリをロードしている。ロード後のコードでは System. という名前空間部分は記述を省略しても良い。

Add-Type -AssemblyName System.Windows.Forms;

次に、System.Threading.Mutex (Mutual Exclusion) を使い、排他制御を実現する。コレにより、同じスクリプトを多重起動しないように制御できる。

$mutex = New-Object Threading.Mutex($False, 'Global\mutex-task-tray-app');  # 第2引数でテキトーな名前をつけておく
if(!($mutex.WaitOne(0, $False))) {  # 後から多重起動されたと判定した場合は終了する
  $mutex.Close();
  Exit;
}
# ↓以下に実際のコードを書いていく

通常の起動時は、スクリプトの終了時に確実に変数 $mutex を解放してやる必要がある。解放漏れがあると、二重起動のつもりでなくても裏にスレッドが残っていて二重起動扱いになってしまうからだ。確実に解放させるには、try / (catch) / finally を使うと良い。

$mutex = ...;  # ← 先程のコード

try {
  # 通常時の全ての処理をココに書く…
}
catch {
  # エラー時
}
finally {
  # 確実に Mutex を解放して終了する
  $mutex.ReleaseMutex();
  $mutex.Close();
}

PowerShell における「スコープ」の感覚がよく分からなかったのだが、後述する「○○をクリックした時のイベントハンドラ」みたいな処理中で書き換えたいグローバル変数を定義する際は、$script:hoge という風に変数名を指定すれば良いようだ。$script 以外にもいくつかスコープを明示する予約語があるみたい。

$script:isTerminalWindowClosed = $True;  # `script` スコープを明示した変数
$example = $True;  # ← コチラは普通の変数宣言 ★

# イベントハンドラ内でそれぞれの変数を「上書き」しようとしてみる
$notifyIcon.add_Click({
  $script:isTerminalWindowClosed = $False;
  $example = $False;
});

# ↑ しかし実際は、親スコープの `$example` (★) は `$False` に上書きされない
# イベントハンドラ内で、親スコープと同じ名前のローカル変数を宣言して消滅した、という扱いになっている

PowerShell のターミナルウィンドウを非表示にするためのコードは次のとおり。

$terminalWindow = Add-Type -MemberDefinition '[DllImport("user32.dll")] public static extern bool ShowWindowAsync(IntPtr hWnd, int nCmdShow);' -Name Win32Functions -PassThru;
$windowHandle   = (Get-Process -PID $Pid).MainWindowHandle;  # 後で同じハンドルを指定するために変数に控えておく
[void]$terminalWindow::ShowWindowAsync($windowHandle, 0);    # `0` で非表示・`1` や `9` などを指定すればウィンドウが表示される

Win32 API の ShowWindowAsync 関数をロードするために、Add-Type を使い C# コードを混ぜ込んでいる。詳しくは ShowWindowAsync 関数の API をググッてもらえば分かると思うが、第2引数に 0 を与えると、ウィンドウ自体が非表示になり、タスクバーにも表示されなくなる。

存在がタスクバーから消されてしまったので、続いてタスクトレイに自身のアイコンを配置するコードを置く。ExtractAssociatedIcon という関数を使うと、指定した .exe ファイルからアイコンを取り出してくれるので、この仕組みを利用して「起動した自プロセス = PowerShell のアイコン」を取得しているのが以下の例。実際のコードでは「コマンドプロンプト」のアイコンとトグル切替できるようにしている。

$notifyIcon         = New-Object Windows.Forms.NotifyIcon;
$notifyIcon.Icon    = [Drawing.Icon]::ExtractAssociatedIcon((Get-Process -id $Pid | Select-Object -ExpandProperty Path));
$notifyIcon.Visible = $True;  # 終了時は `Visible` に `$False` を渡すことでタスクトレイアイコンを非表示にできる

タスクトレイにアイコンを置いたあと、明示的に終了させるまではタスクトレイに常駐してほしいので、ApplicationContext という仕組みを使って「メッセージループ」を実行する。

$applicationContext = New-Object Windows.Forms.ApplicationContext;

[void][Windows.Forms.Application]::Run($applicationContext);
# ↑ この行に到達すると、コレ以下のコードは実行されず常駐状態になる

コレでタスクトレイにアイコンが常駐してくれたので、セットで終了させるための処理を書いておく。タスクトレイアイコンを右クリックしたら「Exit」というコンテキストメニューが表示され、それを押下することで PowerShell を終了できるようにする。

$applicationContext = New-Object Windows.Forms.ApplicationContext;

# アプリ終了メニューを定義する
$menuItemExit      = New-Object Windows.Forms.MenuItem;
$menuItemExit.Text = 'Exit';
# アプリ終了メニューをクリックされたら Application Context を終了させる
$menuItemExit.add_Click({
  $applicationContext.ExitThread();
});

# コンテキストメニューとして定義する
$notifyIcon.ContextMenu = New-Object Windows.Forms.ContextMenu;
$notifyIcon.contextMenu.MenuItems.AddRange($menuItemExit);

[void][Windows.Forms.Application]::Run($applicationContext);

ココまでで、

というところまでが出来た。


次に、タスクトレイアイコンをクリックした時のイベントハンドラを書く。実際のコードではココで、後述するタイマー処理の On・Off を切り替える処理を書いている。

# 先程定義したタスクトレイアイコンをクリックした時のイベントハンドラ
$notifyIcon.add_Click({
  # 左クリックでなければ何もしない (アイコンを右クリックした場合など)
  if(!($_.Button -eq [Windows.Forms.MouseButtons]::Left)) { return; }
  
  # タスクトレイアイコンを左クリックされた時に行いたい処理を実装する…
});

タイマー処理は Windows.Forms.Timer で実現できる。

# タイマーを定義する
$timer = New-Object Windows.Forms.Timer;

# `$timer.Start()` が呼び出された時に実行される処理
$timer.Add_Tick({
  $timer.Stop();  # 先にタイマーを一時停止する
  
  # F15 キーを押下する処理
  [Windows.Forms.SendKeys]::SendWait('{F15}');
  
  # インターバルを再設定してタイマーを再開する
  $timer.Interval = 1000 * 10;  # ← 単位はミリ秒なのでコレで「10秒後に再実行」となる
  $timer.Start();
});

# 即時実行するには次のようにする
$timer.Interval = 1;
$timer.Start();

大まかな骨組みはコレで以上。あとはコレまでに挙げたコードを組み合わせて、「タスクトレイアイコンをクリックされた時に、フラグ変数を反転させて、アイコンを切り替えたり、タイマーを止めたり再開したり」みたいな感じで実装している。全体で150行程度のシングル PowerShell スクリプトとして完結できたので、タスクトレイに常駐するアプリの雛形的にも参考にしてもらえるかもしれない。


最初に ShowWindowAsync 関数を使って PowerShell のウィンドウを非表示にしようとしているものの、PowerShell スクリプトの起動直後に一瞬だけ PowerShell のウィンドウがチラ見えしてしまうのが気になる。そこでいつものように WSH (VBScript) も用意した。

Option Explicit

CreateObject("WScript.Shell").Run "powershell -NoLogo -NoProfile -ExecutionPolicy Unrestricted -File .\task-tray-app.ps1", 7

いつもの WScript.Shell.Run なのだが、第2引数は 0 (ウィンドウ完全非表示) ではなく 7 (最小化状態で起動) をオススメする。ココで 0 を指定して起動してしまうと、確かに PowerShell ウィンドウは完全非表示のまま、タスクトレイ常駐を始めるのだが、後から ShowWindowAsync 関数でウィンドウを再表示しようとしても再表示できなくなってしまう。

コレを防ぐためには、「ウィンドウ最小化状態で起動する」オプション (7) を指定し、タスクバーに一瞬だけ PowerShell が表示されることは許容するというのが、その後で扱いやすいかと思う。ShowWindowAsync で後からターミナルウィンドウを再表示できるようにしておけばデバッグログも確認しやすいのでオススメ。


あと細かいモノとしては、バルーンチップを表示するモノ。

$notifyIcon.BalloonTipIcon  = [Windows.Forms.ToolTipIcon]::Info;
$notifyIcon.BalloonTipTitle = 'タイトル';
$notifyIcon.BalloonTipText  = '本文';
$notifyIcon.ShowBalloonTip(1000);  # 1秒間表示する

VBScript の MsgBox みたいな感じで GUI 上にメッセージボックスを表示させるモノ。ついでに、PowerShell は比較的最近「三項演算子」が実装されたらしく、それ以前は if 文と Write-Output で代替できるようなので、そのサンプルも。

$text = if($script:isEnabled) { Write-Output '有効です'; } else { Write-Output '無効です'; };

[Windows.Forms.MessageBox]::Show("本文 : ${text}", 'タイトル');

この辺は汎用性高そう~。


以上。お仕事サボりたくなったら使ってみてください。(爆