pragma Singleton import Quickshell import Quickshell.Io import Quickshell.Services.Notifications import QtQuick Singleton { id: root property bool panelOpen: false property int unreadCount: 0 property ListModel notifications: ListModel {} property var _refs: ({}) // maps nid → live Notification object property var _pids: ({}) // maps nid → sender process PID (from D-Bus monitor) property ListModel toasts: ListModel {} // Runs every 100ms while toasts are active, decrementing progress (1.0 to 0.0 over 5s) // and removing any toast that has expired Timer { id: toastTick interval: 100 repeat: true running: root.toasts.count > 0 onTriggered: { let i = 0 while (i < root.toasts.count) { const p = root.toasts.get(i).progress - 0.02 if (p <= 0) { root.toasts.remove(i, 1) } else { root.toasts.setProperty(i, "progress", p) i++ } } } } // Executes a niri focus-window command Process { id: _focusProc } // Monitors D-Bus for Notify calls and outputs "nid:sender_pid" so we can // walk /proc and focus the exact window that sent each notification. Process { id: _pidMonitor command: ["bash", String(Qt.resolvedUrl("notif-pid-monitor.sh")).replace("file://", "")] running: true stdout: SplitParser { onRead: line => { const parts = line.split(":") if (parts.length !== 2) return const nid = parseInt(parts[0]) const pid = parts[1].trim() if (!isNaN(nid) && pid) { const p = root._pids p[nid] = pid root._pids = p } } } onExited: (exitCode, exitStatus) => _pidRestartTimer.start() } // Restarts the D-Bus monitor if dbus-monitor exits unexpectedly (e.g. session bus restart). Timer { id: _pidRestartTimer interval: 1000 repeat: false onTriggered: { _pidMonitor.running = false; _pidMonitor.running = true } } NotificationServer { keepOnReload: true onNotification: notif => { const ts = Date.now() const day = root._dayLabel(ts) root.notifications.insert(0, { nid: notif.id, appName: notif.appName || "Unknown", summary: notif.summary || "", body: notif.body || "", appIcon: notif.appIcon || "", image: notif.image || "", desktopEntry: notif.desktopEntry || "", urgency: notif.urgency || 1, timestamp: ts, day: day }) const r = root._refs r[notif.id] = notif root._refs = r if (!root.panelOpen) root.unreadCount++ root.toasts.insert(0, { tid: notif.id, appName: notif.appName || "Unknown", summary: notif.summary || "", body: notif.body || "", appIcon: notif.appIcon || "", image: notif.image || "", desktopEntry: notif.desktopEntry || "", urgency: notif.urgency || 1, progress: 1.0 }) } } function dismiss(index) { if (index < 0 || index >= notifications.count) return const entry = notifications.get(index) const ref = _refs[entry.nid] if (ref) { try { ref.close() } catch (_) {} const r = _refs delete r[entry.nid] _refs = r } const p = _pids delete p[entry.nid] _pids = p notifications.remove(index, 1) } function dismissAll() { for (let i = 0; i < notifications.count; i++) { const ref = _refs[notifications.get(i).nid] if (ref) { try { ref.close() } catch (_) {} } } notifications.clear() _refs = {} _pids = {} unreadCount = 0 } // Invokes the D-Bus action (tells the app which chat/content to open) and // always also focuses the window via niri IPC. // invoke() alone cannot steal focus on Wayland without an XDG activation token; // niri IPC bypasses that restriction because the compositor grants focus directly. // entry = desktopEntry || appName, passed from the model so it works even after // a hot-reload clears _refs. function invokeDefault(nid, entry) { // Send ActionInvoked to the app if we still have a live reference. const ref = _refs[nid] if (ref) { const actions = ref.actions ? Array.from(ref.actions) : [] if (actions.length > 0) { let found = false for (let i = 0; i < actions.length; i++) { if (actions[i].identifier === "default") { try { actions[i].invoke() } catch (_) {} found = true break } } if (!found) { try { actions[0].invoke() } catch (_) {} } } } // Bring the window to front via niri IPC. const senderPid = _pids[nid] if (senderPid) { // Walk /proc from the sender PID upward until we find a PID that owns // a niri window, then focus that window. This correctly handles multiple // instances of the same app (e.g. several kitty terminals). _focusProc.command = [ "bash", "-c", 'P=' + senderPid + '; ' + 'while [ "$P" -gt 1 ]; do ' + ' WIN=$(niri msg --json windows | jq -r --arg p "$P" \'.[] | select(.pid == ($p | tonumber)) | .id // empty\'); ' + ' if [ -n "$WIN" ]; then niri msg action focus-window --id "$WIN"; exit 0; fi; ' + ' P=$(awk \'/^PPid:/{print $2}\' /proc/"$P"/status 2>/dev/null); ' + ' [ -z "$P" ] && exit 1; ' + 'done' ] } else if (entry) { // Fallback when no PID was captured: match by app_id, pick most recently focused. _focusProc.command = [ "bash", "-c", 'WID=$(niri msg --json windows | jq -r --arg id "' + entry + '" ' + '\'[.[] | select(.app_id == $id)] | sort_by(.focus_timestamp.secs, .focus_timestamp.nanos) | last | .id // empty\'); ' + '[ -n "$WID" ] && niri msg action focus-window --id "$WID"' ] } else { return } _focusProc.running = false _focusProc.running = true } function dismissToast(tid) { for (let i = 0; i < toasts.count; i++) { if (toasts.get(i).tid === tid) { toasts.remove(i, 1) return } } } function togglePanel() { panelOpen = !panelOpen if (panelOpen) unreadCount = 0 } function formatTime(ts) { return Qt.formatDateTime(new Date(ts), "hh:mm") } function _dayLabel(ts) { const now = new Date() const date = new Date(ts) const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()) const yesterday = new Date(today.getTime() - 86400000) const day = new Date(date.getFullYear(), date.getMonth(), date.getDate()) if (day.getTime() === today.getTime()) return "Today" if (day.getTime() === yesterday.getTime()) return "Yesterday" return Qt.formatDateTime(date, "dd/MM/yyyy") } }