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.