go-nativenotify

Native notifications in Go
Log | Files | Refs | README | LICENSE

commit aaed0f9b74b8d8960613b43c985c1d505387fefd
Author: Jack Mordaunt <jackmordaunt.dev@gmail.com>
Date:   Wed, 15 Jan 2025 16:27:30 +0800

nativenotify: initial commit

Developing on Linux, likely broken for other platforms.

Signed-off-by: Jack Mordaunt <jackmordaunt.dev@gmail.com>

Diffstat:
Ago.mod | 16++++++++++++++++
Ago.sum | 15+++++++++++++++
Anotify.go | 100+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Anotify_common.go | 27+++++++++++++++++++++++++++
Anotify_darwin.go | 80+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Anotify_linux.go | 79+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Anotify_windows.go | 80+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
7 files changed, 397 insertions(+), 0 deletions(-)

diff --git a/go.mod b/go.mod @@ -0,0 +1,16 @@ +module git.sr.ht/~jackmordaunt/go-nativenotify + +go 1.23.4 + +require ( + git.sr.ht/~jackmordaunt/go-notify-darwin v0.0.0-20241218055608-554e64f1a212 + git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.1 + git.sr.ht/~whereswaldon/shout v0.0.0-20241212204820-26acea6b0007 + github.com/godbus/dbus/v5 v5.1.0 +) + +require ( + github.com/go-ole/go-ole v1.3.0 // indirect + golang.org/x/image v0.23.0 // indirect + golang.org/x/sys v0.28.0 // indirect +) diff --git a/go.sum b/go.sum @@ -0,0 +1,15 @@ +git.sr.ht/~jackmordaunt/go-notify-darwin v0.0.0-20241218055608-554e64f1a212 h1:YV1otfNE3ivthXVzOMTqK/ih/+RneAkjlswxs6FJQqU= +git.sr.ht/~jackmordaunt/go-notify-darwin v0.0.0-20241218055608-554e64f1a212/go.mod h1:uwWDXYGwP2PodSVTd9KU11/P7SoDAA321SmG3IQhoSY= +git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.1 h1:VPS/EEtIAgkxvYubqBUzkQqkB1Uf80twg8SthWX0VhA= +git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.1/go.mod h1:QtOLZGz8olr4qH2vWK0QH0w0O4T9fEIjMuWpKUsH7nc= +git.sr.ht/~whereswaldon/shout v0.0.0-20241212204820-26acea6b0007 h1:5xMwYHPm3ym1ccRmVmUEj4hiESj5uqXMYPwG+1Ruf28= +git.sr.ht/~whereswaldon/shout v0.0.0-20241212204820-26acea6b0007/go.mod h1:VJjhIIJD+YFIyJSH6nCDqoo9ikvAEaD63tUo4uI86pA= +github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= +github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= +github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= +github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +golang.org/x/image v0.23.0 h1:HseQ7c2OpPKTPVzNjG5fwJsOTCiiwS4QdsYi5XU6H68= +golang.org/x/image v0.23.0/go.mod h1:wJJBTdLfCCf3tiHa1fNxpZmUI4mmoZvwMCPP0ddoNKY= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= +golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= diff --git a/notify.go b/notify.go @@ -0,0 +1,100 @@ +// Package nativenotify offers a notification API that works across Windows, +// macOS and Linux. +// +// It provides a common subset, and is not trying to expose all underlying +// features. Not all fields are functional across all operating systems. +// +// Broadly, Windows likes to define buttons and text inputs separately, macOs +// defines them together, and Linux only supports buttons. +// +// Each operating system requires different setup data. This is handled by +// a call to [Setup] that accepts a fat-union containing all the data +// required by all operating systems. +// +// Generally each operating system will accept png icons as file paths, but +// other formats (eg webp) and path types (eg url) vary. +package nativenotify + +import ( + darwinnotify "git.sr.ht/~jackmordaunt/go-notify-darwin" + windowsnotify "git.sr.ht/~jackmordaunt/go-toast/v2" +) + +// Callback is executed when the user interacts with a given notification. +// +// [userData] contains user input data, which is either text input, the +// app payload for a button, or the app payload from the parent notification. +type Callback func(err error, id string, userData map[string]string) + +// Notification describes the notification. +type Notification struct { + // Title text of the notification. + Title string + + // Body text of the notification. + Body string + + // Icon contains the path to an icon. Some operating systems accept url paths. + // All OS accept .png, other formats vary. + Icon string + + // AppPayload is passed to the callback upon activation. + AppPayload string + + // TextActions are for getting textual input from the user. + // Not supported on Linux. + TextActions []TextAction + + // ButtonActions are for getting button input from the user. + ButtonActions []ButtonAction + + // Callback is called upon activation. + Callback Callback +} + +// TextAction describes a text input. +// It might have an associated button. +// The user input text is passed to the callback on activation. +type TextAction struct { + ID string + Title string + PlaceholderHint string + ButtonLabel string +} + +// ButtonAction describes a button input. +// [AppPayload] is passed to the callback on activation. +type ButtonAction struct { + ID string + LabelText string + AppPayload string +} + +// Config is a fat-union of the various initialization data required by each +// operating system. +type Config struct { + Windows WindowsConfig + Linux LinuxConfig + Darwin DarwinConfig +} + +type WindowsConfig = windowsnotify.AppData + +type LinuxConfig struct { + AppName string + AppIcon string +} + +type DarwinConfig struct { + Categories []darwinnotify.Category +} + +// Setup initializes the notification subsystem. +func Setup(cfg Config) error { + return setup(cfg) +} + +// Push a notification to the operating system. +func Push(n Notification) error { + return push(n) +} diff --git a/notify_common.go b/notify_common.go @@ -0,0 +1,27 @@ +package nativenotify + +import ( + "sync" + "sync/atomic" +) + +var ( + nextID atomic.Int64 + callbacks sync.Map +) + +func callbacksTake(m *sync.Map, id string) (Callback, bool) { + v, ok := m.LoadAndDelete(id) + if !ok { + return nil, false + } + cb, ok := v.(Callback) + if !ok { + return nil, false + } + return cb, true +} + +func callbacksPut(m *sync.Map, id string, fn Callback) { + m.Store(id, fn) +} diff --git a/notify_darwin.go b/notify_darwin.go @@ -0,0 +1,80 @@ +package nativenotify + +import ( + "fmt" + "strconv" + "strings" + + darwinnotify "git.sr.ht/~jackmordaunt/go-notify-darwin" +) + +func setup(cfg Config) error { + darwinnotify.SetCallback(func(args darwinnotify.CallbackArgs) { + // Extract the callback id that is prepended to the action id. + id, actionName, _ := strings.Cut(args.Action, "-") + + fn, ok := callbacksTake(&callbacks, id) + if !ok || fn == nil { + return + } + + data := map[string]string{} + + if args.UserText != "" { + data[actionName] = args.UserText + } + + // Map the user data map to the key-value slice style. + for k, v := range args.UserData { + data[k] = v + } + + data["category"] = args.Category + data["action"] = args.Action + + fn(args.Err, id, data) + }) + + return nil +} + +func push(n Notification) (err error) { + id := nextID.And(1) + + defer func() { + if err == nil { + callbacksPut(&callbacks, strconv.FormatInt(id, 10), n.Callback) + } + }() + + var ( + buttons = make([]darwinnotify.Action, 0, len(n.ButtonActions)) + inputs = make([]darwinnotify.TextInputAction, 0, len(n.TextActions)) + ) + + for _, button := range n.ButtonActions { + buttons = append(buttons, darwinnotify.Action{ + ID: fmt.Sprintf("%d-%s", id, button.ID), + Title: button.LabelText, + }) + } + + for _, input := range n.TextActions { + inputs = append(inputs, darwinnotify.TextInputAction{ + ID: fmt.Sprintf("%d-%s", id, input.ID), + Title: input.Title, + Placeholder: input.PlaceholderHint, + ButtonTitle: input.ButtonLabel, + }) + } + + darwinnotify.Notify(darwinnotify.Notification{ + Title: n.Title, + Body: n.Body, + Attachments: []string{n.Icon}, + Actions: buttons, + TextInputActions: inputs, + }) + + return nil +} diff --git a/notify_linux.go b/notify_linux.go @@ -0,0 +1,79 @@ +package nativenotify + +import ( + "fmt" + "strconv" + "sync/atomic" + + "git.sr.ht/~whereswaldon/shout" + "github.com/godbus/dbus/v5" +) + +var notifier atomic.Pointer[shout.Notifier] + +func setup(cfg Config) error { + conn, err := dbus.SessionBus() + if err != nil { + return fmt.Errorf("getting dbus session: %w", err) + } + n, err := shout.NewNotifier( + conn, + cfg.Linux.AppName, + cfg.Linux.AppIcon, + func(id, action string, platformData map[string]dbus.Variant, target, response dbus.Variant, err error) { + fn, ok := callbacksTake(&callbacks, id) + if !ok || fn == nil { + return + } + fn(err, id, nil) + }, + ) + if err != nil { + return fmt.Errorf("building notifier: %w", err) + } + notifier.Store(&n) + return nil +} + +func push(n Notification) (err error) { + notifier := notifier.Load() + + if notifier == nil { + return fmt.Errorf("notifier is nil, call setup to initialize") + } + + id := nextID.Add(1) + + defer func() { + if err == nil { + callbacksPut(&callbacks, strconv.FormatInt(id, 10), n.Callback) + } + }() + + buttons := []shout.Button{} + + for _, a := range n.ButtonActions { + buttons = append(buttons, shout.Button{ + Action: a.AppPayload, // unsure about this [#nocommit] + Label: a.LabelText, + }) + } + + if err := (*notifier).Send(fmt.Sprintf("%d", id), shout.Notification{ + Title: n.Title, + Body: n.Body, + ReplaceID: "", + Markup: false, + IconPath: n.Icon, + Priority: shout.Normal, + DefaultAction: "default", + DefaultActionLabel: "", + DefaultActionTarget: dbus.Variant{}, + Buttons: buttons, + ExpirationTimeout: 0, + }); err != nil { + return fmt.Errorf("sending notification: %w", err) + } + + return nil +} diff --git a/notify_windows.go b/notify_windows.go @@ -0,0 +1,80 @@ +package nativenotify + +import ( + "strconv" + "strings" + + windowsnotify "git.sr.ht/~jackmordaunt/go-toast/v2" +) + +func setup(cfg Config) error { + windowsnotify.SetAppData(cfg.Windows) + windowsnotify.SetActivationCallback(func(args string, userdata []windowsnotify.UserData) { + // Address the fact that the ID might be prepended to some argument + // and extract it. + id, args, _ := strings.Cut(args, "-") + + fn, ok := callbacksTake(&callbacks, id) + if !ok || fn == nil { + return + } + + data := make(map[string]string) + + for _, ud := range userdata { + data[ud.Key] = ud.Value + } + + fn(nil, args, data) + }) + return nil +} + +func push(n Notification) (err error) { + id := nextID.Add(1) + + defer func() { + if err == nil { + callbacksPut(&callbacks, strconv.FormatInt(id, 10), n.Callback) + } + }() + + var ( + actions = make([]windowsnotify.Action, 0, len(n.ButtonActions)) + inputs = make([]windowsnotify.Input, 0, len(n.TextActions)) + ) + + for _, a := range n.ButtonActions { + actions = append(actions, windowsnotify.Action{ + Content: a.LabelText, + Arguments: a.AppPayload, + }) + } + + for _, a := range n.TextActions { + actionID := strconv.FormatInt(nextID.Add(1), 10) + inputs = append(inputs, windowsnotify.Input{ + ID: actionID, + Title: a.Title, + Placeholder: a.PlaceholderHint, + }) + if a.ButtonLabel != "" { + actions = append(actions, windowsnotify.Action{ + InputID: actionID, + Content: a.ButtonLabel, + }) + } + } + + tn := windowsnotify.Notification{ + Title: n.Title, + Body: n.Body, + Icon: n.Icon, + ActivationType: windowsnotify.Foreground, + ActivationArguments: strconv.FormatInt(id, 10), + Actions: actions, + Inputs: inputs, + } + + return tn.Push() +}