diff --git a/internal/notify/notify.go b/internal/notify/notify.go new file mode 100644 index 0000000..0be346f --- /dev/null +++ b/internal/notify/notify.go @@ -0,0 +1,45 @@ +// internal/notify/notify.go + +// Package notify is Keel's desktop-notification effector: a best-effort +// "interrupt the user" primitive behind a Notifier port. On Linux with +// notify-send installed it pops a toast; everywhere else it is a silent nop. +// Mirrors internal/enforce: a pure OS primitive, all policy lives in callers. +package notify + +import ( + "context" + "fmt" + "os/exec" +) + +// Notifier shows a desktop notification. Best-effort: it returns an error for +// diagnostics, but callers never block on it and treat failure as "no toast." +type Notifier interface { + Notify(ctx context.Context, title, body string) error +} + +// NewNotifier returns the notify-send adapter when that binary is on PATH, else +// a nop. This makes a missing binary (or a non-Linux host) degrade to silence +// rather than an error. +func NewNotifier() Notifier { + if path, err := exec.LookPath("notify-send"); err == nil { + return sendNotifier{bin: path} + } + return nopNotifier{} +} + +// sendNotifier shells out to notify-send. The app name groups Keel's toasts. +type sendNotifier struct{ bin string } + +func (s sendNotifier) Notify(ctx context.Context, title, body string) error { + cmd := exec.CommandContext(ctx, s.bin, "--app-name=Keel", title, body) + if err := cmd.Run(); err != nil { + return fmt.Errorf("notify: notify-send: %w", err) + } + return nil +} + +// nopNotifier is the silent fallback. +type nopNotifier struct{} + +func (nopNotifier) Notify(context.Context, string, string) error { return nil } diff --git a/internal/notify/notify_test.go b/internal/notify/notify_test.go new file mode 100644 index 0000000..a6cf8ac --- /dev/null +++ b/internal/notify/notify_test.go @@ -0,0 +1,22 @@ +// internal/notify/notify_test.go +package notify + +import ( + "context" + "testing" +) + +// nopNotifier must be returned when notify-send is absent, and must never error. +func TestNopNotifierIsSilentAndSafe(t *testing.T) { + n := nopNotifier{} + if err := n.Notify(context.Background(), "title", "body"); err != nil { + t.Fatalf("nop Notify must return nil, got %v", err) + } +} + +// NewNotifier always returns a usable Notifier (never nil), whatever the host. +func TestNewNotifierNeverNil(t *testing.T) { + if NewNotifier() == nil { + t.Fatal("NewNotifier returned nil") + } +}