/** * winrt-toast.ts — Windows WinRT Toast 通知后端。 * * 用 PS5 (powershell.exe) + WinRT ToastGeneric API 发现代 Toast 通知。 * 需要先运行 `/notify-install` 注册 AUMID(写注册表 + 开始菜单快捷方式), * 否则 Windows 11 会静默丢弃 Toast(只进通知中心,不弹横幅)。 * * 中文支持:脚本写到临时 .ps1 文件(UTF-8 with BOM),用 -File 执行。 * 不能走 -Command 命令行传参(会按系统 ANSI 转换导致乱码)。 */ import { execFile, execFileSync } from "node:child_process"; import { existsSync, readFileSync, writeFileSync, unlinkSync } from "node:fs"; // 本文件是 notify.ts 的辅助模块,并非扩展入口。 // 根目录 package.json 用 glob `pi-*/extensions/*.ts` 扫描扩展, // 会把本文件也当入口加载,故提供一个空默认工厂避免报错。 export default function () {} import { tmpdir } from "node:os"; import { join } from "node:path"; /** AUMID,必须与 /notify-install 注册的一致 */ export const PI_AUMID = "PiCodingAgent.Notify"; /** Windows 侧固定图标相对路径(install 时部署到 USERPROFILE 下) */ const ICON_WIN_REL = "AppData\\Local\\pi-notify\\pi-logo.png"; const POWERSHELL_EXE = "/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe"; function isWSL(): boolean { try { return /microsoft/i.test(readFileSync("/proc/version", "utf8")); } catch { return false; } } /** 找 powershell.exe(WSL 用 /mnt/c 路径,原生 Windows 用 PATH) */ function findPowerShell(): string | null { if (isWSL()) { return existsSync(POWERSHELL_EXE) ? POWERSHELL_EXE : null; } if (process.platform === "win32") return "powershell.exe"; return null; } /** 通过 powershell.exe 查询 Windows 用户目录(WSL 下 HOME 不可靠) */ export function queryWindowsUserprofile(): string | null { const ps = findPowerShell(); if (!ps) return null; try { const out = execFileSync(ps, ["-NoProfile", "-Command", "Write-Output $env:USERPROFILE"], { timeout: 5000, encoding: "utf8", }).trim(); return out || null; } catch { return null; } } /** 把字符串以 UTF-8 with BOM 写入文件(PS5 见 BOM 才按 UTF-8 解码) */ function writeUtf8Bom(path: string, content: string): void { const BOM = "\uFEFF"; writeFileSync(path, BOM + content, "utf8"); } /** 推断 Windows 侧图标绝对路径(通过 powershell.exe 查 USERPROFILE) */ function resolveIconWinPath(): string | null { const up = queryWindowsUserprofile(); return up ? up + "\\" + ICON_WIN_REL : null; } /** Windows 路径转 WSL 路径(用于在 WSL 侧访问 Windows 文件) */ function wslPath(winPath: string): string | null { if (!isWSL()) return winPath; const m = winPath.match(/^([A-Z]):\\(.+)$/i); if (m) { return "/mnt/" + m[1].toLowerCase() + "/" + m[2].replace(/\\/g, "/"); } return null; } /** WSL 路径转 Windows 路径 */ function winPath(p: string): string { if (!isWSL()) return p; const m = p.match(/^\/mnt\/([a-z])\/(.+)$/i); if (m) { return m[1].toUpperCase() + ":\\" + m[2].replace(/\//g, "\\"); } return p; } /** 检测 Windows 侧文件是否存在(WSL 下转 /mnt/c 路径检查) */ function existsIconWin(winPath: string): boolean { const wsl = wslPath(winPath); if (wsl) return existsSync(wsl); return existsSync(winPath); } /** 构造 Toast XML(ToastGeneric,支持图标)。XML 属性用双引号,便于嵌入 PS 单引号字符串。 */ function buildToastXml(title: string, body: string, iconUri: string | null): string { const imgTag = iconUri ? '' : ""; return ( '' + '' + escapeXml(title) + '' + '' + escapeXml(body) + '' + imgTag + '' ); } function escapeXml(s: string): string { return s.replace(/[<>&'"]/g, (c) => { switch (c) { case "<": return "<"; case ">": return ">"; case "&": return "&"; case "'": return "'"; case '"': return """; default: return c; } }); } /** * 发送 WinRT Toast 通知。 * @returns true 表示命令已成功执行(不代表通知一定显示) */ export function sendWinrtToast(title: string, body: string): boolean { const ps = findPowerShell(); if (!ps) return false; const iconWinPath = resolveIconWinPath(); const iconUri = iconWinPath && existsIconWin(iconWinPath) ? "file:///" + iconWinPath.replace(/\\/g, "/") : null; const xml = buildToastXml(title, body, iconUri); // 写临时 ps1 到 Windows 侧(UTF-8 with BOM;PS5 不认 Linux 路径) const up = queryWindowsUserprofile(); if (!up) return false; const tmpScriptWin = up + "\\AppData\\Local\\Temp\\pi-toast-" + Date.now() + ".ps1"; const tmpScriptWsl = wslPath(tmpScriptWin); if (!tmpScriptWsl) return false; const SQ = String.fromCharCode(39); const script = [ "[Console]::OutputEncoding = [System.Text.Encoding]::UTF8", "$xmlStr = " + SQ + xml + SQ, "$xmlDoc = [Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType = WindowsRuntime]::new()", "$xmlDoc.LoadXml($xmlStr)", "$toast = [Windows.UI.Notifications.ToastNotification, Windows.UI.Notifications, ContentType = WindowsRuntime]::new($xmlDoc)", "[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime]::CreateToastNotifier('" + PI_AUMID + "').Show($toast)", ].join("\n"); try { writeUtf8Bom(tmpScriptWsl, script); } catch { return false; } try { execFile( ps, ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", tmpScriptWin], { timeout: 8000 }, () => { try { unlinkSync(tmpScriptWsl); } catch {} }, ); return true; } catch { return false; } }