WSL2 에서 Windows 토스트 알림 띄우기 — 외부 모듈 없이
BurntToast 나 wsl-notify-send 설치 없이 Windows 내장 WinRT API 를 powershell.exe 로 직접 호출해 데스크톱 알림을 띄운다. 한글 깨짐은 UTF-16LE base64 인코딩으로 잡는다.
WSL2 안에서 오래 걸리는 작업을 돌려놓고 다른 창으로 넘어가면, 그게 언제 끝났는지 알 방법이 없다. 터미널을 계속 쳐다보는 대신 Windows 쪽 알림 센터로 신호를 보내고 싶었다.
검색하면 대부분 BurntToast PowerShell 모듈이나 wsl-notify-send 를 깔라고 한다. 둘 다 설치가 필요하고, 새 기기에 환경을 세팅할 때마다 반복해야 한다. Windows 에 이미 들어 있는 API 만으로도 되는지 확인해보니 됐다.
측정 환경은 Ubuntu on WSL2 (Linux 6.6) + Windows 11 이고, powershell.exe 는 /mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe 를 그대로 썼다.
내장 WinRT API 직접 호출
Windows.UI.Notifications 네임스페이스를 PowerShell 에서 로드하면 토스트를 만들 수 있다.
[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null
[Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom, ContentType = WindowsRuntime] | Out-Null
$tpl = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent([Windows.UI.Notifications.ToastTemplateType]::ToastText02)
$t = $tpl.GetElementsByTagName('text')
$t.Item(0).AppendChild($tpl.CreateTextNode('작업 알림')) | Out-Null
$t.Item(1).AppendChild($tpl.CreateTextNode('빌드 완료')) | Out-Null
$toast = [Windows.UI.Notifications.ToastNotification]::new($tpl)
$appId = '{1AC14E77-02E7-4E5D-B744-2EB1AE5198B7}\WindowsPowerShell\v1.0\powershell.exe'
[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier($appId).Show($toast)
여기서 막히기 쉬운 곳이 $appId 다.
AppUserModelID 를 빌려 쓴다
토스트는 등록된 AppUserModelID 로만 표시된다. 아무 문자열이나 넣으면 예외 없이 조용히 아무 일도 일어나지 않는다. 보통은 시작 메뉴에 바로가기를 만들고 거기에 커스텀 AppID 를 심어 등록하는데, 그것 자체가 또 설치 절차다.
대신 Windows 10/11 에 항상 존재하는 시작 메뉴 PowerShell 바로가기의 AppID 를 그대로 쓰면 등록 절차가 사라진다.
{1AC14E77-02E7-4E5D-B744-2EB1AE5198B7}\WindowsPowerShell\v1.0\powershell.exe
대가는 알림 출처가 “Windows PowerShell” 로 표시된다는 것뿐이다. 내 알림에 내 이름이 안 붙는 게 설치 단계 하나보다 낫다고 판단했다.
NotifyIcon 풍선 방식과의 차이
System.Windows.Forms.NotifyIcon 의 BalloonTip 으로도 알림은 뜬다. 하지만 풍선이 떠 있는 동안 프로세스가 살아 있어야 해서, 스크립트에서 호출하면 수 초간 블로킹된다. 훅이나 후처리 스크립트에 물리면 그만큼 매번 느려진다.
WinRT 쪽 Show() 는 즉시 반환한다. 토스트가 Action Center 에 독립적으로 등록되기 때문에 호출한 프로세스는 바로 죽어도 된다. 이 차이 하나로 WinRT 방식을 택했다.
한글이 깨질 때
powershell.exe -Command "...한글..." 로 문자열을 그대로 넘기면 깨질 수 있다. WSL 쪽은 UTF-8 로 내보내는데 Windows 쪽 콘솔은 시스템 ANSI 코드페이지(한국어 Windows 는 CP949)로 해석하기 때문이다.
-EncodedCommand 는 UTF-16LE base64 를 받으므로 코드페이지 협상 자체를 건너뛴다. 유니코드면 뭐든 안전하다.
encoded="$(printf '%s' "$ps_cmd" | iconv -f UTF-8 -t UTF-16LE | base64 -w0)"
powershell.exe -NoProfile -NonInteractive -EncodedCommand "$encoded" >/dev/null 2>&1
같은 뿌리의 문제가 클립보드에서도 나온다. clip.exe 한글 깨짐은 WSL2 → Windows 클립보드 한글 깨짐에 따로 정리했다.
스크립트로 묶기
제목과 본문을 인자로 받는 형태로 만들어두면 어디서든 부를 수 있다.
#!/usr/bin/env bash
# WSL → Windows 토스트 알림. 사용법: notify-windows.sh [title] [message]
title="${1:-작업 알림}"
message="${2:-작업 완료}"
# PowerShell 리터럴 안의 작은따옴표 이스케이프
esc() { printf '%s' "$1" | sed "s/'/''/g"; }
title="$(esc "$title")"; message="$(esc "$message")"
ps_cmd="
\$ErrorActionPreference='Stop'
[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null
[Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom, ContentType = WindowsRuntime] | Out-Null
\$tpl = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent([Windows.UI.Notifications.ToastTemplateType]::ToastText02)
\$t = \$tpl.GetElementsByTagName('text')
\$t.Item(0).AppendChild(\$tpl.CreateTextNode('$title')) | Out-Null
\$t.Item(1).AppendChild(\$tpl.CreateTextNode('$message')) | Out-Null
\$toast = [Windows.UI.Notifications.ToastNotification]::new(\$tpl)
\$appId = '{1AC14E77-02E7-4E5D-B744-2EB1AE5198B7}\WindowsPowerShell\v1.0\powershell.exe'
[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier(\$appId).Show(\$toast)
"
encoded="$(printf '%s' "$ps_cmd" | iconv -f UTF-8 -t UTF-16LE | base64 -w0)"
powershell.exe -NoProfile -NonInteractive -EncodedCommand "$encoded" >/dev/null 2>&1
esc 가 없으면 제목에 작은따옴표가 들어가는 순간 PowerShell 구문이 깨진다. 커밋 메시지나 에러 문자열을 알림에 실어 보낼 계획이면 반드시 필요하다.
호출 시간은 warm 이 약 0.26초, cold 가 약 1초였다. 셸 프롬프트에 매번 다는 용도로는 무겁고, 긴 작업 끝에 한 번 부르는 용도로는 충분하다.
Claude Code 작업 완료 알림에 물리기
내가 실제로 쓰는 곳은 Claude Code 의 Stop hook 이다. ~/.claude/settings.json 의 hooks.Stop[].hooks 배열에 넣는다.
{
"type": "command",
"command": "\"$HOME/.claude/scripts/notify-windows.sh\" --stop-hook 2>/dev/null || true",
"async": true
}
"async": true 가 핵심이다. Claude Code 가 훅을 논블로킹으로 실행해주므로 셸에서 & 를 붙이는 편법이 필요 없다.
여러 프로젝트를 동시에 돌리면 알림만 보고는 어느 세션이 끝난 건지 모른다. 그래서 --stop-hook 모드를 만들어 stdin 으로 들어오는 Stop hook JSON 에서 .cwd 를 뽑아 제목에 프로젝트 폴더명을, 본문에 전체 경로와 세션 ID 앞 8자리를 넣었다.
cwd 를 찾는 순서는 이렇게 뒀다.
- stdin JSON 의
.cwd - 환경변수
$CLAUDE_PROJECT_DIR $PWD
async 훅이 stdin 을 못 받는 상황이 있는데, 그때도 훅 프로세스의 cwd 가 곧 세션 디렉터리라서 3번 폴백이 정확하다. 세션 ID 는 stdin 없이도 $CLAUDE_CODE_SESSION_ID 환경변수로 들어온다.
Stop hook JSON 에 들어오는 필드는 session_id, cwd, transcript_path, hook_event_name, stop_hook_active 다.
안 뜰 때 볼 곳
| 증상 | 확인할 것 |
|---|---|
| 토스트가 아예 안 뜸 | Windows 집중 지원(Focus assist) / 방해 금지가 켜져 있는지, 설정 → 알림에서 Windows PowerShell 알림이 꺼져 있는지 |
| 설정 파일 고쳤는데 훅이 안 걸림 | Claude Code 세션 시작 시점에 watched 대상이 아니던 파일은 리로드되지 않는다. /hooks 를 한 번 열거나 세션 재시작 |
| 한글만 깨짐 | raw -Command 로 넘어가는 경로가 남아 있는지. -EncodedCommand 로 통일 |
정리
설치 없이 알림을 띄우는 조건은 두 가지였다. 이미 등록되어 있는 PowerShell 의 AppUserModelID 를 빌려 쓸 것, 그리고 문자열은 -EncodedCommand 로만 넘길 것. 이 둘을 지키면 새 기기에서도 스크립트 파일 하나 복사하는 것으로 끝난다.