Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
snotify
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ Audible Notifications.

Install the AUR package, then enable **user** service `snotify.service`.

By default the notification sound is `/opt/snotify/message.ogg`, but can be overriden by environment variable `SNOTIFY_OGG_FILE`

# Credits

- Material Sounds
11 changes: 9 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
module snotify

go 1.21.0
go 1.26.1

require github.com/godbus/dbus v4.1.0+incompatible
require github.com/godbus/dbus/v5 v5.2.2

require (
github.com/jfreymuth/oggvorbis v1.0.5 // indirect
github.com/jfreymuth/pulse v0.1.1 // indirect
github.com/jfreymuth/vorbis v1.0.2 // indirect
golang.org/x/sys v0.27.0 // indirect
)
14 changes: 10 additions & 4 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
github.com/godbus/dbus v4.1.0+incompatible h1:WqqLRTsQic3apZUK9qC5sGNfXthmPXzUZ7nQPrNITa4=
github.com/godbus/dbus v4.1.0+incompatible/go.mod h1:/YcGZj5zSblfDWMMoOzV4fas9FZnQYTkDnsGvmh2Grw=
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
github.com/jfreymuth/oggvorbis v1.0.5 h1:u+Ck+R0eLSRhgq8WTmffYnrVtSztJcYrl588DM4e3kQ=
github.com/jfreymuth/oggvorbis v1.0.5/go.mod h1:1U4pqWmghcoVsCJJ4fRBKv9peUJMBHixthRlBeD6uII=
github.com/jfreymuth/pulse v0.1.1 h1:9WLNBNCijmtZ14ZJpatgJPu/NjwAl3TIKItSFnTh+9A=
github.com/jfreymuth/pulse v0.1.1/go.mod h1:cpYspI6YljhkUf1WLXLLDmeaaPFc3CnGLjDZf9dZ4no=
github.com/jfreymuth/vorbis v1.0.2 h1:m1xH6+ZI4thH927pgKD8JOH4eaGRm18rEE9/0WKjvNE=
github.com/jfreymuth/vorbis v1.0.2/go.mod h1:DoftRo4AznKnShRl1GxiTFCseHr4zR9BN3TWXyuzrqQ=
golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s=
golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
319 changes: 255 additions & 64 deletions snotify.go
Original file line number Diff line number Diff line change
@@ -1,97 +1,288 @@
package main

import (
"bufio"
"errors"
"fmt"
"log"
"os"
"os/exec"
"strconv"
"strings"
"sync"
"time"
"syscall"

"github.com/godbus/dbus/v5"
"github.com/jfreymuth/oggvorbis"
"github.com/jfreymuth/pulse"
)

const (
version float64 = 1.0
)

var (
mu sync.Mutex
lastLine string
busSigChan = make(chan notif, 16)
soundAllowed bool
dndLock sync.RWMutex
oggFile string
)

