Windows toasts cannot call back into the daemon, so the Cancel button opens a talk: link instead: `talk:cancel?id=<id>&port=<port>`. The daemon registers the talk: URL protocol for the current user (HKCU, no admin) and points it at `talk uri "%1"`; Windows starts that on the click, and `talk uri` posts to the new endpoint POST /cancel-item, which cancels only the announcement with that id (queue.CancelItem). The toast is tagged and removed again when the announcement ends. For now the toast is shown under Windows PowerShell's app identity, which unpackaged programs can use without a Start Menu shortcut. Verified on the Windows 11 VM: registry entry written by the daemon, Start-Process "talk:cancel?id=…&port=7800" (what the button does) stops the playing announcement within two seconds, no toast errors in the log. Tests: `talk uri` against a real handler on a loopback port (playing vs waiting id, dead daemon, talk:test, garbage links), POST /cancel-item process test (only the playing item, waiting one plays next, 400 without id). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
67 lines
3.0 KiB
Go
67 lines
3.0 KiB
Go
package notify
|
|
|
|
import (
|
|
"context"
|
|
"encoding/base64"
|
|
"fmt"
|
|
"os/exec"
|
|
)
|
|
|
|
// toastAppID is the AppUserModelID the toasts are shown under. Unpackaged
|
|
// programs need a registered one; PowerShell's own is present on every
|
|
// Windows and lets the toast show reliably (it then appears as coming from
|
|
// Windows PowerShell). A talk-specific identity would need a Start Menu
|
|
// shortcut; left for later.
|
|
const toastAppID = `{1AC14E77-02E7-4E5D-B744-2EB1AE5198B7}\WindowsPowerShell\v1.0\powershell.exe`
|
|
|
|
// toastGroup groups all talk toasts so they can be removed by tag.
|
|
const toastGroup = "talk"
|
|
|
|
// announceWindows shows a toast with the text and a Cancel button that opens
|
|
// a.CancelURI (protocol activation; Windows then starts `talk uri <link>`,
|
|
// which tells the daemon to cancel). The returned close removes the toast by
|
|
// its tag when the announcement ends.
|
|
func announceWindows(ctx context.Context, a Announcement) (func(), error) {
|
|
tag := "talk-" + a.Tag
|
|
script := fmt.Sprintf(`$ErrorActionPreference = 'Stop'
|
|
[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null
|
|
[Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType = WindowsRuntime] | Out-Null
|
|
$dec = { param($b) [System.Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($b)) }
|
|
$esc = { param($s) [System.Security.SecurityElement]::Escape($s) }
|
|
$body = & $esc (& $dec '%s')
|
|
$label = & $esc (& $dec '%s')
|
|
$uri = & $esc (& $dec '%s')
|
|
$xmlText = "<toast><visual><binding template='ToastGeneric'><text>talk</text><text>$body</text></binding></visual><actions><action content='$label' arguments='$uri' activationType='protocol'/></actions></toast>"
|
|
$xml = New-Object Windows.Data.Xml.Dom.XmlDocument
|
|
$xml.LoadXml($xmlText)
|
|
$toast = [Windows.UI.Notifications.ToastNotification]::new($xml)
|
|
$toast.Tag = '%s'
|
|
$toast.Group = '%s'
|
|
[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('%s').Show($toast)
|
|
`, b64(a.Text), b64(a.CancelLabel), b64(a.CancelURI), tag, toastGroup, toastAppID)
|
|
if err := runPowerShell(ctx, script); err != nil {
|
|
return func() {}, err
|
|
}
|
|
return func() {
|
|
remove := fmt.Sprintf(`[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null
|
|
[Windows.UI.Notifications.ToastNotificationManager]::History.Remove('%s', '%s', '%s')
|
|
`, tag, toastGroup, toastAppID)
|
|
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
|
defer cancel()
|
|
_ = runPowerShell(ctx, remove)
|
|
}, nil
|
|
}
|
|
|
|
// runPowerShell runs a script via -EncodedCommand.
|
|
func runPowerShell(ctx context.Context, script string) error {
|
|
ctx, cancel := context.WithTimeout(ctx, timeout)
|
|
defer cancel()
|
|
cmd := exec.CommandContext(ctx, "powershell", "-NoProfile", "-NonInteractive", "-EncodedCommand", psEncode(script))
|
|
if out, err := cmd.CombinedOutput(); err != nil {
|
|
return fmt.Errorf("notify: powershell failed: %w: %s", err, string(out))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func b64(s string) string { return base64.StdEncoding.EncodeToString([]byte(s)) }
|