diff --git a/config/hk.pkl b/config/hk.pkl index e8d0945d..5612e9a3 100644 --- a/config/hk.pkl +++ b/config/hk.pkl @@ -10,6 +10,45 @@ local checks = new Mapping { glob = List("scripts/*.sh") check = "sh -n {{files}}" } + ["powershell-syntax"] { + glob = List("scripts/*.ps1") + check = """ + set -e + + if command -v pwsh >/dev/null 2>&1; then + ps_cmd=pwsh + scripts_dir="$PWD/scripts" + elif command -v powershell.exe >/dev/null 2>&1 && command -v wslpath >/dev/null 2>&1; then + ps_cmd=powershell.exe + scripts_dir="$(wslpath -w "$PWD/scripts")" + else + echo 'warning: PowerShell unavailable; skipping PowerShell syntax validation' >&2 + exit 0 + fi + + "$ps_cmd" -NoLogo -NoProfile -NonInteractive -Command '& { + param([string] $ScriptsDir) + $failed = $false + + Get-ChildItem -LiteralPath $ScriptsDir -Filter "*.ps1" | ForEach-Object { + $tokens = $null + $errors = $null + [System.Management.Automation.Language.Parser]::ParseFile( + $_.FullName, + [ref] $tokens, + [ref] $errors + ) | Out-Null + + if ($errors.Count -gt 0) { + $failed = $true + $errors | ForEach-Object { Write-Error "$($_.Extent.File): $_" } + } + } + + if ($failed) { exit 1 } + }' "$scripts_dir" + """ + } ["mise-tool-disables"] { glob = List("conf.d/packages.pacman.toml", "conf.d/platform.omarchy.toml") check = """ @@ -23,9 +62,22 @@ local checks = new Mapping { check = "bash -n {{files}} && shellcheck -x {{files}}" } ["zsh-syntax"] { - glob = List("../home/.zshrc", "../home/.p10k.zsh") + glob = List( + "../home/.zshrc", + "../home/.p10k.zsh", + "../home/.config/sheldon/plugins/*.zsh", + "tests/*.zsh" + ) check = "zsh -n {{files}}" } + ["wsl-notify"] { + glob = List( + "../home/.config/sheldon/plugins/wsl-notify.plugin.zsh", + "scripts/wsl-notify.ps1", + "tests/wsl-notify.zsh" + ) + check = "zsh tests/wsl-notify.zsh" + } } hooks { diff --git a/config/scripts/wsl-notify.ps1 b/config/scripts/wsl-notify.ps1 new file mode 100644 index 00000000..3be5eeb6 --- /dev/null +++ b/config/scripts/wsl-notify.ps1 @@ -0,0 +1,110 @@ +param( + [Parameter(Mandatory = $true)][string] $Title, + [Parameter(Mandatory = $true)][string] $Message +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +# Exit codes consumed by wsl-notify.plugin.zsh: +# 0 toast displayed +# 10 Windows Terminal is focused; notification intentionally suppressed +# 11 foreground state could not be determined; fail closed +# 20 native toast delivery failed; the shell may fall back to BEL +$ExitToastShown = 0 +$ExitFocused = 10 +$ExitFocusUnknown = 11 +$ExitDeliveryFailed = 20 + +function Get-ForegroundProcessName { + try { + Add-Type -TypeDefinition @" +using System; +using System.Runtime.InteropServices; + +public static class ForegroundWindow { + [DllImport("user32.dll", SetLastError = true)] + public static extern IntPtr GetForegroundWindow(); + + [DllImport("user32.dll", SetLastError = true)] + public static extern uint GetWindowThreadProcessId(IntPtr window, out uint processId); +} +"@ + + $window = [ForegroundWindow]::GetForegroundWindow() + if ($window -eq [IntPtr]::Zero) { + return $null + } + + $foregroundProcessId = [uint32]0 + $threadId = [ForegroundWindow]::GetWindowThreadProcessId($window, [ref] $foregroundProcessId) + if ($threadId -eq 0 -or $foregroundProcessId -eq 0) { + return $null + } + + return (Get-Process -Id $foregroundProcessId -ErrorAction Stop).ProcessName + } + catch { + return $null + } +} + +function Get-NotificationAppId { + $startApps = @(Get-StartApps) + + foreach ($candidate in @( + 'Microsoft.WindowsTerminal_8wekyb3d8bbwe!App', + 'Microsoft.WindowsTerminalPreview_8wekyb3d8bbwe!App' + )) { + if ($startApps.AppID -contains $candidate) { + return $candidate + } + } + + $appId = $startApps | + Where-Object { $_.AppID -match '^Microsoft\.WindowsTerminal.*!App$' } | + Select-Object -First 1 -ExpandProperty AppID + + if (-not [string]::IsNullOrWhiteSpace($appId)) { + return $appId + } + + return $startApps | + Where-Object { $_.AppID -match 'PowerShell' } | + Select-Object -First 1 -ExpandProperty AppID +} + +$foregroundProcess = Get-ForegroundProcessName +if ([string]::IsNullOrWhiteSpace($foregroundProcess)) { + exit $ExitFocusUnknown +} + +if ($foregroundProcess -like 'WindowsTerminal*') { + exit $ExitFocused +} + +try { + Add-Type -AssemblyName System.Runtime.WindowsRuntime + $null = [Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] + $null = [Windows.UI.Notifications.ToastNotification, Windows.UI.Notifications, ContentType = WindowsRuntime] + $null = [Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType = WindowsRuntime] + + $appId = Get-NotificationAppId + if ([string]::IsNullOrWhiteSpace($appId)) { + throw 'No Start-menu AUMID suitable for WSL toast notifications was found.' + } + + $document = [Windows.Data.Xml.Dom.XmlDocument]::new() + $document.LoadXml('') + $textNodes = $document.GetElementsByTagName('text') + [void] $textNodes.Item(0).AppendChild($document.CreateTextNode($Title)) + [void] $textNodes.Item(1).AppendChild($document.CreateTextNode($Message)) + + $toast = [Windows.UI.Notifications.ToastNotification]::new($document) + [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier($appId).Show($toast) + exit $ExitToastShown +} +catch { + [Console]::Error.WriteLine("WSL notification failed: $($_.Exception.Message)") + exit $ExitDeliveryFailed +} diff --git a/config/scripts/wsl-toast.ps1 b/config/scripts/wsl-toast.ps1 deleted file mode 100644 index 94637f2e..00000000 --- a/config/scripts/wsl-toast.ps1 +++ /dev/null @@ -1,42 +0,0 @@ -param( - [Parameter(Mandatory = $true)][string] $Title, - [Parameter(Mandatory = $true)][string] $Message -) - -$ErrorActionPreference = 'Stop' -Set-StrictMode -Version Latest - -Add-Type -AssemblyName System.Runtime.WindowsRuntime -$null = [Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] -$null = [Windows.UI.Notifications.ToastNotification, Windows.UI.Notifications, ContentType = WindowsRuntime] -$null = [Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType = WindowsRuntime] - -$startApps = @(Get-StartApps) -$appId = $startApps | - Where-Object { $_.AppID -eq 'Microsoft.WindowsTerminal_8wekyb3d8bbwe!App' } | - Select-Object -First 1 -ExpandProperty AppID - -if ([string]::IsNullOrWhiteSpace($appId)) { - $appId = $startApps | - Where-Object { $_.AppID -match '^Microsoft\.WindowsTerminal.*!App$' } | - Select-Object -First 1 -ExpandProperty AppID -} - -if ([string]::IsNullOrWhiteSpace($appId)) { - $appId = $startApps | - Where-Object { $_.AppID -match 'PowerShell' } | - Select-Object -First 1 -ExpandProperty AppID -} - -if ([string]::IsNullOrWhiteSpace($appId)) { - throw 'No Start-menu AUMID suitable for WSL toast notifications was found.' -} - -$document = [Windows.Data.Xml.Dom.XmlDocument]::new() -$document.LoadXml('') -$textNodes = $document.GetElementsByTagName('text') -[void] $textNodes.Item(0).AppendChild($document.CreateTextNode($Title)) -[void] $textNodes.Item(1).AppendChild($document.CreateTextNode($Message)) - -$toast = [Windows.UI.Notifications.ToastNotification]::new($document) -[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier($appId).Show($toast) diff --git a/config/tests/wsl-notify.zsh b/config/tests/wsl-notify.zsh new file mode 100644 index 00000000..eb141ee3 --- /dev/null +++ b/config/tests/wsl-notify.zsh @@ -0,0 +1,75 @@ +#!/usr/bin/env zsh +set -eu + +repo_root=${0:A:h:h:h} +plugin="$repo_root/home/.config/sheldon/plugins/wsl-notify.plugin.zsh" +test_root=$(mktemp -d) +trap 'rm -rf "$test_root"' EXIT +mkdir -p "$test_root/bin" + +cat >"$test_root/bin/wslpath" <<'EOF' +#!/bin/sh +printf '%s\n' 'C:\\wsl-notify.ps1' +EOF + +cat >"$test_root/bin/powershell.exe" <<'EOF' +#!/bin/sh +printf '%s\n' "$*" >>"$WSL_NOTIFY_TEST_LOG" +exit "${WSL_NOTIFY_TEST_EXIT:-0}" +EOF + +chmod +x "$test_root/bin/wslpath" "$test_root/bin/powershell.exe" + +export PATH="$test_root/bin:$PATH" +export MISE_CONFIG_DIR="$repo_root/config" +export WSL_DISTRO_NAME=ci +export WT_SESSION=ci +export WSL_NOTIFY_TEST_LOG="$test_root/powershell.log" +unset TERM_PROGRAM +rehash + +source "$plugin" + +[[ "$(bgnotify_appid)" == '__wsl_notify_dispatch__' ]] +[[ "$bgnotify_termid" == '__wsl_notify_terminal_foreground__' ]] +[[ ! -e "$WSL_NOTIFY_TEST_LOG" ]] + +for exit_code in 0 10 11; do + export WSL_NOTIFY_TEST_EXIT=$exit_code + output=$(bgnotify 'build finished' 'command completed' '') + [[ -z $output ]] +done + +export WSL_NOTIFY_TEST_EXIT=20 +output=$(bgnotify 'build finished' 'command completed' '') +[[ "$output" == $'\a' ]] +[[ "$(wc -l <"$WSL_NOTIFY_TEST_LOG")" -eq 4 ]] + +# Exercise the Ghostty hook-removal path without depending on the runner's zsh +# function installation. The fixture is loaded through zsh's real autoload +# mechanism, just like add-zsh-hook is in an interactive shell. +mkdir -p "$test_root/fpath" +cat >"$test_root/fpath/add-zsh-hook" <<'EOF' +local mode=$1 hook=$2 callback=$3 +[[ $mode == -d ]] || return 2 + +case $hook in + preexec) preexec_functions=(${preexec_functions:#$callback}) ;; + precmd) precmd_functions=(${precmd_functions:#$callback}) ;; + *) return 2 ;; +esac +EOF + +zsh -f -c ' + typeset -ga preexec_functions precmd_functions + preexec_functions=(bgnotify_begin) + precmd_functions=(bgnotify_end) + function bgnotify_begin {} + function bgnotify_end {} + fpath=("$2" $fpath) + export WSL_DISTRO_NAME=ci + export TERM_PROGRAM=ghostty + source "$1" + [[ -z ${preexec_functions[(r)bgnotify_begin]-} ]] + [[ -z ${precmd_functions[(r)bgnotify_end]-} ]] +' zsh "$plugin" "$test_root/fpath" diff --git a/home/.config/sheldon/plugins/wsl-notify.plugin.zsh b/home/.config/sheldon/plugins/wsl-notify.plugin.zsh index 81f7599b..a1e0955c 100644 --- a/home/.config/sheldon/plugins/wsl-notify.plugin.zsh +++ b/home/.config/sheldon/plugins/wsl-notify.plugin.zsh @@ -12,41 +12,23 @@ if [[ ${TERM_PROGRAM:-} == ghostty ]]; then return fi -# Windows Terminal is outside WSL's X/Wayland tree. Query Win32 directly so -# bgnotify only fires when Windows Terminal is not the foreground application. if [[ -n ${WT_SESSION:-} ]]; then - if [[ -n ${commands[powershell.exe]:-} ]]; then - function bgnotify_appid { - local process_name - process_name=$( - powershell.exe -NoLogo -NoProfile -NonInteractive -Command ' -Add-Type -TypeDefinition @" -using System; -using System.Runtime.InteropServices; -public static class ForegroundWindow { - [DllImport("user32.dll")] - public static extern IntPtr GetForegroundWindow(); - [DllImport("user32.dll")] - public static extern uint GetWindowThreadProcessId(IntPtr window, out uint processId); -} -"@ -$window = [ForegroundWindow]::GetForegroundWindow() -$foregroundPid = [uint32]0 -[void][ForegroundWindow]::GetWindowThreadProcessId($window, [ref]$foregroundPid) -(Get-Process -Id $foregroundPid).ProcessName -' 2>/dev/null | tr -d '\r\n' - ) - print -r -- "${process_name:-$EPOCHSECONDS}" - } - bgnotify_termid=WindowsTerminal - else - # Without the Win32 foreground probe we cannot guarantee background-only - # notifications, so fail closed instead of notifying while focused. + if [[ -z ${commands[powershell.exe]:-} || -z ${commands[wslpath]:-} ]]; then + # Without the Windows-native helper path we cannot reliably distinguish a + # focused terminal from a background one, so fail closed. autoload -Uz add-zsh-hook add-zsh-hook -d preexec bgnotify_begin add-zsh-hook -d precmd bgnotify_end return fi + + # Keep upstream bgnotify's timing and command formatting, but defer the + # Windows foreground check to wsl-notify.ps1. These fixed, unequal IDs make + # bgnotify_end dispatch without spawning a separate PowerShell probe. + function bgnotify_appid { + print -r -- '__wsl_notify_dispatch__' + } + bgnotify_termid='__wsl_notify_terminal_foreground__' fi function bgnotify { @@ -55,23 +37,35 @@ function bgnotify { local icon="$3" if [[ -n ${WT_SESSION:-} ]]; then - # bgnotify_end already verified that Windows Terminal is in the background. - # Send a real Windows toast; its default audio replaces the terminal BEL. - local toast_script="${MISE_CONFIG_DIR:-$HOME/.config/mise}/scripts/wsl-toast.ps1" - local windows_toast_script + local notify_script="${MISE_CONFIG_DIR:-$HOME/.config/mise}/scripts/wsl-notify.ps1" + local windows_notify_script + local notify_status + + # If the helper cannot be resolved, suppress the notification rather than + # guessing whether Windows Terminal is focused. + [[ -r $notify_script ]] || return 0 + windows_notify_script=$(command wslpath -w "$notify_script") || return 0 + [[ -n $windows_notify_script ]] || return 0 - if [[ -r $toast_script ]] && (( ${+commands[powershell.exe]} )) && (( ${+commands[wslpath]} )); then - windows_toast_script=$(command wslpath -w "$toast_script") || windows_toast_script= - if [[ -n $windows_toast_script ]] && powershell.exe -NoLogo -NoProfile -NonInteractive \ - -ExecutionPolicy Bypass -File "$windows_toast_script" \ - -Title "$title" -Message "$message" >/dev/null 2>&1; then - return - fi + if powershell.exe -NoLogo -NoProfile -NonInteractive \ + -ExecutionPolicy Bypass -File "$windows_notify_script" \ + -Title "$title" -Message "$message" >/dev/null 2>&1; then + notify_status=0 + else + notify_status=$? fi - # Keep the old audible notification as a fallback if native toast delivery - # is unavailable or fails unexpectedly. - print -rn -- $'\a' + case $notify_status in + 0|10|11) + # Toast delivered, intentionally suppressed while focused, or focus + # state was indeterminate and the helper failed closed. + return 0 + ;; + *) + # Native delivery failed after dispatch; retain an audible fallback. + print -rn -- $'\a' + ;; + esac elif (( ${+commands[notify-send]} )); then command notify-send "$title" "$message" \ ${=icon:+--icon "$icon"} ${=bgnotify_extraargs:-}