func monitorDbus(path, member string) {
for {
cmd := exec.Command("dbus-monitor", fmt.Sprintf("path='%s',member='%s'", path, member))
stdout, err := cmd.StdoutPipe()
if err != nil {
fmt.Println("Error creating stdout pipe:", err)
return
}
type notif struct {
Type string
ID string
}

if err := cmd.Start(); err != nil {
fmt.Println("Error starting dbus-monitor:", err)
return
}
func getConn() (conn *dbus.Conn) {
conn, err := dbus.ConnectSessionBus()
if err != nil {
log.Fatalln("Could not connect to session bus:", err)
}
return conn
}

buf := make([]byte, 512) // Limit the line length to 512 bytes
type NotificationSound struct {
FileDescriptor dbus.UnixFD `db:"file-descriptor,omitempty"`
}

for {
n, err := stdout.Read(buf)
if err != nil {
fmt.Println("Error reading from dbus-monitor:", err)
break
type PortalNotification struct {
Title string
Body string
Sound bool
ID string
}

func dndWatcher() () {
//conn := getConn()
env := os.Getenv("XDG_CURRENT_DESKTOP")
switch env {
case "GNOME":
valCmdSlice := []string{
"stdbuf",
"-oL",
"gsettings",
"monitor",
"org.gnome.desktop.notifications",
"show-banners",
}
getCmdSlice := []string{
"gsettings",
"get",
"org.gnome.desktop.notifications",
"show-banners",
}
cmd := exec.Command(valCmdSlice[0], valCmdSlice[1:]...)
attr := syscall.SysProcAttr{
Pdeathsig: syscall.SIGTERM,
}
cmd.SysProcAttr = &attr
var wg sync.WaitGroup
wg.Add(1)
go func () {
pipe, err := cmd.StdoutPipe()
wg.Done()
if err != nil {
log.Println("GSettings failed:", err)
return
}
scanner := bufio.NewScanner(pipe)
cmdGet, err := exec.Command(getCmdSlice[0], getCmdSlice[1:]...).Output()
if err != nil {
log.Println("GSettings failed:", err)
return
}
line := string(cmdGet)
rawVal := strings.TrimSpace(line)
log.Println("Allow sound:", rawVal)
val, err := strconv.ParseBool(rawVal)
if err != nil {
log.Println("Could not parse result:", err)

line := string(buf[:n])
} else {
dndLock.Lock()
soundAllowed = val
dndLock.Unlock()
}
for scanner.Scan() {
line := scanner.Text()
log.Println("Allow status changed:", line)
rawVal := strings.TrimPrefix(line, "show-banners:")
rawVal = strings.TrimSpace(rawVal)
val, err := strconv.ParseBool(rawVal)
if err != nil {
log.Println("Could not parse result:", err)
continue
}
dndLock.Lock()
soundAllowed = val
dndLock.Unlock()
}
} ()
wg.Wait()
err := cmd.Start()
if err != nil {
fmt.Println("Could not start DnD monitor:", err)
return
}
log.Println("Started DnD watcher")
err = cmd.Wait()
if err != nil {
fmt.Println("GSettings monitor returned error:", err)
return
}
default:
log.Println("Do not disturb unsupported:", env)
}
}

// Lock the mutex to safely update lastLine
mu.Lock()
lastLine = line
mu.Unlock()
}
func legacyNotifWatcher() () {
conn := getConn()
monitorObj := conn.Object("org.freedesktop.DBus", "/org/freedesktop/DBus")
ruleSlice := []string{
"type='method_call',interface='org.freedesktop.Notifications',member='Notify',path='/org/freedesktop/Notifications',destination='org.freedesktop.Notifications'",
//"type='method_call',interface='org.gtk.Notifications',member='AddNotification',path='/org/gtk/Notifications',destination='org.gtk.Notifications'", // GTK's notif API, do we really need those?
"type='method_call',interface='org.freedesktop.portal.Notification',member='AddNotification',path='/org/freedesktop/portal/desktop',destination='org.freedesktop.portal.Desktop'",
}
arg2 := uint(0)
call := monitorObj.Call("org.freedesktop.DBus.Monitoring.BecomeMonitor", 0, ruleSlice, arg2)
if call.Err != nil {
log.Fatalln("Could not become bus monitor:", call.Err)
} else {
log.Println("Bus replied:", call.Body)
}
var sigChan = make(chan *dbus.Message, 16)
conn.Eavesdrop(sigChan)
var lastMsg classicNotifBody
log.Println("Initialized D-Bus connection")
for sig := range sigChan {
var con notif
var body classicNotifBody
err := dbus.Store(sig.Body,
&body.App,
&body.ReplaceID,
&body.Icon,
&body.Summary,
&body.Body,
&body.Actions,
&body.Hints,
&body.Expire,
)

// Wait for the command to finish
if err := cmd.Wait(); err != nil {
fmt.Println("dbus-monitor exited with an error:", err)
if err != nil {
notif, err := decodePortalNotif(sig)
if err != nil {
log.Println("Could not decode Portal notification:", err)
continue
}
con.Type = "Portal"
con.ID = notif.ID
if notif.Sound {
log.Println("Portal notification has sound, suppressing ours")
}
} else {
con.Type = "legacy"
con.ID = body.App
if lastMsg.Body == body.Body && lastMsg.App == body.App && lastMsg.Summary == body.Summary {
log.Println("Skipping duplicate notification")
continue
}
lastMsg = body
}

// Sleep for a while before restarting dbus-monitor
time.Sleep(5 * time.Second)
log.Println(con.ID, "sent", con.Type,"notification:", body)
busSigChan <- con
}
}

func playSoundOnNewLine() {
ticker := time.NewTicker(900 * time.Millisecond)
defer ticker.Stop()

for range ticker.C {
// Lock the mutex to safely read lastLine
mu.Lock()
currentLine := lastLine
mu.Unlock()
func decodePortalNotif(con *dbus.Message) (PortalNotification, error) {
var m = make(map[string]dbus.Variant)
var notif PortalNotification
err := dbus.Store(con.Body, &notif.ID, &m)
if err != nil {
return notif, errors.New("Could not store message: " + err.Error())
}
val, ok := m["priority"]
if ok {
log.Println("Portal notification priority:", val)
}
_, ok = m["sound"]
if ok {
notif.Sound = true
}

if currentLine != "" {
fmt.Println("Received a Notify or AddNotification event. Playing sound...")
playSound()
return notif, nil
}

// Clear lastLine to prevent repeated execution
mu.Lock()
lastLine = ""
mu.Unlock()
}
}
type classicNotifBody struct {
App string
ReplaceID uint32
Icon string
Summary string
Body string
Actions []string
Hints map[string]dbus.Variant
Expire int32
}

func playSound() {
soundCmd := exec.Command("paplay", "/opt/snotify/message.ogg", "--client-name=snotify")
if err := soundCmd.Start(); err != nil {
fmt.Println("Error playing sound:", err)
return
func audioController() {
client, err := pulse.NewClient(
pulse.ClientApplicationName("Snotify Notification Sounds"),
pulse.ClientApplicationIconName("notifications-new-symbolic"),
)
if err != nil {
log.Fatalln("Could not connect to PulseAudio:", err)
}
file, err := os.Open(oggFile)
if err != nil {
log.Fatalln("Could not open audio message file:", err)
}
defer file.Close()
readerFile, err := oggvorbis.NewReader(file)
if err != nil {
log.Fatalln("Could not read audio message file:", err)
}

if err := soundCmd.Wait(); err != nil {
fmt.Println("Error waiting for sound:", err)
reader := pulse.Float32Reader(func(f []float32) (int, error) {
return readerFile.Read(f)
})
playback, err := client.NewPlayback(
reader,
pulse.PlaybackSampleRate(readerFile.SampleRate()),
//pulse.PlaybackStereo,
pulse.PlaybackLatency(0.5),
)
if err != nil {
log.Fatalln("Could not request PulseAudio playback:", err)
}
defer playback.Close()
for sig := range busSigChan {
dndLock.RLock()
if soundAllowed == false {
dndLock.RUnlock()
log.Println("Not playing sound with DnD")
continue
}
dndLock.RUnlock()
playback.Stop()
readerFile.SetPosition(0)
log.Println("Playing sound for:", sig)
go playback.Start()
}
}

func main() {
go monitorDbus("/org/freedesktop/Notifications", "Notify") // Start monitoring dbus for the first path and member
go monitorDbus("/org/gtk/Notifications", "AddNotification") // Start monitoring dbus for the second path and member
go playSoundOnNewLine() // Start checking for new lines and playing sound in a goroutine
log.Println("Starting snotify, version", version)
envFile := os.Getenv("SNOTIFY_OGG_FILE")
if len(envFile) == 0 {
oggFile = "/opt/snotify/message.ogg"
} else {
oggFile = envFile
}
go audioController()
go dndWatcher()
var wg sync.WaitGroup
wg.Go(func() {
legacyNotifWatcher()
})

// The program will run indefinitely without waiting for Enter key input
select {}
}
wg.Wait()
}
Loading