feat(notify): Windows toasts under talk's own identity, icon on Linux
The daemon (and talk test --toast) now register the AppUserModelID m3ta.talk under HKCU with display name and the installed icon, so toasts appear as "talk" with the icon instead of Windows PowerShell; the PowerShell identity stays as fallback until the registration has run once. The redundant "talk" title line is dropped under the own identity. notify.Setup bundles icon install, identity and the talk: protocol handler for both callers. Linux notifications carry the installed icon as app_icon. doctor names the active identity. Verified on the Windows 11 VM: registry key, icon file, toast with working Cancel button, exe icon; Linux via quickshell. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
2e77cf0a11
commit
2d9d2a86e9
10 files changed
+290
-48
No files matched your search
@@ -7,23 +7,49 @@ import (
|
||||
"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`
|
||||
// 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
|
||||
script := fmt.Sprintf(`$ErrorActionPreference = 'Stop'
|
||||
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)) }
|
||||
@@ -31,25 +57,22 @@ $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>"
|
||||
$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), tag, toastGroup, toastAppID)
|
||||
if err := runPowerShell(ctx, script); err != nil {
|
||||
return func() {}, err
|
||||
`, 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 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
|
||||
return "<text>" + Title + "</text>"
|
||||
}
|
||||
|
||||
// runPowerShell runs a script via -EncodedCommand.
|
||||
|
||||
Reference in new issue
Block a user