時間のかかるコマンドをデスクトップ通知させる(Mac/WSL)
ターミナルでビルドのような時間がかかるコマンドを実行中のあなた。そのスキマ時間を有効活用しようにも、コマンドの進捗確認のためにちらちら戻ってきてしまい集中できない……。
これを解決するシェルスクリプトを紹介します。コマンドの完了時、実行時間が設定以上の場合にデスクトップ通知をさせます。

背景
先日長い処理には通知音コマンドを仕込んでおくと捗るぞという記事を読みました。
音だけじゃなくデスクトップ通知にしたい、手動でコマンドを仕込むのではなくシェル側がよしなにやってほしい、と考えていたところ、その記事のコメントでzsh-notifyが紹介されていました。
筆者の環境はzsh-notifyのサポート外でしたが実装を見たら意外と簡単であったため、zsh-notifyを参考にして実装しました。
できること
- 時間がかかったコマンドの完了時にデスクトップ通知を送る
- MacとWSLの両方で動作する
- 通知したくないコマンド(例:
nvim
やnpm run dev
)をフィルター可能 - 短時間で終わるコマンドも手動で通知可能
筆者はZshで実装しましたが、他のシェルでもフック機能の部分を書き換えれば動きます。
実装の解説
先にスクリプトの全体を掲載します。
SKIP_NOTIFY_COMMANDS=( fg bat cat lazygit lg man nb nvim ssh vim watch idea "vagrant ssh" "mise run emulate" "npm run dev" "npm run preview" "npm run server" "npm run start" "pnpm run dev" "pnpm run preview" "pnpm run server" "pnpm run start" "yarn run dev" "yarn run preview" "yarn run server" "yarn run start")
function is_skip_command() { local cmd="$1" for skip_cmd in "${SKIP_NOTIFY_COMMANDS[@]}"; do if [[ "$cmd" == "$skip_cmd" || "$cmd" == "$skip_cmd"* ]]; then return 0 fi done return 1}
function windows_notify() { local temp_ps1 temp_ps1=$(mktemp).ps1 cat <<'EOD' > "$temp_ps1"param ( [string]$message = "Command completed!")
$xml = @"<toast>
<visual> <binding template="ToastGeneric"> <text>$($message)</text> <text>Finish!</text> </binding> </visual>
<audio src="ms-winsoundevent:Notification.Reminder"/>
</toast>"@$XmlDocument = [Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType = WindowsRuntime]::New()$XmlDocument.loadXml($xml)$AppId = '{1AC14E77-02E7-4E5D-B744-2EB1AE5198B7}\WindowsPowerShell\v1.0\powershell.exe'[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime]::CreateToastNotifier($AppId).Show($XmlDocument)EOD
local ps1file ps1file=$(wslpath -w "$temp_ps1") /mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe -ExecutionPolicy Bypass "$ps1file" -message "$1" rm -f "$temp_ps1"}
function notify() { local message="${1:-Finish}" if [[ "$(uname -s)" == "Darwin" ]]; then terminal-notifier -message "$message" -sound default > /dev/null 2>&1 elif [[ "$(uname -r)" == *microsoft* ]]; then windows_notify "$message" fi}
function before_command() { declare -g my_notify_last_command="$1" declare -g my_notify_start_time=$EPOCHSECONDS}
function unset_my_notify() { unset my_notify_last_command my_notify_start_time}
function after_command() { local command_complete_timeout=30
if [ -z $my_notify_start_time ]; then return fi if is_skip_command "$my_notify_last_command"; then unset_my_notify return fi
local time_elapsed=$((EPOCHSECONDS - my_notify_start_time)) if (( time_elapsed < command_complete_timeout )); then unset_my_notify return fi
notify "$my_notify_last_command" unset_my_notify}
zmodload zsh/datetimeautoload -U add-zsh-hookadd-zsh-hook preexec before_commandadd-zsh-hook precmd after_command
全体の流れ
全体の流れは次のようになっています。
- コマンド実行直前
- 「実行するコマンド・実行時刻」を記録する
- コマンド実行後
- コマンドの終了時刻を取得する
- コマンドが非通知対象ならスキップ
- コマンドの実行時間が設定以上なら通知
Zshのフックを利用する
「コマンドの実行直前」「コマンド実行後」に処理を仕込むにはシェルのフック機能を使います。
今回はZshなのでそれぞれpreexec
とprecmd
を使いました。
autoload -U add-zsh-hookadd-zsh-hook preexec before_commandadd-zsh-hook precmd after_command
preexec
はコマンドの実行直前のフックです。一方、precmd
は次のプロンプトの表示直前のフックです。
Zshのフックの一覧はzsh: 9 Functionsに掲載されています。
別のシェルでも似たような機能で実現可能です。たとえばfishであればイベントハンドラ、bashであればbash-preexecなど。
グローバル変数の利用
グローバル変数に「実行するコマンド」と「実行の開始時刻」を記録します。
function before_command() { declare -g my_notify_last_command="$1" declare -g my_notify_start_time=$EPOCHSECONDS}
function unset_my_notify() { unset my_notify_last_command my_notify_start_time}
unset
しておかないと、通知後の次のコマンドで空のままEnter
を押したときなどに通知が出てしまいます。他にもやりようはありますが要はクリーンアップです。
コマンド実行時間の計測
EPOCHSECONDS
で時刻を取得できます。EPOCHSECONDS
はzsh: 22 Zsh Modulesに掲載されています。
開始時刻my_notify_start_time
を使って計算しています。
local time_elapsed=$((EPOCHSECONDS - my_notify_start_time))if (( time_elapsed < command_complete_timeout )); then unset_my_notify returnfi
通知したくないコマンドのフィルター
通知させたくないコマンドを配列に突っ込み、判定させる関数で使います。
SKIP_NOTIFY_COMMANDS=( bat nvim # ... "npm run dev")
function is_skip_command() { local cmd="$1" for skip_cmd in "${SKIP_NOTIFY_COMMANDS[@]}"; do if [[ "$cmd" == "$skip_cmd" || "$cmd" == "$skip_cmd"* ]]; then return 0 fi done return 1}
通知の実装(Windows/WSL)
WSL環境でデスクトップ通知させるために、Powershellのスクリプトを実行します。
function windows_notify() { local temp_ps1 temp_ps1=$(mktemp).ps1 cat <<'EOD' > "$temp_ps1"param ( [string]$message = "Command completed!")
$xml = @"<toast>
<visual> <binding template="ToastGeneric"> <text>$($message)</text> <text>Finish!</text> </binding> </visual>
<audio src="ms-winsoundevent:Notification.Reminder"/>
</toast>"@$XmlDocument = [Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType = WindowsRuntime]::New()$XmlDocument.loadXml($xml)$AppId = '{1AC14E77-02E7-4E5D-B744-2EB1AE5198B7}\WindowsPowerShell\v1.0\powershell.exe'[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime]::CreateToastNotifier($AppId).Show($XmlDocument)EOD
local ps1file ps1file=$(wslpath -w "$temp_ps1") /mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe -ExecutionPolicy Bypass "$ps1file" -message "$1" rm -f "$temp_ps1"}
上記だとps1の部分がハイライトされないので、ps1の部分だけのバージョンも掲載します。
param ( [string]$message = "Command completed!")
$xml = @"<toast>
<visual> <binding template="ToastGeneric"> <text>$($message)</text> <text>Finish!</text> </binding> </visual>
<audio src="ms-winsoundevent:Notification.Reminder"/>
</toast>"@$XmlDocument = [Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType = WindowsRuntime]::New()$XmlDocument.loadXml($xml)$AppId = '{1AC14E77-02E7-4E5D-B744-2EB1AE5198B7}\WindowsPowerShell\v1.0\powershell.exe'[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime]::CreateToastNotifier($AppId).Show($XmlDocument)
参考
通知の実装(Mac)
Macでの通知にはterminal-notifierを使いました。
terminal-notifier -message "$message" -sound default > /dev/null 2>&1
インストールするツールを増やしたくない人はosascriptやafplayを使うとよいでしょう。
通知機能の統合
こんな感じで、各OSの通知処理をまとめた関数も実装します。
function notify() { local message="${1:-Finish}" if [[ "$(uname -s)" == "Darwin" ]]; then terminal-notifier -message "$message" -sound default > /dev/null 2>&1 elif [[ "$(uname -r)" == *microsoft* ]]; then windows_notify "$message" fi}
こうすることで、「もしかしたら速く終わるかもしれないから通知つけておきたい!」というコマンドでfoo; notify
のように通知を仕込むことができます。
foo; notify # 成功・失敗にかかわらず通知foo && notify # 成功したら通知foo || notify # 失敗したら通知
意外と短い行数で実現できました。コマンドの進捗確認のチラチラタイムが無くなったので作業に集中しやすくなりました。