1
0
mirror of https://github.com/AvengeMedia/DankMaterialShell.git synced 2026-08-06 05:28:29 -04:00

add(systemd): dms-tray-watcher service & commands for early tray registration

This commit is contained in:
purian23
2026-07-06 14:52:36 -04:00
parent 405ea708b3
commit 3420d2be93
19 changed files with 320 additions and 5 deletions
+1
View File
@@ -774,5 +774,6 @@ func getCommonCommands() []*cobra.Command {
trashCmd,
systemCmd,
switchUserCmd,
trayWatcherCmd,
}
}
+24
View File
@@ -0,0 +1,24 @@
package main
import (
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/AvengeMedia/DankMaterialShell/core/internal/traywatcher"
"github.com/spf13/cobra"
)
var trayWatcherCmd = &cobra.Command{
Use: "tray-watcher",
Short: "Run a minimal StatusNotifierWatcher for early tray registration",
Long: `Run a minimal org.kde.StatusNotifierWatcher daemon.
Started early in the session (Before=graphical-session.target via
dms-tray-watcher.service), it lets tray apps launched by XDG autostart
register their items before the shell finishes loading, and keeps them
registered across shell restarts. The shell's tray host picks items up from
this watcher; if it is not running, the shell's built-in watcher takes over.`,
Run: func(cmd *cobra.Command, args []string) {
if err := traywatcher.Run(); err != nil {
log.Fatalf("%v", err)
}
},
}
+3 -2
View File
@@ -880,8 +880,9 @@ func (cd *ConfigDeployer) transformNiriConfigForNonSystemd(config, terminalComma
config = regexp.MustCompile(`environment \{[^}]*\}`).ReplaceAllString(config, envVars)
spawnDms := `spawn-at-startup "dms" "run"`
if !strings.Contains(config, spawnDms) {
// Watcher spawns first so it owns the SNI name before dms/tray apps start.
spawnDms := "spawn-at-startup \"dms\" \"tray-watcher\"\nspawn-at-startup \"dms\" \"run\""
if !strings.Contains(config, `spawn-at-startup "dms" "run"`) {
// Insert spawn-at-startup for dms after the environment block
envBlockEnd := regexp.MustCompile(`environment \{[^}]*\}`)
if loc := envBlockEnd.FindStringIndex(config); loc != nil {
+12
View File
@@ -520,9 +520,21 @@ func TestHyprlandConfigStructure(t *testing.T) {
assert.Contains(t, HyprlandLuaConfig, "input =")
}
// In non-systemd mode dms is launched from the compositor config, so the tray
// watcher must be launched there too, before dms, to own the SNI name first.
func TestNonSystemdLaunchesTrayWatcherBeforeShell(t *testing.T) {
hypr := transformHyprlandLuaForNonSystemd(HyprlandLuaConfig, "ghostty")
assert.Contains(t, hypr, "hl.exec_cmd(\"dms tray-watcher\")\n\thl.exec_cmd(\"dms run\")")
niri := (&ConfigDeployer{}).transformNiriConfigForNonSystemd(NiriConfig, "ghostty")
assert.Contains(t, niri, "spawn-at-startup \"dms\" \"tray-watcher\"\nspawn-at-startup \"dms\" \"run\"")
}
func TestMangoConfigStructure(t *testing.T) {
assert.Contains(t, MangoConfig, "exec-once=dms run")
assert.NotContains(t, MangoConfig, "exec_once=dms run")
// Tray watcher must start before the shell so it owns the SNI name first.
assert.Contains(t, MangoConfig, "exec-once=dms tray-watcher\nexec-once=dms run")
assert.Contains(t, MangoConfig, "source=./dms/binds.conf")
assert.Contains(t, MangoBindsConfig, "bind=SUPER,H,focusdir,left")
assert.Contains(t, MangoBindsConfig, "bind=SUPER,J,focusdir,down")
+3 -1
View File
@@ -7,7 +7,9 @@ env=XDG_SESSION_TYPE,wayland
# exec-once runs only at startup. Do NOT use exec= for the shell: mango re-runs
# every exec= on each config reload, and DMS reloads the config, which would
# spawn a new shell on every reload.
# spawn a new shell on every reload. The tray watcher starts first so it owns
# the SNI name before dms and the tray apps come up.
exec-once=dms tray-watcher
exec-once=dms run
source=./dms/colors.conf
+1
View File
@@ -116,6 +116,7 @@ func transformHyprlandLuaForNonSystemd(config, terminalCommand string) string {
`hl.env("QT_QPA_PLATFORMTHEME_QT6", "gtk3")` + "\n" +
fmt.Sprintf(`hl.env("TERMINAL", %s)`, strconv.Quote(terminalCommand)) + "\n\n" +
`hl.on("hyprland.start", function()` + "\n" +
` hl.exec_cmd("dms tray-watcher")` + "\n" +
` hl.exec_cmd("dms run")` + "\n" +
`end)` + "\n" +
hyprlandStartupEnd
+4 -2
View File
@@ -587,13 +587,15 @@ TERMINAL=%s
}
func (b *BaseDistribution) EnableDMSService(ctx context.Context, wm deps.WindowManager) error {
// Pull in the tray watcher alongside dms; its Before=graphical-session.target
// ordering makes it claim the SNI name before autostart apps start.
switch wm {
case deps.WindowManagerNiri:
if err := exec.CommandContext(ctx, "systemctl", "--user", "add-wants", "niri.service", "dms").Run(); err != nil {
if err := exec.CommandContext(ctx, "systemctl", "--user", "add-wants", "niri.service", "dms", "dms-tray-watcher").Run(); err != nil {
b.log("Warning: failed to add dms as a want for niri.service")
}
case deps.WindowManagerHyprland:
if err := exec.CommandContext(ctx, "systemctl", "--user", "add-wants", "hyprland-session.target", "dms").Run(); err != nil {
if err := exec.CommandContext(ctx, "systemctl", "--user", "add-wants", "hyprland-session.target", "dms", "dms-tray-watcher").Run(); err != nil {
b.log("Warning: failed to add dms as a want for hyprland-session.target")
}
}
+205
View File
@@ -0,0 +1,205 @@
// Package traywatcher implements a minimal org.kde.StatusNotifierWatcher that
// claims the SNI name early in the session so autostart tray apps can register
// before the shell finishes loading. See docs/TRAY_WATCHER.md.
package traywatcher
import (
"fmt"
"sort"
"strings"
"sync"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/godbus/dbus/v5"
"github.com/godbus/dbus/v5/introspect"
"github.com/godbus/dbus/v5/prop"
)
const (
busName = "org.kde.StatusNotifierWatcher"
objPath = dbus.ObjectPath("/StatusNotifierWatcher")
iface = "org.kde.StatusNotifierWatcher"
)
const introXML = `<node>
<interface name="org.kde.StatusNotifierWatcher">
<method name="RegisterStatusNotifierItem">
<arg type="s" direction="in" name="service"/>
</method>
<method name="RegisterStatusNotifierHost">
<arg type="s" direction="in" name="service"/>
</method>
<property name="RegisteredStatusNotifierItems" type="as" access="read"/>
<property name="IsStatusNotifierHostRegistered" type="b" access="read"/>
<property name="ProtocolVersion" type="i" access="read"/>
<signal name="StatusNotifierItemRegistered">
<arg type="s" name="service"/>
</signal>
<signal name="StatusNotifierItemUnregistered">
<arg type="s" name="service"/>
</signal>
<signal name="StatusNotifierHostRegistered"/>
<signal name="StatusNotifierHostUnregistered"/>
</interface>
` + introspect.IntrospectDataString + prop.IntrospectDataString + `</node>`
type watcher struct {
conn *dbus.Conn
props *prop.Properties
mu sync.Mutex
items map[string]string // item id ("name/path") -> bus name to watch
hosts map[string]bool
}
func (w *watcher) itemListLocked() []string {
list := make([]string, 0, len(w.items))
for item := range w.items {
list = append(list, item)
}
sort.Strings(list)
return list
}
func (w *watcher) emit(signal string, args ...any) {
if err := w.conn.Emit(objPath, iface+"."+signal, args...); err != nil {
log.Warnf("failed to emit %s: %v", signal, err)
}
}
// Clients pass either an object path (item on the caller) or a bus name.
func (w *watcher) RegisterStatusNotifierItem(sender dbus.Sender, service string) *dbus.Error {
var item, watch string
if strings.HasPrefix(service, "/") {
item, watch = string(sender)+service, string(sender)
} else {
item, watch = service+"/StatusNotifierItem", service
}
w.mu.Lock()
if _, exists := w.items[item]; exists {
w.mu.Unlock()
return nil
}
w.items[item] = watch
list := w.itemListLocked()
w.mu.Unlock()
log.Infof("item registered: %s", item)
w.emit("StatusNotifierItemRegistered", item)
w.props.SetMust(iface, "RegisteredStatusNotifierItems", list)
return nil
}
func (w *watcher) RegisterStatusNotifierHost(sender dbus.Sender, service string) *dbus.Error {
watch := string(sender)
if service != "" && !strings.HasPrefix(service, "/") {
watch = service
}
w.mu.Lock()
if w.hosts[watch] {
w.mu.Unlock()
return nil
}
w.hosts[watch] = true
w.mu.Unlock()
log.Infof("host registered: %s", watch)
w.emit("StatusNotifierHostRegistered")
return nil
}
func (w *watcher) pruneOwner(name string) {
w.mu.Lock()
var removed []string
for item, watch := range w.items {
if watch == name {
delete(w.items, item)
removed = append(removed, item)
}
}
list := w.itemListLocked()
hostGone := w.hosts[name]
delete(w.hosts, name)
w.mu.Unlock()
for _, item := range removed {
log.Infof("item gone: %s", item)
w.emit("StatusNotifierItemUnregistered", item)
}
if len(removed) > 0 {
w.props.SetMust(iface, "RegisteredStatusNotifierItems", list)
}
if hostGone {
log.Infof("host gone: %s", name)
w.emit("StatusNotifierHostUnregistered")
}
}
// Run serves the watcher until the session bus connection closes.
func Run() error {
conn, err := dbus.ConnectSessionBus()
if err != nil {
return fmt.Errorf("connect to session bus: %w", err)
}
defer conn.Close()
w := &watcher{conn: conn, items: map[string]string{}, hosts: map[string]bool{}}
if err := conn.Export(w, objPath, iface); err != nil {
return fmt.Errorf("export watcher: %w", err)
}
w.props, err = prop.Export(conn, objPath, map[string]map[string]*prop.Prop{
iface: {
"RegisteredStatusNotifierItems": {Value: []string{}, Emit: prop.EmitTrue},
// Always true: libappindicator clients drop the icon if it is false.
"IsStatusNotifierHostRegistered": {Value: true, Emit: prop.EmitFalse},
"ProtocolVersion": {Value: int32(0), Emit: prop.EmitFalse},
},
})
if err != nil {
return fmt.Errorf("export properties: %w", err)
}
if err := conn.Export(introspect.Introspectable(introXML), objPath, "org.freedesktop.DBus.Introspectable"); err != nil {
return fmt.Errorf("export introspection: %w", err)
}
if err := conn.AddMatchSignal(
dbus.WithMatchSender("org.freedesktop.DBus"),
dbus.WithMatchInterface("org.freedesktop.DBus"),
dbus.WithMatchMember("NameOwnerChanged"),
dbus.WithMatchObjectPath("/org/freedesktop/DBus"),
); err != nil {
return fmt.Errorf("match NameOwnerChanged: %w", err)
}
sigs := make(chan *dbus.Signal, 128)
conn.Signal(sigs)
// No flags: queue for the name and take it when the current owner exits.
reply, err := conn.RequestName(busName, 0)
if err != nil {
return fmt.Errorf("request %s: %w", busName, err)
}
if reply == dbus.RequestNameReplyInQueue {
log.Infof("name busy, queued for %s", busName)
}
for sig := range sigs {
switch sig.Name {
case "org.freedesktop.DBus.NameAcquired":
if len(sig.Body) == 1 && sig.Body[0] == busName {
log.Infof("acquired %s", busName)
}
case "org.freedesktop.DBus.NameOwnerChanged":
if len(sig.Body) != 3 {
continue
}
name, _ := sig.Body[0].(string)
newOwner, _ := sig.Body[2].(string)
if name != busName && newOwner == "" {
w.pruneOwner(name)
}
}
}
return fmt.Errorf("session bus connection closed")
}