From 7a4d76c5db3e7668796486a0a4d9db583f89b8e5 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Mon, 19 Oct 2026 09:52:43 +0200 Subject: feat(notify): resolve sound-file, sound-name and suppress-sound hints sound-name is looked up in a sound theme, its Inherits chain and freedesktop, with the naming spec's trailing-component fallback. A sound-file must be an absolute path to an existing file, else it is treated as absent so a sound-name in the same notification still applies. Co-Authored-By: Claude Opus 5.5 --- internal/notify/sounds.go | 98 +++++++++++++++++++++++++++++++++++ internal/notify/sounds_test.go | 115 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 213 insertions(+) create mode 100644 internal/notify/sounds.go create mode 100644 internal/notify/sounds_test.go (limited to 'internal') diff --git a/internal/notify/sounds.go b/internal/notify/sounds.go new file mode 100644 index 0000000..85f1ecc --- /dev/null +++ b/internal/notify/sounds.go @@ -0,0 +1,98 @@ +// Copyright (C) 2026 Danilo M. +// +// This program is free software; you can redistribute it and/or modify +// it under the terms of the GNU General Public License version 2 as +// published by the Free Software Foundation. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +package notify + +import ( + "log" + "path/filepath" + "strings" + + "github.com/godbus/dbus/v5" +) + +// XDGSoundDirs is the sound theme search path: the user's dir then each data +// dir, the same roots as XDGIconDirs. +func XDGSoundDirs() []string { + dirs := XDGIconDirs() + for i, d := range dirs { + dirs[i] = filepath.Join(filepath.Dir(d), "sounds") + } + return dirs +} + +var soundExts = []string{".oga", ".ogg", ".wav"} + +// LookupSound resolves a sound naming spec name: the theme, its Inherits +// chain, then freedesktop, each searched across every root; within that, the +// full name first and then with trailing -components dropped, as the spec's +// fallback (message-new-email, message-new, message). "" when nothing matches. +func LookupSound(name, theme string) string { + var chain []string + seen := map[string]bool{} + for t := theme; t != "" && !seen[t]; { + seen[t] = true + chain = append(chain, t) + next := "" + for _, root := range XDGSoundDirs() { + if v := inheritsOf(filepath.Join(root, t)); v != "" { + next = v + break + } + } + t = next + } + if !seen["freedesktop"] { + chain = append(chain, "freedesktop") + } + for n := name; n != ""; { + for _, t := range chain { + for _, root := range XDGSoundDirs() { + for _, ext := range soundExts { + p := filepath.Join(root, t, "stereo", n+ext) + if fileExists(p) { + return p + } + } + } + } + i := strings.LastIndexByte(n, '-') + if i < 0 { + break + } + n = n[:i] + } + return "" +} + +// SoundFromHints reads the three freedesktop sound hints. sound-file wins over +// sound-name, but only as an absolute path to an existing file: anything else +// is logged and treated as absent, so the name still gets its turn. silent is +// suppress-sound, published as is; the renderer decides what it means. +func SoundFromHints(hints map[string]dbus.Variant, theme string) (sound string, silent bool) { + if v, ok := hints["suppress-sound"]; ok { + silent, _ = v.Value().(bool) + } + if v, ok := hints["sound-file"]; ok { + if p, _ := v.Value().(string); p != "" { + if filepath.IsAbs(p) && fileExists(p) { + return p, silent + } + log.Printf("notifyd: sound-file %q is not an absolute path to a file", p) + } + } + if v, ok := hints["sound-name"]; ok { + if n, _ := v.Value().(string); n != "" { + return LookupSound(n, theme), silent + } + } + return "", silent +} diff --git a/internal/notify/sounds_test.go b/internal/notify/sounds_test.go new file mode 100644 index 0000000..060bbbe --- /dev/null +++ b/internal/notify/sounds_test.go @@ -0,0 +1,115 @@ +// Copyright (C) 2026 Danilo M. +// +// This program is free software; you can redistribute it and/or modify +// it under the terms of the GNU General Public License version 2 as +// published by the Free Software Foundation. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +package notify + +import ( + "os" + "path/filepath" + "testing" + + "github.com/godbus/dbus/v5" +) + +// soundTree makes $XDG_DATA_HOME/sounds//stereo/ for each entry +// and an index.theme per theme with the given Inherits, and points +// XDG_DATA_HOME there with XDG_DATA_DIRS empty-but-set to another temp dir. +func soundTree(t *testing.T, inherits map[string]string, files ...string) string { + t.Helper() + home := t.TempDir() + t.Setenv("XDG_DATA_HOME", home) + t.Setenv("XDG_DATA_DIRS", t.TempDir()) + for theme, inh := range inherits { + dir := filepath.Join(home, "sounds", theme) + if err := os.MkdirAll(filepath.Join(dir, "stereo"), 0o755); err != nil { + t.Fatal(err) + } + body := "[Sound Theme]\nName=" + theme + "\n" + if inh != "" { + body += "Inherits=" + inh + "\n" + } + if err := os.WriteFile(filepath.Join(dir, "index.theme"), []byte(body), 0o644); err != nil { + t.Fatal(err) + } + } + for _, f := range files { + p := filepath.Join(home, "sounds", f) + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p, []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + } + return filepath.Join(home, "sounds") +} + +func TestLookupSound(t *testing.T) { + root := soundTree(t, + map[string]string{"mine": "parent", "parent": "", "freedesktop": ""}, + "mine/stereo/bell.oga", + "mine/stereo/bell.wav", + "parent/stereo/message-new-instant.ogg", + "freedesktop/stereo/dialog-warning.oga", + "freedesktop/stereo/message.oga", + ) + cases := []struct{ name, want string }{ + {"bell", root + "/mine/stereo/bell.oga"}, // direct, .oga before .wav + {"message-new-instant", root + "/parent/stereo/message-new-instant.ogg"}, // inherited + {"dialog-warning", root + "/freedesktop/stereo/dialog-warning.oga"}, // freedesktop fallback + {"message-new-email", root + "/freedesktop/stereo/message.oga"}, // name fallback + {"nothing-here", ""}, + } + for _, c := range cases { + if got := LookupSound(c.name, "mine"); got != c.want { + t.Errorf("LookupSound(%q) = %q, want %q", c.name, got, c.want) + } + } +} + +func TestLookupSoundInheritsCycle(t *testing.T) { + soundTree(t, map[string]string{"a": "b", "b": "a"}) + if got := LookupSound("bell", "a"); got != "" { + t.Errorf("got %q, want empty", got) + } +} + +func TestSoundFromHints(t *testing.T) { + root := soundTree(t, map[string]string{"freedesktop": ""}, "freedesktop/stereo/bell.oga") + file := filepath.Join(t.TempDir(), "ding.wav") + if err := os.WriteFile(file, []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + v := dbus.MakeVariant + cases := []struct { + name string + hints map[string]dbus.Variant + sound string + silent bool + }{ + {"none", map[string]dbus.Variant{}, "", false}, + {"name", map[string]dbus.Variant{"sound-name": v("bell")}, root + "/freedesktop/stereo/bell.oga", false}, + {"file wins", map[string]dbus.Variant{"sound-name": v("bell"), "sound-file": v(file)}, file, false}, + {"relative file rejected", map[string]dbus.Variant{"sound-file": v("ding.wav")}, "", false}, + {"missing file rejected", map[string]dbus.Variant{"sound-file": v(file + ".gone")}, "", false}, + {"bad file falls to name", map[string]dbus.Variant{"sound-file": v("x"), "sound-name": v("bell")}, root + "/freedesktop/stereo/bell.oga", false}, + {"suppress", map[string]dbus.Variant{"suppress-sound": v(true), "sound-name": v("bell")}, root + "/freedesktop/stereo/bell.oga", true}, + {"wrong types", map[string]dbus.Variant{"suppress-sound": v("yes"), "sound-name": v(3)}, "", false}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + sound, silent := SoundFromHints(c.hints, "freedesktop") + if sound != c.sound || silent != c.silent { + t.Errorf("got (%q, %v), want (%q, %v)", sound, silent, c.sound, c.silent) + } + }) + } +} -- cgit v1.2.3