package trayrecovery import ( "context" "strings" "sync" "time" "github.com/AvengeMedia/DankMaterialShell/core/internal/log" "github.com/godbus/dbus/v5" ) const ( sniWatcherDest = "org.kde.StatusNotifierWatcher" sniWatcherPath = "/StatusNotifierWatcher" sniWatcherIface = "org.kde.StatusNotifierWatcher" sniItemIface = "org.kde.StatusNotifierItem" dbusIface = "org.freedesktop.DBus" propsIface = "org.freedesktop.DBus.Properties" probeTimeout = 300 * time.Millisecond connProbeTimeout = 150 * time.Millisecond batchSize = 30 ) var excludedPrefixes = []string{ "org.freedesktop.", "org.gnome.", "org.kde.StatusNotifier", "com.canonical.AppMenu", "org.mpris.", "org.pipewire.", "org.pulseaudio", "fi.epitaph", "quickshell", "org.kde.quickshell", } func (m *Manager) recoverTrayItems() { registeredItems := m.getRegisteredItems() allNames := m.getDBusNames() if allNames == nil { return } registeredConnIDs := m.buildRegisteredConnIDs(registeredItems) count := len(registeredItems) log.Infof("TrayRecoveryService: scanning DBus for unregistered SNI items (%d already registered)...", count) m.scanWellKnownNames(allNames, registeredItems, registeredConnIDs) m.scanConnectionIDs(allNames, registeredItems, registeredConnIDs) } func (m *Manager) getRegisteredItems() []string { obj := m.conn.Object(sniWatcherDest, sniWatcherPath) variant, err := obj.GetProperty(sniWatcherIface + ".RegisteredStatusNotifierItems") if err != nil { log.Warnf("TrayRecoveryService: failed to get registered items: %v", err) return nil } switch v := variant.Value().(type) { case []string: return v case []any: items := make([]string, 0, len(v)) for _, elem := range v { if s, ok := elem.(string); ok { items = append(items, s) } } return items } return nil } func (m *Manager) getDBusNames() []string { var names []string err := m.conn.BusObject().Call(dbusIface+".ListNames", 0).Store(&names) if err != nil { log.Warnf("TrayRecoveryService: failed to list bus names: %v", err) return nil } return names } func (m *Manager) getNameOwner(name string) string { var owner string err := m.conn.BusObject().Call(dbusIface+".GetNameOwner", 0, name).Store(&owner) if err != nil { return "" } return owner } // buildRegisteredConnIDs resolves every registered SNI item (well-known name // or :1.xxx connection ID) to a canonical connection ID. This prevents // duplicates in both directions. func (m *Manager) buildRegisteredConnIDs(registeredItems []string) map[string]bool { connIDs := make(map[string]bool, len(registeredItems)) for _, item := range registeredItems { name := extractName(item) if strings.HasPrefix(name, ":1.") { connIDs[name] = true } else { owner := m.getNameOwner(name) if owner != "" { connIDs[owner] = true } } } return connIDs } // scanWellKnownNames probes well-known names (e.g. DinoX, nm-applet) for // unregistered SNI items and re-registers them. func (m *Manager) scanWellKnownNames(allNames []string, registeredItems []string, registeredConnIDs map[string]bool) { registeredRaw := strings.Join(registeredItems, "\n") for _, name := range allNames { if strings.HasPrefix(name, ":") { continue } if strings.Contains(registeredRaw, name) { continue } // Skip if this name's connection ID is already in the registered set // (handles the case where the app registered via connection ID instead) connForName := m.getNameOwner(name) if connForName != "" && registeredConnIDs[connForName] { continue } if isExcludedName(name) { continue } short := shortName(name) objectPaths := []string{ "/StatusNotifierItem", "/org/ayatana/NotificationItem/" + short, } for _, objPath := range objectPaths { if m.probeSNI(name, objPath, probeTimeout) { m.registerSNI(name) // Update set so the connection-ID section won't double-register this app if connForName != "" { registeredConnIDs[connForName] = true } break } } } } // scanConnectionIDs probes all :1.xxx connections in parallel for unregistered // SNI items (e.g. Vesktop, Electron apps). Most non-SNI connections return an // error instantly, so this is fast. func (m *Manager) scanConnectionIDs(allNames []string, registeredItems []string, registeredConnIDs map[string]bool) { registeredRaw := strings.Join(registeredItems, "\n") registeredLower := strings.ToLower(registeredRaw) var wg sync.WaitGroup sem := make(chan struct{}, batchSize) for _, name := range allNames { if !strings.HasPrefix(name, ":1.") { continue } if registeredConnIDs[name] { continue } sem <- struct{}{} wg.Add(1) go func(conn string) { defer wg.Done() defer func() { <-sem }() sniID := m.getSNIId(conn, connProbeTimeout) if sniID == "" { return } // Skip if an item with the same Id is already registered (case-insensitive) if strings.Contains(registeredLower, strings.ToLower(sniID)) { return } m.registerSNI(conn) log.Infof("TrayRecovery: re-registered %s (Id: %s)", conn, sniID) }(name) } wg.Wait() } func (m *Manager) probeSNI(dest, path string, timeout time.Duration) bool { ctx, cancel := context.WithTimeout(context.Background(), timeout) defer cancel() obj := m.conn.Object(dest, dbus.ObjectPath(path)) var props map[string]dbus.Variant err := obj.CallWithContext(ctx, propsIface+".GetAll", 0, sniItemIface).Store(&props) if err != nil { return false } _, hasID := props["Id"] return hasID } func (m *Manager) getSNIId(dest string, timeout time.Duration) string { ctx, cancel := context.WithTimeout(context.Background(), timeout) defer cancel() obj := m.conn.Object(dest, "/StatusNotifierItem") var variant dbus.Variant err := obj.CallWithContext(ctx, propsIface+".Get", 0, sniItemIface, "Id").Store(&variant) if err != nil { return "" } id, _ := variant.Value().(string) return id } func (m *Manager) registerSNI(name string) { obj := m.conn.Object(sniWatcherDest, sniWatcherPath) call := obj.Call(sniWatcherIface+".RegisterStatusNotifierItem", 0, name) if call.Err != nil { log.Warnf("TrayRecovery: failed to register %s: %v", name, call.Err) return } log.Infof("TrayRecovery: re-registered %s", name) } func extractName(item string) string { if idx := strings.IndexByte(item, '/'); idx != -1 { return item[:idx] } return item } func shortName(name string) string { parts := strings.Split(name, ".") if len(parts) > 0 { return parts[len(parts)-1] } return name } func isExcludedName(name string) bool { for _, prefix := range excludedPrefixes { if strings.HasPrefix(name, prefix) { return true } } return false }