Files
talk/internal/notify/toast_windows_impl.go
T

89 lines
3.7 KiB
Go
Raw Normal View History

package notify
import (
"context"
"encoding/base64"
"fmt"
"os/exec"
)
// 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`
// toastGroup groups all talk toasts so they can be removed by tag.
const toastGroup = "talk"
// 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
}
// 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
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'
[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'>%s<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), 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 ""
}
return "<text>" + Title + "</text>"
}
// 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)) }