diff options
Diffstat (limited to 'shared')
| -rw-r--r-- | shared/Notify.qml | 172 | ||||
| -rw-r--r-- | shared/Status.qml | 139 |
2 files changed, 311 insertions, 0 deletions
diff --git a/shared/Notify.qml b/shared/Notify.qml new file mode 100644 index 0000000..1abe57d --- /dev/null +++ b/shared/Notify.qml @@ -0,0 +1,172 @@ +// Copyright (C) 2026 Danilo M. <danix@danix.xyz> +// +// 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 <img src> (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 every src it carries decodes to a file: + // URL or a leading-slash absolute path. Every src assignment is checked, + // not just the first, so a decoy attribute cannot shadow a remote one; + // 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(/<img\b[^>]*>/gi, function (tag) { + const re = /(?:^|\s)src\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/gi; + let m, found = false, local = true; + while ((m = re.exec(tag))) { + found = true; + const src = decode(m[1] !== undefined ? m[1] : m[2] !== undefined ? m[2] : m[3]); + if (!(/^file:/i.test(src) || (src.charAt(0) === "/" && src.charAt(1) !== "/"))) local = false; + } + return (found && local) ? 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 + } +} diff --git a/shared/Status.qml b/shared/Status.qml new file mode 100644 index 0000000..8f80b9a --- /dev/null +++ b/shared/Status.qml @@ -0,0 +1,139 @@ +// Copyright (C) 2026 Danilo M. <danix@danix.xyz> +// +// 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 Quickshell.Wayland +import QtQuick + +// Desktop modes as state. One file per mode under $XDG_RUNTIME_DIR, holding +// "0" or "1"; a missing file means off. The runtime directory is tmpfs, so a +// reboot resets every mode with no cleanup code here. +// +// The files are the interface, not this singleton: statusctl reads and writes +// them directly so it works while the shell is down, and the FileView watch +// means an external write repaints the drawer with no polling. It also means +// a mode set from outside still fires its effects, because the watch reaches +// the same handler a tile click would. +Singleton { + id: root + + readonly property string dir: Quickshell.env("XDG_RUNTIME_DIR") || "/tmp" + + readonly property bool dnd: dndFile.value + readonly property bool presentation: presFile.value + + readonly property int activeCount: (root.dnd ? 1 : 0) + (root.presentation ? 1 : 0) + + // Set once by shell.qml. IdleInhibitor does nothing with a null window, + // and the singleton has no window of its own to offer. + property var inhibitWindow: null + + // What dnd was before presentation mode turned it on, so turning + // presentation mode off restores it rather than clearing it. Held here + // rather than in a file: it is meaningful only while presentation mode is + // on, and presentation mode does not survive a reboot. + // + // Known limit: on a shell restart while presentation is already on, + // onPresentationChanged can run before the dnd FileView has loaded, so the + // recorded prior value depends on which file loads first. + property bool dndBeforePresentation: false + + function setMode(name, on) { + if (name === "dnd") { + dndFile.write(on); + } else if (name === "presentation") { + presFile.write(on); + } + } + + function toggleMode(name) { + if (name === "dnd") root.setMode("dnd", !root.dnd); + else if (name === "presentation") root.setMode("presentation", !root.presentation); + } + + // Effects follow the mode rather than the setter, so a mode set by + // statusctl while the drawer is closed asserts them too. + onPresentationChanged: { + if (root.presentation) { + root.dndBeforePresentation = root.dnd; + root.setMode("dnd", true); + root.runBreaktimer("pause"); + } else { + root.setMode("dnd", root.dndBeforePresentation); + root.runBreaktimer("resume"); + } + } + + function runBreaktimer(verb) { + breakProc.command = ["breaktimer.sh", verb]; + breakProc.running = false; + breakProc.running = true; + } + + // breaktimer owns its own state file; this only calls its verbs. Two + // writers on that file would race with its daemon loop, which rewrites it + // on every phase change. + Process { + id: breakProc + onExited: code => { + if (code !== 0) + console.warn("status: breaktimer.sh " + breakProc.command[1] + " exited " + code); + } + } + + // Wayland idle inhibit. The compositor advertises + // zwp_idle_inhibit_manager_v1 and hypridle honours it, so no D-Bus path + // is needed even though elogind runs here. + IdleInhibitor { + window: root.inhibitWindow + enabled: root.presentation && root.inhibitWindow !== null + } + + component ModeFile: FileView { + id: mf + + property bool value: false + + // FileView is documented to fire fileChanged on its own setText, so a + // write would re-enter this handler. Comparing before assigning makes + // that harmless: the reparse yields the value just written and the + // binding does not change. + function reparse() { + const t = mf.text().trim(); + const v = (t === "1"); + if (v !== mf.value) mf.value = v; + } + + function write(on) { + const s = on ? "1\n" : "0\n"; + mf.value = on; + mf.setText(s); + } + + // Both are the documented defaults in 0.3.1, set explicitly because + // the CLI depends on them: statusctl watches close_write,moved_to + // precisely because an atomic write lands as a rename, so a future + // release flipping this default would break the watcher silently. + atomicWrites: true + watchChanges: true + printErrors: false + onFileChanged: mf.reload() + onLoaded: mf.reparse() + // A missing file is the off state, not an error worth logging. + onLoadFailed: mf.value = false + } + + ModeFile { id: dndFile; path: root.dir + "/status.dnd" } + ModeFile { id: presFile; path: root.dir + "/status.presentation" } +} |
