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
51ffd5aa1f
10 files changed
+290
-48
No files matched your search
@@ -1,3 +1,5 @@
|
|||||||
|
<img src="assets/icon/talk.svg" alt="" width="96" align="right">
|
||||||
|
|
||||||
# talk
|
# talk
|
||||||
|
|
||||||
> **Status: WIP**, under active development. Specification with the decision
|
> **Status: WIP**, under active development. Specification with the decision
|
||||||
@@ -106,13 +108,20 @@ announcement ends.
|
|||||||
- **Linux:** talk talks to the freedesktop notification service on the
|
- **Linux:** talk talks to the freedesktop notification service on the
|
||||||
session bus directly (any daemon with the "actions" capability: mako,
|
session bus directly (any daemon with the "actions" capability: mako,
|
||||||
dunst, GNOME Shell, KDE, quickshell) and receives the click over D-Bus.
|
dunst, GNOME Shell, KDE, quickshell) and receives the click over D-Bus.
|
||||||
Without a session bus it falls back to notify-send without a button.
|
The notification carries the talk icon. Without a session bus it falls
|
||||||
- **Windows:** a toast with a Cancel button. The button opens a `talk:`
|
back to notify-send without a button.
|
||||||
link; the daemon registers that URL protocol for the current user (HKCU,
|
- **Windows:** a toast from "talk" with its icon and a Cancel button. The
|
||||||
no admin rights) so Windows starts `talk uri talk:cancel?id=…`, which
|
button opens a `talk:` link; the daemon registers that URL protocol for
|
||||||
tells the daemon to stop the announcement. The toast appears under
|
the current user (HKCU, no admin rights) so Windows starts
|
||||||
Windows PowerShell's identity for now.
|
`talk uri talk:cancel?id=…`, which tells the daemon to stop the
|
||||||
- **macOS:** Notification Center, no button yet.
|
announcement. The toast identity (`AppUserModelId m3ta.talk`, also HKCU)
|
||||||
|
is registered the same way on the first daemon start or `talk test
|
||||||
|
--toast`; before that the toast appears under Windows PowerShell's
|
||||||
|
identity.
|
||||||
|
- **macOS:** Notification Center, no button and no icon yet.
|
||||||
|
|
||||||
|
The icon file lives next to the config (`talk.png` in the config
|
||||||
|
directory); talk writes it there itself.
|
||||||
|
|
||||||
Turn it off with:
|
Turn it off with:
|
||||||
|
|
||||||
@@ -439,6 +448,12 @@ nix develop # devShell with go, gopls, espeak-ng, libnotify
|
|||||||
CI: `.gitea/workflows/test.yml` (Gitea Actions) runs vet, the gofmt check,
|
CI: `.gitea/workflows/test.yml` (Gitea Actions) runs vet, the gofmt check,
|
||||||
tests and build on every push and pull request.
|
tests and build on every push and pull request.
|
||||||
|
|
||||||
|
**Icon:** the source is `assets/icon/talk.svg`. `go run assets/icon/gen.go`
|
||||||
|
(needs inkscape) renders the embedded PNG (`internal/icon/talk.png`), the
|
||||||
|
Windows `talk.ico` and the resource objects `cmd/talk/rsrc_windows_*.syso`
|
||||||
|
that give `talk.exe` its icon; all of them are committed, so a normal build
|
||||||
|
needs no resource compiler.
|
||||||
|
|
||||||
**Release:** push a `v*` tag → `.gitea/workflows/release.yml` tests, builds
|
**Release:** push a `v*` tag → `.gitea/workflows/release.yml` tests, builds
|
||||||
the six targets with `scripts/release.sh` (static, `CGO_ENABLED=0`, version
|
the six targets with `scripts/release.sh` (static, `CGO_ENABLED=0`, version
|
||||||
via ldflags) and creates a Gitea release with `talk_<os>_<arch>[.exe]` and
|
via ldflags) and creates a Gitea release with `talk_<os>_<arch>[.exe]` and
|
||||||
|
|||||||
+11
-2
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -50,7 +51,7 @@ func runTest(args []string, opts *sayOptions, _ io.Reader, stdout, stderr io.Wri
|
|||||||
lang = cfg.Lang
|
lang = cfg.Lang
|
||||||
}
|
}
|
||||||
if opts.toast {
|
if opts.toast {
|
||||||
return runToastTest(lang, stdout, stderr)
|
return runToastTest(lang, getenv, stdout, stderr)
|
||||||
}
|
}
|
||||||
if dir, err := config.Dir(getenv); err == nil {
|
if dir, err := config.Dir(getenv); err == nil {
|
||||||
if (queue.MuteStore{Path: filepath.Join(dir, queue.MuteFileName)}).Muted() {
|
if (queue.MuteStore{Path: filepath.Join(dir, queue.MuteFileName)}).Muted() {
|
||||||
@@ -92,7 +93,15 @@ func runTest(args []string, opts *sayOptions, _ io.Reader, stdout, stderr io.Wri
|
|||||||
// gets one (with a Cancel button where supported), waits a few seconds for a
|
// gets one (with a Cancel button where supported), waits a few seconds for a
|
||||||
// click and reports what happened. Exit 1 when no tool exists or the call
|
// click and reports what happened. Exit 1 when no tool exists or the call
|
||||||
// fails.
|
// fails.
|
||||||
func runToastTest(lang string, stdout, stderr io.Writer) int {
|
func runToastTest(lang string, getenv func(string) string, stdout, stderr io.Writer) int {
|
||||||
|
// Same desktop setup as the daemon, so the test shows what announcements
|
||||||
|
// will show (icon, identity, Cancel button).
|
||||||
|
if dir, err := config.Dir(getenv); err == nil {
|
||||||
|
exe, _ := os.Executable()
|
||||||
|
if err := notify.Setup(dir, exe); err != nil {
|
||||||
|
fmt.Fprintf(stderr, "talk test: %v\n", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
fmt.Fprintf(stdout, "toast: %s\n", notify.Describe())
|
fmt.Fprintf(stdout, "toast: %s\n", notify.Describe())
|
||||||
if !notify.Available() {
|
if !notify.Available() {
|
||||||
fmt.Fprintln(stderr, "talk test: no notification path found; on Linux install libnotify (notify-send) or run a notification daemon, macOS and Windows have one built in")
|
fmt.Fprintln(stderr, "talk test: no notification path found; on Linux install libnotify (notify-send) or run a notification daemon, macOS and Windows have one built in")
|
||||||
|
|||||||
@@ -131,3 +131,9 @@ These points replace the corresponding statements above (grilling state of
|
|||||||
- **Notifications without beeep:** desktop toast via the OS's own tools
|
- **Notifications without beeep:** desktop toast via the OS's own tools
|
||||||
(notify-send / osascript / PowerShell), exec-based like osbuiltin;
|
(notify-send / osascript / PowerShell), exec-based like osbuiltin;
|
||||||
initially only for `quiet_mode = "toast"`. Cancel button: later.
|
initially only for `quiet_mode = "toast"`. Cancel button: later.
|
||||||
|
- **Icon and toast identity (2026-10-25):** one SVG icon (speech bubble
|
||||||
|
with level bars), rendered into the binary: PNG for notifications (Linux
|
||||||
|
app_icon, Windows toast), `.syso` resources for talk.exe. On Windows the
|
||||||
|
daemon registers talk's own AppUserModelID `m3ta.talk` under HKCU (no
|
||||||
|
shortcut, no admin), so toasts appear as "talk" with the icon instead of
|
||||||
|
Windows PowerShell.
|
||||||
@@ -15,7 +15,6 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -85,13 +84,12 @@ func Run(ctx context.Context, opts Options) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
// Windows: the toast's Cancel button opens a talk: link; register the
|
// Desktop side: the icon next to the config, Windows additionally the
|
||||||
// protocol handler for this user so the click starts `talk uri`.
|
// toast identity and the talk: protocol handler for the Cancel button.
|
||||||
if cfg.Notify.OnSpeak && runtime.GOOS == "windows" {
|
if dir, err := config.Dir(getenv); err == nil {
|
||||||
if exe, err := os.Executable(); err == nil {
|
exe, _ := os.Executable()
|
||||||
if err := notify.RegisterProtocol(exe); err != nil {
|
if err := notify.Setup(dir, exe); err != nil {
|
||||||
logger.Printf("warning: %v; the toast's Cancel button will not work", err)
|
logger.Printf("warning: %v; notifications fall back to the plain identity or lose the Cancel button", err)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -152,7 +152,7 @@ func dbusNotify(ctx context.Context, text string, actions []string, onAction fun
|
|||||||
}
|
}
|
||||||
var id uint32
|
var id uint32
|
||||||
err = conn.Object(notifDest, notifPath).CallWithContext(ctx, notifIface+".Notify", 0,
|
err = conn.Object(notifDest, notifPath).CallWithContext(ctx, notifIface+".Notify", 0,
|
||||||
Title, uint32(0), "audio-speakers", Title, text, actions, hints, expire).Store(&id)
|
Title, uint32(0), appIcon(), Title, text, actions, hints, expire).Store(&id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, fmt.Errorf("notify: %w", err)
|
return 0, fmt.Errorf("notify: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
//go:build !windows
|
||||||
|
|
||||||
|
package notify
|
||||||
|
|
||||||
|
// UseIcon remembers the icon file for notifications (Linux: app_icon of the
|
||||||
|
// freedesktop notification). macOS has no way to pass one to osascript.
|
||||||
|
func UseIcon(iconPath string) error {
|
||||||
|
setIconPath(iconPath)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ownIdentity is a Windows concept (toast AppUserModelID).
|
||||||
|
func ownIdentity() bool { return false }
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package notify
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"golang.org/x/sys/windows/registry"
|
||||||
|
)
|
||||||
|
|
||||||
|
// appIDKey is where Windows looks up the identity (name, icon) of an
|
||||||
|
// unpackaged program that shows toasts: HKCU, so no admin rights.
|
||||||
|
const appIDKey = `Software\Classes\AppUserModelId\` + AppID
|
||||||
|
|
||||||
|
var (
|
||||||
|
identityMu sync.Mutex
|
||||||
|
identityKnown bool
|
||||||
|
identityOK bool
|
||||||
|
)
|
||||||
|
|
||||||
|
// UseIcon registers talk's own toast identity for the current user: the
|
||||||
|
// AppUserModelID AppID with display name "talk" and the icon at iconPath.
|
||||||
|
// Toasts then appear as coming from talk instead of Windows PowerShell.
|
||||||
|
// Idempotent; rewritten on every call so it follows the icon's location.
|
||||||
|
func UseIcon(iconPath string) error {
|
||||||
|
setIconPath(iconPath)
|
||||||
|
k, _, err := registry.CreateKey(registry.CURRENT_USER, appIDKey, registry.SET_VALUE)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("registering toast identity: %w", err)
|
||||||
|
}
|
||||||
|
defer k.Close()
|
||||||
|
if err := k.SetStringValue("DisplayName", Title); err != nil {
|
||||||
|
return fmt.Errorf("registering toast identity: %w", err)
|
||||||
|
}
|
||||||
|
if err := k.SetStringValue("IconUri", iconPath); err != nil {
|
||||||
|
return fmt.Errorf("registering toast identity: %w", err)
|
||||||
|
}
|
||||||
|
identityMu.Lock()
|
||||||
|
identityKnown, identityOK = true, true
|
||||||
|
identityMu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ownIdentity reports whether AppID is registered for this user (by this
|
||||||
|
// process or an earlier daemon start); checked once per process.
|
||||||
|
func ownIdentity() bool {
|
||||||
|
identityMu.Lock()
|
||||||
|
defer identityMu.Unlock()
|
||||||
|
if !identityKnown {
|
||||||
|
identityKnown = true
|
||||||
|
identityOK = false
|
||||||
|
if k, err := registry.OpenKey(registry.CURRENT_USER, appIDKey, registry.QUERY_VALUE); err == nil {
|
||||||
|
_, _, err := k.GetStringValue("IconUri")
|
||||||
|
identityOK = err == nil
|
||||||
|
k.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return identityOK
|
||||||
|
}
|
||||||
+65
-11
@@ -19,17 +19,69 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/binary"
|
"encoding/binary"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"runtime"
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
"unicode/utf16"
|
"unicode/utf16"
|
||||||
|
|
||||||
|
"code.m3ta.dev/m3tam3re/talk/internal/icon"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Title is the title of all talk notifications.
|
// Title is the title of all talk notifications.
|
||||||
const Title = "talk"
|
const Title = "talk"
|
||||||
|
|
||||||
|
// AppID is talk's AppUserModelID on Windows: the identity (name, icon) its
|
||||||
|
// toasts are shown under once UseIcon has registered it.
|
||||||
|
const AppID = "m3ta.talk"
|
||||||
|
|
||||||
|
var (
|
||||||
|
iconMu sync.Mutex
|
||||||
|
iconFile string
|
||||||
|
)
|
||||||
|
|
||||||
|
func setIconPath(p string) {
|
||||||
|
iconMu.Lock()
|
||||||
|
iconFile = p
|
||||||
|
iconMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// appIcon is the icon a freedesktop notification carries: the installed
|
||||||
|
// talk icon, or a theme icon while none is installed.
|
||||||
|
func appIcon() string {
|
||||||
|
iconMu.Lock()
|
||||||
|
defer iconMu.Unlock()
|
||||||
|
if iconFile != "" {
|
||||||
|
return iconFile
|
||||||
|
}
|
||||||
|
return "audio-speakers"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup prepares the desktop side once per process (daemon start, talk
|
||||||
|
// test --toast): installs the icon into dir and registers it (Windows: the
|
||||||
|
// toast identity and, with exe, the talk: protocol handler the Cancel
|
||||||
|
// button needs). Best effort: the error is for the log, notifications still
|
||||||
|
// work under the fallback identity.
|
||||||
|
func Setup(dir, exe string) error {
|
||||||
|
path, err := icon.Install(dir)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var errs []error
|
||||||
|
if err := UseIcon(path); err != nil {
|
||||||
|
errs = append(errs, err)
|
||||||
|
}
|
||||||
|
if exe != "" {
|
||||||
|
if err := RegisterProtocol(exe); err != nil {
|
||||||
|
errs = append(errs, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return errors.Join(errs...)
|
||||||
|
}
|
||||||
|
|
||||||
// timeout bounds the call of the OS tool.
|
// timeout bounds the call of the OS tool.
|
||||||
const timeout = 10 * time.Second
|
const timeout = 10 * time.Second
|
||||||
|
|
||||||
@@ -60,7 +112,10 @@ func Describe() string {
|
|||||||
case "darwin":
|
case "darwin":
|
||||||
return "osascript (Notification Center; no cancel button)"
|
return "osascript (Notification Center; no cancel button)"
|
||||||
case "windows":
|
case "windows":
|
||||||
return "powershell (Windows toast with cancel button via the talk: link)"
|
if ownIdentity() {
|
||||||
|
return "powershell (Windows toast as talk, cancel button via the talk: link)"
|
||||||
|
}
|
||||||
|
return "powershell (Windows toast under the Windows PowerShell identity until the daemon has run once; cancel button via the talk: link)"
|
||||||
default:
|
default:
|
||||||
return "notify-send (libnotify; no session bus, so no cancel button)"
|
return "notify-send (libnotify; no session bus, so no cancel button)"
|
||||||
}
|
}
|
||||||
@@ -153,7 +208,7 @@ func showWithTool(ctx context.Context, text string) error {
|
|||||||
script := fmt.Sprintf("display notification %s with title %s", appleString(text), appleString(Title))
|
script := fmt.Sprintf("display notification %s with title %s", appleString(text), appleString(Title))
|
||||||
cmd = exec.CommandContext(ctx, "osascript", "-e", script)
|
cmd = exec.CommandContext(ctx, "osascript", "-e", script)
|
||||||
case "windows":
|
case "windows":
|
||||||
cmd = exec.CommandContext(ctx, "powershell", "-NoProfile", "-NonInteractive", "-EncodedCommand", psEncode(toastScript(text)))
|
cmd = exec.CommandContext(ctx, "powershell", "-NoProfile", "-NonInteractive", "-EncodedCommand", psEncode(toastScript(text, toastAppID())))
|
||||||
default:
|
default:
|
||||||
cmd = exec.CommandContext(ctx, "notify-send", "--app-name="+Title, "--urgency=normal", Title, text)
|
cmd = exec.CommandContext(ctx, "notify-send", "--app-name="+Title, "--urgency=normal", Title, text)
|
||||||
}
|
}
|
||||||
@@ -168,18 +223,17 @@ func appleString(s string) string {
|
|||||||
return `"` + strings.NewReplacer(`\`, `\\`, `"`, `\"`).Replace(s) + `"`
|
return `"` + strings.NewReplacer(`\`, `\\`, `"`, `\"`).Replace(s) + `"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// toastScript is the PowerShell toast (Windows 10/11); the text is passed
|
// toastScript is the PowerShell for a plain toast (Windows 10/11) under
|
||||||
// base64-encoded so there is no quoting problem.
|
// appID; the text is passed base64-encoded so there is no quoting problem.
|
||||||
func toastScript(text string) string {
|
func toastScript(text, appID string) string {
|
||||||
return fmt.Sprintf(`$ErrorActionPreference = 'Stop'
|
return fmt.Sprintf(`$ErrorActionPreference = 'Stop'
|
||||||
[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null
|
[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null
|
||||||
$msg = [System.Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('%s'))
|
[Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType = WindowsRuntime] | Out-Null
|
||||||
$xml = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent([Windows.UI.Notifications.ToastTemplateType]::ToastText02)
|
$msg = [System.Security.SecurityElement]::Escape([System.Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('%s')))
|
||||||
$nodes = $xml.GetElementsByTagName('text')
|
$xml = New-Object Windows.Data.Xml.Dom.XmlDocument
|
||||||
$nodes.Item(0).AppendChild($xml.CreateTextNode('%s')) | Out-Null
|
$xml.LoadXml("<toast><visual><binding template='ToastGeneric'>%s<text>$msg</text></binding></visual></toast>")
|
||||||
$nodes.Item(1).AppendChild($xml.CreateTextNode($msg)) | Out-Null
|
|
||||||
[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('%s').Show([Windows.UI.Notifications.ToastNotification]::new($xml))
|
[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('%s').Show([Windows.UI.Notifications.ToastNotification]::new($xml))
|
||||||
`, base64.StdEncoding.EncodeToString([]byte(text)), Title, Title)
|
`, base64.StdEncoding.EncodeToString([]byte(text)), titleLine(appID), appID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// psEncode encodes a script for -EncodedCommand (UTF-16LE + base64).
|
// psEncode encodes a script for -EncodedCommand (UTF-16LE + base64).
|
||||||
|
|||||||
@@ -66,3 +66,67 @@ func TestAppleString(t *testing.T) {
|
|||||||
t.Errorf("appleString = %s", got)
|
t.Errorf("appleString = %s", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestToastIdentity: under talk's own AppUserModelID the toast header names
|
||||||
|
// talk already, so the scripts carry only the body; under the PowerShell
|
||||||
|
// fallback identity a first line names talk.
|
||||||
|
func TestToastIdentity(t *testing.T) {
|
||||||
|
a := Announcement{Text: "build done", CancelLabel: "Cancel", CancelURI: "talk:cancel?id=1", Tag: "1"}
|
||||||
|
own := cancelToastScript(a, "talk-1", AppID)
|
||||||
|
if !strings.Contains(own, "CreateToastNotifier('"+AppID+"')") || strings.Contains(own, "<text>"+Title+"</text>") {
|
||||||
|
t.Errorf("own identity script wrong:\n%s", own)
|
||||||
|
}
|
||||||
|
fallback := cancelToastScript(a, "talk-1", powerShellAppID)
|
||||||
|
if !strings.Contains(fallback, "CreateToastNotifier('"+powerShellAppID+"')") || !strings.Contains(fallback, "<text>"+Title+"</text>") {
|
||||||
|
t.Errorf("fallback script wrong:\n%s", fallback)
|
||||||
|
}
|
||||||
|
plain := toastScript("quiet", AppID)
|
||||||
|
if strings.Contains(plain, "<text>"+Title+"</text>") || !strings.Contains(plain, "<text>$msg</text>") {
|
||||||
|
t.Errorf("plain own-identity script wrong:\n%s", plain)
|
||||||
|
}
|
||||||
|
if !strings.Contains(toastScript("quiet", powerShellAppID), "<text>"+Title+"</text>") {
|
||||||
|
t.Error("plain fallback script lacks the talk line")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestUseIconOutsideWindows: UseIcon remembers the file and the freedesktop
|
||||||
|
// notification carries it as app_icon; without one a theme icon is used.
|
||||||
|
func TestUseIconOutsideWindows(t *testing.T) {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
t.Skip("registry path on Windows")
|
||||||
|
}
|
||||||
|
setIconPath("")
|
||||||
|
if appIcon() != "audio-speakers" {
|
||||||
|
t.Errorf("appIcon without file = %q", appIcon())
|
||||||
|
}
|
||||||
|
if err := UseIcon("/x/talk.png"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { setIconPath("") })
|
||||||
|
if appIcon() != "/x/talk.png" {
|
||||||
|
t.Errorf("appIcon = %q", appIcon())
|
||||||
|
}
|
||||||
|
if ownIdentity() {
|
||||||
|
t.Error("ownIdentity true outside Windows")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSetup: the icon lands in dir and is used; with no exe the protocol
|
||||||
|
// step is skipped (outside Windows it is a no-op anyway).
|
||||||
|
func TestSetup(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
if err := Setup(dir, ""); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { setIconPath("") })
|
||||||
|
want := filepath.Join(dir, "talk.png")
|
||||||
|
if _, err := os.Stat(want); err != nil {
|
||||||
|
t.Fatalf("icon not installed: %v", err)
|
||||||
|
}
|
||||||
|
if runtime.GOOS != "windows" && appIcon() != want {
|
||||||
|
t.Errorf("appIcon = %q, want %q", appIcon(), want)
|
||||||
|
}
|
||||||
|
if err := Setup("", ""); err == nil {
|
||||||
|
t.Error("Setup without dir succeeded")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,23 +7,49 @@ import (
|
|||||||
"os/exec"
|
"os/exec"
|
||||||
)
|
)
|
||||||
|
|
||||||
// toastAppID is the AppUserModelID the toasts are shown under. Unpackaged
|
// powerShellAppID is the fallback toast identity while talk's own (AppID)
|
||||||
// programs need a registered one; PowerShell's own is present on every
|
// is not registered yet: PowerShell's AppUserModelID exists on every
|
||||||
// Windows and lets the toast show reliably (it then appears as coming from
|
// Windows, the toast then appears as coming from Windows PowerShell.
|
||||||
// Windows PowerShell). A talk-specific identity would need a Start Menu
|
const powerShellAppID = `{1AC14E77-02E7-4E5D-B744-2EB1AE5198B7}\WindowsPowerShell\v1.0\powershell.exe`
|
||||||
// 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.
|
// toastGroup groups all talk toasts so they can be removed by tag.
|
||||||
const toastGroup = "talk"
|
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
|
// announceWindows shows a toast with the text and a Cancel button that opens
|
||||||
// a.CancelURI (protocol activation; Windows then starts `talk uri <link>`,
|
// a.CancelURI (protocol activation; Windows then starts `talk uri <link>`,
|
||||||
// which tells the daemon to cancel). The returned close removes the toast by
|
// which tells the daemon to cancel). The returned close removes the toast by
|
||||||
// its tag when the announcement ends.
|
// its tag when the announcement ends.
|
||||||
func announceWindows(ctx context.Context, a Announcement) (func(), error) {
|
func announceWindows(ctx context.Context, a Announcement) (func(), error) {
|
||||||
tag := "talk-" + a.Tag
|
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.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null
|
||||||
[Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, 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)) }
|
$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')
|
$body = & $esc (& $dec '%s')
|
||||||
$label = & $esc (& $dec '%s')
|
$label = & $esc (& $dec '%s')
|
||||||
$uri = & $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 = New-Object Windows.Data.Xml.Dom.XmlDocument
|
||||||
$xml.LoadXml($xmlText)
|
$xml.LoadXml($xmlText)
|
||||||
$toast = [Windows.UI.Notifications.ToastNotification]::new($xml)
|
$toast = [Windows.UI.Notifications.ToastNotification]::new($xml)
|
||||||
$toast.Tag = '%s'
|
$toast.Tag = '%s'
|
||||||
$toast.Group = '%s'
|
$toast.Group = '%s'
|
||||||
[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('%s').Show($toast)
|
[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('%s').Show($toast)
|
||||||
`, b64(a.Text), b64(a.CancelLabel), b64(a.CancelURI), tag, toastGroup, toastAppID)
|
`, b64(a.Text), b64(a.CancelLabel), b64(a.CancelURI), titleLine(appID), tag, toastGroup, appID)
|
||||||
if err := runPowerShell(ctx, script); err != nil {
|
}
|
||||||
return func() {}, err
|
|
||||||
|
// titleLine is the extra "talk" line a toast needs under a foreign identity.
|
||||||
|
func titleLine(appID string) string {
|
||||||
|
if appID == AppID {
|
||||||
|
return ""
|
||||||
}
|
}
|
||||||
return func() {
|
return "<text>" + Title + "</text>"
|
||||||
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.
|
// runPowerShell runs a script via -EncodedCommand.
|
||||||
|
|||||||
Reference in new issue
Block a user