【WSL2】時間のかかるコマンドが終了したらデスクトップ通知が出るようにした

## やりたかったこと
ビルドやデプロイなど数分単位でかかる処理を待つのが嫌、だけどターミナルに張り付くのは時間の無駄ということで、自動的に通知してくれる仕組みを作りました。

↓こんな感じ
![a.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3485528/2d755777-10ad-0cf7-1a06-2d6195892a8c.png)

## 動作環境

- Windows11 Pro 23H2 
- WSL 2.0.9.0
- Ubuntu 22.04.3 LTS
- Bash 5.1.16

## WSLからWindows側にデスクトップ通知を出す
PowerShellでデスクトップ通知（トースト通知と言うらしい）を簡単に出せます。
スクリプトを書いて、WSLから呼び出す形が良さそうです。
PowerShellで出せる通知の種類やカスタマイズについては[こちらの記事](https://qiita.com/relu/items/b7121487a1d5756dfcf9 "【PowerShell】デスクトップ通知のスニペット【トースト通知】")が参考になりました。

### 実行ポリシー
WSLからPowerShellスクリプトを実行するために実行ポリシーの変更が必要でした。
PowerShellを管理者権限で開き、下記のコマンドを実行します。
```
Set-ExecutionPolicy RemoteSigned
```
### 完成形
WSLから呼び出すことを考慮して作成したスクリプトがこちらです。
```notify.ps1
param (
    [string]$message = "Command completed!"
)

[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] > $null
$template = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent([Windows.UI.Notifications.ToastTemplateType]::ToastText01)
$xml = New-Object Windows.Data.Xml.Dom.XmlDocument
$xml.LoadXml($template.GetXml())
$toastTextElements = $xml.GetElementsByTagName("text")
$toastTextElements.Item(0).AppendChild($xml.CreateTextNode($message)) > $null
$toast = [Windows.UI.Notifications.ToastNotification]::new($xml)
$appId = "WSL Notification"
$notifier = [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier($appId)

$notifier.Show($toast)
```
### 実行
WSLからテキストを指定して実行することができます。
```
powershell.exe -File 'C:\notify.ps1' -message "通知！！"
```
## コマンド終了時に通知する
シェルがBashの場合、`~/.bashrc`に関数を定義すればコマンドを作成できます。
まず思いついたのが、`notify npm run build`のように、実行したい処理の前につけることで動作するコマンドを定義することです。
```~/.bashrc
function notify() {
    start=$(date +%s)
    "$@"
    end=$(date +%s)
    duration=$((end - start))
    powershell.exe -File 'C:\notify.ps1' -message "Command $@ completed in $duration seconds"
}
```
これで要件は満たしているのですが、コマンドの頭に都度`notify `と付けるのは面倒ですし、意識せずとも勝手に通知を出してほしいです。
そこで、最終的には以下のような実装になりました。
### 完成形
```~/.bashrc
function preexec_invoke_exec() {
    if [[ $- == *i* ]]; then
        this_command=$(history 1 | sed -e 's/^[ ]*[0-9]*[ ]*//g')

        # 無視するコマンドのリスト
        ignore_commands=("vim" "nano" "emacs")

        # 現在のコマンドが無視リストに含まれているかチェック
        for cmd in "${ignore_commands[@]}"; do
            if [[ $this_command == $cmd* ]]; then
                unset timer
                return
            fi
        done

        timer=${timer:-$SECONDS}
    fi
}

function precmd_invoke_exec() {
    if [[ $- == *i* ]] && [[ $timer ]]; then
        runtime=$((SECONDS - timer))
        unset timer

        if [[ $runtime -ge 5 ]]; then
            powershell.exe -File 'C:\notify.ps1' -message "Command '$this_command' completed in $runtime seconds"
        fi
    fi
}

trap 'preexec_invoke_exec' DEBUG
PROMPT_COMMAND='precmd_invoke_exec'
```

## 解説
```
trap 'preexec_invoke_exec' DEBUG
PROMPT_COMMAND='precmd_invoke_exec'
```
この2行によって、各コマンドの実行前に`preexec_invoke_exec`、終了後に`precmd_invoke_exec`関数が実行されます。
コマンドの実行前後で時刻を取得することで何秒かかったかを計算し、5秒以上経過していたら`notify.ps1`を実行する仕組みです。

```
    if [[ $- == *i* ]]; then
```
インタラクティブに実行されたコマンドのみを通知の対象としています。
```
        for cmd in "${ignore_commands[@]}"; do
            if [[ $this_command == $cmd* ]]; then
                unset timer
                return
            fi
        done
```
エディタでファイル編集している時間なども計測対象になることに気付いたので、特定のコマンドを無視するようにしました。
あまりスマートな解決方法ではありませんが…

## まとめ
ちょっとしたスクリプトですが、無駄な時間を減らせて地味に便利だと思います