feat(notify): desktop notification per announcement with a Cancel button
Every playing announcement now shows a desktop notification with its text and, on Linux, a Cancel button (Abbrechen for German) that stops exactly that announcement; the notification is closed when the announcement ends. This brings back what the old talk.sh had with notify-send --action, as a default: a long announcement can be cut short without a terminal. Implementation: the freedesktop Notifications service on the session bus via godbus (pure Go): one connection per process, Notify with the action list, ActionInvoked dispatched to the announcement, CloseNotification at the end, expire_timeout 0 so the button stays for the whole announcement. Without a session bus the notify-send fallback shows a plain notification; macOS and Windows show one without a button for now. queue.CancelItem(id) cancels only the playing item. Config [notify] on_speak (default true, env TALK_NOTIFY). `talk test --toast` now shows the real thing and waits for a click; `talk doctor` names the service and whether it supports the button. Config hygiene found on the way: a key written below a [table] header belongs to that table in TOML (lang under [voice] was silently ignored). config.Load now collects unknown keys (undecoded ones and non-language keys under [voice.<provider>]) and `talk doctor` warns with the hint. Tests: process test on a private dbus-daemon with a fake org.freedesktop.Notifications service (Notify payload, cancel click aborts the synthesis, CloseNotification), queue.CancelItem, config keys. All test binaries point DBUS_SESSION_BUS_ADDRESS at a dead socket so no test ever pops a notification on the developer's desktop. flake vendorHash updated for the new dependency. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
6f3e7c9f35
commit
83a15af9c5
22 files changed
+806
-45
No files matched your search
+68
-14
@@ -1,10 +1,12 @@
|
||||
// Package notify shows desktop notifications: best effort, through the
|
||||
// operating system's own tools, without a library dependency and without
|
||||
// CGO (the same line as provider/osbuiltin):
|
||||
// Package notify shows desktop notifications: best effort, never a reason to
|
||||
// abort an announcement.
|
||||
//
|
||||
// Linux: notify-send (libnotify; needs a notification daemon)
|
||||
// macOS: osascript `display notification`
|
||||
// Windows: PowerShell toast (Windows.UI.Notifications)
|
||||
// Linux: the freedesktop Notifications D-Bus service (org.freedesktop.
|
||||
// Notifications, pure Go via godbus): notifications with a Cancel
|
||||
// action, closed again when the announcement ends. Fallback when
|
||||
// no session bus is reachable: notify-send (no action).
|
||||
// macOS: osascript `display notification` (no action).
|
||||
// Windows: PowerShell toast (Windows.UI.Notifications; no action yet).
|
||||
//
|
||||
// Missing tools are a clear error for the caller (log), never an abort: a
|
||||
// notification is an extra, not a contract.
|
||||
@@ -29,29 +31,46 @@ const Title = "talk"
|
||||
// timeout bounds the call of the OS tool.
|
||||
const timeout = 10 * time.Second
|
||||
|
||||
// Available reports whether a notification path exists on this system
|
||||
// (binary present; whether a daemon listens only Show reveals).
|
||||
// Available reports whether a notification path exists on this system: a
|
||||
// reachable notification service on the session bus, or the OS tool.
|
||||
func Available() bool {
|
||||
if dbusAvailable() {
|
||||
return true
|
||||
}
|
||||
_, err := exec.LookPath(tool())
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// Describe names the path for `talk doctor`.
|
||||
// Describe names the path for `talk doctor`: the notification service with
|
||||
// name and version and whether it supports a Cancel action, or the OS tool.
|
||||
func Describe() string {
|
||||
if info, ok := dbusDescribe(); ok {
|
||||
return info
|
||||
}
|
||||
t := tool()
|
||||
if _, err := exec.LookPath(t); err != nil {
|
||||
if runtime.GOOS == "linux" {
|
||||
return "no notification service on the session bus and " + t + " not found"
|
||||
}
|
||||
return t + " not found"
|
||||
}
|
||||
switch runtime.GOOS {
|
||||
case "darwin":
|
||||
return "osascript (Notification Center)"
|
||||
return "osascript (Notification Center; no cancel button)"
|
||||
case "windows":
|
||||
return "powershell (Windows toast)"
|
||||
return "powershell (Windows toast; no cancel button yet)"
|
||||
default:
|
||||
return "notify-send (libnotify)"
|
||||
return "notify-send (libnotify; no session bus, so no cancel button)"
|
||||
}
|
||||
}
|
||||
|
||||
// SupportsCancel reports whether notifications on this system can carry a
|
||||
// Cancel action that reaches talk (Linux with a notification service that
|
||||
// has the "actions" capability).
|
||||
func SupportsCancel() bool {
|
||||
return dbusSupportsActions()
|
||||
}
|
||||
|
||||
func tool() string {
|
||||
switch runtime.GOOS {
|
||||
case "darwin":
|
||||
@@ -63,9 +82,44 @@ func tool() string {
|
||||
}
|
||||
}
|
||||
|
||||
// Show shows a notification with Title and text and waits for the OS call
|
||||
// to finish (not for the notification to be closed).
|
||||
// Show shows a plain notification with Title and text (no action) and waits
|
||||
// for the handover to finish, not for the notification to be closed. Used
|
||||
// for the quiet-hours toast.
|
||||
func Show(ctx context.Context, text string) error {
|
||||
if dbusAvailable() {
|
||||
id, err := dbusNotify(ctx, text, nil, nil)
|
||||
if err == nil {
|
||||
_ = id
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return showWithTool(ctx, text)
|
||||
}
|
||||
|
||||
// Announce shows the notification for an announcement that is playing: the
|
||||
// text plus a Cancel action labelled cancelLabel where the platform supports
|
||||
// it. onCancel runs when the user clicks it. The returned close function
|
||||
// dismisses the notification (call it when the announcement ends); it is
|
||||
// never nil. Platforms without actions show a plain notification and the
|
||||
// close function does nothing.
|
||||
func Announce(ctx context.Context, text, cancelLabel string, onCancel func()) (close func(), err error) {
|
||||
noop := func() {}
|
||||
if dbusAvailable() {
|
||||
id, err := dbusNotify(ctx, text, []string{"cancel", cancelLabel}, func(action string) {
|
||||
if action == "cancel" && onCancel != nil {
|
||||
onCancel()
|
||||
}
|
||||
})
|
||||
if err == nil {
|
||||
return func() { dbusClose(id) }, nil
|
||||
}
|
||||
// fall through to the tool on a bus error
|
||||
}
|
||||
return noop, showWithTool(ctx, text)
|
||||
}
|
||||
|
||||
// showWithTool shows a notification through the OS tool (no action).
|
||||
func showWithTool(ctx context.Context, text string) error {
|
||||
ctx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
var cmd *exec.Cmd
|
||||
|
||||
Reference in new issue
Block a user