時間のかかるコマンドをデスクトップ通知させる(Mac/WSL)

ターミナルでビルドのような時間がかかるコマンドを実行中のあなた。そのスキマ時間を有効活用しようにも、コマンドの進捗確認のためにちらちら戻ってきてしまい集中できない……

これを解決するシェルスクリプトを紹介します。コマンドの完了時、実行時間が設定以上の場合にデスクトップ通知をさせます。

windowsでのデスクトップ通知

背景

先日長い処理には通知音コマンドを仕込んでおくと捗るぞという記事を読みました。
音だけじゃなくデスクトップ通知にしたい、手動でコマンドを仕込むのではなくシェル側がよしなにやってほしい、と考えていたところ、その記事のコメントでzsh-notifyが紹介されていました。
筆者の環境はzsh-notifyのサポート外でしたが実装を見たら意外と簡単であったため、zsh-notifyを参考にして実装しました。

できること

  • 時間がかかったコマンドの完了時にデスクトップ通知を送る
    • MacとWSLの両方で動作する
  • 通知したくないコマンド(例:nvimnpm 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/datetime
autoload -U add-zsh-hook
add-zsh-hook preexec before_command
add-zsh-hook precmd after_command

全体の流れ

全体の流れは次のようになっています。

  • コマンド実行直前
    • 「実行するコマンド・実行時刻」を記録する
  • コマンド実行後
    • コマンドの終了時刻を取得する
    • コマンドが非通知対象ならスキップ
    • コマンドの実行時間が設定以上なら通知

Zshのフックを利用する

「コマンドの実行直前」「コマンド実行後」に処理を仕込むにはシェルのフック機能を使います。
今回はZshなのでそれぞれpreexecprecmdを使いました。

autoload -U add-zsh-hook
add-zsh-hook preexec before_command
add-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で時刻を取得できます。EPOCHSECONDSzsh: 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
return
fi

通知したくないコマンドのフィルター

通知させたくないコマンドを配列に突っ込み、判定させる関数で使います。

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の部分だけのバージョンも掲載します。

Terminal window
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 window
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のように通知を仕込むことができます。

Terminal window
foo; notify # 成功・失敗にかかわらず通知
foo && notify # 成功したら通知
foo || notify # 失敗したら通知

意外と短い行数で実現できました。コマンドの進捗確認のチラチラタイムが無くなったので作業に集中しやすくなりました。