2026-10-24 12:14:38 +02:00
|
|
|
package notify
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
|
|
|
|
"encoding/base64"
|
|
|
|
|
"fmt"
|
|
|
|
|
"os/exec"
|
|
|
|
|
)
|
|
|
|
|
|
2026-10-25 12:43:14 +01:00
|
|
|
// powerShellAppID is the fallback toast identity while talk's own (AppID)
|
|
|
|
|
// is not registered yet: PowerShell's AppUserModelID exists on every
|
|
|
|
|
// Windows, the toast then appears as coming from Windows PowerShell.
|
|
|
|
|
const powerShellAppID = `{1AC14E77-02E7-4E5D-B744-2EB1AE5198B7}\WindowsPowerShell\v1.0\powershell.exe`
|
2026-10-24 12:14:38 +02:00
|
|
|
|
|
|
|
|
// toastGroup groups all talk toasts so they can be removed by tag.
|
|
|
|
|
const toastGroup = "talk"
|
|
|
|
|
|
2026-10-25 12:43:14 +01:00
|
|
|
// toastAppID is the AppUserModelID toasts are shown under: talk's own once
|
|
|
|
|
// UseIcon has registered it, PowerShell's before that.
|
|
|
|
|
func toastAppID() string {
|
|
|
|
|
if ownIdentity() {
|
|
|
|
|
return AppID
|
|
|
|
|
}
|
|
|
|
|
return powerShellAppID
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 12:14:38 +02:00
|
|
|
// 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
|
2026-10-25 12:43:14 +01:00
|
|
|
appID := toastAppID()
|
|
|
|
|
if err := runPowerShell(ctx, cancelToastScript(a, tag, appID)); 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, appID)
|
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
|
|
|
|
defer cancel()
|
|
|
|
|
_ = runPowerShell(ctx, remove)
|
|
|
|
|
}, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// cancelToastScript builds the PowerShell for a toast with a Cancel button
|
|
|
|
|
// shown under appID. Under talk's own identity the header already says
|
|
|
|
|
// "talk" with the icon, so the body is the text alone; under the PowerShell
|
|
|
|
|
// identity a first line names talk.
|
|
|
|
|
func cancelToastScript(a Announcement, tag, appID string) string {
|
|
|
|
|
return fmt.Sprintf(`$ErrorActionPreference = 'Stop'
|
2026-10-24 12:14:38 +02:00
|
|
|
[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')
|
2026-10-25 12:43:14 +01:00
|
|
|
$xmlText = "<toast><visual><binding template='ToastGeneric'>%s<text>$body</text></binding></visual><actions><action content='$label' arguments='$uri' activationType='protocol'/></actions></toast>"
|
2026-10-24 12:14:38 +02:00
|
|
|
$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)
|
2026-10-25 12:43:14 +01:00
|
|
|
`, b64(a.Text), b64(a.CancelLabel), b64(a.CancelURI), titleLine(appID), tag, toastGroup, appID)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// titleLine is the extra "talk" line a toast needs under a foreign identity.
|
|
|
|
|
func titleLine(appID string) string {
|
|
|
|
|
if appID == AppID {
|
|
|
|
|
return ""
|
2026-10-24 12:14:38 +02:00
|
|
|
}
|
2026-10-25 12:43:14 +01:00
|
|
|
return "<text>" + Title + "</text>"
|
2026-10-24 12:14:38 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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)) }
|