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:
@@ -774,5 +774,6 @@ func getCommonCommands() []*cobra.Command {
|
||||
trashCmd,
|
||||
systemCmd,
|
||||
switchUserCmd,
|
||||
trayWatcherCmd,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
Reference in New Issue
Block a user