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:
| A | go.mod | | | 16 | ++++++++++++++++ |
| A | go.sum | | | 15 | +++++++++++++++ |
| A | notify.go | | | 100 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | notify_common.go | | | 27 | +++++++++++++++++++++++++++ |
| A | notify_darwin.go | | | 80 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | notify_linux.go | | | 79 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | notify_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()
+}