├── .github └── workflows │ └── go.yml ├── .gitignore ├── LICENSE.txt ├── README.md ├── doc.go ├── example ├── big.jpg ├── main.go └── small.png ├── go.mod ├── go.sum ├── notification.go └── notification_test.go /.github/workflows/go.yml: -------------------------------------------------------------------------------- 1 | name: Go 2 | on: [push] 3 | jobs: 4 | build: 5 | name: Build 6 | runs-on: ubuntu-latest 7 | strategy: 8 | matrix: 9 | go-version: 10 | - '1.13' 11 | - '1.18' 12 | - '1.19' 13 | - '1.20' 14 | - '1.21' 15 | - '1.22' 16 | steps: 17 | - name: Set up Go ${{ matrix.go-version }} 18 | uses: actions/setup-go@v4 19 | with: 20 | go-version: ${{ matrix.go-version }} 21 | - uses: actions/checkout@v4 22 | - name: Get dependencies 23 | run: go get -v -t -d ./... 24 | - name: Build 25 | run: go build -v . 26 | - name: Build Example 27 | run: | 28 | cd example 29 | go build -v . 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2021, Eivind Siqveland Larsen 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | 7 | * Redistributions of source code must retain the above copyright notice, this 8 | list of conditions and the following disclaimer. 9 | 10 | * Redistributions in binary form must reproduce the above copyright notice, 11 | this list of conditions and the following disclaimer in the documentation 12 | and/or other materials provided with the distribution. 13 | 14 | * Neither the name of the copyright holder nor the names of its 15 | contributors may be used to endorse or promote products derived from 16 | this software without specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 19 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 20 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 22 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 23 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 24 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 25 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 26 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 27 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # notify 2 | 3 | [![go.dev reference](https://img.shields.io/badge/go.dev-reference-007d9c?logo=go&logoColor=white&style=flat-square)](https://pkg.go.dev/github.com/esiqveland/notify?tab=doc) 4 | [![Go Report Card](https://goreportcard.com/badge/github.com/esiqveland/notify)](https://goreportcard.com/report/github.com/esiqveland/notify) 5 | ![Build](https://github.com/esiqveland/notify/actions/workflows/go.yml/badge.svg?branch=master) 6 | 7 | Notify is a go library for interacting with the dbus notification service defined by freedesktop.org: 8 | https://developer.gnome.org/notification-spec/ 9 | 10 | `notify` can deliver desktop notifications over dbus, ala how libnotify does it. 11 | 12 | Please note `notify` is still in motion and APIs are not locked until a 1.0 is released. 13 | 14 | More testers are very welcome =) 15 | 16 | Depends on: 17 | - [godbus](https://github.com/godbus/dbus). 18 | 19 | ## Changelog 20 | - v0.11.2: Introduce helpers ExpireTimeoutSetByNotificationServer, ExpireTimeoutNever 21 | - v0.11.1: Fix a race during Close() #11 22 | - v0.11.0: re-release under BSD license 23 | - v0.10.0: stricter types: [some breaking changes](https://github.com/esiqveland/notify/releases/tag/v0.10.0) 24 | - v0.9.0: [some breaking changes](https://github.com/esiqveland/notify/releases/tag/v0.9.0) 25 | - v0.2.1: dbus: gomod: lock to dbus v5 26 | - v0.2.0: `Notifier.Close()` no longer calls `.Close()` on the underlying `dbus.Conn` 27 | 28 | ## Quick intro 29 | See example: [main.go](https://github.com/esiqveland/notify/blob/master/example/main.go). 30 | 31 | Clone repo and go to examples folder: 32 | 33 | ``` go run main.go ``` 34 | 35 | 36 | ## TODO 37 | 38 | - [x] Add callback support aka dbus signals. 39 | - [ ] Tests. I am very interested in any ideas for writing some (useful) tests for this. 40 | 41 | ## See also 42 | 43 | The Gnome notification spec https://developer.gnome.org/notification-spec/. 44 | 45 | 46 | ## Contributors 47 | Thanks to user [emersion](https://github.com/emersion) for great ideas on receiving signals. 48 | 49 | Thanks to [Merovius](https://github.com/Merovius) for fixing race during Close(). 50 | 51 | ## License 52 | 53 | BSD 3-Clause 54 | 55 | -------------------------------------------------------------------------------- /doc.go: -------------------------------------------------------------------------------- 1 | /* 2 | Package notify is a wrapper around godbus for dbus notification interface 3 | See: https://developer.gnome.org/notification-spec/ and 4 | https://github.com/godbus/dbus 5 | 6 | The package provides exported methods for simple usage, e.g. just show a notification. 7 | It also provides the interface Notifier that includes signal delivery for notifications on the system. 8 | 9 | Each notification created is allocated a unique ID by the server (see Notification). 10 | This ID is unique within the dbus session, and is an increasing number. 11 | While the notification server is running, the ID will not be recycled unless the capacity of a uint32 is exceeded. 12 | 13 | The ID can be used to hide the notification before the expiration timeout is reached (see CloseNotification). 14 | 15 | The ID can also be used to atomically replace the notification with another (Notification.ReplaceID). 16 | This allows you to (for instance) modify the contents of a notification while it's on-screen. 17 | */ 18 | package notify 19 | -------------------------------------------------------------------------------- /example/big.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/esiqveland/notify/22b3f2d84ae32850a2e642dc7966443e4c8eeb4f/example/big.jpg -------------------------------------------------------------------------------- /example/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "image" 6 | "image/draw" 7 | _ "image/jpeg" 8 | _ "image/png" 9 | "log" 10 | "os" 11 | "path/filepath" 12 | "sync" 13 | "sync/atomic" 14 | "time" 15 | 16 | "github.com/godbus/dbus/v5" 17 | 18 | "github.com/esiqveland/notify" 19 | ) 20 | 21 | func main() { 22 | err := runMain() 23 | if err != nil { 24 | log.Printf("\nerror: %v\n", err) 25 | os.Exit(1) 26 | } 27 | } 28 | 29 | func runMain() error { 30 | wg := &sync.WaitGroup{} 31 | 32 | conn, err := dbus.SessionBusPrivate() 33 | if err != nil { 34 | panic(err) 35 | } 36 | defer conn.Close() 37 | 38 | if err = conn.Auth(nil); err != nil { 39 | panic(err) 40 | } 41 | 42 | if err = conn.Hello(); err != nil { 43 | panic(err) 44 | } 45 | 46 | DebugServerFeatures(conn) 47 | 48 | // Basic usage 49 | soundHint := notify.HintSoundWithName( 50 | //"message-new-instant", 51 | "trash-empty", 52 | ) 53 | 54 | // Create a Notification to send 55 | iconName := "mail-unread" 56 | n := notify.Notification{ 57 | AppName: "Test GO App", 58 | ReplacesID: uint32(0), 59 | AppIcon: iconName, 60 | Summary: "Test", 61 | Body: "This is a test of the DBus bindings for go with sound.", 62 | Actions: []notify.Action{ 63 | {Key: "cancel", Label: "Cancel"}, 64 | {Key: "open", Label: "Open"}, 65 | }, 66 | Hints: map[string]dbus.Variant{ 67 | soundHint.ID: soundHint.Variant, 68 | }, 69 | ExpireTimeout: time.Second * 5, 70 | } 71 | n.SetUrgency(notify.UrgencyCritical) 72 | 73 | counter := int32(0) 74 | // Listen for actions invoked! 75 | onAction := func(action *notify.ActionInvokedSignal) { 76 | atomic.AddInt32(&counter, 1) 77 | log.Printf("ActionInvoked: %v Key: %v", action.ID, action.ActionKey) 78 | wg.Done() 79 | } 80 | 81 | onClosed := func(closer *notify.NotificationClosedSignal) { 82 | atomic.AddInt32(&counter, 1) 83 | log.Printf("NotificationClosed: %v Reason: %v", closer.ID, closer.Reason) 84 | wg.Done() 85 | } 86 | 87 | // Notifier instance with event delivery: 88 | notifier, err := notify.New( 89 | conn, 90 | // action event handler 91 | notify.WithOnAction(onAction), 92 | // closed event handler 93 | notify.WithOnClosed(onClosed), 94 | // override with custom logger 95 | notify.WithLogger(log.New(os.Stdout, "notify: ", log.Flags())), 96 | ) 97 | if err != nil { 98 | return err 99 | } 100 | defer notifier.Close() 101 | 102 | rgbaSample, err := readImage("./big.jpg") 103 | if err != nil { 104 | return err 105 | } 106 | 107 | absFilePath, err := filepath.Abs("./small.png") 108 | if err != nil { 109 | return err 110 | } 111 | hintProfileImage := notify.HintImageDataRGBA(rgbaSample) 112 | hintImageByPath := notify.HintImageFilePath(absFilePath) 113 | urgencyHint := notify.HintUrgency(notify.UrgencyCritical) 114 | n.AddHint(urgencyHint) 115 | n.AddHint(hintImageByPath) 116 | // according to spec, image-data hint should have precedence: 117 | n.AddHint(hintProfileImage) 118 | 119 | wg.Add(1) 120 | id, err := notifier.SendNotification(n) 121 | if err != nil { 122 | log.Printf("error sending notification: %v", err) 123 | } 124 | log.Printf("sent notification id: %v", id) 125 | 126 | // add two extra so the process should block forever: 127 | // this is due to a bug in Gnome that delivers multiple copies of the action signal... 128 | wg.Add(2) 129 | wg.Wait() 130 | 131 | log.Printf("total signal count received: %d", atomic.LoadInt32(&counter)) 132 | 133 | return nil 134 | } 135 | 136 | func DebugServerFeatures(conn *dbus.Conn) { 137 | // List server features! 138 | caps, err := notify.GetCapabilities(conn) 139 | if err != nil { 140 | log.Printf("error fetching capabilities: %v", err) 141 | } 142 | for x := range caps { 143 | fmt.Printf("Registered capability: %v\n", caps[x]) 144 | } 145 | 146 | info, err := notify.GetServerInformation(conn) 147 | if err != nil { 148 | log.Printf("error getting server information: %v", err) 149 | } 150 | fmt.Printf("Name: %v\n", info.Name) 151 | fmt.Printf("Vendor: %v\n", info.Vendor) 152 | fmt.Printf("Version: %v\n", info.Version) 153 | fmt.Printf("Spec: %v\n", info.SpecVersion) 154 | } 155 | 156 | func readImage(path string) (*image.RGBA, error) { 157 | fd, err := os.Open(path) 158 | if err != nil { 159 | return nil, err 160 | } 161 | defer fd.Close() 162 | decode, _, err := image.Decode(fd) 163 | if err != nil { 164 | return nil, err 165 | } 166 | img, ok := decode.(*image.RGBA) 167 | if !ok { 168 | b := decode.Bounds() 169 | m := image.NewRGBA(image.Rect(0, 0, b.Dx(), b.Dy())) 170 | draw.Draw(m, m.Bounds(), decode, b.Min, draw.Src) 171 | return m, nil 172 | //return nil, fmt.Errorf("decode RGBA failed of: %v", path) 173 | } 174 | return img, err 175 | } 176 | -------------------------------------------------------------------------------- /example/small.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/esiqveland/notify/22b3f2d84ae32850a2e642dc7966443e4c8eeb4f/example/small.png -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/esiqveland/notify 2 | 3 | go 1.13 4 | 5 | require ( 6 | github.com/godbus/dbus/v5 v5.1.0 7 | github.com/stretchr/testify v1.8.4 8 | ) 9 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 2 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 3 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 4 | github.com/godbus/dbus/v5 v5.0.3 h1:ZqHaoEF7TBzh4jzPmqVhE/5A1z9of6orkAe5uHoAeME= 5 | github.com/godbus/dbus/v5 v5.0.3/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= 6 | github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= 7 | github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= 8 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 9 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 10 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 11 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 12 | github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c= 13 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 14 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 15 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 16 | github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= 17 | github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 18 | github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= 19 | github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 20 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 21 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 22 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 23 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 24 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 25 | -------------------------------------------------------------------------------- /notification.go: -------------------------------------------------------------------------------- 1 | package notify 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "image" 7 | "log" 8 | "sync" 9 | "time" 10 | 11 | "github.com/godbus/dbus/v5" 12 | ) 13 | 14 | const ( 15 | dbusRemoveMatch = "org.freedesktop.DBus.RemoveMatch" 16 | dbusAddMatch = "org.freedesktop.DBus.AddMatch" 17 | dbusObjectPath = "/org/freedesktop/Notifications" // the DBUS object path 18 | dbusNotificationsInterface = "org.freedesktop.Notifications" // DBUS Interface 19 | signalNotificationClosed = "org.freedesktop.Notifications.NotificationClosed" 20 | signalActionInvoked = "org.freedesktop.Notifications.ActionInvoked" 21 | callGetCapabilities = "org.freedesktop.Notifications.GetCapabilities" 22 | callCloseNotification = "org.freedesktop.Notifications.CloseNotification" 23 | callNotify = "org.freedesktop.Notifications.Notify" 24 | callGetServerInformation = "org.freedesktop.Notifications.GetServerInformation" 25 | 26 | channelBufferSize = 10 27 | ) 28 | 29 | // Deprecated: use Hint 30 | type Variant = Hint 31 | 32 | // See: https://specifications.freedesktop.org/notification-spec/latest/ar01s08.html 33 | type Hint struct { 34 | ID string 35 | Variant dbus.Variant 36 | } 37 | 38 | func HintSoundWithName(soundName string) Hint { 39 | return Hint{ 40 | ID: "sound-name", 41 | Variant: dbus.MakeVariant(soundName), 42 | } 43 | } 44 | 45 | func HintUrgency(urgency Urgency) Hint { 46 | return Hint{ 47 | ID: "urgency", 48 | Variant: dbus.MakeVariant(byte(urgency)), 49 | } 50 | } 51 | 52 | type Urgency byte 53 | 54 | const ( 55 | UrgencyLow Urgency = 0 56 | UrgencyNormal Urgency = 1 57 | UrgencyCritical Urgency = 2 58 | ) 59 | 60 | // HintImageFilePath sends a filepath to the notification server as the file of the icon. 61 | // See also: https://specifications.freedesktop.org/notification-spec/latest/ar01s05.html 62 | func HintImageFilePath(imageAbsolutePath string) Hint { 63 | uri := fmt.Sprintf("file://%s", imageAbsolutePath) 64 | return Hint{ 65 | ID: "image-path", 66 | Variant: dbus.MakeVariant(uri), 67 | } 68 | } 69 | 70 | // dbusImageData encodes Hint for "image-data" iiibiiay 71 | // Data format: https://specifications.freedesktop.org/notification-spec/latest/ar01s05.html 72 | type dbusImageData struct { 73 | Width int32 // i 74 | Height int32 // i 75 | RowStride int32 // i 76 | HasAlpha bool // b 77 | BitsPerSample int32 // i 78 | Samples int32 // i 79 | Image []byte // ay 80 | } 81 | 82 | func HintImageDataRGBA(img *image.RGBA) Hint { 83 | imageData := dbusImageData{ 84 | Width: int32(img.Rect.Max.X), 85 | Height: int32(img.Rect.Max.Y), 86 | RowStride: int32(img.Stride), 87 | HasAlpha: true, 88 | BitsPerSample: 8, 89 | Samples: 4, 90 | Image: img.Pix, 91 | } 92 | return Hint{ 93 | ID: "image-data", 94 | Variant: dbus.MakeVariant(imageData), 95 | } 96 | } 97 | 98 | // Notification holds all information needed for creating a notification 99 | type Notification struct { 100 | AppName string 101 | // Setting ReplacesID atomically replaces the notification with this ID. 102 | // Optional. 103 | ReplacesID uint32 104 | // See predefined icons here: http://standards.freedesktop.org/icon-naming-spec/icon-naming-spec-latest.html 105 | // Optional. 106 | AppIcon string 107 | Summary string 108 | Body string 109 | // Actions are tuples of (action_key, label), e.g.: []Action{"cancel", "Cancel", "open", "Open"} 110 | Actions []Action 111 | Hints map[string]dbus.Variant 112 | // ExpireTimeout: duration to show notification. See also ExpireTimeoutSetByNotificationServer and ExpireTimeoutNever. 113 | ExpireTimeout time.Duration 114 | } 115 | 116 | func (n *Notification) SetUrgency(urgency Urgency) { 117 | n.AddHint(HintUrgency(urgency)) 118 | } 119 | 120 | func (n *Notification) AddHint(hint Hint) { 121 | if n.Hints == nil { 122 | n.Hints = map[string]dbus.Variant{} 123 | } 124 | n.Hints[hint.ID] = hint.Variant 125 | } 126 | 127 | // ExpireTimeoutSetByNotificationServer used as ExpireTimeout to leave expiration up to the notification server. 128 | // Expiration is sent as number of millis. 129 | // When -1, the notification's expiration time is dependent on the notification server's settings, and may vary for the type of notification. If 0, never expire. 130 | const ExpireTimeoutSetByNotificationServer = time.Millisecond * -1 131 | const ExpireTimeoutNever time.Duration = 0 132 | 133 | // Action holds key and label for user action buttons. 134 | type Action struct { 135 | // Key is the identifier for the action, used for signaling back which action was selected 136 | Key string 137 | // Label is the localized string that will be displayed to the user 138 | Label string 139 | } 140 | 141 | // NewDefaultAction creates a new default action. 142 | // The default action is usually invoked by clicking on the notification. 143 | // The label can be anything, but implementations are free whether to display it. 144 | func NewDefaultAction(label string) Action { 145 | return Action{Key: "default", Label: label} 146 | } 147 | 148 | // SendNotification is provided for convenience. 149 | // Use if you only want to deliver a notification and do not care about actions or events. 150 | func SendNotification(conn *dbus.Conn, note Notification) (uint32, error) { 151 | actions := []string{} 152 | 153 | for i := range note.Actions { 154 | actions = append(actions, note.Actions[i].Key, note.Actions[i].Label) 155 | } 156 | 157 | durationMs := int32(note.ExpireTimeout.Milliseconds()) 158 | 159 | obj := conn.Object(dbusNotificationsInterface, dbusObjectPath) 160 | call := obj.Call( 161 | callNotify, 162 | 0, 163 | note.AppName, 164 | note.ReplacesID, 165 | note.AppIcon, 166 | note.Summary, 167 | note.Body, 168 | actions, 169 | note.Hints, 170 | durationMs, 171 | ) 172 | if call.Err != nil { 173 | return 0, fmt.Errorf("error sending notification: %w", call.Err) 174 | } 175 | var ret uint32 176 | err := call.Store(&ret) 177 | if err != nil { 178 | return ret, fmt.Errorf("error getting uint32 ret value: %w", err) 179 | } 180 | return ret, nil 181 | } 182 | 183 | // ServerInformation is a holder for information returned by 184 | // GetServerInformation call. 185 | type ServerInformation struct { 186 | Name string 187 | Vendor string 188 | Version string 189 | SpecVersion string 190 | } 191 | 192 | // GetServerInformation returns the information on the server. 193 | // 194 | // org.freedesktop.Notifications.GetServerInformation 195 | // 196 | // GetServerInformation Return Values 197 | // 198 | // Name Type Description 199 | // name STRING The product name of the server. 200 | // vendor STRING The vendor name. For example, "KDE," "GNOME," "freedesktop.org," or "Microsoft." 201 | // version STRING The server's version number. 202 | // spec_version STRING The specification version the server is compliant with. 203 | func GetServerInformation(conn *dbus.Conn) (ServerInformation, error) { 204 | obj := conn.Object(dbusNotificationsInterface, dbusObjectPath) 205 | if obj == nil { 206 | return ServerInformation{}, errors.New("error creating dbus call object") 207 | } 208 | call := obj.Call(callGetServerInformation, 0) 209 | if call.Err != nil { 210 | return ServerInformation{}, fmt.Errorf("error calling %v: %v", callGetServerInformation, call.Err) 211 | } 212 | 213 | ret := ServerInformation{} 214 | err := call.Store(&ret.Name, &ret.Vendor, &ret.Version, &ret.SpecVersion) 215 | if err != nil { 216 | return ret, fmt.Errorf("error reading %v return values: %v", callGetServerInformation, err) 217 | } 218 | return ret, nil 219 | } 220 | 221 | // GetCapabilities gets the capabilities of the notification server. 222 | // This call takes no parameters. 223 | // It returns an array of strings. Each string describes an optional capability implemented by the server. 224 | // 225 | // See also: https://developer.gnome.org/notification-spec/ 226 | // GetCapabilities provide an exported method for this operation 227 | func GetCapabilities(conn *dbus.Conn) ([]string, error) { 228 | obj := conn.Object(dbusNotificationsInterface, dbusObjectPath) 229 | call := obj.Call(callGetCapabilities, 0) 230 | if call.Err != nil { 231 | return []string{}, call.Err 232 | } 233 | var ret []string 234 | err := call.Store(&ret) 235 | if err != nil { 236 | return ret, err 237 | } 238 | return ret, nil 239 | } 240 | 241 | // Notifier is an interface implementing the operations supported by the 242 | // Freedesktop DBus Notifications object. 243 | // 244 | // New() sets up a Notifier that listens on dbus' signals regarding 245 | // Notifications: NotificationClosed and ActionInvoked. 246 | // 247 | // Signal delivery works by subscribing to all signals regarding Notifications, 248 | // which means you will see signals for Notifications also from other sources, 249 | // not just the latest you sent 250 | // 251 | // Users that only want to send a simple notification, but don't care about 252 | // interacting with signals, can use exported method: SendNotification(conn, Notification) 253 | // 254 | // Caller is responsible for calling Close() before exiting, 255 | // to shut down event loop and cleanup dbus registration. 256 | type Notifier interface { 257 | SendNotification(n Notification) (uint32, error) 258 | GetCapabilities() ([]string, error) 259 | GetServerInformation() (ServerInformation, error) 260 | CloseNotification(id uint32) (bool, error) 261 | Close() error 262 | } 263 | 264 | // NotificationClosedHandler is called when we receive a NotificationClosed signal 265 | type NotificationClosedHandler func(*NotificationClosedSignal) 266 | 267 | // ActionInvokedHandler is called when we receive a signal that one of the action_keys was invoked. 268 | // 269 | // Note that invoking an action often also produces a NotificationClosedSignal, 270 | // so you might receive both a Closed signal and a ActionInvoked signal. 271 | // 272 | // I suspect this detail is implementation specific for the UI interaction, 273 | // and does at least happen on XFCE4. 274 | type ActionInvokedHandler func(*ActionInvokedSignal) 275 | 276 | // ActionInvokedSignal holds data from any signal received regarding Actions invoked 277 | type ActionInvokedSignal struct { 278 | // ID of the Notification the action was invoked for 279 | ID uint32 280 | // Key from the tuple (action_key, label) 281 | ActionKey string 282 | } 283 | 284 | // notifier implements Notifier interface 285 | type notifier struct { 286 | conn *dbus.Conn 287 | signal chan *dbus.Signal 288 | onClosed NotificationClosedHandler 289 | onAction ActionInvokedHandler 290 | log logger 291 | group *group 292 | } 293 | 294 | type logger interface { 295 | Printf(format string, v ...interface{}) 296 | } 297 | 298 | // option overrides certain parts of a Notifier 299 | type option func(*notifier) 300 | 301 | // WithLogger sets a new logger func 302 | func WithLogger(logz logger) option { 303 | return func(n *notifier) { 304 | n.log = logz 305 | } 306 | } 307 | 308 | // WithOnAction sets ActionInvokedHandler handler 309 | func WithOnAction(h ActionInvokedHandler) option { 310 | return func(n *notifier) { 311 | n.onAction = h 312 | } 313 | } 314 | 315 | // WithOnClosed sets NotificationClosed handler 316 | func WithOnClosed(h NotificationClosedHandler) option { 317 | return func(n *notifier) { 318 | n.onClosed = h 319 | } 320 | } 321 | 322 | // New creates a new Notifier using conn. 323 | // See also: Notifier 324 | func New(conn *dbus.Conn, opts ...option) (Notifier, error) { 325 | n := ¬ifier{ 326 | conn: conn, 327 | signal: make(chan *dbus.Signal, channelBufferSize), 328 | onClosed: func(s *NotificationClosedSignal) {}, 329 | onAction: func(s *ActionInvokedSignal) {}, 330 | log: &loggerWrapper{"notify: "}, 331 | group: newGroup(), 332 | } 333 | 334 | for _, val := range opts { 335 | val(n) 336 | } 337 | 338 | // add a listener (matcher) in dbus for signals to Notification interface. 339 | err := n.conn.AddMatchSignal( 340 | dbus.WithMatchObjectPath(dbusObjectPath), 341 | dbus.WithMatchInterface(dbusNotificationsInterface), 342 | ) 343 | if err != nil { 344 | return nil, fmt.Errorf("error registering for signals in dbus: %w", err) 345 | } 346 | // register in dbus for signal delivery 347 | n.conn.Signal(n.signal) 348 | 349 | // start eventloop 350 | n.group.Go(n.eventLoop) 351 | 352 | return n, nil 353 | } 354 | 355 | func (n *notifier) eventLoop(done <-chan struct{}) { 356 | for { 357 | select { 358 | case signal, ok := <-n.signal: 359 | if !ok { 360 | n.log.Printf("Signal channel closed, shutting down...") 361 | return 362 | } 363 | n.handleSignal(signal) 364 | case <-done: 365 | n.log.Printf("Got Close() signal, shutting down...") 366 | return 367 | } 368 | } 369 | } 370 | 371 | // signal handler that translates and sends notifications to channels 372 | func (n *notifier) handleSignal(signal *dbus.Signal) { 373 | if signal == nil { 374 | return 375 | } 376 | switch signal.Name { 377 | case signalNotificationClosed: 378 | nc := &NotificationClosedSignal{ 379 | ID: signal.Body[0].(uint32), 380 | Reason: Reason(signal.Body[1].(uint32)), 381 | } 382 | n.onClosed(nc) 383 | case signalActionInvoked: 384 | is := &ActionInvokedSignal{ 385 | ID: signal.Body[0].(uint32), 386 | ActionKey: signal.Body[1].(string), 387 | } 388 | n.onAction(is) 389 | default: 390 | n.log.Printf("Received unknown signal: %+v", signal) 391 | } 392 | } 393 | 394 | func (n *notifier) GetCapabilities() ([]string, error) { 395 | return GetCapabilities(n.conn) 396 | } 397 | func (n *notifier) GetServerInformation() (ServerInformation, error) { 398 | return GetServerInformation(n.conn) 399 | } 400 | 401 | // SendNotification sends a notification to the notification server and returns the ID or an error. 402 | // 403 | // Implements dbus call: 404 | // 405 | // UINT32 org.freedesktop.Notifications.Notify ( 406 | // STRING app_name, 407 | // UINT32 replaces_id, 408 | // STRING app_icon, 409 | // STRING summary, 410 | // STRING body, 411 | // ARRAY actions, 412 | // DICT hints, 413 | // INT32 expire_timeout 414 | // ); 415 | // 416 | // Name Type Description 417 | // app_name STRING The optional name of the application sending the notification. Can be blank. 418 | // replaces_id UINT32 The optional notification ID that this notification replaces. The server must atomically (ie with no flicker or other visual cues) replace the given notification with this one. This allows clients to effectively modify the notification while it's active. A value of value of 0 means that this notification won't replace any existing notifications. 419 | // app_icon STRING The optional program icon of the calling application. Can be an empty string, indicating no icon. 420 | // summary STRING The summary text briefly describing the notification. 421 | // body STRING The optional detailed body text. Can be empty. 422 | // actions ARRAY Actions are sent over as a list of pairs. Each even element in the list (starting at index 0) represents the identifier for the action. Each odd element in the list is the localized string that will be displayed to the user. 423 | // hints DICT Optional hints that can be passed to the server from the client program. Although clients and servers should never assume each other supports any specific hints, they can be used to pass along information, such as the process PID or window ID, that the server may be able to make use of. See Hints. Can be empty. 424 | // expire_timeout INT32 The timeout time in milliseconds since the display of the notification at which the notification should automatically close. 425 | // If -1, the notification's expiration time is dependent on the notification server's settings, and may vary for the type of notification. If 0, never expire. 426 | // 427 | // If replaces_id is 0, the return value is a UINT32 that represent the notification. 428 | // It is unique, and will not be reused unless a MAXINT number of notifications have been generated. 429 | // An acceptable implementation may just use an incrementing counter for the ID. 430 | // The returned ID is always greater than zero. Servers must make sure not to return zero as an ID. 431 | // 432 | // If replaces_id is not 0, the returned value is the same value as replaces_id. 433 | func (n *notifier) SendNotification(note Notification) (uint32, error) { 434 | return SendNotification(n.conn, note) 435 | } 436 | 437 | // CloseNotification causes a notification to be forcefully closed and removed from the user's view. 438 | // It can be used, for example, in the event that what the notification pertains to is no longer relevant, 439 | // or to cancel a notification with no expiration time. 440 | // 441 | // The NotificationClosed (dbus) signal is emitted by this method. 442 | // If the notification no longer exists, an empty D-BUS Error message is sent back. 443 | func (n *notifier) CloseNotification(id uint32) (bool, error) { 444 | obj := n.conn.Object(dbusNotificationsInterface, dbusObjectPath) 445 | call := obj.Call(callCloseNotification, 0, id) 446 | if call.Err != nil { 447 | return false, call.Err 448 | } 449 | return true, nil 450 | } 451 | 452 | // NotificationClosedSignal holds data for *Closed callbacks from Notifications Interface. 453 | type NotificationClosedSignal struct { 454 | // ID of the Notification the signal was invoked for 455 | ID uint32 456 | // A reason given if known 457 | Reason Reason 458 | } 459 | 460 | // Reason for the closed notification 461 | type Reason uint32 462 | 463 | const ( 464 | // ReasonExpired when a notification expired 465 | ReasonExpired Reason = 1 466 | 467 | // ReasonDismissedByUser when a notification has been dismissed by a user 468 | ReasonDismissedByUser Reason = 2 469 | 470 | // ReasonClosedByCall when a notification has been closed by a call to CloseNotification 471 | ReasonClosedByCall Reason = 3 472 | 473 | // ReasonUnknown when as notification has been closed for an unknown reason 474 | ReasonUnknown Reason = 4 475 | ) 476 | 477 | func (r Reason) String() string { 478 | switch r { 479 | case ReasonExpired: 480 | return "Expired" 481 | case ReasonDismissedByUser: 482 | return "DismissedByUser" 483 | case ReasonClosedByCall: 484 | return "ClosedByCall" 485 | case ReasonUnknown: 486 | return "Unknown" 487 | default: 488 | return "Other" 489 | } 490 | } 491 | 492 | // Close cleans up and shuts down signal delivery loop. It is safe to be called 493 | // multiple times. 494 | func (n *notifier) Close() error { 495 | return n.group.Close(func() error { 496 | // remove signal reception 497 | n.conn.RemoveSignal(n.signal) 498 | 499 | // unregister in dbus: 500 | return n.conn.RemoveMatchSignal( 501 | dbus.WithMatchObjectPath(dbusObjectPath), 502 | dbus.WithMatchInterface(dbusNotificationsInterface), 503 | ) 504 | }) 505 | } 506 | 507 | type loggerWrapper struct { 508 | prefix string 509 | } 510 | 511 | func (l *loggerWrapper) Printf(format string, v ...interface{}) { 512 | log.Printf(l.prefix+format, v...) 513 | } 514 | 515 | // group abstracts away shutdown logic for the event loop. 516 | type group struct { 517 | wg sync.WaitGroup 518 | closeOnce sync.Once 519 | done chan struct{} 520 | err error 521 | } 522 | 523 | func newGroup() *group { 524 | return &group{ 525 | done: make(chan struct{}), 526 | } 527 | } 528 | 529 | // Go runs f in a new goroutine. done is closed as a signal for f to shut down. 530 | // g.Close waits for f to finish before returning. 531 | func (g *group) Go(f func(done <-chan struct{})) { 532 | g.wg.Add(1) 533 | defer g.wg.Done() 534 | go f(g.done) 535 | } 536 | 537 | // Close signals all goroutines started by g to shut down and waits for them to 538 | // finish. It then calls f for further clean up. It is safe to be called 539 | // multiple times. 540 | func (g *group) Close(f func() error) error { 541 | g.closeOnce.Do(func() { 542 | close(g.done) 543 | g.wg.Wait() 544 | g.err = f() 545 | }) 546 | return g.err 547 | } 548 | -------------------------------------------------------------------------------- /notification_test.go: -------------------------------------------------------------------------------- 1 | package notify 2 | 3 | import ( 4 | "github.com/stretchr/testify/require" 5 | "testing" 6 | ) 7 | 8 | func TestExpiration(t *testing.T) { 9 | require.EqualValues(t, 0, ExpireTimeoutNever.Milliseconds()) 10 | require.EqualValues(t, -1, ExpireTimeoutSetByNotificationServer.Milliseconds()) 11 | 12 | // test assignment compiles: 13 | n := Notification{} 14 | n.ExpireTimeout = ExpireTimeoutNever 15 | n.ExpireTimeout = ExpireTimeoutSetByNotificationServer 16 | } 17 | --------------------------------------------------------------------------------