diff --git a/README.md b/README.md
index 860c4f3..ea37d3f 100644
--- a/README.md
+++ b/README.md
@@ -1,3 +1,5 @@
+
+
# talk
> **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
session bus directly (any daemon with the "actions" capability: mako,
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.
-- **Windows:** a toast with a Cancel button. The button opens a `talk:`
- link; the daemon registers that URL protocol for the current user (HKCU,
- no admin rights) so Windows starts `talk uri talk:cancel?id=…`, which
- tells the daemon to stop the announcement. The toast appears under
- Windows PowerShell's identity for now.
-- **macOS:** Notification Center, no button yet.
+ The notification carries the talk icon. Without a session bus it falls
+ back to notify-send without a button.
+- **Windows:** a toast from "talk" with its icon and a Cancel button. The
+ button opens a `talk:` link; the daemon registers that URL protocol for
+ the current user (HKCU, no admin rights) so Windows starts
+ `talk uri talk:cancel?id=…`, which tells the daemon to stop the
+ 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:
@@ -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,
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
the six targets with `scripts/release.sh` (static, `CGO_ENABLED=0`, version
via ldflags) and creates a Gitea release with `talk__[.exe]` and
diff --git a/cmd/talk/testcmd.go b/cmd/talk/testcmd.go
index bf1fbe1..a2efc24 100644
--- a/cmd/talk/testcmd.go
+++ b/cmd/talk/testcmd.go
@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"io"
+ "os"
"path/filepath"
"time"
@@ -50,7 +51,7 @@ func runTest(args []string, opts *sayOptions, _ io.Reader, stdout, stderr io.Wri
lang = cfg.Lang
}
if opts.toast {
- return runToastTest(lang, stdout, stderr)
+ return runToastTest(lang, getenv, stdout, stderr)
}
if dir, err := config.Dir(getenv); err == nil {
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
// click and reports what happened. Exit 1 when no tool exists or the call
// 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())
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")
diff --git a/docs/spec.md b/docs/spec.md
index 57ba745..814b2c6 100644
--- a/docs/spec.md
+++ b/docs/spec.md
@@ -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
(notify-send / osascript / PowerShell), exec-based like osbuiltin;
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.
diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go
index e7bd55f..fff1c40 100644
--- a/internal/daemon/daemon.go
+++ b/internal/daemon/daemon.go
@@ -15,7 +15,6 @@ import (
"net/http"
"os"
"path/filepath"
- "runtime"
"strconv"
"strings"
"sync"
@@ -85,13 +84,12 @@ func Run(ctx context.Context, opts Options) error {
if err != nil {
return err
}
- // Windows: the toast's Cancel button opens a talk: link; register the
- // protocol handler for this user so the click starts `talk uri`.
- if cfg.Notify.OnSpeak && runtime.GOOS == "windows" {
- if exe, err := os.Executable(); err == nil {
- if err := notify.RegisterProtocol(exe); err != nil {
- logger.Printf("warning: %v; the toast's Cancel button will not work", err)
- }
+ // Desktop side: the icon next to the config, Windows additionally the
+ // toast identity and the talk: protocol handler for the Cancel button.
+ if dir, err := config.Dir(getenv); err == nil {
+ exe, _ := os.Executable()
+ if err := notify.Setup(dir, exe); err != nil {
+ logger.Printf("warning: %v; notifications fall back to the plain identity or lose the Cancel button", err)
}
}
diff --git a/internal/notify/dbus_linux.go b/internal/notify/dbus_linux.go
index c2d4a53..9f30f2e 100644
--- a/internal/notify/dbus_linux.go
+++ b/internal/notify/dbus_linux.go
@@ -152,7 +152,7 @@ func dbusNotify(ctx context.Context, text string, actions []string, onAction fun
}
var id uint32
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 {
return 0, fmt.Errorf("notify: %w", err)
}
diff --git a/internal/notify/identity_other.go b/internal/notify/identity_other.go
new file mode 100644
index 0000000..a2a2804
--- /dev/null
+++ b/internal/notify/identity_other.go
@@ -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 }
diff --git a/internal/notify/identity_windows.go b/internal/notify/identity_windows.go
new file mode 100644
index 0000000..1c98097
--- /dev/null
+++ b/internal/notify/identity_windows.go
@@ -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
+}
diff --git a/internal/notify/notify.go b/internal/notify/notify.go
index 68b387b..759d572 100644
--- a/internal/notify/notify.go
+++ b/internal/notify/notify.go
@@ -19,17 +19,69 @@ import (
"context"
"encoding/base64"
"encoding/binary"
+ "errors"
"fmt"
"os/exec"
"runtime"
"strings"
+ "sync"
"time"
"unicode/utf16"
+
+ "code.m3ta.dev/m3tam3re/talk/internal/icon"
)
// Title is the title of all talk notifications.
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.
const timeout = 10 * time.Second
@@ -60,7 +112,10 @@ func Describe() string {
case "darwin":
return "osascript (Notification Center; no cancel button)"
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:
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))
cmd = exec.CommandContext(ctx, "osascript", "-e", script)
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:
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) + `"`
}
-// toastScript is the PowerShell toast (Windows 10/11); the text is passed
-// base64-encoded so there is no quoting problem.
-func toastScript(text string) string {
+// toastScript is the PowerShell for a plain toast (Windows 10/11) under
+// appID; the text is passed base64-encoded so there is no quoting problem.
+func toastScript(text, appID string) string {
return fmt.Sprintf(`$ErrorActionPreference = 'Stop'
[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null
-$msg = [System.Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('%s'))
-$xml = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent([Windows.UI.Notifications.ToastTemplateType]::ToastText02)
-$nodes = $xml.GetElementsByTagName('text')
-$nodes.Item(0).AppendChild($xml.CreateTextNode('%s')) | Out-Null
-$nodes.Item(1).AppendChild($xml.CreateTextNode($msg)) | Out-Null
+[Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType = WindowsRuntime] | Out-Null
+$msg = [System.Security.SecurityElement]::Escape([System.Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('%s')))
+$xml = New-Object Windows.Data.Xml.Dom.XmlDocument
+$xml.LoadXml("%s$msg")
[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).
diff --git a/internal/notify/notify_test.go b/internal/notify/notify_test.go
index fda3222..f240cd2 100644
--- a/internal/notify/notify_test.go
+++ b/internal/notify/notify_test.go
@@ -66,3 +66,67 @@ func TestAppleString(t *testing.T) {
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, ""+Title+"") {
+ t.Errorf("own identity script wrong:\n%s", own)
+ }
+ fallback := cancelToastScript(a, "talk-1", powerShellAppID)
+ if !strings.Contains(fallback, "CreateToastNotifier('"+powerShellAppID+"')") || !strings.Contains(fallback, ""+Title+"") {
+ t.Errorf("fallback script wrong:\n%s", fallback)
+ }
+ plain := toastScript("quiet", AppID)
+ if strings.Contains(plain, ""+Title+"") || !strings.Contains(plain, "$msg") {
+ t.Errorf("plain own-identity script wrong:\n%s", plain)
+ }
+ if !strings.Contains(toastScript("quiet", powerShellAppID), ""+Title+"") {
+ 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")
+ }
+}
diff --git a/internal/notify/toast_windows_impl.go b/internal/notify/toast_windows_impl.go
index a5f61a9..1849eac 100644
--- a/internal/notify/toast_windows_impl.go
+++ b/internal/notify/toast_windows_impl.go
@@ -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 `,
// 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 = "talk$body"
+$xmlText = "%s$body"
$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 "" + Title + ""
}
// runPowerShell runs a script via -EncodedCommand.