diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5bb89d1 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +snotify \ No newline at end of file diff --git a/README.md b/README.md index addea8a..fac2a40 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/go.mod b/go.mod index 2169fd2..fc65c8a 100644 --- a/go.mod +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum index 54239ec..f8405d2 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/snotify.go b/snotify.go index c5efd30..3903e70 100644 --- a/snotify.go +++ b/snotify.go @@ -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, ¬if.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() +} \ No newline at end of file