// 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. pragma Singleton import Quickshell import Quickshell.Io import QtQuick // The notification daemon's state, read from the files it publishes under // $XDG_RUNTIME_DIR/notifyd/. The files are the interface, the same convention // the status registry set: the daemon writes them, this reads them, and // notifyctl is the one path back for a mutation. Nothing here speaks D-Bus. // // A shell restart loses nothing: the daemon keeps running, the files stay, and // this singleton reads them back. Singleton { id: root readonly property string dir: (Quickshell.env("XDG_RUNTIME_DIR") || "/tmp") + "/notifyd" // Parsed whole on every change. Malformed or missing JSON is treated as // empty rather than propagated: a renderer with a bad array is worse than // a renderer that briefly shows nothing. property var queue: [] property var history: [] // Written by the drawer, read here so the balloons know to stand down. property bool drawerOpen: false // Epoch milliseconds; 0 means not snoozing. property double snoozeUntil: 0 // Read from the status registry's own file rather than from the Status // singleton. The renderers only read DND; referencing Status here would // instantiate it, and its onPresentationChanged writes status.dnd and // shells out to breaktimer.sh, side effects a read-only consumer must not // trigger. The file is the interface, so reading it directly is the same // value with none of the write side. property bool dnd: false function parseQueue() { try { root.queue = JSON.parse(queueFile.text() || "[]"); } catch (e) { root.queue = []; } } function parseHistory() { try { root.history = JSON.parse(historyFile.text() || "[]"); } catch (e) { root.history = []; } } // Mutations go through notifyctl, the only thing that talks to the daemon. function run(args) { ctl.command = ["notifyctl"].concat(args); ctl.running = false; ctl.running = true; } function close(id) { root.run(["close", String(id)]); } function closeAll() { root.run(["close-all"]); } // Inline images are local only. A notification is untrusted input, and any // remote (http(s), protocol-relative //host, ftp, data, or an // entity-encoded scheme) would make the shell fetch or embed something the // sender chose, which leaks that the notification was shown. Deny by // default: a tag survives only if its src decodes to a file: URL or a // leading-slash absolute path. Entities are decoded before the test, so an // encoded scheme cannot slip past. function sanitize(body) { function decode(s) { return s.replace(/&(?:#x([0-9a-f]+)|#(\d+)|(amp|colon|sol|tab|quot));/gi, function (m, hex, dec, name) { if (hex !== undefined) return String.fromCharCode(parseInt(hex, 16)); if (dec !== undefined) return String.fromCharCode(parseInt(dec, 10)); return { amp: "&", colon: ":", sol: "/", tab: "\t", quot: "\"" }[name.toLowerCase()]; }); } return (body || "").replace(/]*>/gi, function (tag) { const m = tag.match(/\bsrc\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/i); if (!m) return ""; const src = decode(m[1] !== undefined ? m[1] : m[2] !== undefined ? m[2] : m[3]); return /^file:/i.test(src) || (src.charAt(0) === "/" && src.charAt(1) !== "/") ? tag : ""; }); } function action(id, key) { root.run(["action", String(id), key]); } function clearHistory() { root.run(["clear-history"]); } // The rofi picker lives in the notifications component; a caller passes // the notification id and its [key, label] pairs. Only that component // uses this, but the singleton owns the Process so there is one place a // notifyctl-adjacent command is built. function actions(id, pairs) { const cmd = ["notify-actions.sh", String(id)]; for (const pair of pairs) { cmd.push(pair[1]); cmd.push(pair[0]); } actProc.command = cmd; actProc.running = false; actProc.running = true; } Process { id: ctl } Process { id: actProc } FileView { id: queueFile path: root.dir + "/queue.json" watchChanges: true printErrors: false onFileChanged: reload() onLoaded: root.parseQueue() onLoadFailed: root.queue = [] } FileView { id: historyFile path: root.dir + "/history.json" watchChanges: true printErrors: false onFileChanged: reload() onLoaded: root.parseHistory() onLoadFailed: root.history = [] } FileView { id: drawerFile path: root.dir + "/drawer" watchChanges: true printErrors: false onFileChanged: reload() onLoaded: root.drawerOpen = drawerFile.text().trim() === "1" onLoadFailed: root.drawerOpen = false } FileView { id: dndFile path: (Quickshell.env("XDG_RUNTIME_DIR") || "/tmp") + "/status.dnd" watchChanges: true printErrors: false onFileChanged: reload() onLoaded: root.dnd = dndFile.text().trim() === "1" onLoadFailed: root.dnd = false } FileView { id: snoozeFile path: root.dir + "/snooze" watchChanges: true printErrors: false onFileChanged: reload() onLoaded: { const v = parseInt(snoozeFile.text().trim(), 10); root.snoozeUntil = isNaN(v) ? 0 : v * 1000; } onLoadFailed: root.snoozeUntil = 0 } }