feat(notify): Cancel button on Windows toasts via the talk: protocol handler

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>
This commit is contained in:
m3ta-chironandClaude Fable 5.1 committed 2026-10-24 12:14:38 +02:00
1 parent 83a15af9c5
commit 00eb76128b
16 files changed
+446 -22

No files matched your search

+38 -13
View File
@@ -6,7 +6,9 @@
// action, closed again when the announcement ends. Fallback when
// no session bus is reachable: notify-send (no action).
// macOS: osascript `display notification` (no action).
// Windows: PowerShell toast (Windows.UI.Notifications; no action yet).
// Windows: PowerShell toast (Windows.UI.Notifications) with a Cancel
// button that opens a talk: link; the talk: protocol handler
// (registered in HKCU, `talk uri`) relays the click to the daemon.
//
// Missing tools are a clear error for the caller (log), never an abort: a
// notification is an extra, not a contract.
@@ -58,17 +60,18 @@ func Describe() string {
case "darwin":
return "osascript (Notification Center; no cancel button)"
case "windows":
return "powershell (Windows toast; no cancel button yet)"
return "powershell (Windows toast with cancel button via the talk: link)"
default:
return "notify-send (libnotify; no session bus, so no cancel button)"
}
}
// SupportsCancel reports whether notifications on this system can carry a
// Cancel action that reaches talk (Linux with a notification service that
// has the "actions" capability).
// Cancel button that reaches talk: Linux with a notification service that
// has the "actions" capability, or Windows (toast button via the talk:
// protocol handler).
func SupportsCancel() bool {
return dbusSupportsActions()
return dbusSupportsActions() || runtime.GOOS == "windows"
}
func tool() string {
@@ -96,16 +99,35 @@ func Show(ctx context.Context, text string) error {
return showWithTool(ctx, text)
}
// Announcement is what Announce shows for a playing announcement.
type Announcement struct {
// Text is the announcement text (the notification body).
Text string
// CancelLabel is the button label ("Cancel", "Abbrechen").
CancelLabel string
// CancelURI is the talk: link the button opens where the click cannot
// reach the daemon directly (Windows toast): talk:cancel?id=…&port=….
// The talk: protocol handler (talk uri) turns it into POST /cancel-item.
CancelURI string
// Tag identifies the notification for closing it again where the
// platform needs a tag (Windows); typically the announcement id.
Tag string
}
// Announce shows the notification for an announcement that is playing: the
// text plus a Cancel action labelled cancelLabel where the platform supports
// it. onCancel runs when the user clicks it. The returned close function
// dismisses the notification (call it when the announcement ends); it is
// never nil. Platforms without actions show a plain notification and the
// close function does nothing.
func Announce(ctx context.Context, text, cancelLabel string, onCancel func()) (close func(), err error) {
// text plus a Cancel button where the platform supports it. onCancel runs
// when the user clicks it on platforms that report the click back (Linux
// D-Bus); on Windows the click opens CancelURI instead. The returned close
// function dismisses the notification (call it when the announcement
// ends); it is never nil. Platforms without actions show a plain
// notification and the close function does nothing.
func Announce(ctx context.Context, a Announcement, onCancel func()) (close func(), err error) {
noop := func() {}
if dbusAvailable() {
id, err := dbusNotify(ctx, text, []string{"cancel", cancelLabel}, func(action string) {
id, err := dbusNotify(ctx, a.Text, []string{"cancel", a.CancelLabel}, func(action string) {
if action == "cancel" && onCancel != nil {
onCancel()
}
@@ -115,7 +137,10 @@ func Announce(ctx context.Context, text, cancelLabel string, onCancel func()) (c
}
// fall through to the tool on a bus error
}
return noop, showWithTool(ctx, text)
if runtime.GOOS == "windows" && a.CancelURI != "" {
return announceWindows(ctx, a)
}
return noop, showWithTool(ctx, a.Text)
}
// showWithTool shows a notification through the OS tool (no action).
+7
View File
@@ -0,0 +1,7 @@
//go:build !windows
package notify
// RegisterProtocol is a no-op outside Windows: Linux reports clicks over
// D-Bus, macOS has no button.
func RegisterProtocol(string) error { return nil }
+36
View File
@@ -0,0 +1,36 @@
//go:build windows
package notify
import (
"fmt"
"golang.org/x/sys/windows/registry"
)
// RegisterProtocol registers the talk: URL protocol for the current user
// (HKCU\Software\Classes\talk, no admin rights needed) so that a toast
// button with a talk: link starts `<exe> uri <link>`. Idempotent; the
// command is rewritten on every call so it follows the binary's location.
func RegisterProtocol(exe string) error {
root, _, err := registry.CreateKey(registry.CURRENT_USER, `Software\Classes\talk`, registry.SET_VALUE)
if err != nil {
return fmt.Errorf("registering talk: protocol: %w", err)
}
defer root.Close()
if err := root.SetStringValue("", "URL:talk protocol"); err != nil {
return fmt.Errorf("registering talk: protocol: %w", err)
}
if err := root.SetStringValue("URL Protocol", ""); err != nil {
return fmt.Errorf("registering talk: protocol: %w", err)
}
cmdKey, _, err := registry.CreateKey(registry.CURRENT_USER, `Software\Classes\talk\shell\open\command`, registry.SET_VALUE)
if err != nil {
return fmt.Errorf("registering talk: protocol: %w", err)
}
defer cmdKey.Close()
if err := cmdKey.SetStringValue("", fmt.Sprintf(`"%s" uri "%%1"`, exe)); err != nil {
return fmt.Errorf("registering talk: protocol: %w", err)
}
return nil
}
+66
View File
@@ -0,0 +1,66 @@
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)) }