diff options
36 files changed, 8086 insertions, 9 deletions
@@ -1 +1,2 @@ TODO.md +.superpowers/ @@ -181,6 +181,69 @@ changing that component. The ones that generalise: only a runtime `TypeError` in the log and a clean smoke check (the drawer was closed, so nothing was instantiated). Inject an id under a different name: `net`, `bt`, `kc`. +- **Neither scan property self-terminates, and closing the drawer does not stop + them.** `BluetoothAdapter.discovering` is plain BlueZ discovery and + `WifiDevice.scannerEnabled` is a repeating scanner; only `discoverable` and + `pairable` have timeout properties, and the scan ones have none. The scan + button was the sole writer of each, and a drawer close destroys the page + without touching them, so a scan started and forgotten kept the radio busy + for the rest of the session. Both pages now carry a `Timer` bound to the scan + property rather than started by the button, so a manual stop cancels the + clock and reopening the page re-arms a fresh 60s. +- **This machine does run a logind.** `elogind` is running, `pam_elogind.so` is + in the PAM stack for `login`, `sddm`, `xdm` and `kde`, and `loginctl + list-sessions` reports a tracked session on seat0. "Slackware has no logind" + is the obvious assumption and it is wrong; it reached a committed spec once. + The real caveat is narrower: `man 8 pam_elogind` says the module does nothing + if the system was not booted with elogind as its init, which here it is not, + so its documented removal of `$XDG_RUNTIME_DIR` at last logout is not + guaranteed for this configuration. A reboot clears it regardless, because + `/run/user/1000` is tmpfs. +- **hypridle honours the Wayland idle-inhibit protocol here.** The compositor + advertises `zwp_idle_inhibit_manager_v1` (version 1), confirmed with + `wayland-info`, and waybar's built-in `idle_inhibitor` already drives it. So + an idle inhibitor is asserted over Wayland, not D-Bus, and needs no extra + service. `hypridle` has `ignore_dbus_inhibit` and `ignore_systemd_inhibit` + knobs; neither is set in the live config. +- **waybar's built-in `idle_inhibitor` cannot indicate state it does not own.** + It holds its own inhibitor object, and its `activated`/`deactivated` classes + are that object's state, with no input path. It cannot be demoted to an + indicator for a mode owned elsewhere, and left running alongside another + inhibitor it asserts a second, independent one, so idle resumes only when + both release. Replace it with a `custom/` module reading the owning + component's state. +- **A `FileView` that writes the file it watches sees its own write.** + `watchChanges` fires `fileChanged` on `setText()` as well as on an external + change, so a handler that writes in response to a change loops. The status + registry compares the reparsed value against the current one and assigns + only on a difference, which makes the self-write a no-op. +- **`IdleInhibitor` needs a non-null `window`.** It has no window of its own + and does nothing without one. A singleton therefore cannot assert an + inhibitor unaided: `shell.qml` hands it the keepalive `PanelWindow`, which + is the one window that exists for the whole session. +- **A layer-surface idle inhibitor is not observable via `hyprctl clients` or + `hyprctl layers`.** `hyprctl clients | grep -ci inhibit` counts the + per-toplevel `inhibitingIdle` field only, and an inhibitor asserted on a + `PanelWindow` layer surface appears in neither listing, so the count does not + move even when the inhibitor is asserted. Check it behaviorally: run a + throwaway `hypridle -c` with a short timeout whose `on-timeout` writes a + marker, confirm the marker does not appear while presentation is on, and + that it appears within a few seconds of turning presentation off. +- **The notification daemon is a separate process that owns the D-Bus name.** + The quickshell side only reads its published files under + `$XDG_RUNTIME_DIR/notifyd/`; the files are the interface, and `notifyctl` is + the one path back. `notifications/` is inert without it: balloons still + draw, but close and actions do nothing. +- **Suppression is in the balloon shell, not the daemon.** `status.dnd` + withholds low and normal balloons and `notifyd/snooze` withholds all, but + the drawer's reserved space lists everything, because a list the user opened + is not an interruption. +- **A notification appears as a balloon or in the drawer's reserved space, + never both.** The drawer writes `notifyd/drawer` and the balloon shell reads + it; `desktop/NotificationList.qml` is the space. +- **The dead `waybar/modules/custom/notification.jsonc` and + `waybar/scripts/notifications.py` still shell out to `dunstctl`.** They are + not in the live waybar config; do not resurrect them. ## Theme diff --git a/desktop/Drawer.qml b/desktop/Drawer.qml index 4ed37d3..c02a4ba 100644 --- a/desktop/Drawer.qml +++ b/desktop/Drawer.qml @@ -10,6 +10,7 @@ // GNU General Public License for more details. import Quickshell +import Quickshell.Io import Quickshell.Wayland import QtQuick @@ -29,6 +30,25 @@ Scope { // Which module's page is showing. Empty means the grid. property string page: "" + // Set by NotificationList's History button and read by the history view + // loader below; reset when the drawer closes. + property bool history: false + + // The balloons read this to stand down while the drawer holds the space. + FileView { + id: drawerFlag + path: (Quickshell.env("XDG_RUNTIME_DIR") || "/tmp") + "/notifyd/drawer" + atomicWrites: true + printErrors: false + } + + onOpenChanged: drawerFlag.setText(root.open ? "1\n" : "0\n") + + // onOpenChanged does not fire for the initial value, so a restart while + // the drawer was open would leave the flag stale and suppress every + // balloon. Publish the starting state explicitly. + Component.onCompleted: drawerFlag.setText(root.open ? "1\n" : "0\n") + readonly property var screenObj: Quickshell.screens.find(s => s.name === root.monitor) ?? Quickshell.screens[0] @@ -41,6 +61,9 @@ Scope { } function show(name) { + // Deep-linking to a page must leave the history view, or both loaders + // stay active and the later-declared history loader paints on top. + root.history = false; root.page = root.modules.some(m => m.name === name) ? name : ""; root.open = true; } @@ -50,6 +73,7 @@ Scope { // Reset to the grid: a panel that reopens somewhere unexpected is // worse than one extra click. root.page = ""; + root.history = false; } function toggle(name) { @@ -109,7 +133,8 @@ Scope { // to it. focus: true Keys.onEscapePressed: { - if (root.page) root.page = ""; + if (root.history) root.history = false; + else if (root.page) root.page = ""; else root.close(); } @@ -121,18 +146,17 @@ Scope { Item { anchors.fill: parent anchors.margins: 16 - visible: root.page === "" - - // Reserved for the notification engine. An empty Item that - // claims the space rather than a placeholder graphic: the - // grid has to sit where it will sit once notifications - // arrive, or the layout is tuned against a position that - // does not survive. - Item { + visible: root.page === "" && !root.history + + // The notification engine's space. The live queue lists + // here while the drawer is open; the balloons stand down + // because the drawer flag above is set. + NotificationList { id: notifications anchors { top: parent.top; left: parent.left; right: parent.right } anchors.bottom: grid.top anchors.bottomMargin: 16 + onHistory: root.history = true } // Fixed, never scrolled. Three columns at 600px with a @@ -196,6 +220,22 @@ Scope { } Behavior on opacity { NumberAnimation { duration: 160 } } } + + // --- history view --- + + Loader { + id: historyLoader + anchors.fill: parent + anchors.margins: 16 + active: root.history + sourceComponent: Component { + Page { + title: "History" + NotificationHistory { width: parent.width } + } + } + onLoaded: if (item && item.back) item.back.connect(() => root.history = false) + } } } } diff --git a/desktop/NotificationHistory.qml b/desktop/NotificationHistory.qml new file mode 100644 index 0000000..8499192 --- /dev/null +++ b/desktop/NotificationHistory.qml @@ -0,0 +1,70 @@ +// 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. + +import QtQuick + +// The history ring: the last 20 dismissed or evicted notifications, newest +// first, with a clear all. Rows are inert; only clear-all removes them, since +// the daemon has no per-id history deletion. +Column { + id: page + + spacing: 8 + + Item { + width: parent.width + height: 28 + + Text { + anchors { left: parent.left; verticalCenter: parent.verticalCenter } + text: Notify.history.length + " in history" + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } + color: Theme.subtext + } + + Text { + id: clearBtn + anchors { right: parent.right; verticalCenter: parent.verticalCenter } + visible: Notify.history.length > 0 + text: "Clear all" + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } + color: clearArea.containsMouse ? Theme.red : Theme.subtext + + MouseArea { + id: clearArea + anchors.fill: parent + anchors.margins: -6 + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: Notify.clearHistory() + } + } + } + + Repeater { + model: Notify.history + + NotificationRow { + required property var modelData + width: page.width + notification: modelData + live: false + } + } + + Text { + width: parent.width + visible: Notify.history.length === 0 + text: "Nothing in history" + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } + color: Theme.overlay + } +} diff --git a/desktop/NotificationList.qml b/desktop/NotificationList.qml new file mode 100644 index 0000000..9fe4328 --- /dev/null +++ b/desktop/NotificationList.qml @@ -0,0 +1,90 @@ +// 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. + +import QtQuick + +// The drawer's reserved notification space: a header with a History button, +// then the live queue, scrollable because the queue can hold 20 and the grid +// below is fixed. The grid owns its own position, so this space only fills +// what the grid leaves. +Item { + id: list + + signal history + + Column { + anchors.fill: parent + spacing: 6 + + Item { + width: parent.width + height: 28 + + Text { + anchors { left: parent.left; verticalCenter: parent.verticalCenter } + text: "Notifications" + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4; bold: true } + color: Theme.subtext + } + + Text { + id: historyBtn + anchors { right: parent.right; verticalCenter: parent.verticalCenter } + text: "History" + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } + color: historyArea.containsMouse ? Theme.accent : Theme.subtext + + MouseArea { + id: historyArea + anchors.fill: parent + anchors.margins: -6 + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: list.history() + } + } + } + + Flickable { + id: flick + width: parent.width + height: parent.height - 34 + clip: true + contentWidth: width + contentHeight: col.implicitHeight + boundsBehavior: Flickable.StopAtBounds + + Column { + id: col + width: flick.width + spacing: 8 + + Repeater { + model: Notify.queue + NotificationRow { + required property var modelData + width: col.width + notification: modelData + live: true + } + } + + Text { + width: col.width + visible: Notify.queue.length === 0 + text: "No notifications" + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } + color: Theme.overlay + } + } + } + } +} diff --git a/desktop/NotificationRow.qml b/desktop/NotificationRow.qml new file mode 100644 index 0000000..923cc9e --- /dev/null +++ b/desktop/NotificationRow.qml @@ -0,0 +1,126 @@ +// 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. + +import QtQuick + +// One notification in the drawer: app, summary and body. Live rows close with +// the X and click through to their actions; a history row is inert. +Rectangle { + id: row + + radius: 8 + color: Qt.alpha(Theme.surface, 0.6) + border.width: 1 + border.color: Qt.alpha(Theme.text, 0.08) + + required property var notification + // A history row has no live client: no X, and a click does nothing. + property bool live: true + + readonly property bool inert: row.notification.expires !== 0 && Date.now() >= row.notification.expires + + implicitHeight: texts.implicitHeight + 24 + + // "Sep 15 14:32" from the notification's created epoch-ms, local time. No + // year: the history ring is only 20 entries, so everything here is recent. + function stamp(ms) { + const d = new Date(ms); + const months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", + "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; + function pad(n) { return (n < 10 ? "0" : "") + n; } + return months[d.getMonth()] + " " + d.getDate() + " " + + pad(d.getHours()) + ":" + pad(d.getMinutes()); + } + + MouseArea { + anchors.fill: parent + acceptedButtons: Qt.LeftButton | Qt.RightButton + cursorShape: Qt.PointingHandCursor + onClicked: mouse => { + if (mouse.button === Qt.RightButton) { + Notify.closeAll(); + return; + } + if (!row.live) return; + const acts = row.notification.actions || []; + if (acts.length > 0 && !row.inert) Notify.actions(row.notification.id, acts); + else Notify.close(row.notification.id); + } + } + + Column { + id: texts + anchors { + left: parent.left + leftMargin: 12 + right: closeBtn.visible ? closeBtn.left : parent.right + rightMargin: 10 + verticalCenter: parent.verticalCenter + } + spacing: 2 + + Row { + id: head + width: parent.width + spacing: 8 + + Text { + width: head.width - (stamp.visible ? stamp.implicitWidth + head.spacing : 0) + text: (row.notification.app || "") + (row.notification.summary ? " " + row.notification.summary : "") + elide: Text.ElideRight + font { family: Theme.fontFamily; pixelSize: Theme.fontSize + 2 } + color: Theme.text + } + + Text { + id: stamp + visible: row.notification.created > 0 + text: visible ? row.stamp(row.notification.created) : "" + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } + color: Theme.overlay + } + } + + Text { + width: parent.width + visible: text !== "" + text: Notify.sanitize(row.notification.body) + textFormat: Text.RichText + wrapMode: Text.WordWrap + maximumLineCount: 2 + elide: Text.ElideRight + font { family: Theme.fontFamily; pixelSize: Theme.fontSize } + color: Theme.subtext + } + } + + Text { + id: closeBtn + visible: row.live + anchors { right: parent.right; rightMargin: 8; verticalCenter: parent.verticalCenter } + width: 20 + height: 20 + text: "\uf00d" + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + font { family: Theme.iconFamily; pixelSize: 13 } + color: closeArea.containsMouse ? Theme.red : Theme.subtext + + MouseArea { + id: closeArea + anchors.fill: parent + anchors.margins: -6 + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: Notify.close(row.notification.id) + } + } +} diff --git a/desktop/Notify.qml b/desktop/Notify.qml new file mode 120000 index 0000000..635f31d --- /dev/null +++ b/desktop/Notify.qml @@ -0,0 +1 @@ +../shared/Notify.qml
\ No newline at end of file diff --git a/desktop/Status.qml b/desktop/Status.qml new file mode 120000 index 0000000..e7188a3 --- /dev/null +++ b/desktop/Status.qml @@ -0,0 +1 @@ +../shared/Status.qml
\ No newline at end of file diff --git a/desktop/modules/bluetooth/BluetoothPage.qml b/desktop/modules/bluetooth/BluetoothPage.qml index b47c506..5bfdc0b 100644 --- a/desktop/modules/bluetooth/BluetoothPage.qml +++ b/desktop/modules/bluetooth/BluetoothPage.qml @@ -82,6 +82,17 @@ Column { text: page.bt.adapter && page.bt.adapter.discovering ? "Stop scan" : "Scan" onClicked: if (page.bt.adapter) page.bt.adapter.discovering = !page.bt.adapter.discovering } + + // BlueZ discovery runs until stopped, and closing the drawer does not + // stop it, so a forgotten scan keeps the radio busy for the session. + // Bound to the property rather than started by the button: a manual + // stop cancels the clock, and reopening the page re-arms a fresh 60s + // on a scan still running from before. + Timer { + interval: 60000 + running: page.bt.adapter ? page.bt.adapter.discovering : false + onTriggered: if (page.bt.adapter) page.bt.adapter.discovering = false + } } // --- Connected --- diff --git a/desktop/modules/network/NetworkPage.qml b/desktop/modules/network/NetworkPage.qml index 975e68c..3749688 100644 --- a/desktop/modules/network/NetworkPage.qml +++ b/desktop/modules/network/NetworkPage.qml @@ -127,6 +127,15 @@ Column { text: page.net.wifiDevice && page.net.wifiDevice.scannerEnabled ? "Scanning…" : "Scan" onClicked: if (page.net.wifiDevice) page.net.wifiDevice.scannerEnabled = !page.net.wifiDevice.scannerEnabled } + + // The scanner repeats until disabled, and closing the drawer does not + // disable it. A continuous scan also costs throughput on the connected + // link. See the matching timer in BluetoothPage. + Timer { + interval: 60000 + running: page.net.wifiDevice ? page.net.wifiDevice.scannerEnabled : false + onTriggered: if (page.net.wifiDevice) page.net.wifiDevice.scannerEnabled = false + } } Repeater { diff --git a/desktop/modules/status/README.md b/desktop/modules/status/README.md new file mode 100644 index 0000000..84736d5 --- /dev/null +++ b/desktop/modules/status/README.md @@ -0,0 +1,59 @@ +# status + +Desktop modes as state: `dnd` and `presentation`, owned by the `Status` +singleton and stored as files under `$XDG_RUNTIME_DIR`. + +## The files are the interface + + $XDG_RUNTIME_DIR/status.dnd + $XDG_RUNTIME_DIR/status.presentation + +Each holds `0` or `1`; a missing file means off. That directory is tmpfs, so a +reboot resets every mode and there is no cleanup code. A shell restart does +not: the files outlive the process and the singleton reads them back. + +Anything can read a mode with `cat`. `statusctl` is the convenience, not the +mechanism, which is why it keeps working while quickshell is down. + +## statusctl + + statusctl <mode> get prints 0 or 1 + statusctl <mode> set 0|1 + statusctl <mode> toggle + statusctl <mode> watch waybar JSON on every change + +The repo copy is the source; the user installs it to `~/bin`. `watch` watches +the directory rather than the file, because an atomic write replaces the file +and a watch on the old inode dies with it. + +Setting a mode with `statusctl` records the state without firing its effects. +The shell sees the change through its own `FileView` watch and asserts them, +so the effects follow either way. If the shell is down, the state is recorded +and reasserted when it returns. + +## Effects + +`dnd` has none of its own. It is state the notification daemon reads. + +`presentation` sets `dnd`, asserts a Wayland idle inhibitor, and pauses +breaktimer. Turning it off restores `dnd` to the value it had before rather +than clearing it, so hand-set DND survives a presentation. + +breaktimer owns `$XDG_RUNTIME_DIR/breaktimer.state`. This module calls +`breaktimer.sh pause|resume` and never writes that file: its daemon loop +rewrites it on every phase change, and two writers would race. + +## Waybar + +`custom/presentation` reads `statusctl presentation watch`. It replaces +waybar's built-in `idle_inhibitor`, which cannot be kept alongside it: that +module owns its own inhibitor object, so both would have to be released +before the screen could lock. + +## The check + + ./test-statusctl.sh + +Points `XDG_RUNTIME_DIR` at a temporary directory, so it never touches live +modes. Covers the file format, the atomic write, the toggle, the unknown-mode +error, both the activated report and the absent-file down report. diff --git a/desktop/modules/status/SnoozeRow.qml b/desktop/modules/status/SnoozeRow.qml new file mode 100644 index 0000000..046d7cb --- /dev/null +++ b/desktop/modules/status/SnoozeRow.qml @@ -0,0 +1,114 @@ +// 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. + +import Quickshell +import Quickshell.Io +import QtQuick +import QtQuick.Controls +import "../.." + +// A switch plus a minutes field. On snoozes for the typed minutes through +// notify-snooze.sh; off clears. The last used value is persisted by the +// script, so the field prefills from it. +Item { + id: row + + // A tick so the switch reflects the snooze ending on its own. + property double now: Date.now() + Timer { + interval: 1000 + running: true + repeat: true + onTriggered: row.now = Date.now() + } + + readonly property bool active: Notify.snoozeUntil > row.now + + implicitHeight: Math.max(texts.implicitHeight, sw.implicitHeight) + 16 + + function apply(on) { + if (on) { + const n = parseInt(minutes.text, 10); + snooze.command = ["notify-snooze.sh", String(isNaN(n) || n < 1 ? 30 : n)]; + } else { + snooze.command = ["notify-snooze.sh", "off"]; + } + snooze.running = false; + snooze.running = true; + } + + Process { id: snooze } + + FileView { + id: lastUsed + path: `${Quickshell.env("HOME")}/.local/state/notify-snooze.minutes` + printErrors: false + onLoaded: minutes.text = text().trim() + onLoadFailed: minutes.text = "30" + } + + Column { + id: texts + anchors { + left: parent.left + right: controls.left + rightMargin: 12 + verticalCenter: parent.verticalCenter + } + spacing: 2 + + Text { + text: "Snooze" + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2; bold: true } + color: Theme.text + } + + Text { + width: parent.width + wrapMode: Text.WordWrap + text: row.active ? "All notification balloons are held until snooze ends." + : "Hold every notification balloon for a number of minutes." + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } + color: Theme.subtext + } + } + + Row { + id: controls + anchors { right: parent.right; verticalCenter: parent.verticalCenter } + spacing: 8 + + TextField { + id: minutes + width: 48 + height: 28 + text: "30" + horizontalAlignment: TextInput.AlignHCenter + color: Theme.text + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } + background: Rectangle { + radius: 6 + color: Qt.alpha(Theme.surface, 0.9) + border.width: 1 + border.color: Qt.alpha(Theme.text, 0.15) + } + validator: IntValidator { bottom: 1; top: 1440 } + } + + Switch { + id: sw + checked: row.active + onToggled: row.apply(!row.active) + } + } + + onNowChanged: sw.checked = row.active +} diff --git a/desktop/modules/status/StatusModule.qml b/desktop/modules/status/StatusModule.qml new file mode 100644 index 0000000..30f8557 --- /dev/null +++ b/desktop/modules/status/StatusModule.qml @@ -0,0 +1,39 @@ +// 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. + +import QtQuick +import "../.." + +// Always active: the singleton holds the modes and their effects, and those +// have to be asserted whether or not anyone has opened the drawer. The module +// itself is thin, a tile and a page over Status. +Module { + id: mod + + name: "status" + label: "Status" + alwaysActive: true + + // A toggle glyph, present in Inconsolata Nerd Font. + icon: "\uf205" + + // The tile renders in its accent while any mode is on. + active: Status.activeCount > 0 + + tileContent: Component { StatusTile {} } + + page: Component { + Page { + title: "Status" + StatusPage { width: parent.width } + } + } +} diff --git a/desktop/modules/status/StatusPage.qml b/desktop/modules/status/StatusPage.qml new file mode 100644 index 0000000..2fbdc9b --- /dev/null +++ b/desktop/modules/status/StatusPage.qml @@ -0,0 +1,53 @@ +// 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. + +import QtQuick +import "../.." + +// One row per mode. Adding a mode is one file in the singleton and one row +// here, which is the point of a registry rather than two toggles. +Column { + id: page + + spacing: 4 + + StatusRow { + width: page.width + mode: "dnd" + label: "Do not disturb" + description: "Silences notification popups. Critical ones still appear." + value: Status.dnd + } + + Rectangle { + width: page.width + height: 1 + color: Qt.alpha(Theme.text, 0.08) + } + + StatusRow { + width: page.width + mode: "presentation" + label: "Presentation" + description: "Do not disturb, no screen lock, breaktimer paused." + value: Status.presentation + } + + Rectangle { + width: page.width + height: 1 + color: Qt.alpha(Theme.text, 0.08) + } + + SnoozeRow { + width: page.width + } +} diff --git a/desktop/modules/status/StatusRow.qml b/desktop/modules/status/StatusRow.qml new file mode 100644 index 0000000..c9e78c0 --- /dev/null +++ b/desktop/modules/status/StatusRow.qml @@ -0,0 +1,61 @@ +// 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. + +import QtQuick +import "../.." + +// One mode: a label, a line saying what it does, and a switch. +Item { + id: row + + required property string mode + required property string label + required property string description + required property bool value + + implicitHeight: Math.max(texts.implicitHeight, sw.implicitHeight) + 16 + + Column { + id: texts + anchors { + left: parent.left + right: sw.left; rightMargin: 12 + verticalCenter: parent.verticalCenter + } + spacing: 2 + + Text { + text: row.label + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2; bold: true } + color: Theme.text + } + + Text { + width: parent.width + wrapMode: Text.WordWrap + text: row.description + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } + color: Theme.subtext + } + } + + Switch { + id: sw + anchors { right: parent.right; verticalCenter: parent.verticalCenter } + checked: row.value + onToggled: Status.toggleMode(row.mode) + } + + // The shared Switch's click handler writes `checked` directly, which drops + // the declarative binding above. Resync on any value change so an external + // write (statusctl, waybar) moves the switch back into agreement. + onValueChanged: sw.checked = row.value +} diff --git a/desktop/modules/status/StatusTile.qml b/desktop/modules/status/StatusTile.qml new file mode 100644 index 0000000..c4b87d4 --- /dev/null +++ b/desktop/modules/status/StatusTile.qml @@ -0,0 +1,23 @@ +// 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. + +import QtQuick +import "../.." + +Text { + width: parent ? parent.width : implicitWidth + elide: Text.ElideRight + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } + color: Status.activeCount > 0 ? Theme.text : Theme.subtext + text: Status.presentation ? "Presenting" + : Status.dnd ? "Do not disturb" + : "All clear" +} diff --git a/desktop/modules/status/statusctl b/desktop/modules/status/statusctl new file mode 100755 index 0000000..6fbb689 --- /dev/null +++ b/desktop/modules/status/statusctl @@ -0,0 +1,132 @@ +#!/bin/bash +# +# 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. +# +# Read, set and watch desktop modes. The modes are files under +# XDG_RUNTIME_DIR holding 0 or 1; a missing file means off. +# +# This talks to the files, not to the shell, so it works while quickshell is +# down. Setting a mode that way records the state without firing its effects; +# the shell sees the change through its own watch and reasserts them. +# +# statusctl <mode> get prints 0 or 1 +# statusctl <mode> set 0|1 +# statusctl <mode> toggle +# statusctl <mode> watch waybar JSON on every change + +set -u + +MODES="dnd presentation" +DIR="${XDG_RUNTIME_DIR:-/tmp}" + +usage() { + printf 'usage: %s <%s> <get|set 0|1|toggle|watch>\n' \ + "${0##*/}" "$(printf '%s' "$MODES" | tr ' ' '|')" >&2 + exit 1 +} + +[[ $# -ge 2 ]] || usage + +mode="$1" +action="$2" + +# A typo must fail loudly rather than read as a mode that happens to be off. +case " $MODES " in + *" $mode "*) ;; + *) printf '%s: unknown mode: %s\n' "${0##*/}" "$mode" >&2; exit 1 ;; +esac + +file="$DIR/status.$mode" + +read_mode() { + local v="" + # A missing file is the normal state before anything has written one and + # reads as off. Guarding on existence keeps the shell's redirection error + # off stderr: `2>/dev/null` on the command cannot suppress a failure of + # its own input redirect. + [[ -e "$file" ]] && v="$(tr -d '[:space:]' < "$file" 2>/dev/null)" + [[ "$v" == "1" ]] && printf '1' || printf '0' +} + +# Write through a temporary file and rename, so no reader ever sees a +# half-written value. This is also what FileView does on the QML side, and it +# is why a watcher has to listen for moved_to as well as close_write. +write_mode() { + local want="$1" tmp + tmp="$(mktemp "$DIR/.status.$mode.XXXXXX")" || exit 1 + printf '%s\n' "$want" > "$tmp" + # The temp file is made in the same directory as the target, so this is a + # rename rather than a copy, and therefore atomic. A failure here has to + # be loud: reporting success on a write that did not land would leave the + # caller and the shell disagreeing about the mode, with an orphan temp + # file as the only trace. + mv -f "$tmp" "$file" || { rm -f "$tmp"; exit 1; } +} + +emit() { + local state="$1" + printf '{"text": "", "alt": "%s", "class": "%s", "tooltip": "%s"}\n' \ + "$state" "$state" "$(tooltip "$state")" +} + +tooltip() { + case "$1" in + activated) printf '%s: on' "$mode" ;; + deactivated) printf '%s: off' "$mode" ;; + down) printf '%s: no state file' "$mode" ;; + esac +} + +state_now() { + [[ -e "$file" ]] || { printf 'down'; return; } + [[ "$(read_mode)" == "1" ]] && printf 'activated' || printf 'deactivated' +} + +case "$action" in + get) + read_mode + printf '\n' + ;; + set) + [[ $# -eq 3 ]] || usage + case "$3" in + 0|1) write_mode "$3" ;; + *) usage ;; + esac + ;; + toggle) + [[ "$(read_mode)" == "1" ]] && write_mode 0 || write_mode 1 + ;; + watch) + emit "$(state_now)" + # Watch the directory rather than the file: an atomic write replaces + # the file, so a watch held on the old inode dies with it. This is the + # same trap the mail watcher hit with Xapian. + # + # inotifywait must die with us. Piped straight into the while loop it + # would be a pipeline sibling, not a child, so a plain kill on this + # process (which is exactly how waybar stops and respawns its exec + # children on every reload) leaves it running, watching a directory + # nobody reads anymore. Process substitution makes it a real child + # whose PID we can hold and kill from a trap. + exec 3< <(inotifywait -q -m -e close_write,moved_to,delete --format '%f' "$DIR" 2>/dev/null) + watcher=$! + trap 'kill "$watcher" 2>/dev/null' EXIT TERM INT + while read -r changed <&3; do + [[ "$changed" == "status.$mode" ]] || continue + emit "$(state_now)" + done + ;; + *) + usage + ;; +esac diff --git a/desktop/modules/status/test-statusctl.sh b/desktop/modules/status/test-statusctl.sh new file mode 100755 index 0000000..3b8f7ba --- /dev/null +++ b/desktop/modules/status/test-statusctl.sh @@ -0,0 +1,87 @@ +#!/bin/bash +# +# 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. +# +# The one runnable check for statusctl. It points XDG_RUNTIME_DIR at a +# temporary directory, so nothing here touches the live modes. +# +# Usage: ./test-statusctl.sh (exit 0 = all passed) + +set -u + +here="$(cd "$(dirname "$0")" && pwd)" +ctl="$here/statusctl" +tmp="$(mktemp -d)" +trap 'rm -rf "$tmp"' EXIT +export XDG_RUNTIME_DIR="$tmp" + +pass=0 +fail=0 + +check() { + local label="$1" want="$2" got="$3" + if [[ "$want" == "$got" ]]; then + printf 'ok %s\n' "$label" + pass=$((pass + 1)) + else + printf 'FAIL %s: want %q, got %q\n' "$label" "$want" "$got" + fail=$((fail + 1)) + fi +} + +# A mode with no file reads as off. +check "missing file reads 0" "0" "$("$ctl" dnd get)" + +# set writes the file and get reads it back. +"$ctl" dnd set 1 +check "set 1 writes the file" "1" "$(cat "$tmp/status.dnd" | tr -d '[:space:]')" +check "get after set 1" "1" "$("$ctl" dnd get)" + +# toggle flips it. +"$ctl" dnd toggle +check "toggle from 1" "0" "$("$ctl" dnd get)" +"$ctl" dnd toggle +check "toggle from 0" "1" "$("$ctl" dnd get)" + +# set 0 writes rather than removing, so a reader sees an explicit off. +"$ctl" dnd set 0 +check "set 0 writes the file" "0" "$("$ctl" dnd get)" + +# An unknown mode is an error, not a silent success: a typo must not look +# like a mode that is off. +"$ctl" nosuch get >/dev/null 2>&1 +check "unknown mode exits non-zero" "1" "$?" + +# watch prints a line on change, and the class reflects the value. The +# atomic write arrives as a rename, which is why the watch needs moved_to. +out="$tmp/watch.out" +"$ctl" presentation watch > "$out" 2>/dev/null & +watcher=$! +sleep 0.3 +"$ctl" presentation set 1 +sleep 0.5 +kill "$watcher" 2>/dev/null +wait "$watcher" 2>/dev/null +check "watch reports activated" "1" "$(grep -c '"class": *"activated"' "$out")" + +# A missing file is reported as down, distinct from a mode that is off. +rm -f "$tmp/status.presentation" +out2="$tmp/watch2.out" +"$ctl" presentation watch > "$out2" 2>/dev/null & +watcher2=$! +sleep 0.5 +kill "$watcher2" 2>/dev/null +wait "$watcher2" 2>/dev/null +check "watch reports down when absent" "1" "$(grep -c '"class": *"down"' "$out2")" + +printf '\n%d passed, %d failed\n' "$pass" "$fail" +[[ "$fail" -eq 0 ]] diff --git a/desktop/shell.qml b/desktop/shell.qml index 6a37827..1149124 100644 --- a/desktop/shell.qml +++ b/desktop/shell.qml @@ -12,18 +12,25 @@ import Quickshell import Quickshell.Io import Quickshell.Wayland +import QtQuick import "modules/appearance" import "modules/bluetooth" import "modules/kdeconnect" import "modules/mail" import "modules/network" import "modules/sound" +import "modules/status" import "modules/vm" ShellRoot { // Quickshell exits once no window is visible, and the drawer is closed // most of the time. See AGENTS.md. + // + // It is also the window the idle inhibitor attaches to: IdleInhibitor + // needs a non-null window, and this is the one window that exists for + // the whole life of the shell. PanelWindow { + id: keepalive visible: true implicitWidth: 1 implicitHeight: 1 @@ -31,6 +38,9 @@ ShellRoot { exclusionMode: ExclusionMode.Ignore mask: Region {} WlrLayershell.keyboardFocus: WlrKeyboardFocus.None + + // The singleton has no window of its own and IdleInhibitor needs one. + Component.onCompleted: Status.inhibitWindow = keepalive } Drawer { @@ -42,6 +52,7 @@ ShellRoot { KdeConnectModule {}, MailModule {}, AppearanceModule {}, + StatusModule {}, VmModule {}, ] } diff --git a/docs/superpowers/plans/2026-09-15-notification-image-renderers.md b/docs/superpowers/plans/2026-09-15-notification-image-renderers.md new file mode 100644 index 0000000..660949e --- /dev/null +++ b/docs/superpowers/plans/2026-09-15-notification-image-renderers.md @@ -0,0 +1,218 @@ +# Notification Image Renderers Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** The balloon draws a notification's content image as a large preview below the text, and the RichText body never fetches a remote image. + +**Architecture:** `NotificationBalloon` gains one `Image` bound to the new `notifyd` contract field `image`, sized to the balloon width and capped in height. Inline `<img>` already renders through the existing RichText body; a small sanitizer in the `Notify` singleton strips remote sources before display, so the shell cannot be made to fetch a URL. + +**Tech Stack:** Quickshell 0.3.1, Qt6 QML, `Quickshell.Io.FileView`, `Quickshell.Io.Process`. + +**Spec:** `docs/superpowers/specs/2026-09-15-notification-images-design.md` (read it before starting). The daemon half is a separate plan and must ship for the image path to be exercised; this plan tolerates a daemon without the field. + +## Global Constraints + +- Quickshell 0.3.1, Qt6 QML. Run a config with `qs -p <dir>`. The running process is `qs`: `pkill -x qs`, `pgrep -cx qs`, never `pkill -f`. +- GPLv2 only. Existing headers stay; no new source file in this plan needs one. +- `image` may be absent on an older daemon, so every read guards `!== undefined`. +- Inline images are local only: a `<img>` whose `src` is `http:` or `https:` is stripped before display. Local paths and `file://` are left alone. +- No em dashes. No home paths in committed files. +- Smoke check, harness owns the process: + +```bash +timeout 8 qs -p <dir> 2>&1 | grep -E 'ERROR|TypeError|ReferenceError|is not defined|Cannot assign|Unable to assign' && echo "ERRORS ABOVE" || echo "clean" +``` + +--- + +## File Structure + + notifications/NotificationBalloon.qml the large preview image (modify) + shared/Notify.qml the sanitizer for inline sources (modify) + desktop/NotificationRow.qml use the sanitizer for the row body (modify) + +--- + +### Task 1: The balloon image preview + +**Files:** +- Modify: `notifications/NotificationBalloon.qml` + +**Interfaces:** +- Consumes: the daemon's `image` field (`notification.image`, a path string or undefined). +- Produces: nothing consumed by later tasks. + +- [ ] **Step 1: Make the balloon height account for the image** + +In `notifications/NotificationBalloon.qml`, change: + +```qml + implicitHeight: texts.implicitHeight + 20 +``` + +to: + +```qml + implicitHeight: texts.implicitHeight + 20 + (preview.visible ? preview.height + 8 : 0) +``` + +- [ ] **Step 2: Add the preview image** + +Insert this block immediately after the closing `}` of the `Column { id: texts ... }` +and before the `Text { id: close ... }`: + +```qml + // The content image (a screenshot or an app-provided image), below the + // text. The daemon writes the path; an older daemon without the field + // leaves this hidden. The height matches the scaled width so + // PreserveAspectFit does not letterbox, and a tall screenshot is capped at + // 240px. Asynchronous so a large screenshot does not stall the shell. + Image { + id: preview + visible: b.notification.image !== "" && b.notification.image !== undefined + anchors { + left: parent.left + right: parent.right + top: texts.bottom + leftMargin: 10 + rightMargin: 10 + topMargin: 8 + } + height: visible && implicitWidth > 0 + ? Math.min(width * implicitHeight / implicitWidth, 240) + : 0 + source: visible ? "file://" + b.notification.image : "" + fillMode: Image.PreserveAspectFit + asynchronous: true + } +``` + +- [ ] **Step 3: Smoke check** + +```bash +timeout 8 qs -p ./notifications 2>&1 | grep -E 'ERROR|TypeError|ReferenceError|is not defined|Cannot assign|Unable to assign' && echo "ERRORS ABOVE" || echo "clean" +``` + +Expected: `clean`. The running daemon currently publishes no `image` field, so +this also proves the `undefined` guard holds: nothing new is drawn. + +- [ ] **Step 4: Confirm by hand, once the daemon plan has shipped** + +Ask the user to send: + +```bash +notify-send -u critical -t 30000 -i ~/.cache/opencode/packages/@mohak34/opencode-notifier@latest/node_modules/@mohak34/opencode-notifier/logos/opencode-logo-dark.png "preview" "the logo should fill the balloon width" +``` + +Expected: a balloon with the logo as a large image below the text, undistorted and capped in height. A grimblast screenshot (`notify-send -i <screenshot>`) behaves the same. + +- [ ] **Step 5: Commit** + +```bash +git add notifications/NotificationBalloon.qml +git commit -m "feat(notifications): draw the content image in the balloon + +The daemon now publishes an image path; the balloon shows it below the +text, scaled to the balloon width with a 240px cap. A daemon without the +field leaves it hidden, so the renderer and the daemon can ship in +either order." +``` + +--- + +### Task 2: Strip remote inline image sources + +**Files:** +- Modify: `shared/Notify.qml` +- Modify: `notifications/NotificationBalloon.qml` +- Modify: `desktop/NotificationRow.qml` + +**Interfaces:** +- Consumes: nothing. +- Produces: `Notify.sanitize(body)` returning the body with remote `<img>` tags removed. + +- [ ] **Step 1: Add the sanitizer** + +In `shared/Notify.qml`, add this function beside `run`/`close`: + +```qml + // Inline images are local only. A notification is untrusted input, and a + // remote <img src> would otherwise make the shell fetch a URL, which leaks + // that the notification was shown. This removes such tags before the + // RichText body renders; a local path or file:// source is left alone. + function sanitize(body) { + return (body || "").replace(/<img\b[^>]*\bsrc\s*=\s*["']?\s*https?:\/\/[^>]*>/gi, ""); + } +``` + +- [ ] **Step 2: Use it in both renderers** + +In `notifications/NotificationBalloon.qml`, change the body text: + +```qml + text: b.notification.body || "" +``` + +to: + +```qml + text: Notify.sanitize(b.notification.body) +``` + +In `desktop/NotificationRow.qml`, change: + +```qml + text: row.notification.body || "" +``` + +to: + +```qml + text: Notify.sanitize(row.notification.body) +``` + +The balloon already resolves `Notify`; the row does too, through the +`desktop/Notify.qml` symlink. + +- [ ] **Step 3: Smoke check both configs** + +```bash +timeout 8 qs -p ./notifications 2>&1 | grep -E 'ERROR|TypeError|ReferenceError|is not defined|Cannot assign|Unable to assign' && echo "ERRORS ABOVE" || echo "clean" +timeout 8 qs -p ./desktop 2>&1 | grep -E 'ERROR|TypeError|ReferenceError|is not defined|Cannot assign|Unable to assign' && echo "ERRORS ABOVE" || echo "clean" +``` + +Expected: both `clean`. + +- [ ] **Step 4: Confirm by hand** + +Ask the user to send both, with the logo path from Task 1: + +```bash +notify-send -u critical -t 30000 "inline local" "above<br><img src='file://<logo-path>' width='200'><br>below" +notify-send -u critical -t 30000 "inline remote" "above<br><img src='https://example.org/does-not-exist.png' width='200'><br>below" +``` + +Expected: the first shows the image inline between the lines of text. The +second shows only the text, with no image and no network request. + +- [ ] **Step 5: Commit** + +```bash +git add shared/Notify.qml notifications/NotificationBalloon.qml desktop/NotificationRow.qml +git commit -m "feat(notifications): strip remote inline image sources + +A notification is untrusted input. Inline <img> now renders only for +local sources; an http(s) source is removed before the RichText body is +shown, so a remote sender cannot make the shell fetch a URL. The row and +the balloon share the one sanitizer in the Notify singleton." +``` + +--- + +## Self-Review + +**Spec coverage:** the balloon large preview (Task 1) and the local-only inline policy (Task 2) are the renderer half of the spec. The drawer row correctly gets no image. App-icon theme names are resolved daemon-side, so the renderer is unchanged there. + +**Placeholder scan:** none; every step carries its code. + +**Type consistency:** `Notify.sanitize(body)` is defined in Task 2 and used by both renderers; `notification.image` is read as a string path in Task 1. diff --git a/docs/superpowers/plans/2026-09-15-notification-renderers.md b/docs/superpowers/plans/2026-09-15-notification-renderers.md new file mode 100644 index 0000000..fdcbe7a --- /dev/null +++ b/docs/superpowers/plans/2026-09-15-notification-renderers.md @@ -0,0 +1,1380 @@ +# Notification Renderers Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** The quickshell side of the notification daemon: balloon popups, the drawer's reserved notification space and history page, and the Status page snooze row. + +**Architecture:** `shared/Notify.qml` reads the daemon's published files (`queue.json`, `history.json`, `drawer`, `snooze`) and is the one place both renderers share; mutations go through `notifyctl` over a `Process`. A new `notifications/` component draws balloons bottom-right of `DP-1` over conky. The desktop drawer's existing reserved `Item` is filled with a scrollable live list plus a History button, and the Status page gains a snooze row. Nothing here talks D-Bus; the files are the interface, the same convention the status registry set. + +**Tech Stack:** Quickshell 0.3.1, Qt6 QML, `Quickshell.Io.FileView`, `Quickshell.Io.Process`, `notifyctl` and `notify-snooze.sh` in `~/bin`. + +**Spec:** `docs/superpowers/specs/2026-09-15-notification-daemon-design.md`. Plan 1 (the daemon, in the `notifyd` repo) is a prerequisite and is shipped. + +## Global Constraints + +- Quickshell 0.3.1, Qt6 QML. Run a config with `qs -p <dir>`. The running process is `qs`: `pkill -x qs`, `pgrep -cx qs`, never `pkill -f`. +- GPLv2 only. Every new `.qml` file begins with this exact header: + +```qml +// 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. +``` + +Shell scripts use the same notice with `#` markers after the shebang. + +- A component with no always-visible window exits. Every new component (`notifications/`) holds itself open with a 1x1 transparent `PanelWindow` with `mask: Region {}`. See AGENTS.md. +- The daemon's published JSON is the contract. `created` and `expires` are epoch milliseconds, `0` meaning never; `actions` is an array of `[key, label]` pairs; `urgency` is `"low"`, `"normal"` or `"critical"`. +- `$XDG_RUNTIME_DIR/notifyd/` holds `queue.json`, `history.json`, (written by the daemon), `drawer` (written by the drawer) and `snooze` (written by `notify-snooze.sh`). +- Suppression lives here, not in the daemon: balloons are withheld by `dnd` (low and normal only) and by snooze (all). The drawer's reserved space lists everything. +- Reusing a `Process` needs `running = false` immediately before `running = true`. +- No em dashes. No home paths in committed files; `~` in documentation only. Nerd Font glyphs are written as `\uXXXX` and their bytes verified with `git diff`. +- Smoke check, harness owns the process and reads the log, never a later `pgrep`: + +```bash +timeout 8 qs -p <dir> 2>&1 | grep -E 'ERROR|TypeError|ReferenceError|is not defined|Cannot assign|Unable to assign' && echo "ERRORS ABOVE" || echo "clean" +``` + +Expected: `clean`. + +## Verified Facts (from Plan 1 and this repo) + +- The daemon is installed as `~/bin/notifyd` and is running; `notifyctl` and `notify-snooze.sh` are in `~/bin`. `notifyctl list` prints the live queue as JSON. +- `expire_timeout` direction is the freedesktop one: `-1` takes the urgency default, `0` means never. +- `Theme` carries `base surface text subtext red green yellow surfaceAlt overlay accent`, plus `fontFamily`, `fontSize` (16) and `iconFamily`. +- `Drawer.qml:131` already has an empty `Item { id: notifications }` documented as "Reserved for the notification engine", anchored above the grid inside the grid view. +- `Status.qml` (the registry singleton) is symlinked into `desktop/`; `Status.dnd` and `Status.presentation` are booleans. +- The Hyprland blur rules live in `~/.config/hypr/sections/decorations.lua`; each quickshell layer needs its own rule matched on its namespace. +- `custom/notification.jsonc` and `waybar/scripts/notifications.py` reference `dunstctl` but are not in the live waybar config; they are dead and not part of this plan. + +--- + +## File Structure + + shared/Notify.qml the shared singleton (create) + desktop/Notify.qml symlink to it (create) + notifications/ the balloon component (create) + shell.qml ShellRoot, keepalive, Balloons + Balloons.qml the stack and the suppression filter + NotificationBalloon.qml one balloon + notify-actions.sh the rofi action picker + Theme.qml, Status.qml, Notify.qml symlinks into ../shared (create) + README.md component notes (create) + desktop/Drawer.qml reserved space, history view, drawer flag (modify) + desktop/NotificationList.qml the reserved space's list (create) + desktop/NotificationRow.qml one row, live or history (create) + desktop/NotificationHistory.qml the history page body (create) + desktop/modules/status/SnoozeRow.qml the snooze control (create) + desktop/modules/status/StatusPage.qml add the snooze row (modify) + AGENTS.md traps (modify) + +--- + +### Task 1: The shared Notify singleton + +**Files:** +- Create: `shared/Notify.qml` +- Create: `desktop/Notify.qml` (symlink) + +**Interfaces:** +- Consumes: `Quickshell.Io.FileView`, `Quickshell.Io.Process`, `notifyctl` on PATH. +- Produces: singleton `Notify` with `readonly property var queue`, `readonly property var history`, `readonly property bool drawerOpen`, `readonly property double snoozeUntil`, and the functions `close(id)`, `closeAll()`, `action(id, key)`, `actions(id, pairs)`, `clearHistory()`. Every later task uses these. + +- [ ] **Step 1: Write `shared/Notify.qml`** + +```qml +// 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 + + 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"]); } + 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; printErrors: false } + Process { id: actProc; printErrors: false } + + 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: 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 + } +} +``` + +- [ ] **Step 2: Symlink it into the drawer** + +```bash +ln -s ../shared/Notify.qml desktop/Notify.qml +ls -l desktop/Notify.qml +``` + +Expected: `desktop/Notify.qml -> ../shared/Notify.qml`. + +- [ ] **Step 3: Smoke check that the singleton parses** + +```bash +timeout 8 qs -p ./desktop 2>&1 | grep -E 'ERROR|TypeError|ReferenceError|is not defined|Cannot assign|Unable to assign' && echo "ERRORS ABOVE" || echo "clean" +``` + +Expected: `clean`. The daemon is running, so the files exist; nothing references the singleton yet. + +- [ ] **Step 4: Commit** + +```bash +git add shared/Notify.qml desktop/Notify.qml +git commit -m "feat(desktop): add the Notify singleton + +The daemon publishes its queue, history, drawer flag and snooze as files; +this reads them for both renderers, the same files-are-the-interface +convention the status registry set. Mutations and the rofi action picker run +notifyctl through a Process, which is the one path back to the daemon." +``` + +--- + +### Task 2: The balloon shell + +**Files:** +- Create: `notifications/shell.qml` +- Create: `notifications/Balloons.qml` +- Create: `notifications/NotificationBalloon.qml` +- Create: `notifications/Theme.qml`, `notifications/Status.qml`, `notifications/Notify.qml` (symlinks) +- Create: `notifications/README.md` + +**Interfaces:** +- Consumes: `Notify`, `Status`, `Theme`. +- Produces: a running component that draws one balloon per unsuppressed live notification, bottom-right of `DP-1`. Task 3 adds interaction; this task draws. + +- [ ] **Step 1: Create the component directory and its symlinks** + +```bash +mkdir -p notifications +ln -s ../shared/Theme.qml notifications/Theme.qml +ln -s ../shared/Status.qml notifications/Status.qml +ln -s ../shared/Notify.qml notifications/Notify.qml +ls -l notifications/ +``` + +Expected: three symlinks into `../shared`. + +- [ ] **Step 2: Write `notifications/shell.qml`** + +```qml +// 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. + +import Quickshell +import Quickshell.Wayland + +ShellRoot { + // Quickshell exits once no window is visible, and the balloons are + // hidden whenever the queue is empty, so this keeps the shell alive. See + // AGENTS.md. + PanelWindow { + visible: true + implicitWidth: 1 + implicitHeight: 1 + color: "transparent" + exclusionMode: ExclusionMode.Ignore + mask: Region {} + WlrLayershell.keyboardFocus: WlrKeyboardFocus.None + } + + Balloons {} +} +``` + +- [ ] **Step 3: Write `notifications/Balloons.qml`** + +```qml +// 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. + +import Quickshell +import Quickshell.Wayland +import QtQuick + +// The balloon stack, bottom-right of DP-1, over conky. +Scope { + id: root + + // A tick drives both expiry and the end of a snooze without waiting for a + // file change. 250ms keeps ronema's -t 1 near-instant. + property double now: Date.now() + Timer { + interval: 250 + running: true + repeat: true + onTriggered: root.now = Date.now() + } + + // Which live notifications draw here. Suppression is deliberately here + // and not in the daemon: the drawer lists a notification DND chose not to + // pop, because a list the user opened is not an interruption. + readonly property var visible: (Notify.queue || []).filter(p => { + if (Notify.drawerOpen) return false; + if (p.expires !== 0 && root.now >= p.expires) return false; + if (Notify.snoozeUntil > root.now) return false; + if (Status.dnd && p.urgency !== "critical") return false; + return true; + }) + + PanelWindow { + id: win + + visible: root.visible.length > 0 + screen: Quickshell.screens.find(s => s.name === "DP-1") ?? Quickshell.screens[0] + anchors { bottom: true; right: true } + margins { bottom: 12; right: 12 } + implicitWidth: 340 + implicitHeight: column.implicitHeight + color: "transparent" + exclusionMode: ExclusionMode.Ignore + WlrLayershell.layer: WlrLayer.Overlay + WlrLayershell.namespace: "quickshell-notifications" + WlrLayershell.keyboardFocus: WlrKeyboardFocus.None + + Column { + id: column + width: parent.width + anchors { bottom: parent.bottom; right: parent.right } + spacing: 8 + + Repeater { + model: root.visible + NotificationBalloon { notification: modelData } + } + } + } +} +``` + +- [ ] **Step 4: Write `notifications/NotificationBalloon.qml`** + +```qml +// 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. + +import QtQuick + +// One balloon: icon at the left, app, summary and body, an X, and the click +// targets. The body is markup, which is why the daemon advertises body-markup. +Rectangle { + id: b + + required property var notification + + width: parent ? parent.width : 340 + implicitHeight: texts.implicitHeight + 20 + radius: 10 + color: Qt.alpha(Theme.base, 0.82) + border.width: 1 + border.color: Qt.alpha(Theme.text, 0.12) + + // The background click target is declared first so the X, declared later, + // sits above it and wins its corner. + MouseArea { + anchors.fill: parent + acceptedButtons: Qt.LeftButton | Qt.RightButton + cursorShape: Qt.PointingHandCursor + onClicked: mouse => { + if (mouse.button === Qt.RightButton) { + Notify.closeAll(); + return; + } + const acts = b.notification.actions || []; + const inert = b.notification.expires !== 0 && Date.now() >= b.notification.expires; + if (acts.length > 0 && !inert) Notify.actions(b.notification.id, acts); + else Notify.close(b.notification.id); + } + } + + Image { + id: icon + visible: b.notification.icon !== "" && b.notification.icon !== undefined + anchors { left: parent.left; top: parent.top; margins: 10 } + width: 32 + height: 32 + source: visible ? "file://" + b.notification.icon : "" + sourceSize { width: 64; height: 64 } + } + + Text { + id: close + anchors { right: parent.right; top: parent.top; margins: 6 } + width: 20 + height: 20 + text: "\uf00d" + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + font { family: Theme.iconFamily; pixelSize: 11 } + color: closeArea.containsMouse ? Theme.red : Theme.subtext + + MouseArea { + id: closeArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: Notify.close(b.notification.id) + } + } + + Column { + id: texts + anchors { + left: icon.visible ? icon.right : parent.left + leftMargin: 10 + right: parent.right + rightMargin: 10 + top: parent.top + topMargin: 10 + } + spacing: 2 + + Text { + width: parent.width + text: b.notification.app || "" + elide: Text.ElideRight + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4; bold: true } + color: Theme.subtext + } + + Text { + width: parent.width + text: b.notification.summary || "" + elide: Text.ElideRight + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 } + color: Theme.text + } + + Text { + width: parent.width + visible: text !== "" + text: b.notification.body || "" + textFormat: Text.RichText + wrapMode: Text.WordWrap + maximumLineCount: 3 + elide: Text.ElideRight + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } + color: Theme.subtext + } + } +} +``` + +- [ ] **Step 5: Write `notifications/README.md`** + +```markdown +# notifications + +The balloon renderer for the notification daemon (`notifyd`, a separate repo). +It reads the daemon's published files through the `Notify` singleton and draws +one balloon per live notification, bottom-right of `DP-1`. + +## Suppression lives here + +The daemon does not know about DND or snooze. This component withholds +balloons: `status.dnd` suppresses low and normal, `notifyd/snooze` suppresses +everything. The drawer's reserved space lists every live notification anyway. + +## The files are the interface + + $XDG_RUNTIME_DIR/notifyd/queue.json the live queue + $XDG_RUNTIME_DIR/notifyd/history.json the ring of 20 + $XDG_RUNTIME_DIR/notifyd/drawer "1" while the drawer holds the space + $XDG_RUNTIME_DIR/notifyd/snooze an epoch second while snoozing + +`notifyctl` and `notify-snooze.sh` are in `~/bin`; without them the balloons +draw but close and actions do nothing. + +## Blur + +Hyprland blurs a layer surface only when a rule names its namespace. This +component sets `quickshell-notifications`; the rule is in +`~/.config/hypr/sections/decorations.lua`. +``` + +- [ ] **Step 6: Smoke check** + +```bash +timeout 8 qs -p ./notifications 2>&1 | grep -E 'ERROR|TypeError|ReferenceError|is not defined|Cannot assign|Unable to assign' && echo "ERRORS ABOVE" || echo "clean" +``` + +Expected: `clean`. (This briefly starts a second copy; it dies with the timeout.) + +- [ ] **Step 7: Commit** + +```bash +git add notifications/ +git commit -m "feat(notifications): add the balloon shell + +Reads the daemon's queue through the Notify singleton and draws a balloon +per live notification, bottom-right of DP-1 over conky. Suppression is here, +not in the daemon: dnd withholds low and normal, snooze withholds all, and +the drawer still lists them." +``` + +--- + +### Task 3: Balloon interactions + +**Files:** +- Create: `notifications/notify-actions.sh` + +**Interfaces:** +- Consumes: `Notify.actions`, `Notify.close`, `Notify.closeAll`. +- Produces: `notify-actions.sh <id> <label> <key> [label key ...]`, which reads a selection from rofi and runs `notifyctl action`. + +The click handlers were written in Task 2; this task supplies the picker they call. + +- [ ] **Step 1: Write `notifications/notify-actions.sh`** + +```bash +#!/bin/bash +# +# 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. +# +# Pick one of a notification's actions with rofi and invoke it. The renderer +# passes the labels and keys as arguments, so nothing here parses JSON. +# +# notify-actions.sh <id> <label> <key> [label key ...] + +set -u + +[[ $# -ge 3 ]] || { echo "usage: ${0##*/} <id> <label> <key> ..." >&2; exit 2; } + +id="$1"; shift +labels=() +keys=() +while [[ $# -ge 2 ]]; do + labels+=("$1") + keys+=("$2") + shift 2 +done + +choice="$(printf '%s\n' "${labels[@]}" | rofi -dmenu -i -p "Notification")" +[[ -n "$choice" ]] || exit 0 + +for i in "${!labels[@]}"; do + if [[ "${labels[$i]}" == "$choice" ]]; then + notifyctl action "$id" "${keys[$i]}" + exit $? + fi +done +``` + +- [ ] **Step 2: Make it runnable, install it, and smoke check** + +The `Notify.actions` helper runs it from PATH, so it is installed beside `notifyctl`: + +```bash +chmod +x notifications/notify-actions.sh +install -m 755 notifications/notify-actions.sh ~/bin/notify-actions.sh +command -v notify-actions.sh +timeout 8 qs -p ./notifications 2>&1 | grep -E 'ERROR|TypeError|ReferenceError|is not defined|Cannot assign|Unable to assign' && echo "ERRORS ABOVE" || echo "clean" +``` + +Expected: the resolved path, and `clean`. + +- [ ] **Step 3: Confirm by hand** + +Ask the user to run the component (`qs -p notifications`) and send a mail-shaped notification with an action, then click its balloon body and confirm rofi lists the action and choosing it opens the target: + +```bash +notify-send -a test -u normal -A default=open "Action test" "click the body" +``` + +Expected: rofi appears with `open`; choosing it invokes the action. An X closes the notification, a right click closes all. + +- [ ] **Step 4: Commit** + +```bash +git add notifications/notify-actions.sh +git commit -m "feat(notifications): add the rofi action picker + +The renderer passes the action labels and keys as arguments, so the picker +parses no JSON; a chosen label maps to its key and runs notifyctl action." +``` + +--- + +### Task 4: The drawer's reserved space + +**Files:** +- Modify: `desktop/Drawer.qml` (the reserved `Item`, and the drawer flag write) +- Create: `desktop/NotificationList.qml` +- Create: `desktop/NotificationRow.qml` + +**Interfaces:** +- Consumes: `Notify`, `Theme`. +- Produces: `NotificationList` with a `history` signal; `NotificationRow` with a `required property var notification` and a `live` boolean. Task 5 uses the signal. + +- [ ] **Step 1: Write `desktop/NotificationRow.qml`** + +```qml +// 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. + +import QtQuick + +// One notification in the drawer: app, summary and body. Live rows close with +// the X and click through to their actions; a history row is inert. +Item { + id: row + + required property var notification + // A history row has no live client: no X, and a click does nothing. + property bool live: true + + readonly property bool inert: row.notification.expires !== 0 && Date.now() >= row.notification.expires + + implicitHeight: texts.implicitHeight + 16 + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: { + if (!row.live) return; + const acts = row.notification.actions || []; + if (acts.length > 0 && !row.inert) Notify.actions(row.notification.id, acts); + else Notify.close(row.notification.id); + } + } + + Column { + id: texts + anchors { + left: parent.left + right: closeBtn.visible ? closeBtn.left : parent.right + rightMargin: 10 + verticalCenter: parent.verticalCenter + } + spacing: 2 + + Text { + width: parent.width + text: (row.notification.app || "") + (row.notification.summary ? " " + row.notification.summary : "") + elide: Text.ElideRight + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 } + color: Theme.text + } + + Text { + width: parent.width + visible: text !== "" + text: row.notification.body || "" + textFormat: Text.RichText + wrapMode: Text.WordWrap + maximumLineCount: 2 + elide: Text.ElideRight + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 5 } + color: Theme.subtext + } + } + + Text { + id: closeBtn + visible: row.live + anchors { right: parent.right; verticalCenter: parent.verticalCenter } + width: 20 + height: 20 + text: "\uf00d" + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + font { family: Theme.iconFamily; pixelSize: 11 } + color: closeArea.containsMouse ? Theme.red : Theme.subtext + + MouseArea { + id: closeArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: Notify.close(row.notification.id) + } + } +} +``` + +- [ ] **Step 2: Write `desktop/NotificationList.qml`** + +```qml +// 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. + +import QtQuick + +// The drawer's reserved notification space: a header with a History button, +// then the live queue, scrollable because the queue can hold 20 and the grid +// below is fixed. The grid owns its own position, so this space only fills +// what the grid leaves. +Item { + id: list + + signal history + + Column { + anchors.fill: parent + spacing: 6 + + Item { + width: parent.width + height: 28 + + Text { + anchors { left: parent.left; verticalCenter: parent.verticalCenter } + text: "Notifications" + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4; bold: true } + color: Theme.subtext + } + + Text { + id: historyBtn + anchors { right: parent.right; verticalCenter: parent.verticalCenter } + text: "History" + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } + color: historyArea.containsMouse ? Theme.accent : Theme.subtext + + MouseArea { + id: historyArea + anchors.fill: parent + anchors.margins: -6 + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: list.history() + } + } + } + + Flickable { + id: flick + width: parent.width + height: parent.height - 34 + clip: true + contentWidth: width + contentHeight: col.implicitHeight + boundsBehavior: Flickable.StopAtBounds + + Column { + id: col + width: flick.width + spacing: 2 + + Repeater { + model: Notify.queue + NotificationRow { + required property var modelData + width: col.width + notification: modelData + live: true + } + } + + Text { + width: col.width + visible: Notify.queue.length === 0 + text: "No notifications" + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } + color: Theme.overlay + } + } + } + } +} +``` + +- [ ] **Step 3: Fill the reserved space and write the drawer flag in `desktop/Drawer.qml`** + +Replace the reserved `Item` block: + +```qml + // Reserved for the notification engine. An empty Item that + // claims the space rather than a placeholder graphic: the + // grid has to sit where it will sit once notifications + // arrive, or the layout is tuned against a position that + // does not survive. + Item { + id: notifications + anchors { top: parent.top; left: parent.left; right: parent.right } + anchors.bottom: grid.top + anchors.bottomMargin: 16 + } +``` + +with: + +```qml + // The notification engine's space. The live queue lists + // here while the drawer is open; the balloons stand down + // because the drawer flag below is set. + NotificationList { + id: notifications + anchors { top: parent.top; left: parent.left; right: parent.right } + anchors.bottom: grid.top + anchors.bottomMargin: 16 + onHistory: root.history = true + } +``` + +Add the imports at the top (the file imports `Quickshell`, `Quickshell.Wayland`, `QtQuick`): add `import Quickshell.Io`. + +Add the drawer flag and the open/close writes. After the `Scope { id: root ... }` properties, add: + +```qml + // The balloons read this to stand down while the drawer holds the space. + FileView { + id: drawerFlag + path: (Quickshell.env("XDG_RUNTIME_DIR") || "/tmp") + "/notifyd/drawer" + atomicWrites: true + printErrors: false + } + + onOpenChanged: drawerFlag.setText(root.open ? "1\n" : "0\n") +``` + +- [ ] **Step 4: Smoke check** + +```bash +timeout 8 qs -p ./desktop 2>&1 | grep -E 'ERROR|TypeError|ReferenceError|is not defined|Cannot assign|Unable to assign' && echo "ERRORS ABOVE" || echo "clean" +``` + +Expected: `clean`. + +- [ ] **Step 5: Confirm by hand** + +Ask the user to restart the drawer, open it, and confirm: the reserved space above the grid shows the live queue, its History button is present (inert until Task 5), a notification sent while the drawer is open appears there and **not** as a balloon, and closing it with the X removes it to history. Then with the drawer closed, confirm a balloon appears again. + +- [ ] **Step 6: Commit** + +```bash +git add desktop/Drawer.qml desktop/NotificationList.qml desktop/NotificationRow.qml +git commit -m "feat(desktop): fill the drawer's notification space + +The reserved Item becomes the live queue with a History button, scrollable +because the queue can hold 20. The drawer writes notifyd/drawer so the +balloon shell stands down while this space is showing, which is what stops a +notification appearing twice." +``` + +--- + +### Task 5: The history page + +**Files:** +- Create: `desktop/NotificationHistory.qml` +- Modify: `desktop/Drawer.qml` (a history view and the `history` property) + +**Interfaces:** +- Consumes: `Notify.history`, `Notify.clearHistory`, `NotificationRow`. +- Produces: a drawer view reachable from the reserved space's History button. This plan ships a clear-all only; see the deviation note in Task 6's README. + +- [ ] **Step 1: Write `desktop/NotificationHistory.qml`** + +```qml +// 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. + +import QtQuick + +// The history ring: the last 20 dismissed or evicted notifications, newest +// first, with a clear all. Rows are inert; only clear-all removes them, since +// the daemon has no per-id history deletion. +Column { + id: page + + spacing: 4 + + Item { + width: parent.width + height: 28 + + Text { + anchors { left: parent.left; verticalCenter: parent.verticalCenter } + text: Notify.history.length + " in history" + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } + color: Theme.subtext + } + + Text { + id: clearBtn + anchors { right: parent.right; verticalCenter: parent.verticalCenter } + visible: Notify.history.length > 0 + text: "Clear all" + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } + color: clearArea.containsMouse ? Theme.red : Theme.subtext + + MouseArea { + id: clearArea + anchors.fill: parent + anchors.margins: -6 + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: Notify.clearHistory() + } + } + } + + Repeater { + model: Notify.history + + NotificationRow { + required property var modelData + width: page.width + notification: modelData + live: false + } + } + + Text { + width: parent.width + visible: Notify.history.length === 0 + text: "Nothing in history" + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } + color: Theme.overlay + } +} +``` + +- [ ] **Step 2: Add the history view to `desktop/Drawer.qml`** + +Add to the root, beside `property string page`: + +```qml + // The history page is reachable only from the reserved space, so it is not + // a module and has no tile: a boolean, reset when the drawer closes. + property bool history: false +``` + +In `close()`, add `root.history = false;` after `root.page = "";`. + +In the grid view's `visible:` condition, add the history guard so the grid does not show through the history page (the page has no opaque background): + +```qml + visible: root.page === "" && !root.history +``` + +Add the history loader after the page view Loader: + +```qml + // --- history view --- + + Loader { + id: historyLoader + anchors.fill: parent + anchors.margins: 16 + active: root.history + sourceComponent: Component { + Page { + title: "History" + NotificationHistory { width: parent.width } + } + } + onLoaded: if (item && item.back) item.back.connect(() => root.history = false) + } +``` + +The `Page` and `Theme` names resolve through the root directory's own types, the same way the module page loader uses `Page`. + +- [ ] **Step 3: Also let Escape leave history** + +In the panel's `Keys.onEscapePressed`, change: + +```qml + Keys.onEscapePressed: { + if (root.page) root.page = ""; + else root.close(); + } +``` + +to: + +```qml + Keys.onEscapePressed: { + if (root.history) root.history = false; + else if (root.page) root.page = ""; + else root.close(); + } +``` + +- [ ] **Step 4: Smoke check** + +```bash +timeout 8 qs -p ./desktop 2>&1 | grep -E 'ERROR|TypeError|ReferenceError|is not defined|Cannot assign|Unable to assign' && echo "ERRORS ABOVE" || echo "clean" +``` + +Expected: `clean`. + +- [ ] **Step 5: Confirm by hand** + +Ask the user to open the drawer, click History, and confirm the ring lists closed notifications newest-first with a clear all that empties it, that the back arrow and Escape return to the grid, and that a notification closed in the reserved space then appears in history. + +- [ ] **Step 6: Commit** + +```bash +git add desktop/Drawer.qml desktop/NotificationHistory.qml +git commit -m "feat(desktop): add the notification history page + +Reachable only from the reserved space's History button, so it is a drawer +view, not a module and not a tile. Rows are inert; clear-all empties the +ring, since the daemon has no per-id history deletion." +``` + +--- + +### Task 6: The Status page snooze row + +**Files:** +- Create: `desktop/modules/status/SnoozeRow.qml` +- Modify: `desktop/modules/status/StatusPage.qml` + +**Interfaces:** +- Consumes: `Notify.snoozeUntil`, `notify-snooze.sh`, the shared `Switch`, `Theme`, and the last-used file `~/.local/state/notify-snooze.minutes`. +- Produces: a snooze control on the Status page. The balloon shell already reads `Notify.snoozeUntil`, so this only drives the file. + +- [ ] **Step 1: Write `desktop/modules/status/SnoozeRow.qml`** + +```qml +// 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. + +import Quickshell +import Quickshell.Io +import QtQuick +import QtQuick.Controls +import "../.." + +// A switch plus a minutes field. On snoozes for the typed minutes through +// notify-snooze.sh; off clears. The last used value is persisted by the +// script, so the field prefills from it. +Item { + id: row + + // A tick so the switch reflects the snooze ending on its own. + property double now: Date.now() + Timer { + interval: 1000 + running: true + repeat: true + onTriggered: row.now = Date.now() + } + + readonly property bool active: Notify.snoozeUntil > row.now + + implicitHeight: Math.max(texts.implicitHeight, sw.implicitHeight) + 16 + + function apply(on) { + if (on) { + const n = parseInt(minutes.text, 10); + snooze.command = ["notify-snooze.sh", String(isNaN(n) || n < 1 ? 30 : n)]; + } else { + snooze.command = ["notify-snooze.sh", "off"]; + } + snooze.running = false; + snooze.running = true; + } + + Process { id: snooze; printErrors: false } + + FileView { + id: lastUsed + path: `${Quickshell.env("HOME")}/.local/state/notify-snooze.minutes` + printErrors: false + onLoaded: minutes.text = text().trim() + onLoadFailed: minutes.text = "30" + } + + Column { + id: texts + anchors { + left: parent.left + right: controls.left + rightMargin: 12 + verticalCenter: parent.verticalCenter + } + spacing: 2 + + Text { + text: "Snooze" + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2; bold: true } + color: Theme.text + } + + Text { + width: parent.width + wrapMode: Text.WordWrap + text: row.active ? "All notification balloons are held until snooze ends." + : "Hold every notification balloon for a number of minutes." + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } + color: Theme.subtext + } + } + + Row { + id: controls + anchors { right: parent.right; verticalCenter: parent.verticalCenter } + spacing: 8 + + TextField { + id: minutes + width: 48 + height: 28 + text: "30" + horizontalAlignment: TextInput.AlignHCenter + color: Theme.text + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } + background: Rectangle { + radius: 6 + color: Qt.alpha(Theme.surface, 0.9) + border.width: 1 + border.color: Qt.alpha(Theme.text, 0.15) + } + validator: IntValidator { bottom: 1; top: 1440 } + } + + Switch { + id: sw + checked: row.active + onToggled: row.apply(!row.active) + } + } + + onNowChanged: sw.checked = row.active +} +``` + +- [ ] **Step 2: Add the row to `desktop/modules/status/StatusPage.qml`** + +Append after the presentation row: + +```qml + Rectangle { + width: page.width + height: 1 + color: Qt.alpha(Theme.text, 0.08) + } + + SnoozeRow { + width: page.width + } +``` + +- [ ] **Step 3: Smoke check** + +```bash +timeout 8 qs -p ./desktop 2>&1 | grep -E 'ERROR|TypeError|ReferenceError|is not defined|Cannot assign|Unable to assign' && echo "ERRORS ABOVE" || echo "clean" +``` + +Expected: `clean`. + +- [ ] **Step 4: Confirm by hand** + +Ask the user to open Status. Confirm: the Snooze row has a minutes field and a switch; typing `2` and toggling on sets the switch and writes `notifyd/snooze` about two minutes out; sending a notification draws no balloon while snoozed but still appears in the reserved space; `notify-snooze.sh off` or the switch off ends it; reopening the page after a shell restart shows the last used minutes. + +- [ ] **Step 5: Commit** + +```bash +git add desktop/modules/status/SnoozeRow.qml desktop/modules/status/StatusPage.qml +git commit -m "feat(status): add the snooze row + +A switch and a free-text minutes field driving notify-snooze.sh. Snooze is a +file the balloon shell reads, so the row only writes it; the switch follows +the file, including a snooze that ends while the page is open." +``` + +--- + +### Task 7: Wire it up and record the traps + +**Files:** +- Modify: `notifications/README.md` +- Modify: `AGENTS.md` +- Modify outside the repo: `~/.config/hypr/sections/decorations.lua`, `~/.config/hypr/sections/autostart.lua` (user steps) + +**Interfaces:** +- Consumes: the finished component. +- Produces: nothing executable. + +- [ ] **Step 1: Ask the user to add the blur rule** + +Hyprland blurs a layer surface only when a rule names its namespace. Ask the user to append to `~/.config/hypr/sections/decorations.lua`: + +```lua +-- Frosted glass for the quickshell notification balloons. +hl.layer_rule({ + name = "blur-notifications", + match = { namespace = "^(quickshell-notifications)$" }, + blur = true, + xray = false, + ignore_alpha = 0.1, +}) +``` + +Then `hyprctl reload`. + +- [ ] **Step 2: Ask the user to add the component to autostart** + +In `~/.config/hypr/sections/autostart.lua`, beside the other quickshell lines, using the same absolute prefix those lines use: + +```lua + hl.exec_cmd("qs -p ~/Programming/GIT/quickshell/notifications") +``` + +- [ ] **Step 3: Add the deviation note to `notifications/README.md`** + +Append: + +```markdown +## History rows + +The spec calls history rows closable individually. `notifyctl` has no per-id +history delete, only `clear-history`, so the history page ships a clear all +and inert rows. The daemon verb and the renderer row it needs are tracked in +the `notifyd` repo's `TODO.md`; when that ships, the row's X is wired to +`notifyctl history-remove`. +``` + +- [ ] **Step 4: Add the traps to `AGENTS.md`** + +Append to the per-component notes list: + +```markdown +- **The notification daemon is a separate process that owns the D-Bus name.** + The quickshell side only reads its published files under + `$XDG_RUNTIME_DIR/notifyd/`; the files are the interface, and `notifyctl` is + the one path back. `notifications/` is inert without it: balloons still + draw, but close and actions do nothing. +- **Suppression is in the balloon shell, not the daemon.** `status.dnd` + withholds low and normal balloons and `notifyd/snooze` withholds all, but + the drawer's reserved space lists everything, because a list the user opened + is not an interruption. +- **A notification appears as a balloon or in the drawer's reserved space, + never both.** The drawer writes `notifyd/drawer` and the balloon shell reads + it; `Drawer.qml`'s reserved `Item` is the space. +- **The dead `waybar/modules/custom/notification.jsonc` and + `waybar/scripts/notifications.py` still shell out to `dunstctl`.** They are + not in the live waybar config; do not resurrect them. +``` + +- [ ] **Step 5: Run every check** + +```bash +timeout 8 qs -p ./desktop 2>&1 | grep -E 'ERROR|TypeError|ReferenceError|is not defined|Cannot assign|Unable to assign' && echo "ERRORS ABOVE" || echo "clean" +timeout 8 qs -p ./notifications 2>&1 | grep -E 'ERROR|TypeError|ReferenceError|is not defined|Cannot assign|Unable to assign' && echo "ERRORS ABOVE" || echo "clean" +bash desktop/modules/status/test-statusctl.sh +``` + +Expected: both `clean`; statusctl `9 passed, 0 failed`. + +- [ ] **Step 6: Confirm the process count** + +```bash +pgrep -cx qs +``` + +Expected: the shells the user runs, one more than before if `notifications/` is running (normally four: desktop, appearance, window-switcher, notifications). + +- [ ] **Step 7: Final visual pass** + +Ask the user to confirm the whole loop: a balloon bottom-right on DP-1 with an icon and markup body; opening the drawer replaces it with a row in the reserved space; the History button opens the ring; DND still pops critical only while listing both; snooze holds every balloon; and a mail notification's action opens through rofi. + +- [ ] **Step 8: Commit** + +```bash +git add notifications/README.md AGENTS.md +git commit -m "docs(notifications): document the renderers and record the traps + +The daemon is a separate process and the files are the interface; suppression +lives in the balloon shell so the drawer can list what DND held back; and the +drawer flag is what stops a notification appearing as both a balloon and a +row." +``` + +--- + +## Notes for the implementer + +**Suppression is a filter, not daemon state.** DND and snooze withhold a balloon; they never stop the notification reaching the queue or history. If you move the filter into the daemon, the drawer loses the items DND held back, which is the behaviour the spec rejected. + +**The drawer flag is the whole no-double-show mechanism.** `Drawer.qml` writes `notifyd/drawer`; `Balloons.qml` reads `Notify.drawerOpen`. Do not add a second source of truth. + +**`Notify.actions` passes pairs as flattened arguments.** A pair from the JSON is `[key, label]`; the picker takes `<label> <key>`. Getting that order backwards makes rofi display the key and invoke the label. + +**A missing published file is normal at first start.** Every `FileView` here has an `onLoadFailed` that means empty or off, never an error: the daemon writes `[]` on its own start, but a renderer may win the race. diff --git a/docs/superpowers/plans/2026-09-15-notifyd-images.md b/docs/superpowers/plans/2026-09-15-notifyd-images.md new file mode 100644 index 0000000..104e566 --- /dev/null +++ b/docs/superpowers/plans/2026-09-15-notifyd-images.md @@ -0,0 +1,1000 @@ +# notifyd Image Support Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** The daemon accepts the freedesktop image hints, publishes a content image path for every notification, resolves app_icon / image-path theme names to files, and cleans up what it owns. + +**Architecture:** Image hints are parsed as pure functions in `internal/notify`, decoded and encoded to PNG with the standard library, and written under `$XDG_RUNTIME_DIR/notifyd/img/`. `Popup` gains an `image` field that the renderers read. A theme-name resolver reads qt6ct (then GTK3, then hicolor) and searches the XDG icon directories. The store gains an injected cleanup callback so a daemon-owned image is unlinked when its notification leaves the live queue. + +**Tech Stack:** Go, `github.com/godbus/dbus/v5`, the standard library (`image`, `image/png`), `dbus-run-session` for the integration test. + +**Spec:** `docs/superpowers/specs/2026-09-15-notification-images-design.md` (in the quickshell repo; read it before starting). The renderer half is a separate plan. + +## Global Constraints + +- Go module path `danix.xyz/notifyd`. The only third-party dependency is `github.com/godbus/dbus/v5`; everything else is the standard library. +- GPLv2 only. Every new `.go` file begins with the standard per-file header notice (copy it from `policy.go`). +- The published contract is exact: `Popup` gains `"image"` (a path, empty when none); `created` and `expires` stay epoch milliseconds. +- `GetCapabilities` becomes `actions`, `body-markup`, `body-images`, `icon-static`, `persistence`. +- The spec's image priority is `image-data`, then `image-path`, then the deprecated `icon_data`; `app_icon` stays the icon, not a fallback image. +- `image-data` / `icon_data` are a D-Bus `(iiibiiay)` struct: width, height, rowstride, has_alpha, bits_per_sample, channels, data (RGB byte order). +- Theme source is qt6ct `icon_theme`, then GTK3 `gtk-icon-theme-name`, then `hicolor`. Search `$XDG_DATA_HOME/icons` then `$XDG_DATA_DIRS/icons`. +- No home paths in committed files. `gofmt` clean. `go vet ./...` clean. +- Test commands: `go test ./...` for pure logic; `dbus-run-session -- go test ./internal/notify` for the bus test; `bash test-notifyctl.sh` for the end to end check. +- Work in the `notifyd` repo (`~/Programming/GIT/notifyd`), not the quickshell repo. + +--- + +## File Structure + + internal/notify/image.go image hint parsing and PNG encoding (create) + internal/notify/image_test.go + internal/notify/icons.go theme-name resolution to an icon file (create) + internal/notify/icons_test.go + internal/notify/policy.go add image hint entry points (modify) + internal/notify/store.go Popup.Image and the removal callback (modify) + internal/notify/store_test.go + internal/notify/files.go image directory and PNG write (modify) + internal/notify/files_test.go + internal/notify/service.go capabilities, materialisation, wiring (modify) + internal/notify/service_test.go + test-notifyctl.sh assert the image field survives publish (modify) + +--- + +### Task 1: Image hint parsing and PNG encoding + +**Files:** +- Create: `internal/notify/image.go` +- Test: `internal/notify/image_test.go` + +**Interfaces:** +- Consumes: `github.com/godbus/dbus/v5`. +- Produces: `type RawImage struct { Width, Height, RowStride int; HasAlpha bool; BitsPerSample, Channels int; Data []byte }`; `ImageDataFromHints(hints map[string]dbus.Variant) (*RawImage, bool)`; `ImagePathFromHints(hints map[string]dbus.Variant) (string, bool)`; `(*RawImage) PNG() ([]byte, error)`. Tasks 3 and 4 use these. + +- [ ] **Step 1: Write the failing test** + +Create `internal/notify/image_test.go`: + +```go +// 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. + +package notify + +import ( + "bytes" + "image/png" + "testing" + + "github.com/godbus/dbus/v5" +) + +// rawVariant builds the (iiibiiay) struct the bus delivers for image-data. +func rawVariant(w, h, stride int, alpha bool, ch int, data []byte) dbus.Variant { + return dbus.MakeVariant([]interface{}{ + int32(w), int32(h), int32(stride), alpha, int32(8), int32(ch), data, + }) +} + +func TestImageDataFromHints(t *testing.T) { + // 2x1 RGBA: red, green. + rgba := []byte{255, 0, 0, 255, 0, 255, 0, 255} + cases := []struct { + name string + hints map[string]dbus.Variant + wantW int + want bool + }{ + {"image-data wins", map[string]dbus.Variant{ + "image-data": rawVariant(2, 1, 8, true, 4, rgba), + "image-path": dbus.MakeVariant("/tmp/x.png"), + }, 2, true}, + {"icon_data fallback", map[string]dbus.Variant{ + "icon_data": rawVariant(2, 1, 8, true, 4, rgba), + }, 2, true}, + {"image_data alias", map[string]dbus.Variant{ + "image_data": rawVariant(2, 1, 8, true, 4, rgba), + }, 2, true}, + {"absent", map[string]dbus.Variant{}, 0, false}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got, ok := ImageDataFromHints(c.hints) + if ok != c.want { + t.Fatalf("ok = %v, want %v", ok, c.want) + } + if ok && got.Width != c.wantW { + t.Fatalf("width = %d, want %d", got.Width, c.wantW) + } + }) + } +} + +func TestImagePathFromHints(t *testing.T) { + got, ok := ImagePathFromHints(map[string]dbus.Variant{"image-path": dbus.MakeVariant("/tmp/shot.png")}) + if !ok || got != "/tmp/shot.png" { + t.Fatalf("got %q ok=%v", got, ok) + } + if _, ok := ImagePathFromHints(map[string]dbus.Variant{}); ok { + t.Fatal("empty hints must not report a path") + } +} + +func TestRawImagePNG(t *testing.T) { + // 2x1 RGBA on a rowstride wider than the data, to prove stride is honoured. + r := &RawImage{Width: 2, Height: 1, RowStride: 12, HasAlpha: true, BitsPerSample: 8, Channels: 4, + Data: []byte{255, 0, 0, 255, 0, 255, 0, 255, 9, 9, 9, 9}} + data, err := r.PNG() + if err != nil { + t.Fatalf("PNG: %v", err) + } + img, err := png.Decode(bytes.NewReader(data)) + if err != nil { + t.Fatalf("decode: %v", err) + } + if img.Bounds().Dx() != 2 || img.Bounds().Dy() != 1 { + t.Fatalf("bounds = %v", img.Bounds()) + } + r0, g0, b0, a0 := img.At(0, 0).RGBA() + if r0>>8 != 255 || g0>>8 != 0 || b0>>8 != 0 || a0>>8 != 255 { + t.Fatalf("pixel 0 = %d %d %d %d", r0>>8, g0>>8, b0>>8, a0>>8) + } +} + +func TestRawImagePNGRejectsBadData(t *testing.T) { + if _, err := (&RawImage{Width: 0, Height: 1, Channels: 4, BitsPerSample: 8}).PNG(); err == nil { + t.Fatal("zero width must error") + } + if _, err := (&RawImage{Width: 1, Height: 1, Channels: 2, BitsPerSample: 8}).PNG(); err == nil { + t.Fatal("channels 2 must error") + } +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `go test ./internal/notify -run 'TestImage|TestRaw' -v` +Expected: FAIL with `undefined: RawImage` and the hint functions. + +- [ ] **Step 3: Write the implementation** + +Create `internal/notify/image.go`: + +```go +// 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. + +package notify + +import ( + "bytes" + "fmt" + "image" + "image/color" + "image/png" + + "github.com/godbus/dbus/v5" +) + +// RawImage is the spec's image-data structure (iiibiiay). Data is RGB byte +// order: 4 bytes per pixel with alpha, 3 without, and rows are RowStride +// bytes apart, which may exceed Width*Channels. +type RawImage struct { + Width int + Height int + RowStride int + HasAlpha bool + BitsPerSample int + Channels int + Data []byte +} + +// ImageDataFromHints reads the raw image struct, preferring the spec key then +// the deprecated icon_data, then the underscore alias older libnotify sent. +func ImageDataFromHints(hints map[string]dbus.Variant) (*RawImage, bool) { + for _, key := range []string{"image-data", "icon_data", "image_data"} { + v, ok := hints[key] + if !ok { + continue + } + if r, ok := rawImageFromVariant(v); ok { + return r, true + } + } + return nil, false +} + +// ImagePathFromHints reads image-path, a URI, a path, or a theme icon name. +func ImagePathFromHints(hints map[string]dbus.Variant) (string, bool) { + v, ok := hints["image-path"] + if !ok { + return "", false + } + s, ok := v.Value().(string) + if !ok || s == "" { + return "", false + } + return s, true +} + +// rawImageFromVariant accepts the []interface{} godbus yields for a struct. +// Each numeric field may arrive as int32 or int depending on the encoder. +func rawImageFromVariant(v dbus.Variant) (*RawImage, bool) { + f, ok := v.Value().([]interface{}) + if !ok || len(f) != 7 { + return nil, false + } + r := &RawImage{} + var okW, okH, okS, okC, okD bool + r.Width, okW = asInt(f[0]) + r.Height, okH = asInt(f[1]) + r.RowStride, okS = asInt(f[2]) + r.HasAlpha, _ = f[3].(bool) + r.BitsPerSample, _ = asInt(f[4]) + r.Channels, okC = asInt(f[5]) + r.Data, okD = f[6].([]byte) + if !okW || !okH || !okS || !okC || !okD { + return nil, false + } + return r, true +} + +func asInt(v any) (int, bool) { + switch n := v.(type) { + case int: + return n, true + case int32: + return int(n), true + case int64: + return int(n), true + case uint32: + return int(n), true + } + return 0, false +} + +// PNG encodes the raw pixels as a PNG the renderer can load. +func (r *RawImage) PNG() ([]byte, error) { + if r.Width <= 0 || r.Height <= 0 { + return nil, fmt.Errorf("notifyd: image %dx%d", r.Width, r.Height) + } + if r.BitsPerSample != 8 || (r.Channels != 3 && r.Channels != 4) { + return nil, fmt.Errorf("notifyd: image bits=%d channels=%d", r.BitsPerSample, r.Channels) + } + stride := r.RowStride + if stride < r.Width*r.Channels { + stride = r.Width * r.Channels + } + if len(r.Data) < stride*(r.Height-1)+r.Width*r.Channels { + return nil, fmt.Errorf("notifyd: image data short: %d bytes", len(r.Data)) + } + img := image.NewRGBA(image.Rect(0, 0, r.Width, r.Height)) + for y := 0; y < r.Height; y++ { + row := r.Data[y*stride:] + for x := 0; x < r.Width; x++ { + if r.Channels == 4 { + i := x * 4 + img.SetRGBA(x, y, color.RGBA{row[i], row[i+1], row[i+2], row[i+3]}) + } else { + i := x * 3 + img.SetRGBA(x, y, color.RGBA{row[i], row[i+1], row[i+2], 255}) + } + } + } + var buf bytes.Buffer + if err := png.Encode(&buf, img); err != nil { + return nil, err + } + return buf.Bytes(), nil +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `go test ./internal/notify -run 'TestImage|TestRaw' -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/notify/image.go internal/notify/image_test.go +git commit -m "feat(notify): parse the image hints and encode them to PNG + +image-data (and the deprecated icon_data) is the (iiibiiay) struct; the +PNG encoder honours rowstride and both 3- and 4-channel data. The path +and data readers are pure, so the service can apply the spec's priority +and the store stays free of image handling." +``` + +--- + +### Task 2: Theme-name resolution + +**Files:** +- Create: `internal/notify/icons.go` +- Test: `internal/notify/icons_test.go` + +**Interfaces:** +- Consumes: nothing but the standard library and `os`. +- Produces: `IconThemeName() string`; `ResolveIcon(value string) string`; `XDGIconDirs() []string`. Task 4 uses both entry points. + +- [ ] **Step 1: Write the failing test** + +Create `internal/notify/icons_test.go`: + +```go +// 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. + +package notify + +import ( + "os" + "path/filepath" + "testing" +) + +func TestResolveIconPathAndURI(t *testing.T) { + if got := ResolveIcon("/usr/share/icons/x/apps/48/firefox.png"); got != "/usr/share/icons/x/apps/48/firefox.png" { + t.Fatalf("path passthrough: %q", got) + } + if got := ResolveIcon("file:///tmp/shot.png"); got != "/tmp/shot.png" { + t.Fatalf("uri: %q", got) + } + if got := ResolveIcon(""); got != "" { + t.Fatalf("empty: %q", got) + } +} + +func TestResolveIconThemeName(t *testing.T) { + root := t.TempDir() + // Theme "Plum" inherits "Base"; the icon is only in Base. + theme := filepath.Join(root, "icons", "Plum") + base := filepath.Join(root, "icons", "Base") + plumIndex := filepath.Join(theme, "index.theme") + baseApp := filepath.Join(base, "apps", "48") + if err := os.MkdirAll(filepath.Join(theme, "apps", "scalable"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(baseApp, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(plumIndex, []byte("[Icon Theme]\nInherits=Base\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(baseApp, "firefox.svg"), []byte("<svg/>"), 0o644); err != nil { + t.Fatal(err) + } + + t.Setenv("XDG_DATA_HOME", root) + t.Setenv("XDG_DATA_DIRS", "") + t.Setenv("HOME", filepath.Join(root, "home")) + if err := os.MkdirAll(filepath.Join(root, "home", ".config", "qt6ct"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "home", ".config", "qt6ct", "qt6ct.conf"), []byte("icon_theme=Plum\n"), 0o644); err != nil { + t.Fatal(err) + } + + got := ResolveIcon("firefox") + want := filepath.Join(root, "icons", "Base", "apps", "48", "firefox.svg") + if got != want { + t.Fatalf("resolved %q, want %q", got, want) + } + if got := ResolveIcon("no-such-icon-xyz"); got != "" { + t.Fatalf("missing name must be empty, got %q", got) + } +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `go test ./internal/notify -run TestResolveIcon -v` +Expected: FAIL with `undefined: ResolveIcon`. + +- [ ] **Step 3: Write the implementation** + +Create `internal/notify/icons.go`: + +```go +// 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. + +package notify + +import ( + "os" + "path/filepath" + "sort" + "strconv" + "strings" +) + +// IconThemeName reads the desktop's current icon theme: qt6ct is the truth on +// this desktop, then the GTK3 setting, then hicolor. A missing or empty value +// falls through. +func IconThemeName() string { + if v := iniValue(filepath.Join(homeDir(), ".config", "qt6ct", "qt6ct.conf"), "icon_theme"); v != "" { + return v + } + if v := iniValue(filepath.Join(homeDir(), ".config", "gtk-3.0", "settings.ini"), "gtk-icon-theme-name"); v != "" { + return v + } + return "hicolor" +} + +// ResolveIcon turns an app_icon or image-path value into a file path. A URI is +// trimmed, a path is returned unchanged, and a bare name is looked up in the +// icon theme. Nothing found is an empty string, which renders no image. +func ResolveIcon(value string) string { + if value == "" { + return "" + } + if strings.HasPrefix(value, "file://") { + return strings.TrimPrefix(value, "file://") + } + if strings.Contains(value, "/") { + return value + } + return lookupThemeIcon(value, IconThemeName()) +} + +// XDGIconDirs is the icon search path: the user's dir then each data dir. +func XDGIconDirs() []string { + home := os.Getenv("XDG_DATA_HOME") + if home == "" { + home = filepath.Join(homeDir(), ".local", "share") + } + dirs := []string{filepath.Join(home, "icons")} + for _, d := range filepath.SplitList(os.Getenv("XDG_DATA_DIRS")) { + if d != "" { + dirs = append(dirs, filepath.Join(d, "icons")) + } + } + if len(dirs) == 1 { + dirs = append(dirs, "/usr/local/share/icons", "/usr/share/icons") + } + return dirs +} + +// lookupThemeIcon searches the theme, then its Inherits chain, then hicolor. +func lookupThemeIcon(name, theme string) string { + seen := map[string]bool{} + for theme != "" && !seen[theme] { + seen[theme] = true + found, next := "", "" + for _, root := range XDGIconDirs() { + base := filepath.Join(root, theme) + if p := findInTheme(base, name); p != "" { + found = p + break + } + if next == "" { + next = inheritsOf(base) + } + } + if found != "" { + return found + } + theme = next + } + for _, root := range XDGIconDirs() { + if p := findInTheme(filepath.Join(root, "hicolor"), name); p != "" { + return p + } + } + return "" +} + +// findInTheme prefers scalable then the largest raster under apps/. +func findInTheme(base, name string) string { + sizes := []string{"scalable"} + matches, _ := filepath.Glob(filepath.Join(base, "apps", "[0-9]*")) + for _, m := range matches { + sizes = append(sizes, filepath.Base(m)) + } + numeric := sizes[1:] + sort.Slice(numeric, func(i, j int) bool { + a, _ := strconv.Atoi(strings.TrimSuffix(numeric[i], "@2x")) + b, _ := strconv.Atoi(strings.TrimSuffix(numeric[j], "@2x")) + return a > b + }) + for _, size := range sizes { + for _, ext := range []string{"svg", "png", "xpm"} { + p := filepath.Join(base, "apps", size, name+"."+ext) + if fileExists(p) { + return p + } + } + } + return "" +} + +func inheritsOf(base string) string { + v := iniValue(filepath.Join(base, "index.theme"), "Inherits") + if i := strings.IndexByte(v, ','); i >= 0 { + v = v[:i] + } + return strings.TrimSpace(v) +} + +func iniValue(path, key string) string { + data, err := os.ReadFile(path) + if err != nil { + return "" + } + for _, line := range strings.Split(string(data), "\n") { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "#") || !strings.Contains(line, "=") { + continue + } + k, v, _ := strings.Cut(line, "=") + if strings.TrimSpace(k) == key { + return strings.TrimSpace(v) + } + } + return "" +} + +func homeDir() string { + if h := os.Getenv("HOME"); h != "" { + return h + } + return os.TempDir() +} + +func fileExists(path string) bool { + info, err := os.Stat(path) + return err == nil && !info.IsDir() +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `go test ./internal/notify -run TestResolveIcon -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/notify/icons.go internal/notify/icons_test.go +git commit -m "feat(notify): resolve theme icon names to files + +qt6ct's icon_theme is authoritative on this desktop, with the GTK3 +setting and hicolor as fallbacks. The lookup prefers scalable, then the +largest raster, and follows the theme's Inherits chain, so an app that +passes a name instead of a path gets an icon." +``` + +--- + +### Task 3: The image field and its lifecycle + +**Files:** +- Modify: `internal/notify/store.go` +- Modify: `internal/notify/files.go` +- Test: `internal/notify/store_test.go` +- Test: `internal/notify/files_test.go` + +**Interfaces:** +- Consumes: `Popup` from `store.go`. +- Produces: `Popup.Image string`; `NewStore(emit, publish, removeImage)` with a third parameter `removeImage func(string)`; `(*Store) SetImage(id uint32, path string)`; `WriteImage(dir string, id uint32, data []byte) (string, error)`; `ImagesDir(dir string) string`. Task 4 wires them. + +- [ ] **Step 1: Write the failing test** + +Append to `internal/notify/store_test.go`: + +```go +func TestStoreRemovesImageOnDismissAndExpire(t *testing.T) { + var removed []string + s := NewStore(func(uint32, uint32) {}, func(_, _ []Popup) {}, func(p string) { removed = append(removed, p) }) + id, _ := s.Add(&Popup{Image: "/run/img/1.png"}, "", 0) + s.SetImage(id, "/run/img/other.png") + s.Dismiss(id, 2) + if len(removed) != 1 || removed[0] != "/run/img/other.png" { + t.Fatalf("dismiss removed %v", removed) + } +} + +func TestStoreRemovesImageOnReplace(t *testing.T) { + var removed []string + s := NewStore(func(uint32, uint32) {}, func(_, _ []Popup) {}, func(p string) { removed = append(removed, p) }) + s.Add(&Popup{Image: "/run/img/old.png"}, "tag", 0) + id, _ := s.Add(&Popup{Image: "/run/img/new.png"}, "tag", 0) + _ = id + if len(removed) != 1 || removed[0] != "/run/img/old.png" { + t.Fatalf("replace removed %v", removed) + } +} +``` + +Create `internal/notify/files_test.go` if it does not exist, else append: + +```go +func TestWriteImage(t *testing.T) { + dir := t.TempDir() + p, err := WriteImage(dir, 7, []byte("png-bytes")) + if err != nil { + t.Fatal(err) + } + if filepath.Base(p) != "7.png" { + t.Fatalf("path %q", p) + } + if b, _ := os.ReadFile(p); string(b) != "png-bytes" { + t.Fatalf("contents %q", b) + } + if got := ImagesDir(dir); got != filepath.Join(dir, "img") { + t.Fatalf("ImagesDir %q", got) + } +} +``` + +Add the needed imports (`os`, `path/filepath`) to the test files. + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test ./internal/notify -run 'TestStoreRemovesImage|TestWriteImage' -v` +Expected: FAIL with `undefined: SetImage` / `WriteImage` and a NewStore arity error. + +- [ ] **Step 3: Write the implementation** + +In `internal/notify/store.go`, add the field to `Popup`: + +```go + Image string `json:"image"` +``` + +Add the callback to `Store` and `NewStore`: + +```go +type Store struct { + // ...existing fields... + removeImage func(string) +} + +func NewStore(emit func(id, reason uint32), publish func(live, history []Popup), removeImage func(string)) *Store { + return &Store{ + nextID: 1, + entries: map[uint32]*live{}, + emit: emit, + publish: publish, + removeImage: removeImage, + } +} +``` + +In `Add`, before overwriting a replaced entry, remove its daemon-owned image: + +```go + add := &live{Popup: *n, Stack: stack} + if old != nil { + if s.removeImage != nil { + s.removeImage(old.Image) + } + add.ID = old.ID + // ...unchanged... +``` + +Add `SetImage` and clean up in `removeLocked` and `Expire`: + +```go +// SetImage attaches a materialised image path to a live entry and republishes. +func (s *Store) SetImage(id uint32, path string) { + s.mu.Lock() + defer s.mu.Unlock() + n, ok := s.entries[id] + if !ok { + return + } + n.Image = path + s.publishLocked() +} +``` + +In `Expire`, after `n.Closed = true`, remove the image (the balloon is gone): + +```go + if s.removeImage != nil { + s.removeImage(n.Image) + } +``` + +In `removeLocked`, remove the image before deleting: + +```go +func (s *Store) removeLocked(id uint32) { + if n, ok := s.entries[id]; ok && s.removeImage != nil { + s.removeImage(n.Image) + } + delete(s.entries, id) + // ...unchanged... +``` + +Update the two existing `NewStore(...)` call sites in `store_test.go` to pass `nil` or a no-op as the third argument. + +In `internal/notify/files.go`, add: + +```go +// ImagesDir is where the daemon writes decoded image-data. +func ImagesDir(dir string) string { + return filepath.Join(dir, "img") +} + +// WriteImage writes a decoded image as <id>.png under the image directory. +func WriteImage(dir string, id uint32, data []byte) (string, error) { + imgDir := ImagesDir(dir) + if err := os.MkdirAll(imgDir, 0o700); err != nil { + return "", err + } + path := filepath.Join(imgDir, strconv.FormatUint(uint64(id), 10)+".png") + tmp, err := os.CreateTemp(imgDir, ".img-*") + if err != nil { + return "", err + } + tmpName := tmp.Name() + if _, err := tmp.Write(data); err != nil { + tmp.Close() + os.Remove(tmpName) + return "", err + } + if err := tmp.Close(); err != nil { + os.Remove(tmpName) + return "", err + } + if err := os.Rename(tmpName, path); err != nil { + os.Remove(tmpName) + return "", err + } + return path, nil +} +``` + +Add `strconv` to the `files.go` imports. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./internal/notify -run 'TestStoreRemovesImage|TestWriteImage' -v` +Expected: PASS. Then run `go test ./...` and `go vet ./...`. + +- [ ] **Step 5: Commit** + +```bash +git add internal/notify/store.go internal/notify/store_test.go internal/notify/files.go internal/notify/files_test.go +git commit -m "feat(notify): add the image field and its cleanup + +Popup gains image, and the store calls an injected removeImage when an +entry is dismissed, evicted, replaced or expired, so a daemon-written +PNG does not outlive its balloon. The service decides what is +daemon-owned; the store only names the path." +``` + +--- + +### Task 4: Service materialisation and capabilities + +**Files:** +- Modify: `internal/notify/service.go` +- Test: `internal/notify/service_test.go` +- Modify: `test-notifyctl.sh` + +**Interfaces:** +- Consumes: `ImageDataFromHints`, `ImagePathFromHints`, `(*RawImage).PNG`, `ResolveIcon`, `WriteImage`, `ImagesDir`, `Store.SetImage`. +- Produces: a `Popup.Image` populated for every notification and `body-images` advertised. + +- [ ] **Step 1: Write the failing test** + +In `internal/notify/service_test.go`, add a capability assertion and an image-path assertion. The existing bus test builds a `NewService(...)`; keep its shape and add: + +```go +func TestCapabilitiesIncludeBodyImages(t *testing.T) { + caps, err := NewService(nil, t.TempDir()).GetCapabilities() + if err != nil { + t.Fatal(err) + } + found := false + for _, c := range caps { + if c == "body-images" { + found = true + } + } + if !found { + t.Fatalf("body-images missing from %v", caps) + } +} +``` + +Add a pure test that an image-path hint becomes `Popup.Image`. The test file is +`package notify`, so it builds a Service directly and captures the publish: + +```go +func TestNotifyPublishesImagePath(t *testing.T) { + var live []Popup + s := &Service{dir: t.TempDir()} + s.store = NewStore(s.emitClosed, func(l, _ []Popup) { live = l }, s.removeImage) + hints := map[string]dbus.Variant{"image-path": dbus.MakeVariant("/tmp/shot.png")} + if _, err := s.Notify("t", 0, "", "s", "b", nil, hints, -1); err != nil { + t.Fatal(err) + } + if len(live) != 1 || live[0].Image != "/tmp/shot.png" { + t.Fatalf("published %+v", live) + } +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `go test ./...` +Expected: FAIL with the missing capability and the missing `newServiceWithDir` if used. + +- [ ] **Step 3: Write the implementation** + +In `internal/notify/service.go`: + +```go +func (s *Service) GetCapabilities() ([]string, *dbus.Error) { + return []string{"actions", "body-markup", "body-images", "icon-static", "persistence"}, nil +} +``` + +Add the image removal hook and wire it in `NewService`: + +```go +// removeImage unlinks only what the daemon wrote, so a client's own image-path +// is never touched. +func (s *Service) removeImage(path string) { + if path == "" { + return + } + if !strings.HasPrefix(path, ImagesDir(s.dir)+string(os.PathSeparator)) { + return + } + os.Remove(path) +} +``` + +Update `NewService` to pass it: + +```go + s.store = NewStore(s.emitClosed, func(live, history []Popup) { + if err := Publish(s.dir, live, history); err != nil { + log.Printf("notifyd: publish: %v", err) + } + }, s.removeImage) +``` + +In `Start`, clear leftovers from a previous run: + +```go + if err := os.RemoveAll(ImagesDir(s.dir)); err != nil { + log.Printf("notifyd: clear images: %v", err) + } +``` + +In `Notify`, resolve the icon and the image: + +```go + n := &Popup{ + App: appName, + Summary: summary, + Body: body, + Urgency: u, + Icon: ResolveIcon(appIcon), + Actions: ParseActions(actions), + Created: now, + } + if raw, ok := ImageDataFromHints(hints); ok { + if data, err := raw.PNG(); err == nil { + // The id is assigned by Add; remember the blob and write it after. + pendingImage = data + } + } else if path, ok := ImagePathFromHints(hints); ok { + n.Image = ResolveIcon(path) + } +``` + +Then after `Add`: + +```go + id, _ := s.store.Add(n, tag, replacesID) + if pendingImage != nil { + if path, err := WriteImage(s.dir, id, pendingImage); err == nil { + s.store.SetImage(id, path) + } else { + log.Printf("notifyd: write image: %v", err) + } + } + s.arm(id, ms) + return id, nil +``` + +Declare `var pendingImage []byte` before the `Popup` literal. Add `os` and `strings` to the imports. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test ./... && go vet ./...` +Expected: PASS and clean. Then `dbus-run-session -- go test ./internal/notify` and `bash test-notifyctl.sh`. + +Extend `test-notifyctl.sh`. The file is structured around a `dbus-run-session` +that prints one line per observation into `$tmp/out`, then a `check label want +got` per line. Add the image-path notification and its emitted line inside the +session, after the first `notifyctl list` echo, then add the matching check and +shift the existing "list cleared" check to the third line: + +Inside the `dbus-run-session` script, change the block to: + +```bash + notify-send -a test -u normal "t1" "b1" || exit 3 + sleep 0.3 + echo "$(notifyctl list | grep -c "\"summary\": \"t1\"")" + notify-send -a test -u normal --hint=string:image-path:/tmp/x.png "t2" "b2" || exit 3 + sleep 0.3 + echo "$(notifyctl list | grep -c "\"image\": \"/tmp/x.png\"")" + notifyctl close-all + sleep 0.3 + echo "$(notifyctl list | grep -c "\"summary\": \"t1\"")" + kill $daemon +``` + +Then add and adjust the checks at the bottom of the script: + +```bash +check "list shows the notification" "1" "$(sed -n 1p "$tmp/out")" +check "image-path is published" "1" "$(sed -n 2p "$tmp/out")" +check "list clears" "0" "$(sed -n 3p "$tmp/out")" +``` + +- [ ] **Step 5: Commit** + +```bash +git add internal/notify/service.go internal/notify/service_test.go test-notifyctl.sh +git commit -m "feat(notify): materialise notification images + +Notify resolves app_icon and image-path theme names, decodes image-data to +a PNG under the image directory, and re-publishes the entry with its +image path. The daemon advertises body-images, and removeImage refuses to +touch a path outside its own directory so a client's screenshot file is +never deleted." +``` + +--- + +## Self-Review + +**Spec coverage:** hints and priority (Task 1), theme resolution including qt6ct authority and Inherits (Task 2), the `image` contract field and cleanup lifecycle (Task 3), capabilities and materialisation (Task 4). The inline-image and renderer sections belong to the renderer plan. + +**Placeholder scan:** none; every code step carries the code. + +**Type consistency:** `RawImage`, `ImageDataFromHints`, `ImagePathFromHints`, `PNG`, `ResolveIcon`, `WriteImage`, `ImagesDir`, `SetImage`, and the `NewStore` third parameter are used with the same signatures across tasks. diff --git a/docs/superpowers/plans/2026-09-15-notifyd.md b/docs/superpowers/plans/2026-09-15-notifyd.md new file mode 100644 index 0000000..afb938f --- /dev/null +++ b/docs/superpowers/plans/2026-09-15-notifyd.md @@ -0,0 +1,1829 @@ +# notifyd Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** A Go daemon that owns `org.freedesktop.Notifications`, applies notification policy, and publishes its state as files that quickshell renderers read, plus a `notifyctl` CLI. + +**Architecture:** The daemon is the D-Bus service of the freedesktop notification spec. It keeps a live queue and a history ring in memory, publishes both to `$XDG_RUNTIME_DIR/notifyd/` on every change with an atomic write, and serves `notifyctl` over a private D-Bus interface. Rendering is a separate plan; this plan produces a daemon that works and is testable on its own through `notifyctl`. + +**Tech Stack:** Go, `github.com/godbus/dbus/v5`, `dbus-run-session` for the integration tests, bash for the CLI check. + +**Spec:** `docs/superpowers/specs/2026-09-15-notification-daemon-design.md` (in the quickshell repo; read it before starting). The renderer plan is separate. + +## Global Constraints + +- Go module path `danix.xyz/notifyd`. The only third-party dependency is `github.com/godbus/dbus/v5`; everything else is the standard library. +- GPLv2 only. Ship `LICENSE` with the full GPLv2 text and the standard per-file header notice on every `.go` and `.sh` file. +- The file contract is exact and lives in the spec: `$XDG_RUNTIME_DIR/notifyd/queue.json`, `history.json`, `drawer`, `snooze`. `created` and `expires` are epoch milliseconds, `0` meaning never. +- D-Bus: well-known name `org.freedesktop.Notifications`, object `/org/freedesktop/Notifications`, interface `org.freedesktop.Notifications`. Private control interface `xyz.danix.Notifyd` at object `/xyz/danix/Notifyd`. +- Identity is `danix`; spec version `1.2`; capabilities `actions`, `body-markup`, `icon-static`, `persistence`. +- Close reasons: `1` expired, `2` dismissed, `3` closed by a `CloseNotification` call. +- No home paths in committed files. `gofmt` clean. `go vet ./...` clean. +- Test commands: `go test ./...` for pure logic, `dbus-run-session -- go test ./internal/notify` for the bus test, `bash test-notifyctl.sh` for the end to end check. +- Work in the `notifyd` repo, not the quickshell repo. + +--- + +## File Structure + + notifyd/ + go.mod, go.sum + LICENSE + README.md + cmd/notifyd/main.go claims the bus name, starts the service + cmd/notifyctl/main.go the CLI + internal/notify/ + policy.go urgency, timeout, stack tag, actions (pure) + policy_test.go + store.go the live queue and history ring (pure state) + store_test.go + files.go runtime dir and atomic JSON publish + files_test.go + service.go the D-Bus service and its timers + service_test.go session-bus integration test + control.go the private control interface + scripts/notify-snooze.sh + test-notifyctl.sh + test-notify-snooze.sh + +--- + +### Task 1: The repository, the module, and the policy functions + +**Files:** +- Create: the `notifyd` repo (user step), `go.mod`, `LICENSE`, `README.md` +- Create: `internal/notify/policy.go` +- Test: `internal/notify/policy_test.go` + +**Interfaces:** +- Consumes: nothing. +- Produces: `type Urgency string` with `Low`, `Normal`, `Critical`; `UrgencyFromHints(map[string]dbus.Variant) Urgency`; `EffectiveTimeoutMS(expire int32, u Urgency) int64`; `StackTagFromHints(map[string]dbus.Variant) string`; `ParseActions(flat []string) [][2]string`. Every later task uses these. + +- [x] **Step 1: Create the repo on the server and clone it** (done) + +Created public under the `Linux` cgit section, and cloned to `~/Programming/GIT/notifyd`: + +```bash +gitctl -y repo create notifyd --section Linux --desc "Desktop notification daemon for Hyprland: owns org.freedesktop.Notifications, publishes state as files for a quickshell renderer" +git clone danix_git:notifyd ~/Programming/GIT/notifyd +``` + +All later steps run in `~/Programming/GIT/notifyd`. + +- [ ] **Step 2: Initialise the module and the license** + +```bash +cd ~/Programming/GIT/notifyd +go mod init danix.xyz/notifyd +go get github.com/godbus/dbus/v5@latest +``` + +Fetch the GPLv2 text into `LICENSE`: + +```bash +curl -fsSL https://www.gnu.org/licenses/old-licenses/gpl-2.0.txt -o LICENSE +head -3 LICENSE +``` + +Expected: `GNU GENERAL PUBLIC LICENSE` and `Version 2, June 1991`. + +- [ ] **Step 3: Write the failing test for the policy functions** + +Create `internal/notify/policy_test.go`: + +```go +// 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. + +package notify + +import ( + "testing" + + "github.com/godbus/dbus/v5" +) + +func TestUrgencyFromHints(t *testing.T) { + cases := []struct { + name string + hints map[string]dbus.Variant + want Urgency + }{ + {"missing is normal", map[string]dbus.Variant{}, Normal}, + {"low byte", map[string]dbus.Variant{"urgency": dbus.MakeVariant(byte(0))}, Low}, + {"normal byte", map[string]dbus.Variant{"urgency": dbus.MakeVariant(byte(1))}, Normal}, + {"critical byte", map[string]dbus.Variant{"urgency": dbus.MakeVariant(byte(2))}, Critical}, + {"critical int32", map[string]dbus.Variant{"urgency": dbus.MakeVariant(int32(2))}, Critical}, + {"wrong type is normal", map[string]dbus.Variant{"urgency": dbus.MakeVariant("2")}, Normal}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if got := UrgencyFromHints(c.hints); got != c.want { + t.Errorf("UrgencyFromHints = %q, want %q", got, c.want) + } + }) + } +} + +func TestEffectiveTimeoutMS(t *testing.T) { + cases := []struct { + name string + expire int32 + u Urgency + want int64 + }{ + {"minus one uses low default", -1, Low, 10_000}, + {"minus one uses normal default", -1, Normal, 10_000}, + {"minus one critical never", -1, Critical, 0}, + {"zero never", 0, Normal, 0}, + {"explicit wins", 3_000, Normal, 3_000}, + {"sub second exact", 1, Normal, 1}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if got := EffectiveTimeoutMS(c.expire, c.u); got != c.want { + t.Errorf("EffectiveTimeoutMS(%d, %q) = %d, want %d", c.expire, c.u, got, c.want) + } + }) + } +} + +func TestStackTagFromHints(t *testing.T) { + dunst := map[string]dbus.Variant{"x-dunst-stack-tag": dbus.MakeVariant("mail-a")} + danix := map[string]dbus.Variant{"x-danix-stack-tag": dbus.MakeVariant("mail-b")} + both := map[string]dbus.Variant{ + "x-dunst-stack-tag": dbus.MakeVariant("mail-a"), + "x-danix-stack-tag": dbus.MakeVariant("mail-b"), + } + if got := StackTagFromHints(dunst); got != "mail-a" { + t.Errorf("dunst tag = %q, want mail-a", got) + } + if got := StackTagFromHints(danix); got != "mail-b" { + t.Errorf("danix tag = %q, want mail-b", got) + } + if got := StackTagFromHints(both); got != "mail-a" { + t.Errorf("dunst wins when both present = %q, want mail-a", got) + } + if got := StackTagFromHints(map[string]dbus.Variant{}); got != "" { + t.Errorf("empty = %q, want empty", got) + } +} + +func TestParseActions(t *testing.T) { + got := ParseActions([]string{"default", "open", "other", "do the thing"}) + want := [][2]string{{"default", "open"}, {"other", "do the thing"}} + if len(got) != len(want) { + t.Fatalf("len = %d, want %d", len(got), len(want)) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("action %d = %v, want %v", i, got[i], want[i]) + } + } + if got := ParseActions(nil); len(got) != 0 { + t.Errorf("nil actions = %v, want empty", got) + } +} +``` + +- [ ] **Step 4: Run the test to verify it fails** + +Run: `go test ./internal/notify` +Expected: FAIL, the package does not compile because the functions are undefined. + +- [ ] **Step 5: Write the implementation** + +Create `internal/notify/policy.go`: + +```go +// 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. + +package notify + +import "github.com/godbus/dbus/v5" + +// Urgency is the freedesktop urgency level. +type Urgency string + +const ( + Low Urgency = "low" + Normal Urgency = "normal" + Critical Urgency = "critical" +) + +// DefaultTimeoutMS is the balloon lifetime for an urgency when the client +// leaves the choice to the server (expire_timeout -1). Critical never expires, +// which is 0 here. +func DefaultTimeoutMS(u Urgency) int64 { + switch u { + case Low, Normal: + return 10_000 + default: + return 0 + } +} + +// EffectiveTimeoutMS resolves the client's expire_timeout per the freedesktop +// spec: -1 means the server decides (the urgency default), 0 means never, and +// anything positive is milliseconds and wins. The spec is precise about the +// direction and libnotify's default is -1, so getting it backwards would make +// every plain notify-send immortal. +func EffectiveTimeoutMS(expire int32, u Urgency) int64 { + switch { + case expire == -1: + return DefaultTimeoutMS(u) + case expire == 0: + return 0 + default: + return int64(expire) + } +} + +// UrgencyFromHints reads the urgency byte: 0 low, 1 normal, 2 critical. A +// missing or malformed value is normal, the same default libnotify uses. +func UrgencyFromHints(hints map[string]dbus.Variant) Urgency { + v, ok := hints["urgency"] + if !ok { + return Normal + } + switch n := v.Value().(type) { + case uint8: + switch n { + case 0: + return Low + case 2: + return Critical + } + case int32: + switch n { + case 0: + return Low + case 2: + return Critical + } + } + return Normal +} + +// StackTagFromHints reads the dunst stack tag, then the danix spelling. Both +// mean the same thing and dunst wins when a client sends both. +func StackTagFromHints(hints map[string]dbus.Variant) string { + for _, key := range []string{"x-dunst-stack-tag", "x-danix-stack-tag"} { + if v, ok := hints[key]; ok { + if s, ok := v.Value().(string); ok && s != "" { + return s + } + } + } + return "" +} + +// ParseActions turns the spec's flat [key, label, key, label] array into pairs. +func ParseActions(flat []string) [][2]string { + out := make([][2]string, 0, len(flat)/2) + for i := 0; i+1 < len(flat); i += 2 { + out = append(out, [2]string{flat[i], flat[i+1]}) + } + return out +} +``` + +- [ ] **Step 6: Run the test to verify it passes** + +Run: `go test ./internal/notify` +Expected: PASS. + +- [ ] **Step 7: Write `README.md`** + +```markdown +# notifyd + +A freedesktop notification daemon for this desktop, replacing dunst. + +The daemon owns `org.freedesktop.Notifications` and holds the state. It +publishes the live queue and the history ring as JSON under +`$XDG_RUNTIME_DIR/notifyd/` for a quickshell renderer to draw, and `notifyctl` +is the only thing that talks back over D-Bus. + + go build ./... + +The design and the exact file contract are in the quickshell repo, at +`docs/superpowers/specs/2026-09-15-notification-daemon-design.md`. + +## Development Approach + +This project is developed using AI-assisted tools. Code is generated with the help of AI based on human-provided specifications, design decisions, and iterative feedback. + +All contributions are reviewed, tested, and curated by the maintainer before being included in the codebase. AI is used as a productivity and exploration tool, while human oversight remains central to all decisions. + +The goal is to combine the flexibility of AI-assisted development with standard open-source practices such as transparency, review, and accountability. +``` + +- [ ] **Step 8: Commit** + +```bash +gofmt -w . && go vet ./... && go test ./... +git add . +git commit -m "feat: add the module and the notification policy + +The policy functions are pure so they are tested without a bus: urgency from +the hints, the timeout rule with its urgency defaults, the two stack tag +spellings, and the action pair parse." +``` + +--- + +### Task 2: The store + +**Files:** +- Create: `internal/notify/store.go` +- Test: `internal/notify/store_test.go` + +**Interfaces:** +- Consumes: `Urgency`, `Popup` (defined here). +- Produces: `type Popup struct { ID uint32; App, Summary, Body string; Urgency Urgency; Icon string; Actions [][2]string; Created, Expires int64 }` with the exact JSON tags; `type Store`; `NewStore(emit func(id, reason uint32), publish func(live, history []Popup)) *Store`; `(*Store) Add(n *Popup, stack string, replacesID uint32) (id uint32, replaced bool)`; `(*Store) Expire(id uint32)`; `(*Store) Dismiss(id, reason uint32)`; `(*Store) DismissAll()`; `(*Store) ClearHistory()`; `(*Store) Actionable(id uint32) bool`; `(*Store) Reset()`. Tasks 3 to 6 use all of these. + +- [ ] **Step 1: Write the failing test** + +Create `internal/notify/store_test.go`: + +```go +// 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. + +package notify + +import "testing" + +type event struct { + id uint32 + reason uint32 +} + +func newTestStore() (*Store, *[]event) { + emitted := &[]event{} + s := NewStore( + func(id, reason uint32) { *emitted = append(*emitted, event{id, reason}) }, + func(live, history []Popup) {}, + ) + return s, emitted +} + +func popup(app string) *Popup { + return &Popup{App: app, Urgency: Normal, Created: 1} +} + +func TestAddAssignsIdsFromOne(t *testing.T) { + s, _ := newTestStore() + a, replaced := s.Add(popup("a"), "", 0) + b, _ := s.Add(popup("b"), "", 0) + if replaced { + t.Fatal("first add reported replaced") + } + if a != 1 || b != 2 { + t.Errorf("ids = %d, %d, want 1, 2", a, b) + } +} + +func TestReplaceByIDReusesTheIDAndEmitsNothing(t *testing.T) { + s, emitted := newTestStore() + id, _ := s.Add(popup("a"), "", 0) + *emitted = nil + again, replaced := s.Add(popup("a2"), "", id) + if !replaced { + t.Fatal("replace by id not reported") + } + if again != id { + t.Errorf("replace id = %d, want %d", again, id) + } + if len(*emitted) != 0 { + t.Errorf("replace emitted %v, want none", *emitted) + } +} + +func TestReplaceByStackTag(t *testing.T) { + s, _ := newTestStore() + first, _ := s.Add(popup("mail"), "mail-account", 0) + second, replaced := s.Add(popup("mail"), "mail-account", 0) + if !replaced || second != first { + t.Errorf("stack replace = id %d replaced %v, want id %d replaced true", second, replaced, first) + } +} + +func TestExpireClosesOnceAndKeepsTheEntry(t *testing.T) { + s, emitted := newTestStore() + id, _ := s.Add(popup("a"), "", 0) + s.Expire(id) + s.Expire(id) + if len(*emitted) != 1 || (*emitted)[0].reason != 1 { + t.Fatalf("emitted = %v, want one reason 1", *emitted) + } + if s.Actionable(id) { + t.Error("expired entry still actionable") + } +} + +func TestDismissFilesHistoryAndEmitsDismissed(t *testing.T) { + s, emitted := newTestStore() + id, _ := s.Add(popup("a"), "", 0) + s.Dismiss(id, 2) + if len(*emitted) != 1 || (*emitted)[0].reason != 2 { + t.Fatalf("emitted = %v, want one reason 2", *emitted) + } + if s.Actionable(id) { + t.Error("dismissed entry still live") + } +} + +func TestDismissAfterExpireEmitsNothingMore(t *testing.T) { + s, emitted := newTestStore() + id, _ := s.Add(popup("a"), "", 0) + s.Expire(id) + *emitted = nil + s.Dismiss(id, 2) + if len(*emitted) != 0 { + t.Errorf("dismiss after expire emitted %v, want none", *emitted) + } +} + +func TestQueueCapEvictsToHistory(t *testing.T) { + s, emitted := newTestStore() + for i := 0; i < 21; i++ { + s.Add(popup("a"), "", 0) + } + if len(*emitted) == 0 { + t.Fatal("eviction emitted nothing") + } +} + +func TestHistoryRingCapsAtTwenty(t *testing.T) { + s, _ := newTestStore() + for i := 0; i < 30; i++ { + id, _ := s.Add(popup("a"), "", 0) + s.Dismiss(id, 2) + } + if got := len(s.historySnapshot()); got != 20 { + t.Errorf("history length = %d, want 20", got) + } +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `go test ./internal/notify -run TestAdd` +Expected: FAIL, `Store` is undefined. + +- [ ] **Step 3: Write the implementation** + +Create `internal/notify/store.go`: + +```go +// 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. + +package notify + +import "sync" + +// Popup is one notification as the renderers see it. The JSON tags are the +// file contract in the spec. +type Popup struct { + ID uint32 `json:"id"` + App string `json:"app"` + Summary string `json:"summary"` + Body string `json:"body"` + Urgency Urgency `json:"urgency"` + Icon string `json:"icon"` + Actions [][2]string `json:"actions"` + Created int64 `json:"created"` + Expires int64 `json:"expires"` +} + +// live rounds out a Popup with the state the renderers do not need: the stack +// tag it replaces on, and whether the D-Bus client has already been closed. +type live struct { + Popup + Stack string + Closed bool +} + +const ( + liveCap = 20 + historyCap = 20 +) + +// Store holds the live queue and the history ring. Time, signals and file +// writes are injected, so the whole thing is tested without a bus or a clock. +type Store struct { + mu sync.Mutex + nextID uint32 + order []uint32 + entries map[uint32]*live + history []*live + emit func(id, reason uint32) + publish func(live, history []Popup) +} + +func NewStore(emit func(id, reason uint32), publish func(live, history []Popup)) *Store { + return &Store{ + nextID: 1, + entries: map[uint32]*live{}, + emit: emit, + publish: publish, + } +} + +// Add inserts n, replacing the entry named by replacesID or stack when one +// matches. A replace reuses the id and emits nothing: the old client is told +// nothing because a new client owns the id now. +func (s *Store) Add(n *Popup, stack string, replacesID uint32) (uint32, bool) { + s.mu.Lock() + defer s.mu.Unlock() + + var old *live + if replacesID != 0 { + old = s.entries[replacesID] + } + if old == nil && stack != "" { + for _, id := range s.order { + if s.entries[id].Stack == stack { + old = s.entries[id] + break + } + } + } + add := &live{Popup: *n, Stack: stack} + if old != nil { + add.ID = old.ID + s.entries[add.ID] = add + s.moveToFrontLocked(add.ID) + s.evictLocked() + s.publishLocked() + return add.ID, true + } + add.ID = s.nextID + s.nextID++ + s.entries[add.ID] = add + s.order = append([]uint32{add.ID}, s.order...) + s.evictLocked() + s.publishLocked() + return add.ID, false +} + +// Expire is the balloon timeout: tell the client, keep the entry as inert. +func (s *Store) Expire(id uint32) { + s.mu.Lock() + defer s.mu.Unlock() + n, ok := s.entries[id] + if !ok || n.Closed { + return + } + n.Closed = true + s.emit(id, 1) +} + +// Dismiss is an explicit close from either renderer. The client is told only +// if expiry has not already told it, then the entry is filed. +func (s *Store) Dismiss(id, reason uint32) { + s.mu.Lock() + defer s.mu.Unlock() + n, ok := s.entries[id] + if !ok { + return + } + s.removeLocked(id) + s.fileLocked(n, reason) + s.publishLocked() +} + +// DismissAll dismisses every live entry. +func (s *Store) DismissAll() { + s.mu.Lock() + defer s.mu.Unlock() + for _, id := range append([]uint32(nil), s.order...) { + n := s.entries[id] + if n == nil { + continue + } + s.removeLocked(id) + s.fileLocked(n, 2) + } + s.publishLocked() +} + +// ClearHistory empties the history ring. +func (s *Store) ClearHistory() { + s.mu.Lock() + defer s.mu.Unlock() + s.history = nil + s.publishLocked() +} + +// Actionable reports whether a client is still listening on the id. +func (s *Store) Actionable(id uint32) bool { + s.mu.Lock() + defer s.mu.Unlock() + n, ok := s.entries[id] + return ok && !n.Closed +} + +// Reset makes the state empty, which is what a start needs: nothing from a +// previous run is resurrected. +func (s *Store) Reset() { + s.mu.Lock() + defer s.mu.Unlock() + s.order = nil + s.entries = map[uint32]*live{} + s.history = nil + s.publishLocked() +} + +// historySnapshot is for tests. +func (s *Store) historySnapshot() []*live { + s.mu.Lock() + defer s.mu.Unlock() + return append([]*live(nil), s.history...) +} + +func (s *Store) removeLocked(id uint32) { + delete(s.entries, id) + for i, v := range s.order { + if v == id { + s.order = append(s.order[:i], s.order[i+1:]...) + return + } + } +} + +func (s *Store) moveToFrontLocked(id uint32) { + for i, v := range s.order { + if v == id { + s.order = append(s.order[:i], s.order[i+1:]...) + break + } + } + s.order = append([]uint32{id}, s.order...) +} + +func (s *Store) fileLocked(n *live, reason uint32) { + if !n.Closed { + n.Closed = true + s.emit(n.ID, reason) + } + s.history = append([]*live{n}, s.history...) + if len(s.history) > historyCap { + s.history = s.history[:historyCap] + } +} + +func (s *Store) evictLocked() { + for len(s.order) > liveCap { + id := s.order[len(s.order)-1] + n := s.entries[id] + s.removeLocked(id) + s.fileLocked(n, 1) + } +} + +func (s *Store) publishLocked() { + liveList := make([]Popup, 0, len(s.order)) + for _, id := range s.order { + liveList = append(liveList, s.entries[id].Popup) + } + historyList := make([]Popup, 0, len(s.history)) + for _, n := range s.history { + historyList = append(historyList, n.Popup) + } + s.publish(liveList, historyList) +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `go test ./internal/notify` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```go +gofmt -w . && go vet ./... && go test ./... +git add . +git commit -m "feat: add the notification store + +The store holds the live queue and the history ring as pure state. Expiry +tells the client and keeps the entry inert; dismissal and eviction file it +in history. Replacing reuses the id and emits nothing." +``` + +--- + +### Task 3: Atomic file publishing + +**Files:** +- Create: `internal/notify/files.go` +- Test: `internal/notify/files_test.go` + +**Interfaces:** +- Consumes: `Popup`. +- Produces: `RuntimeDir() string`; `Publish(dir string, live, history []Popup) error`. Task 5 uses both. + +- [ ] **Step 1: Write the failing test** + +Create `internal/notify/files_test.go`: + +```go +// 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. + +package notify + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" +) + +func TestPublishWritesBothFiles(t *testing.T) { + dir := t.TempDir() + live := []Popup{{ID: 1, App: "a", Urgency: Normal, Created: 1, Expires: 2}} + history := []Popup{{ID: 2, App: "b", Urgency: Low, Created: 3}} + if err := Publish(dir, live, history); err != nil { + t.Fatalf("Publish: %v", err) + } + var gotLive []Popup + data, err := os.ReadFile(filepath.Join(dir, "queue.json")) + if err != nil { + t.Fatalf("read queue: %v", err) + } + if err := json.Unmarshal(data, &gotLive); err != nil { + t.Fatalf("queue not JSON: %v", err) + } + if len(gotLive) != 1 || gotLive[0].ID != 1 { + t.Errorf("queue = %+v, want one id 1", gotLive) + } + if _, err := os.Stat(filepath.Join(dir, "history.json")); err != nil { + t.Errorf("history.json missing: %v", err) + } +} + +func TestPublishEmptyIsAnEmptyArray(t *testing.T) { + dir := t.TempDir() + if err := Publish(dir, nil, nil); err != nil { + t.Fatalf("Publish: %v", err) + } + data, _ := os.ReadFile(filepath.Join(dir, "queue.json")) + var got []Popup + if err := json.Unmarshal(data, &got); err != nil { + t.Fatalf("empty queue not JSON array: %v (%s)", err, data) + } + if string(data) != "[]" { + t.Errorf("empty queue encoded as %q, want []", data) + } +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `go test ./internal/notify -run TestPublish` +Expected: FAIL, `Publish` undefined. + +- [ ] **Step 3: Write the implementation** + +Create `internal/notify/files.go`: + +```go +// 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. + +package notify + +import ( + "encoding/json" + "os" + "path/filepath" +) + +// RuntimeDir is where the daemon publishes. It is tmpfs, so a reboot clears +// every file and there is no cleanup code. +func RuntimeDir() string { + if d := os.Getenv("XDG_RUNTIME_DIR"); d != "" { + return filepath.Join(d, "notifyd") + } + return filepath.Join(os.TempDir(), "notifyd") +} + +// Publish writes the live queue and the history ring, each whole, through a +// temporary file and a rename. A reader never sees a half-written value, the +// same atomic write the status registry relies on. +func Publish(dir string, live, history []Popup) error { + if err := os.MkdirAll(dir, 0o700); err != nil { + return err + } + if live == nil { + live = []Popup{} + } + if history == nil { + history = []Popup{} + } + if err := writeJSON(filepath.Join(dir, "queue.json"), live); err != nil { + return err + } + return writeJSON(filepath.Join(dir, "history.json"), history) +} + +func writeJSON(path string, v any) error { + data, err := json.Marshal(v) + if err != nil { + return err + } + tmp, err := os.CreateTemp(filepath.Dir(path), ".tmp-*") + if err != nil { + return err + } + if _, err := tmp.Write(data); err != nil { + tmp.Close() + os.Remove(tmp.Name()) + return err + } + if err := tmp.Close(); err != nil { + os.Remove(tmp.Name()) + return err + } + return os.Rename(tmp.Name(), path) +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `go test ./internal/notify` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +gofmt -w . && go vet ./... && go test ./... +git add . +git commit -m "feat: publish the queue and history atomically + +Both files are written whole through a temporary file and a rename, so a +renderer never reads a half-written value. An empty queue is [] rather than +null, because the renderer parses it as an array." +``` + +--- + +### Task 4: The D-Bus service + +**Files:** +- Create: `internal/notify/service.go` +- Create: `cmd/notifyd/main.go` +- Test: `internal/notify/service_test.go` + +**Interfaces:** +- Consumes: `Store`, `Publish`, `RuntimeDir`, the policy functions. +- Produces: `const Name = "org.freedesktop.Notifications"`; `NewService(conn *dbus.Conn, dir string) *Service`; `(*Service) Start() error`; the service methods `Notify`, `CloseNotification`, `GetCapabilities`, `GetServerInformation`; `(*Service) emitClosed` and `(*Service) arm`. Task 5 adds the control interface against the same `Service`. + +- [ ] **Step 1: Write the failing test** + +Create `internal/notify/service_test.go`: + +```go +// 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. + +package notify + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + "time" + + "github.com/godbus/dbus/v5" +) + +// The integration tests need a session bus this process can own the name on. +// Run them with dbus-run-session -- go test ./internal/notify +func busOrSkip(t *testing.T) *dbus.Conn { + t.Helper() + conn, err := dbus.SessionBus() + if err != nil { + t.Skipf("no session bus: %v", err) + } + reply, err := conn.RequestName(Name, dbus.NameFlagDoNotQueue) + if err != nil { + t.Skipf("cannot request the name: %v", err) + } + // AlreadyOwner happens on the second bus test in one process, because + // dbus.SessionBus is a shared connection. That is fine: the name is ours. + if reply != dbus.RequestNameReplyPrimaryOwner && reply != dbus.RequestNameReplyAlreadyOwner { + t.Skipf("cannot own the name, run under dbus-run-session (reply %v)", reply) + } + return conn +} + +func TestIdentityAndCapabilities(t *testing.T) { + s := &Service{} + name, vendor, _, spec, err := s.GetServerInformation() + if err != nil { + t.Fatalf("GetServerInformation: %v", err) + } + if name != "danix" || vendor != "danix" || spec != "1.2" { + t.Errorf("identity = %q/%q spec %q, want danix/danix 1.2", name, vendor, spec) + } + caps, err := s.GetCapabilities() + if err != nil { + t.Fatalf("GetCapabilities: %v", err) + } + for _, want := range []string{"actions", "body-markup", "icon-static", "persistence"} { + found := false + for _, c := range caps { + if c == want { + found = true + } + } + if !found { + t.Errorf("missing capability %q in %v", want, caps) + } + } +} + +func TestNotifyReturnsAnIDAndPublishes(t *testing.T) { + conn := busOrSkip(t) + dir := t.TempDir() + svc := NewService(conn, dir) + if err := svc.Start(); err != nil { + t.Fatalf("Start: %v", err) + } + + obj := conn.Object(Name, dbus.ObjectPath(objPath)) + call := obj.Call(iface+".Notify", 0, + "app", uint32(0), "icon", "summary", "body", + []string{"default", "open"}, + map[string]dbus.Variant{"urgency": dbus.MakeVariant(byte(1))}, + int32(0)) + if call.Err != nil { + t.Fatalf("Notify: %v", call.Err) + } + var id uint32 + if err := call.Store(&id); err != nil { + t.Fatalf("Notify reply: %v", err) + } + if id == 0 { + t.Fatal("Notify returned id 0") + } + + data, err := os.ReadFile(filepath.Join(dir, "queue.json")) + if err != nil { + t.Fatalf("read queue: %v", err) + } + var live []Popup + if err := json.Unmarshal(data, &live); err != nil { + t.Fatalf("queue not JSON: %v", err) + } + if len(live) != 1 || live[0].Summary != "summary" { + t.Fatalf("queue = %+v, want one summary", live) + } + if live[0].Expires == 0 { + t.Error("expires is zero, want a normal timeout") + } +} + +func TestCloseNotificationEmitsReasonThree(t *testing.T) { + conn := busOrSkip(t) + dir := t.TempDir() + svc := NewService(conn, dir) + if err := svc.Start(); err != nil { + t.Fatalf("Start: %v", err) + } + signals := make(chan *dbus.Signal, 4) + conn.Signal(signals) + if err := conn.AddMatchSignal(dbus.WithMatchObjectPath(dbus.ObjectPath(objPath))); err != nil { + t.Fatalf("AddMatchSignal: %v", err) + } + + obj := conn.Object(Name, dbus.ObjectPath(objPath)) + call := obj.Call(iface+".Notify", 0, "app", uint32(0), "", "s", "b", []string{}, map[string]dbus.Variant{}, int32(-1)) + var id uint32 + if err := call.Store(&id); err != nil { + t.Fatalf("Notify reply: %v", err) + } + if err := obj.Call(iface+".CloseNotification", 0, id).Err; err != nil { + t.Fatalf("CloseNotification: %v", err) + } + + select { + case sig := <-signals: + if sig.Name != iface+".NotificationClosed" { + t.Fatalf("signal %q, want NotificationClosed", sig.Name) + } + if sig.Body[0].(uint32) != id || sig.Body[1].(uint32) != 3 { + t.Errorf("signal body = %v, want id %d reason 3", sig.Body, id) + } + case <-time.After(2 * time.Second): + t.Fatal("no NotificationClosed signal") + } +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `dbus-run-session -- go test ./internal/notify -run TestNotify` +Expected: FAIL, `Service` is undefined. + +- [ ] **Step 3: Write the implementation** + +Create `internal/notify/service.go`: + +```go +// 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. + +package notify + +import ( + "log" + "sync" + "time" + + "github.com/godbus/dbus/v5" +) + +// Name is the well-known bus name the daemon owns. +const Name = "org.freedesktop.Notifications" + +const ( + objPath = "/org/freedesktop/Notifications" + iface = "org.freedesktop.Notifications" + ctrlPath = "/xyz/danix/Notifyd" + ctrlIface = "xyz.danix.Notifyd" +) + +// Service is the org.freedesktop.Notifications object. +type Service struct { + conn *dbus.Conn + store *Store + dir string + + mu sync.Mutex + timers map[uint32]*time.Timer +} + +func NewService(conn *dbus.Conn, dir string) *Service { + s := &Service{conn: conn, dir: dir, timers: map[uint32]*time.Timer{}} + s.store = NewStore(s.emitClosed, func(live, history []Popup) { + if err := Publish(s.dir, live, history); err != nil { + log.Printf("notifyd: publish: %v", err) + } + }) + return s +} + +// Start exports the interfaces and empties the state. Nothing from a previous +// run is resurrected. +func (s *Service) Start() error { + if err := s.conn.Export(s, dbus.ObjectPath(objPath), iface); err != nil { + return err + } + if err := s.conn.Export(&control{s}, dbus.ObjectPath(ctrlPath), ctrlIface); err != nil { + return err + } + s.store.Reset() + return nil +} + +func (s *Service) emitClosed(id, reason uint32) { + s.conn.Emit(dbus.ObjectPath(objPath), iface+".NotificationClosed", id, reason) +} + +// GetCapabilities tells clients what the daemon understands. actions and +// body-markup are load-bearing: mail-notify sends actions and escapes its body +// because the running dunst advertises markup. +func (s *Service) GetCapabilities() ([]string, *dbus.Error) { + return []string{"actions", "body-markup", "icon-static", "persistence"}, nil +} + +func (s *Service) GetServerInformation() (string, string, string, string, *dbus.Error) { + return "danix", "danix", "0.1", "1.2", nil +} + +// Notify is the spec's entry point. The id is returned to the client; a +// replace reuses the id of what it replaced. +func (s *Service) Notify(appName string, replacesID uint32, appIcon, summary, body string, actions []string, hints map[string]dbus.Variant, expireTimeout int32) (uint32, *dbus.Error) { + u := UrgencyFromHints(hints) + tag := StackTagFromHints(hints) + now := time.Now().UnixMilli() + ms := EffectiveTimeoutMS(expireTimeout, u) + n := &Popup{ + App: appName, + Summary: summary, + Body: body, + Urgency: u, + Icon: appIcon, + Actions: ParseActions(actions), + Created: now, + } + if ms > 0 { + n.Expires = now + ms + } + id, _ := s.store.Add(n, tag, replacesID) + s.arm(id, ms) + return id, nil +} + +// CloseNotification is the spec's programmatic close, reason 3. +func (s *Service) CloseNotification(id uint32) *dbus.Error { + s.stopTimer(id) + s.store.Dismiss(id, 3) + return nil +} + +func (s *Service) arm(id uint32, ms int64) { + s.mu.Lock() + defer s.mu.Unlock() + s.stopTimerLocked(id) + if ms <= 0 { + return + } + s.timers[id] = time.AfterFunc(time.Duration(ms)*time.Millisecond, func() { + s.store.Expire(id) + }) +} + +func (s *Service) stopTimer(id uint32) { + s.mu.Lock() + defer s.mu.Unlock() + s.stopTimerLocked(id) +} + +func (s *Service) stopTimerLocked(id uint32) { + if t, ok := s.timers[id]; ok { + t.Stop() + delete(s.timers, id) + } +} +``` + +Create `cmd/notifyd/main.go`: + +```go +// 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. + +package main + +import ( + "log" + + "danix.xyz/notifyd/internal/notify" + "github.com/godbus/dbus/v5" +) + +func main() { + conn, err := dbus.SessionBus() + if err != nil { + log.Fatalf("notifyd: session bus: %v", err) + } + reply, err := conn.RequestName(notify.Name, dbus.NameFlagDoNotQueue) + if err != nil { + log.Fatalf("notifyd: request %s: %v", notify.Name, err) + } + if reply != dbus.RequestNameReplyPrimaryOwner { + log.Fatalf("notifyd: %s is already owned (is dunst running?)", notify.Name) + } + svc := notify.NewService(conn, notify.RuntimeDir()) + if err := svc.Start(); err != nil { + log.Fatalf("notifyd: %v", err) + } + log.Printf("notifyd: listening on %s", notify.Name) + select {} +} +``` + +- [ ] **Step 4: Write a minimal control object so the package compiles** + +Create `internal/notify/control.go` with the struct only; Task 5 fills the methods: + +```go +// 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. + +package notify + +import "github.com/godbus/dbus/v5" + +// control is the private interface notifyctl drives. +type control struct { + svc *Service +} + +func (c *control) CloseAll() *dbus.Error { + c.svc.store.DismissAll() + return nil +} +``` + +- [ ] **Step 5: Run the tests to verify they pass** + +Run: `dbus-run-session -- go test ./internal/notify` +Expected: PASS. Also `go test ./internal/notify` without a bus skips the bus tests and passes. + +- [ ] **Step 6: Commit** + +```bash +gofmt -w . && go vet ./... && dbus-run-session -- go test ./... +git add . +git commit -m "feat: add the D-Bus service and the daemon + +Notify assigns an id and publishes; a replaces_id or stack tag reuses the id. +CloseNotification closes with reason 3. The daemon claims +org.freedesktop.Notifications and exits non-zero if it cannot, which is what +happens while dunst still holds it." +``` + +--- + +### Task 5: The control interface and notifyctl + +**Files:** +- Modify: `internal/notify/control.go` +- Modify: `internal/notify/store.go` (add nothing unless needed; `Actionable` and `Dismiss` exist) +- Create: `cmd/notifyctl/main.go` +- Test: `test-notifyctl.sh` + +**Interfaces:** +- Consumes: `Service`, `Store`, `RuntimeDir`, `Popup`. +- Produces: control methods `CloseAll`, `Dismiss(id)`, `InvokeAction(id, key)`, `ClearHistory`; the `notifyctl` verbs `list`, `history [n]`, `close <id>`, `close-all`, `action <id> <key>`, `clear-history`. + +- [ ] **Step 1: Extend the control interface** + +Replace `internal/notify/control.go` with: + +```go +// 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. + +package notify + +import ( + "errors" + + "github.com/godbus/dbus/v5" +) + +// control is the private interface notifyctl drives. It exists because the +// freedesktop spec has no close-all, no way to invoke an action, and no +// history to clear. +type control struct { + svc *Service +} + +// CloseAll dismisses every live notification, reason 2. +func (c *control) CloseAll() *dbus.Error { + c.svc.store.DismissAll() + return nil +} + +// Dismiss closes one live notification, reason 2, which is what a click on the +// X means. +func (c *control) Dismiss(id uint32) *dbus.Error { + c.svc.stopTimer(id) + c.svc.store.Dismiss(id, 2) + return nil +} + +// InvokeAction emits ActionInvoked for a live notification. An inert entry has +// no client left, so it is an error rather than a silent no-op. +func (c *control) InvokeAction(id uint32, key string) *dbus.Error { + if !c.svc.store.Actionable(id) { + return dbus.MakeFailedError(errors.New("notification is no longer live")) + } + c.svc.conn.Emit(dbus.ObjectPath(objPath), iface+".ActionInvoked", id, key) + return nil +} + +// ClearHistory empties the history ring. +func (c *control) ClearHistory() *dbus.Error { + c.svc.store.ClearHistory() + return nil +} +``` + +- [ ] **Step 2: Write `cmd/notifyctl/main.go`** + +```go +// 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. + +package main + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strconv" + + "danix.xyz/notifyd/internal/notify" + "github.com/godbus/dbus/v5" +) + +func usage() { + fmt.Fprintf(os.Stderr, `usage: %s <verb> + list the live queue as JSON + history [n] the history ring as JSON, default 20 + close <id> dismiss one notification + close-all dismiss every notification + action <id> <key> invoke an action on a live notification + clear-history empty the history ring +`, filepath.Base(os.Args[0])) + os.Exit(2) +} + +func main() { + if len(os.Args) < 2 { + usage() + } + verb := os.Args[1] + + switch verb { + case "list": + printFile("queue.json", 0) + case "history": + limit := 20 + if len(os.Args) >= 3 { + n, err := strconv.Atoi(os.Args[2]) + if err != nil { + usage() + } + limit = n + } + printFile("history.json", limit) + case "close": + need(3) + id := parseID(os.Args[2]) + callControl("Dismiss", id) + case "close-all": + callControl("CloseAll") + case "action": + need(4) + id := parseID(os.Args[2]) + callControl("InvokeAction", id, os.Args[3]) + case "clear-history": + callControl("ClearHistory") + default: + usage() + } +} + +func need(n int) { + if len(os.Args) < n { + usage() + } +} + +func parseID(s string) uint32 { + id, err := strconv.ParseUint(s, 10, 32) + if err != nil { + fmt.Fprintf(os.Stderr, "%s: bad id %q\n", filepath.Base(os.Args[0]), s) + os.Exit(1) + } + return uint32(id) +} + +// printFile reads a published file, because the files are the interface. +func printFile(name string, limit int) { + data, err := os.ReadFile(filepath.Join(notify.RuntimeDir(), name)) + if err != nil { + fmt.Fprintf(os.Stderr, "notifyctl: %v\n", err) + os.Exit(1) + } + if limit > 0 { + var all []notify.Popup + if err := json.Unmarshal(data, &all); err != nil { + fmt.Fprintf(os.Stderr, "notifyctl: %v\n", err) + os.Exit(1) + } + if len(all) > limit { + all = all[:limit] + } + out, _ := json.MarshalIndent(all, "", " ") + fmt.Println(string(out)) + return + } + var pretty any + json.Unmarshal(data, &pretty) + out, _ := json.MarshalIndent(pretty, "", " ") + fmt.Println(string(out)) +} + +// callControl drives the daemon over the private interface. It is the only +// thing that mutates state. +func callControl(method string, args ...any) { + conn, err := dbus.SessionBus() + if err != nil { + fmt.Fprintf(os.Stderr, "notifyctl: session bus: %v\n", err) + os.Exit(1) + } + obj := conn.Object(notify.Name, dbus.ObjectPath("/xyz/danix/Notifyd")) + if err := obj.Call("xyz.danix.Notifyd."+method, 0, args...).Err; err != nil { + fmt.Fprintf(os.Stderr, "notifyctl: %v\n", err) + os.Exit(1) + } +} +``` + +- [ ] **Step 3: Write the failing check `test-notifyctl.sh`** + +```bash +#!/bin/bash +# +# 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. +# +# The one runnable check for notifyctl. It runs the daemon and the CLI on a +# private session bus with a temporary runtime directory, so nothing here +# touches the live notification state. +# +# Usage: ./test-notifyctl.sh (exit 0 = all passed) + +set -u + +here="$(cd "$(dirname "$0")" && pwd)" +tmp="$(mktemp -d)" +trap 'rm -rf "$tmp"' EXIT + +pass=0 +fail=0 +check() { + local label="$1" want="$2" got="$3" + if [[ "$want" == "$got" ]]; then + printf 'ok %s\n' "$label" + pass=$((pass + 1)) + else + printf 'FAIL %s: want %q, got %q\n' "$label" "$want" "$got" + fail=$((fail + 1)) + fi +} + +go build -o "$tmp/notifyd" "$here/cmd/notifyd" || exit 1 +go build -o "$tmp/notifyctl" "$here/cmd/notifyctl" || exit 1 + +mkdir -p "$tmp/run" +export XDG_RUNTIME_DIR="$tmp/run" +export PATH="$tmp:$PATH" + +dbus-run-session -- bash -c ' + set -u + "$1/notifyd" >"$1/daemon.log" 2>&1 & daemon=$! + sleep 0.5 + notifyctl close-all >/dev/null 2>&1 + notify-send -a test -u normal "t1" "b1" || exit 3 + sleep 0.3 + echo "$(notifyctl list | grep -c "\"summary\": \"t1\"")" + notifyctl close-all + sleep 0.3 + echo "$(notifyctl list | grep -c "\"summary\": \"t1\"")" + kill $daemon +' _ "$tmp" > "$tmp/out" 2>"$tmp/err" + +check "list shows the notification" "1" "$(sed -n 1p "$tmp/out")" +check "close-all empties the live queue" "0" "$(sed -n 2p "$tmp/out")" +check "no errors on stderr" "" "$(cat "$tmp/err")" + +printf '\n%d passed, %d failed\n' "$pass" "$fail" +[[ "$fail" -eq 0 ]] +``` + +The daemon's log goes to its own file, so `$tmp/err` only carries real errors. `notify-send` comes from libnotify, which is installed. The sleeps give the daemon time to claim the name before the first send. + +- [ ] **Step 4: Run the check** + +Run: `bash test-notifyctl.sh` +Expected: `3 passed, 0 failed`. (Timing is generous: the daemon needs a moment to claim the name before the first `notify-send`, hence the sleeps.) + +- [ ] **Step 5: Run everything** + +Run: `gofmt -w . && go vet ./... && go test ./... && dbus-run-session -- go test ./... && bash test-notifyctl.sh` +Expected: all pass. + +- [ ] **Step 6: Commit** + +```bash +chmod +x test-notifyctl.sh +git add . +git commit -m "feat: add the control interface and notifyctl + +The private interface carries what the spec cannot: close-all, invoke action +and clear history. notifyctl reads the published files for list and history, +because the files are the interface, and uses D-Bus only for the mutations." +``` + +--- + +### Task 6: notify-snooze.sh + +**Files:** +- Create: `scripts/notify-snooze.sh` +- Test: `test-notify-snooze.sh` + +**Interfaces:** +- Consumes: `$XDG_RUNTIME_DIR/notifyd/` (writes `snooze`). +- Produces: `notify-snooze.sh <minutes|off>`, and the last used value in `~/.local/state/notify-snooze.minutes`. The renderer plan reads both files. + +- [ ] **Step 1: Write the failing check** + +Create `test-notify-snooze.sh`: + +```bash +#!/bin/bash +# +# 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. +# +# Usage: ./test-notify-snooze.sh + +set -u +here="$(cd "$(dirname "$0")" && pwd)" +tmp="$(mktemp -d)" +trap 'rm -rf "$tmp"' EXIT +export XDG_RUNTIME_DIR="$tmp" +export HOME="$tmp/home" +mkdir -p "$HOME/.local/state" + +pass=0; fail=0 +check() { if [[ "$2" == "$3" ]]; then echo "ok $1"; pass=$((pass+1)); else echo "FAIL $1: want $2 got $3"; fail=$((fail+1)); fi; } + +before=$(date +%s) +bash "$here/scripts/notify-snooze.sh" 30 +snooze="$tmp/notifyd/snooze" +check "writes the snooze file" "yes" "$([[ -f "$snooze" ]] && echo yes)" +delta=$(( $(cat "$snooze") - before )) +check "is about 30 minutes out" "yes" "$([[ $delta -ge 1700 && $delta -le 1900 ]] && echo yes)" +check "remembers the minutes" "30" "$(cat "$HOME/.local/state/notify-snooze.minutes")" +bash "$here/scripts/notify-snooze.sh" off +check "off removes the file" "no" "$([[ -f "$snooze" ]] && echo yes || echo no)" +bash "$here/scripts/notify-snooze.sh" nope >/dev/null 2>&1 +check "rejects a bad argument" "1" "$?" + +printf '\n%d passed, %d failed\n' "$pass" "$fail" +[[ "$fail" -eq 0 ]] +``` + +- [ ] **Step 2: Run the check to verify it fails** + +Run: `bash test-notify-snooze.sh` +Expected: FAIL, the script does not exist. + +- [ ] **Step 3: Write the script** + +Create `scripts/notify-snooze.sh`: + +```bash +#!/bin/bash +# +# 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. +# +# Suppress every notification balloon for a number of minutes. The balloon +# renderer reads the file and withholds them; the daemon is untouched. This is +# deliberate: the notification still arrives, still enters the drawer's list +# and still reaches history, only its balloon is held back. +# +# notify-snooze.sh 30 +# notify-snooze.sh off + +set -u + +dir="${XDG_RUNTIME_DIR:-/tmp}/notifyd" +last="${HOME}/.local/state/notify-snooze.minutes" + +case "${1:-}" in + off) + rm -f "$dir/snooze" + exit 0 + ;; + ''|*[!0-9]*) + printf 'usage: %s <minutes|off>\n' "${0##*/}" >&2 + exit 1 + ;; +esac + +mkdir -p "$dir" "$(dirname "$last")" +printf '%s\n' "$(( $(date +%s) + $1 * 60 ))" > "$dir/snooze.tmp" +mv "$dir/snooze.tmp" "$dir/snooze" +printf '%s\n' "$1" > "$last.tmp" +mv "$last.tmp" "$last" +``` + +- [ ] **Step 4: Run the check to verify it passes** + +Run: `bash test-notify-snooze.sh` +Expected: `5 passed, 0 failed`. + +- [ ] **Step 5: Commit** + +```bash +chmod +x scripts/notify-snooze.sh test-notify-snooze.sh +git add . +git commit -m "feat: add notify-snooze.sh + +Snooze is a file the balloon renderer reads, not daemon state: the +notification still arrives, lists and files, only its balloon is withheld. +The last used value is kept under XDG state so a reboot does not forget it." +``` + +--- + +### Task 7: Install and handover + +**Files:** +- Modify: `README.md` (add the install and handover section) + +**Interfaces:** +- Consumes: the built binaries and scripts. +- Produces: the user runbook that switches the desktop off dunst. + +- [ ] **Step 1: Add the install and handover section to `README.md`** + +```markdown +## Install and handover + +Build and install the two binaries and the script, beside the statusctl CLI: + + go build -o ~/bin/notifyd ./cmd/notifyd + go build -o ~/bin/notifyctl ./cmd/notifyctl + install -m 755 scripts/notify-snooze.sh ~/bin/notify-snooze.sh + +dunst is not removed until this proves itself. To switch: + +1. Stop dunst (`pkill -x dunst` or its service) so the bus name is free. +2. Add `hl.exec_cmd("notifyd")` to `~/.config/hypr/sections/autostart.lua`, + beside the quickshell lines. +3. Change `rofipass`'s one `dunstctl close-all` to `notifyctl close-all`. +4. Start `notifyd` and send a test notification. + +The renderer that draws the balloons is a separate plan; until it ships the +queue is visible through `notifyctl list`. +``` + +- [ ] **Step 2: Verify a real notification round trip by hand** + +Ask the user to run, with `notifyd` started in another terminal: + +```bash +notifyd & sleep 1 +notify-send -a test -u normal "hello" "world" +notifyctl list +notifyctl history +``` + +Expected: `notifyctl list` shows the notification; after it times out or is closed, it is in `notifyctl history`. + +- [ ] **Step 3: Run the full suite once more** + +Run: `gofmt -w . && go vet ./... && go test ./... && dbus-run-session -- go test ./... && bash test-notifyctl.sh && bash test-notify-snooze.sh` +Expected: all pass. + +- [ ] **Step 4: Commit and push** + +```bash +git add . +git commit -m "docs: add the install and handover runbook" +git push +``` + +--- + +## Notes for the implementer + +**The two lifetimes are the point.** Expiry emits `NotificationClosed(id, 1)` and keeps the entry; dismissal and eviction file it in history. Do not collapse those: a `--wait` client must be freed on time, and the drawer's list must outlive the balloon. + +**The file JSON is the interface.** A renderer in another plan parses `queue.json` and `history.json`. Changing a key name breaks it; the spec is the authority. + +**`notifyctl` reads files for queries and uses D-Bus only for mutations.** That is deliberate, so a query works even if the bus call would be pointless, and so the files stay the single source the renderers read. + +**`replaces_id` reuse emits nothing.** A client that receives a new id owns it; the old client is not told anything, because the notification it sent was replaced, not closed. diff --git a/docs/superpowers/plans/2026-09-15-status-registry.md b/docs/superpowers/plans/2026-09-15-status-registry.md new file mode 100644 index 0000000..c98769d --- /dev/null +++ b/docs/superpowers/plans/2026-09-15-status-registry.md @@ -0,0 +1,1265 @@ +# Status Registry Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** A `status` singleton and drawer module owning desktop modes (`dnd`, `presentation`) as files in `$XDG_RUNTIME_DIR`, with a `statusctl` CLI so anything on the system can read, set and watch them. + +**Architecture:** One `pragma Singleton` in `shared/Status.qml`, symlinked into `desktop/`, holding a `FileView` per mode with `atomicWrites` and `watchChanges` set explicitly. Modes are booleans; `presentation` additionally asserts an `IdleInhibitor`, pauses breaktimer through a `Process`, and drives `dnd` while recording the prior value. A thin drawer module renders a tile and a page over the singleton. `~/bin/statusctl` reads and writes the same files directly, so it works when the shell is down. + +**Tech Stack:** Quickshell 0.3.1, Qt6 QML, `Quickshell.Io.FileView`, `Quickshell.Wayland.IdleInhibitor`, bash, `inotifywait` (inotify-tools 4.23.9.0). + +**Spec:** `docs/superpowers/specs/2026-09-15-status-registry-design.md` + +--- + +## Global Constraints + +- Quickshell 0.3.1, Qt6 QML. Run configs with `qs -p ./desktop`. The running process is `qs`: `pkill -x qs`, `pgrep -cx qs`, never `pkill -f` (it kills the calling shell). +- GPLv2 only. Every new `.qml` file begins with this exact header, no exceptions: + +```qml +// 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. +``` + +Shell scripts use the same notice with `#` comment markers, after the shebang. + +- Module files live under `desktop/modules/status/` and reference root types (`Module`, `Page`, `Switch`, `Theme`), so each uses `import "../.."`. +- Inject the module into its tile and page under a short name, never `mod`. A component property named the same as the enclosing object's `id` binds to itself and arrives undefined. This module uses `st`. +- Reusing a `Process` needs `running = false` immediately before `running = true`. +- No em dashes anywhere. No home paths in committed files; `~` in documentation only. Nerd Font glyphs are written as `\uXXXX` in QML and their bytes verified with `git diff`. +- Every glyph must exist in Inconsolata Nerd Font and mean what it says. Check the font cmap rather than trusting a codepoint. +- Smoke-check command, harness owns the process and the log is read, never a later `pgrep`: + +```bash +timeout 8 qs -p ./desktop 2>&1 | grep -E 'ERROR|TypeError|ReferenceError|is not defined|Cannot assign|Unable to assign' && echo "ERRORS ABOVE" || echo "clean" +``` + +Expected: `clean`. This briefly starts a second drawer instance; it dies with the timeout. + +## Verified Facts (probed on this machine, 2026-09-15) + +Do not re-probe these; they are measured, not assumed. + +- `$XDG_RUNTIME_DIR` is `/run/user/1000`, a tmpfs (`mode=700,uid=1000` in `/proc/mounts`). A reboot clears it. +- `elogind` runs here (PID present, `pam_elogind.so` in the PAM stack for `login`, `sddm`, `xdm`, `kde`, `loginctl list-sessions` shows a tracked session on seat0). `man 8 pam_elogind` documents removing the runtime directory at last logout but also says the module no-ops when the system was not booted with elogind as init, which on Slackware it is not. Do not claim logout clears the files. +- The compositor advertises `zwp_idle_inhibit_manager_v1` version 1 (`wayland-info`). waybar's built-in `idle_inhibitor` already drives it, so hypridle honours the Wayland path. No D-Bus inhibit needed. +- `inotifywait` is `/usr/bin/inotifywait`, version 4.23.9.0. +- No `statusctl` or `notifyctl` on PATH, no `status.*` in the runtime directory, no colliding names in `~/bin`. +- `breaktimer.sh` accepts `start stop pause resume toggle status`, keeps `running|paused` in `$XDG_RUNTIME_DIR/breaktimer.state`, and is autostarted from `autostart.lua`. The registry must never write that file, only call the verbs. +- `Theme` carries `base surface text subtext red green yellow surfaceAlt overlay accent`, plus `fontFamily`, `fontSize` (16) and `iconFamily`. +- Inconsolata Nerd Font lives in `~/.fonts/i/InconsolataNerdFont-Regular.ttf`, not under + `/usr/share/fonts` or `~/.local/share/fonts`. A cmap search that misses `~/.fonts` reports + every codepoint absent, which reads as a missing glyph rather than a bad search. The font + carries 11326 codepoints and `\uf205` is among them, confirmed with fontTools. +- The keepalive `PanelWindow` in `desktop/shell.qml` currently has **no `id`**. Task 3 adds one; `IdleInhibitor.window` needs a non-null reference. + +## Unverified, confirm during implementation + +Two claims come from the documentation and have not been observed running. Record what actually happens in the task report, and add an `AGENTS.md` trap in Task 8 for whichever bites. + +- `FileView` with `watchChanges: true` is documented to fire `fileChanged` on its own `setText()`. If so, the singleton sees its own writes and must not re-enter. Task 1 handles this with a value comparison rather than a re-entrancy flag; confirm the comparison is actually needed. +- `IdleInhibitor` is documented to need a non-null `window` to do anything. Confirm that assigning the keepalive window is sufficient. The `hyprctl clients` count does not change: the inhibit lands on a layer surface, which that listing ignores, so check it behaviorally with a throwaway `hypridle -c` (Task 4 Step 4). + +--- + +## File Structure + +| file | responsibility | +|---|---| +| `shared/Status.qml` (create) | The singleton. Mode files, read/write, effects, the DND restore rule. | +| `desktop/Status.qml` (create, symlink) | Resolves the singleton for the drawer, matching `Theme.qml`. | +| `desktop/shell.qml` (modify) | Give the keepalive window an `id`; register `StatusModule`. | +| `desktop/modules/status/StatusModule.qml` (create) | Registration, tile and page components. | +| `desktop/modules/status/StatusTile.qml` (create) | Active mode count as the tile state line. | +| `desktop/modules/status/StatusPage.qml` (create) | One row per mode with a `Switch`. | +| `desktop/modules/status/StatusRow.qml` (create) | One mode row: label, description, switch. | +| `desktop/modules/status/README.md` (create) | Module notes, per repo convention. | +| `~/bin/statusctl` (create, outside repo) | CLI: get, set, toggle, watch. | +| `desktop/modules/status/statusctl` (create) | The tracked copy of the CLI, installed to `~/bin` by the user. | +| `desktop/modules/status/test-statusctl.sh` (create) | The one runnable check. | + +`statusctl` lives in the repo and is copied to `~/bin` by the user, the same arrangement as `mail-notify.sh` and `waybar-mail.sh` under `modules/mail/`. + +--- + +### Task 1: The singleton, DND only + +**Files:** +- Create: `shared/Status.qml` +- Create: `desktop/Status.qml` (symlink) + +**Interfaces:** +- Consumes: `Quickshell.Io.FileView`. +- Produces: singleton `Status` with `readonly property bool dnd`, `function setMode(name, on)`, `function toggleMode(name)`, `readonly property int activeCount`. Tasks 2 through 6 use all of these. + +- [ ] **Step 1: Write `shared/Status.qml` with the dnd mode only** + +```qml +// 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 + +// 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. +Singleton { + id: root + + readonly property string dir: Quickshell.env("XDG_RUNTIME_DIR") || "/tmp" + + readonly property bool dnd: dndFile.value + + // Number of modes currently on. The tile shows this. + readonly property int activeCount: (root.dnd ? 1 : 0) + + function setMode(name, on) { + if (name === "dnd") dndFile.write(on); + } + + function toggleMode(name) { + if (name === "dnd") dndFile.write(!root.dnd); + } + + // One mode file. Reads "1" as true and anything else, including a missing + // file, as false. + 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" } +} +``` + +- [ ] **Step 2: Create the symlink** + +```bash +ln -s ../shared/Status.qml desktop/Status.qml +ls -l desktop/Status.qml +``` + +Expected: `desktop/Status.qml -> ../shared/Status.qml`. + +- [ ] **Step 3: Smoke check that the singleton parses** + +```bash +timeout 8 qs -p ./desktop 2>&1 | grep -E 'ERROR|TypeError|ReferenceError|is not defined|Cannot assign|Unable to assign' && echo "ERRORS ABOVE" || echo "clean" +``` + +Expected: `clean`. The singleton is not referenced by anything yet, so this only proves it compiles when resolved. + +- [ ] **Step 4: Commit** + +```bash +git add shared/Status.qml desktop/Status.qml +git commit -m "feat(desktop): add the status singleton with the dnd mode + +Modes live as files under XDG_RUNTIME_DIR, one per mode, holding 0 or 1, +with a missing file meaning off. That directory is tmpfs, so a reboot +resets every mode and no cleanup code is needed. + +FileView covers both directions: atomicWrites for the write, watchChanges +for the watch, so an external writer repaints the drawer with no polling. +The documented behaviour is that a FileView fires its own fileChanged on +setText, so the reparse compares before assigning and a self-write is a +no-op rather than a loop." +``` + +--- + +### Task 2: The statusctl CLI + +**Files:** +- Create: `desktop/modules/status/statusctl` +- Test: `desktop/modules/status/test-statusctl.sh` + +**Interfaces:** +- Consumes: the file format from Task 1 (`0`/`1`, missing means off). +- Produces: `statusctl <mode> get|set|toggle|watch`. Task 7 installs it and wires waybar to `watch` and `toggle`. + +- [ ] **Step 1: Write the failing test** + +```bash +mkdir -p desktop/modules/status +cat > desktop/modules/status/test-statusctl.sh <<'SCRIPT' +#!/bin/bash +# +# 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. +# +# The one runnable check for statusctl. It points XDG_RUNTIME_DIR at a +# temporary directory, so nothing here touches the live modes. +# +# Usage: ./test-statusctl.sh (exit 0 = all passed) + +set -u + +here="$(cd "$(dirname "$0")" && pwd)" +ctl="$here/statusctl" +tmp="$(mktemp -d)" +trap 'rm -rf "$tmp"' EXIT +export XDG_RUNTIME_DIR="$tmp" + +pass=0 +fail=0 + +check() { + local label="$1" want="$2" got="$3" + if [[ "$want" == "$got" ]]; then + printf 'ok %s\n' "$label" + pass=$((pass + 1)) + else + printf 'FAIL %s: want %q, got %q\n' "$label" "$want" "$got" + fail=$((fail + 1)) + fi +} + +# A mode with no file reads as off. +check "missing file reads 0" "0" "$("$ctl" dnd get)" + +# set writes the file and get reads it back. +"$ctl" dnd set 1 +check "set 1 writes the file" "1" "$(cat "$tmp/status.dnd" | tr -d '[:space:]')" +check "get after set 1" "1" "$("$ctl" dnd get)" + +# toggle flips it. +"$ctl" dnd toggle +check "toggle from 1" "0" "$("$ctl" dnd get)" +"$ctl" dnd toggle +check "toggle from 0" "1" "$("$ctl" dnd get)" + +# set 0 writes rather than removing, so a reader sees an explicit off. +"$ctl" dnd set 0 +check "set 0 writes the file" "0" "$("$ctl" dnd get)" + +# An unknown mode is an error, not a silent success: a typo must not look +# like a mode that is off. +"$ctl" nosuch get >/dev/null 2>&1 +check "unknown mode exits non-zero" "1" "$?" + +# watch prints a line on change, and the class reflects the value. The +# atomic write arrives as a rename, which is why the watch needs moved_to. +out="$tmp/watch.out" +"$ctl" presentation watch > "$out" 2>/dev/null & +watcher=$! +sleep 0.3 +"$ctl" presentation set 1 +sleep 0.5 +kill "$watcher" 2>/dev/null +wait "$watcher" 2>/dev/null +check "watch reports activated" "1" "$(grep -c '"class": *"activated"' "$out")" + +# A missing file is reported as down, distinct from a mode that is off. +rm -f "$tmp/status.presentation" +out2="$tmp/watch2.out" +"$ctl" presentation watch > "$out2" 2>/dev/null & +watcher2=$! +sleep 0.5 +kill "$watcher2" 2>/dev/null +wait "$watcher2" 2>/dev/null +check "watch reports down when absent" "1" "$(grep -c '"class": *"down"' "$out2")" + +printf '\n%d passed, %d failed\n' "$pass" "$fail" +[[ "$fail" -eq 0 ]] +SCRIPT +chmod +x desktop/modules/status/test-statusctl.sh +``` + +- [ ] **Step 2: Run it to verify it fails** + +```bash +bash desktop/modules/status/test-statusctl.sh +``` + +Expected: every check fails, because `statusctl` does not exist. The first line reads `FAIL missing file reads 0`. + +- [ ] **Step 3: Write `statusctl`** + +```bash +cat > desktop/modules/status/statusctl <<'SCRIPT' +#!/bin/bash +# +# 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. +# +# Read, set and watch desktop modes. The modes are files under +# XDG_RUNTIME_DIR holding 0 or 1; a missing file means off. +# +# This talks to the files, not to the shell, so it works while quickshell is +# down. Setting a mode that way records the state without firing its effects; +# the shell sees the change through its own watch and reasserts them. +# +# statusctl <mode> get prints 0 or 1 +# statusctl <mode> set 0|1 +# statusctl <mode> toggle +# statusctl <mode> watch waybar JSON on every change + +set -u + +MODES="dnd presentation" +DIR="${XDG_RUNTIME_DIR:-/tmp}" + +usage() { + printf 'usage: %s <%s> <get|set 0|1|toggle|watch>\n' \ + "${0##*/}" "$(printf '%s' "$MODES" | tr ' ' '|')" >&2 + exit 1 +} + +[[ $# -ge 2 ]] || usage + +mode="$1" +action="$2" + +# A typo must fail loudly rather than read as a mode that happens to be off. +case " $MODES " in + *" $mode "*) ;; + *) printf '%s: unknown mode: %s\n' "${0##*/}" "$mode" >&2; exit 1 ;; +esac + +file="$DIR/status.$mode" + +read_mode() { + local v="" + # A missing file is the normal state before anything has written one and + # reads as off. Guarding on existence keeps the shell's redirection error + # off stderr: `2>/dev/null` on the command cannot suppress a failure of + # its own input redirect. + [[ -e "$file" ]] && v="$(tr -d '[:space:]' < "$file" 2>/dev/null)" + [[ "$v" == "1" ]] && printf '1' || printf '0' +} + +# Write through a temporary file and rename, so no reader ever sees a +# half-written value. This is also what FileView does on the QML side, and it +# is why a watcher has to listen for moved_to as well as close_write. +write_mode() { + local want="$1" tmp + tmp="$(mktemp "$DIR/.status.$mode.XXXXXX")" || exit 1 + printf '%s\n' "$want" > "$tmp" + # The temp file is made in the same directory as the target, so this is a + # rename rather than a copy, and therefore atomic. A failure here has to + # be loud: reporting success on a write that did not land would leave the + # caller and the shell disagreeing about the mode, with an orphan temp + # file as the only trace. + mv -f "$tmp" "$file" || { rm -f "$tmp"; exit 1; } +} + +emit() { + local state="$1" + printf '{"text": "", "alt": "%s", "class": "%s", "tooltip": "%s"}\n' \ + "$state" "$state" "$(tooltip "$state")" +} + +tooltip() { + case "$1" in + activated) printf '%s: on' "$mode" ;; + deactivated) printf '%s: off' "$mode" ;; + down) printf '%s: no state file' "$mode" ;; + esac +} + +state_now() { + [[ -e "$file" ]] || { printf 'down'; return; } + [[ "$(read_mode)" == "1" ]] && printf 'activated' || printf 'deactivated' +} + +case "$action" in + get) + read_mode + printf '\n' + ;; + set) + [[ $# -eq 3 ]] || usage + case "$3" in + 0|1) write_mode "$3" ;; + *) usage ;; + esac + ;; + toggle) + [[ "$(read_mode)" == "1" ]] && write_mode 0 || write_mode 1 + ;; + watch) + emit "$(state_now)" + # Watch the directory rather than the file: an atomic write replaces + # the file, so a watch held on the old inode dies with it. This is the + # same trap the mail watcher hit with Xapian. + inotifywait -q -m -e close_write,moved_to,delete --format '%f' "$DIR" 2>/dev/null | + while read -r changed; do + [[ "$changed" == "status.$mode" ]] || continue + emit "$(state_now)" + done + ;; + *) + usage + ;; +esac +SCRIPT +chmod +x desktop/modules/status/statusctl +``` + +- [ ] **Step 4: Run the test to verify it passes** + +```bash +bash desktop/modules/status/test-statusctl.sh +``` + +Expected: `9 passed, 0 failed`, exit 0. + +- [ ] **Step 5: Commit** + +```bash +git add desktop/modules/status/statusctl desktop/modules/status/test-statusctl.sh +git commit -m "feat(desktop): add the statusctl CLI and its check + +statusctl reads and writes the mode files directly rather than going +through the shell, so it works while quickshell is down. Setting a mode +that way records the state without firing its effects; the shell sees the +change through its own watch and reasserts them. + +The watch listens on the directory, not the file: an atomic write replaces +the file, so a watch held on the old inode dies with it. Same trap the mail +watcher hit with Xapian, and the reason moved_to is in the event list. + +An unknown mode exits non-zero rather than reading as off, so a typo +cannot masquerade as a mode that happens to be disabled." +``` + +--- + +### Task 3: Give the keepalive window an id + +**Files:** +- Modify: `desktop/shell.qml:26-34` + +**Interfaces:** +- Produces: `keepalive`, referenced by `IdleInhibitor.window` in Task 4. Nothing else changes. + +- [ ] **Step 1: Add the id** + +In `desktop/shell.qml`, the keepalive `PanelWindow` currently opens with `visible: true`. Add an `id` as its first line and extend the comment: + +```qml + // Quickshell exits once no window is visible, and the drawer is closed + // most of the time. See AGENTS.md. + // + // It is also the window the idle inhibitor attaches to: IdleInhibitor + // needs a non-null window, and this is the one window that exists for + // the whole life of the shell. + PanelWindow { + id: keepalive + visible: true + implicitWidth: 1 + implicitHeight: 1 + color: "transparent" + exclusionMode: ExclusionMode.Ignore + mask: Region {} + WlrLayershell.keyboardFocus: WlrKeyboardFocus.None + } +``` + +- [ ] **Step 2: Smoke check** + +```bash +timeout 8 qs -p ./desktop 2>&1 | grep -E 'ERROR|TypeError|ReferenceError|is not defined|Cannot assign|Unable to assign' && echo "ERRORS ABOVE" || echo "clean" +``` + +Expected: `clean`. + +- [ ] **Step 3: Commit** + +```bash +git add desktop/shell.qml +git commit -m "refactor(desktop): name the keepalive window + +IdleInhibitor needs a non-null window and this is the only one that lives +for the whole session, so presentation mode attaches to it. Naming it is a +prerequisite for that and changes nothing else." +``` + +--- + +### Task 4: Presentation mode and its effects + +**Files:** +- Modify: `shared/Status.qml` +- Modify: `desktop/shell.qml` (pass the keepalive window to the singleton) + +**Interfaces:** +- Consumes: `keepalive` from Task 3, `breaktimer.sh`. +- Produces: `Status.presentation`, `Status.inhibitWindow` (write-once from `shell.qml`), and the DND restore rule. Tasks 5 and 6 render these. + +- [ ] **Step 1: Add the presentation mode, the effects, and the restore rule** + +Replace the body of `shared/Status.qml` after the header with this. The `ModeFile` component is unchanged from Task 1; the additions are the second `ModeFile`, `inhibitWindow`, the `IdleInhibitor`, the breaktimer `Process`, and the `setMode` logic. + +```qml +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. + 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 } + + // 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" } +} +``` + +- [ ] **Step 2: Hand the keepalive window to the singleton** + +In `desktop/shell.qml`, inside the `PanelWindow` from Task 3, add a completion handler as its last line before the closing brace: + +```qml + WlrLayershell.keyboardFocus: WlrKeyboardFocus.None + + // The singleton has no window of its own and IdleInhibitor needs one. + Component.onCompleted: Status.inhibitWindow = keepalive + } +``` + +- [ ] **Step 3: Smoke check** + +```bash +timeout 8 qs -p ./desktop 2>&1 | grep -E 'ERROR|TypeError|ReferenceError|is not defined|Cannot assign|Unable to assign' && echo "ERRORS ABOVE" || echo "clean" +``` + +Expected: `clean`. + +- [ ] **Step 4: Confirm the effects fire, with the live shell** + +This needs the user's running shell, not the transient smoke instance. Ask the user to restart their drawer (`pkill -x qs` then the three `qs` lines from `autostart.lua`, or a logout), then run: + +```bash +statusctl_path=desktop/modules/status/statusctl +cat > /tmp/hypridle-check.conf <<'EOF' +listener { + timeout = 8 + on-timeout = touch /tmp/hypridle-fired +} +EOF +rm -f /tmp/hypridle-fired +hypridle -c /tmp/hypridle-check.conf & +idle_pid=$! + +bash "$statusctl_path" presentation set 1 +sleep 1 +echo "dnd now: $(bash "$statusctl_path" dnd get) (expect 1)" +~/bin/breaktimer.sh status +sleep 10 +test -e /tmp/hypridle-fired && echo "inhibitor FAILED: fired while presenting" || echo "inhibitor held" + +bash "$statusctl_path" presentation set 0 +sleep 1 +echo "dnd now: $(bash "$statusctl_path" dnd get) (expect 0)" +~/bin/breaktimer.sh status +sleep 10 +test -e /tmp/hypridle-fired && echo "inhibitor released, idle fired" || echo "inhibitor STUCK" +kill "$idle_pid" +``` + +Expected: `dnd now: 1`, breaktimer reports `paused`, `inhibitor held`, then `dnd now: 0`, breaktimer reports `running` and `inhibitor released, idle fired`. The check is behavioral because `hyprctl clients | grep -ci inhibit` counts the per-toplevel `inhibitingIdle` field only; the inhibitor is asserted on the keepalive layer surface, which that listing does not cover, so the count never moves. Record the marker results in the task report. + +- [ ] **Step 5: Commit** + +```bash +git add shared/Status.qml desktop/shell.qml +git commit -m "feat(desktop): add presentation mode and its effects + +Presentation mode sets DND, asserts a Wayland idle inhibitor and pauses +breaktimer. The effects hang off the mode property rather than the setter, +so a mode set with statusctl while the drawer is closed asserts them too. + +DND has two writers once presentation mode exists, so turning presentation +off restores the value DND had before rather than clearing it, or an +afternoon of hand-set DND would vanish when a talk ends. That prior value +lives in the singleton, not in a file: it means nothing once presentation +mode is off, and presentation mode does not survive a reboot. + +breaktimer owns its own state file and is driven only through its verbs. +Two writers on that file would race with its daemon loop." +``` + +--- + +### Task 5: The module, tile and row + +**Files:** +- Create: `desktop/modules/status/StatusModule.qml` +- Create: `desktop/modules/status/StatusTile.qml` +- Create: `desktop/modules/status/StatusRow.qml` + +**Interfaces:** +- Consumes: `Status`, the `Module`, `Tile`, `Switch`, `Theme` root types. +- Produces: `StatusModule` with `name: "status"`, injected into its children as `st`. Task 6 adds the page and registers the module. + +- [ ] **Step 1: Create `StatusModule.qml`** + +```qml +// 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. + +import QtQuick +import "../.." + +// Always active: the singleton holds the modes and their effects, and those +// have to be asserted whether or not anyone has opened the drawer. The module +// itself is thin, a tile and a page over Status. +Module { + id: mod + + name: "status" + label: "Status" + alwaysActive: true + + // A toggle glyph, present in Inconsolata Nerd Font. + icon: "\uf205" + + // The tile renders in its accent while any mode is on. + active: Status.activeCount > 0 + + tileContent: Component { StatusTile { st: mod } } + + page: Component { + Page { + title: "Status" + StatusPage { width: parent.width } + } + } +} +``` + +- [ ] **Step 2: Create `StatusTile.qml`** + +```qml +// 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. + +import QtQuick +import "../.." + +// Injected as st, never mod: a property named the same as the enclosing +// object's id binds to itself and arrives undefined. See AGENTS.md. +Text { + required property var st + + width: parent ? parent.width : implicitWidth + elide: Text.ElideRight + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } + color: Status.activeCount > 0 ? Theme.text : Theme.subtext + text: Status.presentation ? "Presenting" + : Status.dnd ? "Do not disturb" + : "All clear" +} +``` + +- [ ] **Step 3: Create `StatusRow.qml`** + +```qml +// 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. + +import QtQuick +import "../.." + +// One mode: a label, a line saying what it does, and a switch. +Item { + id: row + + required property string mode + required property string label + required property string description + required property bool value + + implicitHeight: Math.max(texts.implicitHeight, sw.implicitHeight) + 16 + + Column { + id: texts + anchors { + left: parent.left + right: sw.left; rightMargin: 12 + verticalCenter: parent.verticalCenter + } + spacing: 2 + + Text { + text: row.label + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2; bold: true } + color: Theme.text + } + + Text { + width: parent.width + wrapMode: Text.WordWrap + text: row.description + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } + color: Theme.subtext + } + } + + Switch { + id: sw + anchors { right: parent.right; verticalCenter: parent.verticalCenter } + checked: row.value + onToggled: Status.toggleMode(row.mode) + } +} +``` + +- [ ] **Step 4: Smoke check** + +```bash +timeout 8 qs -p ./desktop 2>&1 | grep -E 'ERROR|TypeError|ReferenceError|is not defined|Cannot assign|Unable to assign' && echo "ERRORS ABOVE" || echo "clean" +``` + +Expected: `clean`. `StatusPage` does not exist yet, but the page `Component` is lazily loaded, so nothing instantiates it. + +- [ ] **Step 5: Commit** + +```bash +git add desktop/modules/status/StatusModule.qml desktop/modules/status/StatusTile.qml desktop/modules/status/StatusRow.qml +git commit -m "feat(desktop): add the status module, tile and row + +The module is thin because the singleton owns the modes: it is a tile and +a page over Status, always active so the effects hold whether or not the +drawer has been opened. + +Injected as st rather than mod, since a component property named the same +as the enclosing object's id binds to itself and arrives undefined." +``` + +--- + +### Task 6: The page, and register the module + +**Files:** +- Create: `desktop/modules/status/StatusPage.qml` +- Modify: `desktop/shell.qml` (import and module registry) + +**Interfaces:** +- Consumes: `StatusRow`, `Status`. +- Produces: the finished module in the drawer grid. + +- [ ] **Step 1: Create `StatusPage.qml`** + +```qml +// 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. + +import QtQuick +import "../.." + +// One row per mode. Adding a mode is one file in the singleton and one row +// here, which is the point of a registry rather than two toggles. +Column { + id: page + + spacing: 4 + + StatusRow { + width: page.width + mode: "dnd" + label: "Do not disturb" + description: "Silences notification popups. Critical ones still appear." + value: Status.dnd + } + + Rectangle { + width: page.width + height: 1 + color: Qt.alpha(Theme.text, 0.08) + } + + StatusRow { + width: page.width + mode: "presentation" + label: "Presentation" + description: "Do not disturb, no screen lock, breaktimer paused." + value: Status.presentation + } +} +``` + +- [ ] **Step 2: Register the module in `desktop/shell.qml`** + +Add the import beside the others, keeping them alphabetical: + +```qml +import "modules/sound" +import "modules/status" +import "modules/vm" +``` + +Add the module to the registry. Status goes last before `VmModule`, so the grid reads Sound, Network, Bluetooth, KdeConnect, Mail, Appearance, Status, Machines: + +```qml + modules: [ + SoundModule {}, + NetworkModule {}, + BluetoothModule {}, + KdeConnectModule {}, + MailModule {}, + AppearanceModule {}, + StatusModule {}, + VmModule {}, + ] +``` + +- [ ] **Step 3: Smoke check** + +```bash +timeout 8 qs -p ./desktop 2>&1 | grep -E 'ERROR|TypeError|ReferenceError|is not defined|Cannot assign|Unable to assign' && echo "ERRORS ABOVE" || echo "clean" +``` + +Expected: `clean`. + +- [ ] **Step 4: Confirm the page renders and the switches drive the modes** + +Hot reload does not pick up a new component file until something that imports the directory reloads, and `shell.qml` was just edited, so the rescan has happened. Ask the user to open the drawer, open the Status page, and confirm: two rows with switches, the tile reads "All clear" when both are off, toggling Do not disturb makes the tile read "Do not disturb", toggling Presentation makes it read "Presenting" and flips the DND switch on as well. + +Then confirm the file side agrees: + +```bash +cat /run/user/1000/status.dnd /run/user/1000/status.presentation +``` + +Expected: the values match what the switches show. + +- [ ] **Step 5: Commit** + +```bash +git add desktop/modules/status/StatusPage.qml desktop/shell.qml +git commit -m "feat(desktop): add the status page and register the module + +One row per mode. Adding a mode is one file in the singleton and one row +here, which is what a registry buys over two separate toggles." +``` + +--- + +### Task 7: Install the CLI and swap the waybar module + +**Files:** +- Modify outside the repo: `~/bin/statusctl` (user installs), the live waybar configuration (user applies) + +**Interfaces:** +- Consumes: `statusctl` from Task 2. +- Produces: nothing executable in the repo. + +- [ ] **Step 1: Ask the user to install the CLI** + +The repo copy is the source; `~/bin` is not in this repository. Ask the user to run: + +```bash +install -m 755 desktop/modules/status/statusctl ~/bin/statusctl +statusctl dnd get +``` + +Expected: `0` or `1`, not a "command not found". + +- [ ] **Step 2: Ask the user to replace the waybar idle_inhibitor module** + +waybar's built-in `idle_inhibitor` owns its own inhibitor object and cannot indicate a mode owned elsewhere. Left running alongside the registry it asserts a second, independent inhibitor, and idle then resumes only when both release. + +Ask the user to create `~/.config/waybar/modules/custom/presentation.jsonc`: + +```jsonc +{ + "custom/presentation": { + "exec": "~/bin/statusctl presentation watch", + "return-type": "json", + "on-click": "~/bin/statusctl presentation toggle", + "format": "{icon}", + "format-icons": { + "activated": " ", + "deactivated": " ", + "down": " " + }, + "tooltip": true + } +} +``` + +The two glyphs are the ones the built-in module already uses, copied from `~/.config/waybar/modules/idle_inhibitor.jsonc` so the bar does not change appearance. They carry trailing variation selectors; copy the bytes rather than retyping them. + +Then in `~/.config/waybar/config.jsonc`: replace the `idle_inhibitor.jsonc` include with the new file, and replace `"idle_inhibitor"` in the module list with `"custom/presentation"`. Reload waybar. + +Record in the task report which lines changed, so the change is traceable. + +- [ ] **Step 3: Confirm the bar and the drawer agree** + +Ask the user to click the waybar glyph and confirm the drawer's Status page switch follows, then toggle the drawer switch and confirm the bar glyph follows. This is the whole point of the file being the interface, and it is the one check that exercises both directions. + +- [ ] **Step 4: Confirm only one inhibitor is asserted** + +```bash +cat > /tmp/hypridle-check.conf <<'EOF' +listener { + timeout = 8 + on-timeout = touch /tmp/hypridle-fired +} +EOF +rm -f /tmp/hypridle-fired +hypridle -c /tmp/hypridle-check.conf & +idle_pid=$! +bash desktop/modules/status/statusctl presentation set 1 +sleep 10 +test -e /tmp/hypridle-fired && echo "inhibitor FAILED: fired while presenting" || echo "inhibitor held" +bash desktop/modules/status/statusctl presentation set 0 +sleep 10 +test -e /tmp/hypridle-fired && echo "inhibitor released, idle fired" || echo "second inhibitor still held" +kill "$idle_pid" +``` + +Ask the user to run this. Expected: `inhibitor held` while presentation is on, then `inhibitor released, idle fired` within a few seconds of turning it off. `second inhibitor still held` would mean the built-in waybar module is still running. `hyprctl clients | grep -ci inhibit` is not the check: a layer-surface inhibitor does not appear in that listing. + +--- + +### Task 8: README, traps, final check + +**Files:** +- Create: `desktop/modules/status/README.md` +- Modify: `AGENTS.md` + +**Interfaces:** +- Consumes: the finished module. +- Produces: nothing executable. + +- [ ] **Step 1: Write `desktop/modules/status/README.md`** + +```markdown +# status + +Desktop modes as state: `dnd` and `presentation`, owned by the `Status` +singleton and stored as files under `$XDG_RUNTIME_DIR`. + +## The files are the interface + + $XDG_RUNTIME_DIR/status.dnd + $XDG_RUNTIME_DIR/status.presentation + +Each holds `0` or `1`; a missing file means off. That directory is tmpfs, so a +reboot resets every mode and there is no cleanup code. A shell restart does +not: the files outlive the process and the singleton reads them back. + +Anything can read a mode with `cat`. `statusctl` is the convenience, not the +mechanism, which is why it keeps working while quickshell is down. + +## statusctl + + statusctl <mode> get prints 0 or 1 + statusctl <mode> set 0|1 + statusctl <mode> toggle + statusctl <mode> watch waybar JSON on every change + +The repo copy is the source; the user installs it to `~/bin`. `watch` watches +the directory rather than the file, because an atomic write replaces the file +and a watch on the old inode dies with it. + +Setting a mode with `statusctl` records the state without firing its effects. +The shell sees the change through its own `FileView` watch and asserts them, +so the effects follow either way. If the shell is down, the state is recorded +and reasserted when it returns. + +## Effects + +`dnd` has none of its own. It is state the notification daemon reads. + +`presentation` sets `dnd`, asserts a Wayland idle inhibitor, and pauses +breaktimer. Turning it off restores `dnd` to the value it had before rather +than clearing it, so hand-set DND survives a presentation. + +breaktimer owns `$XDG_RUNTIME_DIR/breaktimer.state`. This module calls +`breaktimer.sh pause|resume` and never writes that file: its daemon loop +rewrites it on every phase change, and two writers would race. + +## Waybar + +`custom/presentation` reads `statusctl presentation watch`. It replaces +waybar's built-in `idle_inhibitor`, which cannot be kept alongside it: that +module owns its own inhibitor object, so both would have to be released +before the screen could lock. + +## The check + + ./test-statusctl.sh + +Points `XDG_RUNTIME_DIR` at a temporary directory, so it never touches live +modes. Covers the file format, the atomic write, the toggle, the unknown-mode +error and both watch states. +``` + +- [ ] **Step 2: Add the traps to `AGENTS.md`** + +Append to the per-component notes list, plus whichever of the two unverified claims actually bit during Task 4: + +```markdown +- **A `FileView` that writes the file it watches sees its own write.** + `watchChanges` fires `fileChanged` on `setText()` as well as on an external + change, so a handler that writes in response to a change loops. The status + registry compares the reparsed value against the current one and assigns + only on a difference, which makes the self-write a no-op. +- **`IdleInhibitor` needs a non-null `window`.** It has no window of its own + and does nothing without one. A singleton therefore cannot assert an + inhibitor unaided: `shell.qml` hands it the keepalive `PanelWindow`, which + is the one window that exists for the whole session. +``` + +- [ ] **Step 3: Run every check** + +```bash +bash desktop/modules/status/test-statusctl.sh +bash desktop/modules/mail/test-mail-notify.sh +bash desktop/modules/kdeconnect/test-kdeconnect-state.sh +timeout 8 qs -p ./desktop 2>&1 | grep -E 'ERROR|TypeError|ReferenceError|is not defined|Cannot assign|Unable to assign' && echo "ERRORS ABOVE" || echo "clean" +``` + +Expected: statusctl `9 passed, 0 failed`; mail 16 of 16; kdeconnect 5 of 5; `clean`. + +- [ ] **Step 4: Confirm the process count** + +```bash +pgrep -cx qs +``` + +Expected: the shells the user has running, normally three. Run this after the smoke check has returned, never against a detached process from an earlier call. + +- [ ] **Step 5: Ask the user for the final visual pass** + +Ask the user to confirm: the Status tile sits between Appearance and Machines; it reads "All clear", "Do not disturb" or "Presenting" as the modes change; the page has two rows with working switches; turning Presentation on flips DND on and turning it off restores DND to what it was; the waybar glyph and the drawer switch follow each other. + +- [ ] **Step 6: Commit** + +```bash +git add desktop/modules/status/README.md AGENTS.md +git commit -m "docs(status): document the module and record its traps + +A FileView fires its own fileChanged on setText, so a handler that writes +in response to a change loops unless it compares first. IdleInhibitor has +no window of its own and does nothing without one, so the singleton is +handed the keepalive window by shell.qml. + +Both were read from the documentation while designing and confirmed while +implementing." +``` + +--- + +## Notes for the implementer + +**Do not write `breaktimer.state`.** The breaktimer daemon rewrites it on every phase change. Call `breaktimer.sh pause|resume` and let it own its file. + +**The effects hang off the mode property, not the setter.** This is deliberate: `statusctl presentation set 1` writes a file the shell is watching, and the effects must fire from that path as well as from a tile click. If you move the effects into `setMode`, a mode set from the CLI records state and does nothing. + +**The glyph in `StatusModule.qml` is `\uf205`.** It is present in Inconsolata Nerd Font, confirmed against the cmap, so the only open question is whether it reads as a toggle at tile size. Confirm that visually with the user; if it does not, pick another and check the cmap at the path in Verified Facts. + +**`statusctl watch` emits an immediate line before entering the loop.** waybar needs a value at startup, not only on the first change. diff --git a/docs/superpowers/specs/2026-09-15-notification-daemon-design.md b/docs/superpowers/specs/2026-09-15-notification-daemon-design.md new file mode 100644 index 0000000..4e2806a --- /dev/null +++ b/docs/superpowers/specs/2026-09-15-notification-daemon-design.md @@ -0,0 +1,312 @@ +# Notification Daemon + +Replaces dunst. A Go daemon owns `org.freedesktop.Notifications`, a quickshell +shell draws the popups, and the desktop drawer gains a notification centre. The +`dnd` mode that gates it belongs to the status registry, not here. + +This is the second of the two specs the status registry named. It consumes the +registry's `dnd` mode rather than owning it. + +## Shape + +Three pieces, two repos: + + notifyd/ separate repo: the daemon and notifyctl, Go + notifications/ this repo: the balloon shell + desktop/ this repo: the reserved space and history page + shared/Notify.qml this repo: the singleton the renderers share + +The daemon owns the D-Bus name and holds the state. The renderers are quickshell +and read the state from files. `notifyctl` is the one control surface back to +the daemon, over D-Bus. + +## What it is not + +It is not the status registry. `dnd` and `presentation` belong to `statusctl`, +and this never writes them; the balloon shell only reads `status.dnd`. + +It is not a dunst wrapper. dunst stays installed until this proves itself, and +then is removed. Nothing here calls into dunst. + +## The daemon + +`notifyd` is a Go program using `godbus/v5`. It registers +`org.freedesktop.Notifications` on the session bus and serves the freedesktop +notification spec: + + Notify(app_name, replaces_id, app_icon, summary, body, + actions, hints, expire_timeout) -> id + CloseNotification(id) + GetCapabilities() -> capabilities + GetServerInformation() -> (name, vendor, version, spec_version) + + NotificationClosed(id, reason) signal + ActionInvoked(id, action_key) signal + +`GetServerInformation` reports the identity `danix` for name and vendor. The +spec version is `1.2`. + +`GetCapabilities` reports `actions`, `body-markup`, `icon-static` and +`persistence`. The first two are load-bearing: `mail-notify.sh` sends actions +and escapes its body because the running dunst advertises `body-markup`. +`icon-static` means a client may pass an absolute icon path, which every live +consumer does. + +Close reasons are the spec's: `1` expired, `2` dismissed by the user, `3` +closed by a `CloseNotification` call. + +## Policy + +All of this is in the daemon and is independent of any renderer. + +**Timeout, and the two lifetimes.** `expire_timeout` from `Notify` is honoured +exactly as the notification's balloon lifetime, in the spec's direction: `-1` +means the server decides, so it takes the urgency default; `0` means never; any +positive value is milliseconds and wins. The urgency defaults are `10s` for +low, `10s` for normal and never for critical, matching the running dunst. +libnotify sends `-1` by default, so a plain `notify-send` gets the urgency +default, and `dunst` agrees. `ronema` relies on `-t 0` meaning never and `-t 1` +meaning effectively immediate; both work unchanged. + +At expiry the daemon emits `NotificationClosed(id, 1)` and the balloon goes, +which is exactly what `notify-send --wait` and `dunstify -b` clients are +blocked on. The notification is **not** removed from the live queue. It stays +in the drawer as an inert entry until it is dismissed or evicted. The two +lifetimes are deliberately separate: the D-Bus lifetime is the balloon's, so a +waiting client is freed on time, while the drawer's entry outlives it, so a +notification missed while the drawer was shut is still there when it opens. +An inert entry has no client left: its actions are gone, clicking it does +nothing, and only the X removes it, to history. + +**Replace.** A new notification replaces an existing one when its `replaces_id` +matches, or when its stack tag matches. Both `x-dunst-stack-tag` (what +`mail-notify.sh` sends today) and `x-danix-stack-tag` (the new spelling) are +honoured. A replace reuses the replaced notification's id and emits no +`NotificationClosed` for it, which is what a client blocking on that id +expects. A `replaces_id` that matches nothing is a new notification with a new +id, and `0` always means new. Replacing resets the timeout and moves the +notification to the top of the stack, which is what "one notification per +account" has to mean when mail keeps arriving. A replace that targets an +inert (already expired) entry re-arms its balloon and gives it a fresh expiry; +the id is reused, so the new client's actions work and no `NotificationClosed` +is emitted for the id it inherited. + +**History.** A ring of 20, sticky. A notification enters it when it is +dismissed, in either form, or evicted from the live queue by the cap. Expiry +alone does not put it in history, because the drawer still lists it. This is +the `history_length` and `sticky_history` of the running dunst, with the popup +and the drawer separated. + +DND is deliberately absent here. Suppression is a display decision, so it lives +in the balloon shell (see below), which lets the drawer list a notification that +DND chose not to pop. The registry spec assumed the daemon would read `dnd`; +that assumption is superseded. + +## The files + +The daemon publishes its state under `$XDG_RUNTIME_DIR/notifyd/`, the same +runtime directory the registry uses, so a reboot clears it and there is no +cleanup code. Every write is atomic (temporary file, then rename), so a reader +never sees a half-written value. + + queue.json the live notifications, in stack order, newest first + history.json the last 20 dismissed or evicted notifications, newest first + drawer "1" while the drawer is open, written by the drawer + snooze an epoch second while snoozing; absent means off + +A live notification is one object: + + { + "id": 12, + "app": "New Mail", + "summary": "danixland (2)", + "body": "Ada Lovelace\nRe: ...\n\n+1 more", + "urgency": "normal", + "icon": "/home/you/.local/share/icons/.../mail-unread-multiple.svg", + "actions": [["default", "open"]], + "created": 1758000000000, + "expires": 1758000010000 + } + +`app`, `summary` and `body` are markup. `icon` is an absolute path or empty. +`actions` is the spec's key and label pairs. `created` and `expires` are epoch +milliseconds, so a sub-second timeout (`ronema` sends `-t 1`) is exact rather +than rounding to zero, which would read as never. `expires` is `0` for a +notification that never expires; a value in the past means the balloon has gone +and the entry is inert. The history objects are the same shape. + +`queue.json` is capped at 20; an arrival beyond the cap pushes the oldest into +history. + +The renderers read these files and never write them, except `drawer`, which is +the drawer's own state and the one file the daemon does not own. + +## notifyctl + +A second command in the same Go module, installed to `~/bin`. It is the only +thing that talks to the daemon, and it is what the renderers and rofi drive: + + notifyctl list the live queue, JSON + notifyctl history [n] the history ring, JSON, default 20 + notifyctl close <id> close one, reason 2 + notifyctl close-all close every live notification, reason 2 + notifyctl action <id> <key> invoke an action on a live notification + notifyctl clear-history empty the history ring + +`rofipass` calls `dunstctl close-all` today; it switches to +`notifyctl close-all`. That is the only change any existing consumer needs. + +## The balloon shell + +`notifications/` is a quickshell component in the same shape as the others: it +holds itself open with a 1x1 transparent `PanelWindow` (see AGENTS.md), has its +own namespace and its own `hl.layer_rule` for blur, and runs for the session. + +It watches `queue.json` through `Notify.qml` and draws one balloon per live +notification, bottom-right on `DP-1`, over conky. `exclusionMode` is +`ExclusionMode.Ignore`: balloons are an overlay, not a reserved zone, and +covering conky is intended. + +A balloon is drawn only while the notification is inside its `expires`, so the +shell runs a timer per balloon and drops it at expiry. An entry whose time has +passed is not the shell's to draw; it is left to the drawer, whose copy of the +same queue outlives the balloon. + +A balloon is the app name in bold, the summary, and the body, with the icon at +the left when one is present, following the running dunst's `format` +(`<b>%a</b>` then `%s` then `%b`). Progress bars, hovering, and body images are +out of scope. + +**Suppression is here.** The shell reads `status.dnd` and `snooze`: + +- `dnd` on suppresses low and normal balloons; critical still pops. +- `snooze` active suppresses every balloon, critical included. +- A suppressed notification is still in the live queue and still expires on + schedule. Only its balloon is withheld. + +## The drawer + +The reserved `Item` in `Drawer.qml`, already present and documented as +"Reserved for the notification engine", is filled with the live notifications, +above the grid. There is no tile and no module: the space is part of the grid +view. + +The reserved space is a **scrollable** list, not a fixed strip. The grid below +it is fixed and never scrolled, so the reserved space absorbs the overflow: it +is bounded by the grid's top and scrolls when the live queue is long. That +matters now that an entry outlives its balloon, because the queue can hold up +to 20 at once. + +It has a header holding a **History** button, and one row per live notification, +sharing the balloon's content. A row whose balloon has expired is inert: it +still lists and still closes, but it carries no action, because its client is +gone. The History button opens a QML history page: the ring of 20, newest +first, each row closable, with a clear all. + +The drawer writes `notifyd/drawer` `1` on open and `0` on close. That is how +the balloon shell knows to withhold its balloons while the drawer is open, so a +notification appears as a balloon or in the reserved space, never both. + +Unlike the balloons, the reserved space lists every live notification, +including the ones DND or snooze suppressed. A list the user deliberately +opened is not an interruption, and hiding items from it would make DND +indistinguishable from a lost notification. + +## Interactions + +Identical in both forms. + +- The **X** on a balloon or row closes that notification (`notifyctl close`). +- **Clicking** a notification with a live client and actions opens a rofi menu + of its labels; choosing one invokes it (`notifyctl action`). A notification + with no actions, or an inert one whose balloon has expired, closes on click. +- **Right click** closes all (`notifyctl close-all`). + +Closing in either form removes it from the live queue, and it survives only in +the history ring, which is the point of the history page. + +Clicking an action on a `dunstify -b` notification (mail) is what makes the +blocked `dunstify` process print its action key and launch `qtmaildir`. That +round trip goes through `ActionInvoked`, exactly as it does with dunst today. + +## Snooze + +Snoozing suppresses every balloon, critical included, for a fixed time. It does +not touch `dnd`. + +`notify-snooze.sh <minutes>` writes `$XDG_RUNTIME_DIR/notifyd/snooze` as an +epoch second and `notify-snooze.sh off` removes it. The script is the entry +point, so a rofi line or a keybind can snooze without opening the drawer. + +The Status page in the drawer gains a third row, **Snooze**, a switch and a +free text minutes field. Flipping the switch on snoozes for the minutes in the +field; flipping it off clears the file. The last used value is kept in +`~/.local/state/notify-snooze.minutes` so it survives a reboot, which the +runtime file does not. + +A snooze outliving a shell restart is desired and automatic: the file is in the +runtime directory, so the shell reads it back. A snooze outliving a reboot is +not, and does not happen. + +## Handover + +dunst is not removed until this is in place. The switch is: stop dunst, start +`notifyd` (from `autostart.lua`, beside the quickshell lines) and let the +`notifications/` shell start with the others. `notifyctl` and `notify-snooze.sh` +install to `~/bin`. `rofipass` changes its one `dunstctl` line. Only then is +dunst dropped. + +## Failure + +The daemon cannot lose mail, and it cannot lose a mode: a notification is +transient by nature. What it can do is misreport. + +`notifyd` requests `org.freedesktop.Notifications` at startup. If the name is +already taken, which is what happens while dunst is still running, it says so and +exits non-zero rather than starting deaf. + +If `notifyd` dies, clients that send a notification fail to connect and say so, +which is the honest outcome. On restart it writes an empty queue and starts a +fresh history; it does not resurrect the previous run's notifications, because +they are transient and their timeouts have passed. + +If a renderer dies, the daemon keeps its queue and the other renderer keeps +working. On restart the renderer reads the current queue. A notification whose +balloon expired while no renderer was up is still in the queue: the drawer +lists it as an inert entry, the balloon shell draws nothing for it, and +nothing is re-shown. + +`queue.json` is capped, so a renderer that is down cannot make the daemon grow +without bound. + +## Verification + +Pure daemon logic gets Go unit tests: the timeout rule, the replace by id and by +stack tag, the history ring and its cap, and the DND and snooze policies as the +renderer computes them. These are table tests over the policy functions, no bus +and no clock. + +`notifyctl` gets one runnable check, `test-notifyctl.sh`, in the same shape as +`test-statusctl.sh`: it points `XDG_RUNTIME_DIR` at a temporary directory, drives +the file contract and the CLI, and asserts the parse, the replace, the close and +the history ring. It fails if any of them break. + +The bus and the pixels need a person. The end to end check is: send a +`notify-send`, confirm a balloon; open the drawer, confirm the same notification +is in the reserved space and not also a balloon; close it in the drawer, confirm +the balloon goes too and the item is in the history page; send one and let it +time out, confirm the balloon goes but the entry is still in the drawer and is +inert; send enough to overflow the reserved space and confirm it scrolls rather +than pushing the grid; toggle DND and confirm low and normal balloons stop while +critical still pops and the drawer still lists; snooze and confirm nothing pops +at all; click a mail notification's action and confirm `qtmaildir` opens. + +## Deferred + +Each of these is a real dunst feature and none has a live consumer: + +- Pausing a balloon's timeout while the pointer hovers it. +- The progress bar (`value` and `progress` hints). +- Resolving an icon *name* through a theme; absolute paths only for now. +- Body images and hyperlink handling beyond markup. +- A context menu on left click; rofi replaced it. diff --git a/docs/superpowers/specs/2026-09-15-notification-images-design.md b/docs/superpowers/specs/2026-09-15-notification-images-design.md new file mode 100644 index 0000000..fd7eabf --- /dev/null +++ b/docs/superpowers/specs/2026-09-15-notification-images-design.md @@ -0,0 +1,201 @@ +# Notification Images + +The daemon and the renderers gain content images: a screenshot or an +application image attached to a notification, shown large in the balloon, plus +inline images inside the body markup, plus resolution of app icons given as +theme names. + +This extends the shipped daemon design +(`2026-09-15-notification-daemon-design.md`) and the renderers built from +`../plans/2026-09-15-notification-renderers.md`. + +## Why + +The daemon currently reads only the `app_icon` parameter. It ignores every +image hint, so content images never arrive. Two live consumers show the gap: + + opencode its notifier passes its logo with notify-send --icon + grimblast it passes the screenshot with notify-send -i + +libnotify 0.8.8 splits two flags that were once one: `-i/--icon` is the content +image (it lands in the `image-path` hint) and `-n/--app-icon` is the +`app_icon` parameter. Both consumers use the content image. The daemon drops +that hint, so opencode shows no logo and a screenshot shows nothing. + +Separately, `app_icon` is only usable when it is an absolute path. Apps that +send a theme name (the spec's other allowed form) render nothing, because the +renderer builds `file://<name>`. + +## What it is not + +It is not a change to the daemon's or renderer's suppression, timeout, replace +or history behaviour. It is not animated images, multiple images, progress +bars, or body hyperlink handling. It is not a resolver for `desktop-entry`. + +## The freedesktop contract + +The specification defines one image per notification. An implementation that +can display both the app icon and the image shows `app_icon` as the icon and +picks the image in this order: + + 1. image-data (raw pixels, a (iiibiiay) struct) + 2. image-path (a URI or a theme icon name) + 3. icon_data (deprecated, the same struct as image-data) + +An implementation that can show only one image picks from image-data, +image-path, app_icon, then icon_data. The daemon here shows both, so it uses +the first order and keeps `app_icon` as the icon. + +`image-data` and `icon_data` are a D-Bus structure `(iiibiiay)`: + + width (i) width in pixels + height (i) height in pixels + rowstride (i) bytes between row starts + has_alpha (b) whether there is an alpha channel + bits_per_sample (i) always 8 + channels (i) 4 with alpha, 3 without + data (ay) pixels, RGB byte order + +`image-path`, and `app_icon`, are each either a `file://` URI or a name in a +freedesktop icon theme. A name must be resolved against a theme. + +## The daemon + +### Hints + +`Notify` gains hint parsing beside the existing urgency and stack-tag reads: + +- `image-data` and the deprecated `icon_data` (struct): decode to an image. +- `image-path` (string): a `file://` URI, an absolute path, or a theme name. + +`image-data` wins when both a data and a path hint are present, per the +priority above. The underscore spelling `image_data`, which older libnotify +sent, is accepted as an alias. + +### Materialisation and lifecycle + +A raw `image-data` is decoded in Go and written as a PNG to +`$XDG_RUNTIME_DIR/notifyd/img/<id>.png`. A replaced notification reuses its id +and overwrites the same file. The file is removed when the notification leaves +the live queue: on dismiss, on eviction, and on expiry. Only the balloon shows +an image, and the drawer row does not, so nothing needs it once the balloon is +gone. This bounds the directory to the live balloons. + +An `image-path` is the client's file and is published as-is; the daemon never +deletes it. A theme name is resolved to a file first. A `file://` URI is +converted to a path. + +### Capabilities + +`GetCapabilities` adds `body-images`, which is the spec's token for inline +image support. It keeps `actions`, `body-markup`, `icon-static` and +`persistence`. + +### Icon and image theme-name resolution + +`app_icon` and `image-path` values without a `/` are theme names and are +resolved to a file. A value that is already a path or a `file://` URI is used +as-is. + +The theme is read from qt6ct, `~/.config/qt6ct/qt6ct.conf`, key `icon_theme`. +On this machine that is `Material-Black-Plum-Suru`, and it is authoritative; +GTK3, gsettings and qt6ct agree, and the one dissent (GTK4's `breeze-dark`) +has been corrected to match. If qt6ct has no value, fall back to the GTK3 +setting `gtk-icon-theme-name` (or `gsettings`), then `hicolor`. + +Lookup, once a theme name is known: + +1. Search `$XDG_DATA_HOME/icons/<theme>`, then each `$XDG_DATA_DIRS/icons/<theme>` + in order. +2. Within the theme, prefer `apps/scalable`, then the largest available size + under `apps/`. +3. Follow the theme's `Inherits` chain from its `index.theme`. +4. Fall back to `hicolor`. +5. If nothing matches, leave the value empty, which renders no image. This is + today's behaviour and stays the honest outcome. + +## The published contract + +`Popup` gains one field: + + { + "id": 57, + "app": "grimblast", + "summary": "Screenshot of Area", + "body": "...", + "urgency": "normal", + "icon": "", // app identity, a resolved path + "image": "/run/user/1000/notifyd/img/57.png", // content image, empty if none + "actions": [], + "created": 1758000000000, + "expires": 1758000010000 + } + +`icon` keeps its meaning and its consumers. `image` is new and empty when the +notification has none. History objects carry the same field; history rows do +not render it, so a stale path there is inert. + +## The renderers + +### Balloon + +When `image` is non-empty, the balloon shows it below the text block, +`Image.PreserveAspectFit`, the balloon's width, capped at 240px tall. The +32px `icon` slot is unchanged, and both can appear together. The balloon grows +to fit, exactly as it does for a long body. + +### Inline images in the body + +The body is already rendered as `Text.RichText`, and Qt's rich text engine +loads `<img>` from a local path or a `file://` URI (verified: a 200px +`<img>` raised the text's `contentHeight` from 52 to 307). No new rendering +code is needed for the common case. + +Policy: only local sources render. Before display, the renderer strips any +`<img>` whose `src` begins with `http:` or `https:`, so a notification from an +untrusted sender cannot make the shell fetch a URL. Remote inline images are +not shown. Sources that are absolute paths or `file://` URIs are left alone. + +The existing body line caps still apply: three lines in the balloon, two in a +drawer row. + +### Drawer rows + +No image. The reserved space and the history page stay text only, as they are +today. + +## Privacy + +A notification is untrusted input. The only new outward action the design can +cause is a network fetch, and it is closed by the inline policy above: remote +image sources are stripped, never fetched. The daemon writes a decoded image +only under its own runtime directory. + +## Verification + +Daemon (Go, table tests, no bus or clock): + +- image hint parsing: `image-data` beats `image-path`; `icon_data` and + `image_data` aliases are read; a `file://` URI becomes a path. +- `image-data` decode to PNG for 3- and 4-channel data with a rowstride. +- theme-name resolution against a fake icon directory: a plain name, a name + found only through `Inherits`, a missing name, and the largest-size + preference. + +`test-notifyctl.sh` is extended if the contract change reaches it. + +End to end, by hand: + +- a grimblast screenshot and an opencode event each draw a large preview. +- `notify-send -n firefox` draws the themed Firefox icon. +- a `gdbus` call sending `image-data` draws the image. +- a body containing `<img src="https://example.org/x.png">` draws no image and + makes no request. +- the renderer smoke checks stay clean. + +## Out of scope + +- Icon caches and theme re-scan; a name is resolved per notification. +- `desktop-entry` resolution. +- Animated images, more than one image, an image in a drawer row. +- Progress bars and body hyperlink handling, unchanged deferrals. diff --git a/docs/superpowers/specs/2026-09-15-status-registry-design.md b/docs/superpowers/specs/2026-09-15-status-registry-design.md new file mode 100644 index 0000000..db1e79c --- /dev/null +++ b/docs/superpowers/specs/2026-09-15-status-registry-design.md @@ -0,0 +1,231 @@ +# Status Registry + +Desktop modes as state: do not disturb, presentation mode, and whatever +follows. One place that owns them, one way to read them, one way to set them. + +This is the first of two specs. The notification daemon that replaces dunst is +the second, and it consumes the registry's `dnd` mode rather than owning it. +The registry comes first because DND belongs to the desktop, not to the +notification daemon, and because the notification daemon is the larger and +riskier component. + +## What a mode is + +A named boolean plus a set of effects asserted while it is true. + +Two modes in this project: + +**`dnd`** has no effects of its own. It is state that the notification daemon +reads. On its own it is a flag; the behaviour lives in the consumer. + +**`presentation`** has three effects: + +| effect | mechanism | +|---|---| +| do not disturb | sets the `dnd` mode | +| idle inhibited | `IdleInhibitor` on the drawer's keepalive window | +| breaktimer paused | `breaktimer.sh pause`, restored with `resume` | + +Not in this project: any further mode. Gaming, focus, and the rest are one file +and one row each when they arrive, which is the point of building the registry +rather than two toggles. + +## Why a registry rather than two toggles + +Because the second mode is where the coupling appears. Presentation mode sets +DND, so DND has two writers: the user, and presentation mode. Turning +presentation mode off must restore DND to what the user had, not unconditionally +clear it, or an afternoon of hand-set DND vanishes when a talk ends. That rule +has to live somewhere, and a registry is that somewhere. + +## State + +One file per mode in `$XDG_RUNTIME_DIR`, containing `0` or `1`: + + status.dnd + status.presentation + +Flat, one value per file, matching the convention already on disk: +`breaktimer.pid`, `breaktimer.state`, `breaktimer.phase`, `breaktimer.remain` +are written exactly this way by the breaktimer daemon. + +A missing file means the mode is off. This is not a fallback, it is the +mechanism: `$XDG_RUNTIME_DIR` is `/run/user/1000`, a tmpfs, so a reboot clears +every mode with no cleanup code and no persistence logic. A mode surviving a +reboot would need code; a mode resetting is the absence of it. + +Logout without reboot is less certain and the design does not rely on it. +`elogind` runs here and `pam_elogind.so` is in the PAM stack, and its manual +says the runtime directory and its contents are removed when a user's last +concurrent session ends. The same manual says the module does nothing if the +system was not booted with elogind as its init, which on Slackware it is not. +Session tracking demonstrably works, so the removal probably happens, but it is +not guaranteed by the documentation for this configuration. If it does not, a +mode survives a logout, which is the same behaviour as surviving a shell +restart, described next. + +A shell restart is a different event. The files live in the runtime directory, +not in the shell, so a `qs` restart, a hot reload failure or a crash leaves +modes intact and the registry reads them back at startup. A presentation that +outlives a shell crash is the desired behaviour; a presentation that outlives a +reboot is not. + +### Reading and writing + +`FileView` covers both halves with no shell-out: + +- `atomicWrites: true` (the default) writes a temporary file and renames it over + the target, so no reader ever sees a half-written value. +- `watchChanges: true` with `onFileChanged: reload()` means an external writer + changes the file and the drawer repaints. No polling, in either direction. + +Because atomic writes arrive as a rename rather than a write, any external +watcher must watch for `close_write,moved_to`, not `close_write` alone. This is +the same trap the mail watcher hit with Xapian, recorded in AGENTS.md: a watch +that sees only writes never fires on a file that is replaced. + +### Ownership + +The registry writes `status.*` and nothing else. `breaktimer.state` belongs to +the breaktimer daemon and is driven only through `breaktimer.sh pause|resume`. +Two writers on one file is a race, and breaktimer's own daemon loop rewrites +that file on every phase change. + +## Components + + shared/Status.qml the singleton: modes, effects, files + desktop/Status.qml symlink to the above + desktop/modules/status/ + Module.qml registration + Tile.qml active mode count, click opens the page + Page.qml one row per mode, a switch each + ~/bin/statusctl the CLI + +`Status.qml` is a `pragma Singleton` rather than a module service because modes +outlive any page, and because the notification daemon needs to read `dnd` at +startup, before any page is instantiated. A directory import resolves it with no +`qmldir`, the same way `Theme.qml` resolves. + +The symlink rather than a shared import path follows the existing convention: a +singleton outside the config directory needs a `qmldir`, which is the friction +that keeps `Theme.qml` symlinked into each component. + +## The CLI + + statusctl <mode> get prints 0 or 1 + statusctl <mode> set 0|1 + statusctl <mode> toggle + statusctl <mode> watch JSON on change, for waybar + +`get`, `set` and `toggle` read and write the file directly. They do not go +through the shell, so they work when the shell is down, and they cost no +process spawn beyond the script itself. + +`watch` runs `inotifywait -m -e close_write,moved_to` on the file's directory, +debounced, and prints a waybar JSON line on each change. One long-lived process, +no polling. When the file is absent it prints `class: "down"` rather than +`deactivated`, so a dead registry is visibly different from a mode that is off. + +Setting a mode by file rather than through the shell means the effects do not +fire. `statusctl presentation set 1` writes the file; the shell sees the change +through its watch and asserts the inhibitor and pauses breaktimer. If the shell +is down, the file changes and nothing else happens, which is the correct failure: +the state is recorded and reasserted when the shell returns. + +## Effects + +Effects are asserted by the singleton when a mode turns on and released when it +turns off. + +**Idle inhibit** is `IdleInhibitor` bound to the drawer's keepalive +`PanelWindow`, which is the one window guaranteed to exist for the shell's +lifetime. The property needs a non-null `window` to do anything. + +The compositor advertises `zwp_idle_inhibit_manager_v1` (version 1), confirmed +by `wayland-info`, and waybar's built-in `idle_inhibitor` module already drives +it on this machine. So hypridle honours the Wayland protocol here, which is why +the inhibitor is asserted that way rather than over D-Bus. `elogind` does run on +this system, so a D-Bus inhibit path exists, but the Wayland one is confirmed +working and needs no extra service. + +**Breaktimer** is paused with `breaktimer.sh pause` and restored with `resume`. +The verbs exist, are backed by the daemon's own state file, and survive the +daemon restarting. The exit status is readable, so a failure to pause is +detectable, unlike the inhibitor. + +**DND from presentation mode** records the user's DND value when presentation +mode turns on, sets DND on, and restores the recorded value when presentation +mode turns off. The recorded value is held in the singleton, not in a file: it +is meaningful only while presentation mode is on, and presentation mode does not +survive a reboot. + +## Waybar + +The built-in `idle_inhibitor` module is replaced by a `custom/presentation` +module reading the registry. + +This is a replacement rather than a demotion because the built-in module has no +input mode: it owns an inhibitor object, its `activated` state is that object's +state, and it cannot display state owned by anything else. Left in place +alongside the registry it would assert a second, independent inhibitor, and idle +would resume only when both were released. "I turned presentation mode off and +the screen still will not lock" is the failure that produces. + + "custom/presentation": { + "exec": "~/bin/statusctl presentation watch", + "return-type": "json", + "on-click": "~/bin/statusctl presentation toggle", + "format": "{icon}", + "format-icons": { "activated": " ", "deactivated": " " } + } + +The glyphs, the slot and the CSS ids carry over from the built-in module, whose +own tooltip already reads "Presentation Mode". The name is settled by precedent. + +A `custom/dnd` module is available the same way but is not part of this project: +the notification daemon's own waybar presence is the second spec's business. + +## Drawer + +A Status tile in the grid, showing the number of active modes, and a page with +one row per mode and a switch each. Adding a mode is one file and one row. + +## Failure + +The registry cannot lose data: every mode is a boolean that resets at reboot by +design. What it can do is misreport. + +If the shell dies, the `status.*` files remain and waybar keeps showing the last +known state while nothing enforces it. The inhibitor is released, because it is +a Wayland object owned by the dead process, and breaktimer stays paused, because +nothing told it otherwise. So a crash during presentation mode leaves the screen +able to lock and breaktimer still quiet, which is the safer half of each pair. + +`statusctl watch` prints `class: "down"` when a file is missing, which +distinguishes a dead registry from a mode that is off, but only for the missing +case. A crash leaves the file present and stale. That is the honest limit of a +file as a liveness signal, and the alternative, a heartbeat, is more machinery +than two booleans justify. + +## Verification + +One runnable check, `test-statusctl.sh`, exercising the file contract without +the shell: + +- `statusctl dnd set 1`, assert the file contains `1`, assert `get` prints `1` +- `statusctl dnd toggle`, assert `0` +- `statusctl presentation watch` in the background, change the file, assert a + JSON line appears, assert it carries `activated` +- remove the file, assert the next line carries `down` + +That covers the parse, the write, the atomic rename and the watch together, and +it fails if any of them break. + +The effects need the shell and are confirmed by hand, once, because each is +observable: a throwaway `hypridle -c` instance with a short timeout writes a +marker on `on-timeout`, and the marker must not appear while presentation is on +and must appear within a few seconds of it being turned off; `breaktimer.sh +status` reports `paused`; and the notification daemon's own behaviour under DND +is the second spec's verification. + +Per AGENTS.md, anything visual is confirmed by the user rather than screenshotted. diff --git a/notifications/Balloons.qml b/notifications/Balloons.qml new file mode 100644 index 0000000..3676e67 --- /dev/null +++ b/notifications/Balloons.qml @@ -0,0 +1,72 @@ +// 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. + +import Quickshell +import Quickshell.Wayland +import QtQuick + +// The balloon stack, bottom-right of DP-1, over conky. +Scope { + id: root + + // A tick drives both expiry and the end of a snooze without waiting for a + // file change. 250ms keeps ronema's -t 1 near-instant. + property double now: Date.now() + Timer { + interval: 250 + running: true + repeat: true + onTriggered: root.now = Date.now() + } + + // The Repeater model is Notify.queue itself, so its identity only changes + // when the daemon rewrites the file, not on the 250ms tick. Expiry and + // snooze are the balloon's own business: the tick reassigns `now`, each + // balloon re-evaluates its visible binding and leaves the column at + // expiry. Rebuilding the model on the tick would recreate every delegate + // (reloading icons, resetting hover) four times a second. + readonly property var live: Notify.queue || [] + + PanelWindow { + id: win + + // From the queue, never from layout: a hidden window stops polishing, + // and Column computes implicitHeight during polish, so deriving + // visibility from it would never turn true again after the first + // hide. Queue membership is a plain property the binding re-reads + // whether or not the window is mapped, so a later notification + // re-maps it. Expired entries that stay in the queue keep a + // zero-height window mapped, which is harmless. + visible: root.live.length > 0 + screen: Quickshell.screens.find(s => s.name === "DP-1") ?? Quickshell.screens[0] + anchors { bottom: true; right: true } + margins { bottom: 12; right: 12 } + implicitWidth: 340 + implicitHeight: column.implicitHeight + color: "transparent" + exclusionMode: ExclusionMode.Ignore + WlrLayershell.layer: WlrLayer.Overlay + WlrLayershell.namespace: "quickshell-notifications" + WlrLayershell.keyboardFocus: WlrKeyboardFocus.None + + Column { + id: column + width: parent.width + anchors { bottom: parent.bottom; right: parent.right } + spacing: 8 + + Repeater { + model: root.live + NotificationBalloon { notification: modelData; now: root.now } + } + } + } +} diff --git a/notifications/NotificationBalloon.qml b/notifications/NotificationBalloon.qml new file mode 100644 index 0000000..49ec0b4 --- /dev/null +++ b/notifications/NotificationBalloon.qml @@ -0,0 +1,160 @@ +// 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. + +import QtQuick + +// One balloon: icon at the left, app, summary and body, an X, and the click +// targets. The body is markup, which is why the daemon advertises body-markup. +Rectangle { + id: b + + // Neither property is `required` on purpose. A required property makes + // QML create the delegate in its own context, where `modelData` and + // `index` are not defined, so the Repeater's `notification: modelData` + // silently arrives undefined and every binding on it fails. + property var notification + property double now: 0 + + // Driven by the shell's tick. The balloon drops itself once its expiry + // passes, or while a suppression says so: the drawer and snooze withhold + // everything, DND withholds low and normal but still shows critical. The + // drawer lists a notification DND chose not to pop, because a list the + // user opened is not an interruption. + visible: { + if (Notify.drawerOpen) return false; + if (Notify.snoozeUntil > b.now) return false; + if (Notify.dnd && b.notification.urgency !== "critical") return false; + if (b.notification.expires !== 0 && b.now >= b.notification.expires) return false; + return true; + } + + width: parent ? parent.width : 340 + implicitHeight: texts.implicitHeight + 20 + (preview.visible ? preview.height + 8 : 0) + radius: 10 + color: Qt.alpha(Theme.base, 0.82) + border.width: 1 + border.color: Qt.alpha(Theme.text, 0.12) + + // The background click target is declared first so the X, declared later, + // sits above it and wins its corner. + MouseArea { + anchors.fill: parent + acceptedButtons: Qt.LeftButton | Qt.RightButton + cursorShape: Qt.PointingHandCursor + onClicked: mouse => { + if (mouse.button === Qt.RightButton) { + Notify.closeAll(); + return; + } + const acts = b.notification.actions || []; + const inert = b.notification.expires !== 0 && Date.now() >= b.notification.expires; + if (acts.length > 0 && !inert) Notify.actions(b.notification.id, acts); + else Notify.close(b.notification.id); + } + } + + Image { + id: icon + visible: b.notification.icon !== "" && b.notification.icon !== undefined + anchors { left: parent.left; top: parent.top; margins: 10 } + width: 32 + height: 32 + source: visible ? "file://" + b.notification.icon : "" + sourceSize { width: 64; height: 64 } + } + + Text { + id: close + anchors { right: parent.right; top: parent.top; margins: 6 } + width: 20 + height: 20 + text: "\uf00d" + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + font { family: Theme.iconFamily; pixelSize: 13 } + color: closeArea.containsMouse ? Theme.red : Theme.subtext + + MouseArea { + id: closeArea + anchors.fill: parent + anchors.margins: -6 + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: Notify.close(b.notification.id) + } + } + + Column { + id: texts + anchors { + left: icon.visible ? icon.right : parent.left + leftMargin: 10 + right: parent.right + rightMargin: 10 + top: parent.top + topMargin: 10 + } + spacing: 2 + + Text { + width: parent.width + text: b.notification.app || "" + elide: Text.ElideRight + font { family: Theme.fontFamily; pixelSize: Theme.fontSize; bold: true } + color: Theme.subtext + } + + Text { + width: parent.width + text: b.notification.summary || "" + elide: Text.ElideRight + font { family: Theme.fontFamily; pixelSize: Theme.fontSize + 2 } + color: Theme.text + } + + Text { + width: parent.width + visible: text !== "" + text: Notify.sanitize(b.notification.body) + textFormat: Text.RichText + wrapMode: Text.WordWrap + maximumLineCount: 3 + elide: Text.ElideRight + font { family: Theme.fontFamily; pixelSize: Theme.fontSize } + color: Theme.subtext + } + } + + // The content image (a screenshot or an app-provided image), below the + // text. The daemon writes the path; an older daemon without the field + // leaves this hidden. The height matches the scaled width so + // PreserveAspectFit does not letterbox, and a tall screenshot is capped at + // 240px. Asynchronous so a large screenshot does not stall the shell. + Image { + id: preview + visible: b.notification.image !== "" && b.notification.image !== undefined + anchors { + left: parent.left + right: parent.right + top: texts.bottom + leftMargin: 10 + rightMargin: 10 + topMargin: 8 + } + height: visible && implicitWidth > 0 + ? Math.min(width * implicitHeight / implicitWidth, 240) + : 0 + source: visible ? "file://" + b.notification.image : "" + fillMode: Image.PreserveAspectFit + asynchronous: true + cache: false + } +} diff --git a/notifications/Notify.qml b/notifications/Notify.qml new file mode 120000 index 0000000..635f31d --- /dev/null +++ b/notifications/Notify.qml @@ -0,0 +1 @@ +../shared/Notify.qml
\ No newline at end of file diff --git a/notifications/README.md b/notifications/README.md new file mode 100644 index 0000000..91fb758 --- /dev/null +++ b/notifications/README.md @@ -0,0 +1,35 @@ +# notifications + +The balloon renderer for the notification daemon (`notifyd`, a separate repo). +It reads the daemon's published files through the `Notify` singleton and draws +one balloon per live notification, bottom-right of `DP-1`. + +## Suppression lives here + +The daemon does not know about DND or snooze. This component withholds +balloons: `status.dnd` suppresses low and normal, `notifyd/snooze` suppresses +everything. The drawer's reserved space lists every live notification anyway. + +## The files are the interface + + $XDG_RUNTIME_DIR/notifyd/queue.json the live queue + $XDG_RUNTIME_DIR/notifyd/history.json the ring of 20 + $XDG_RUNTIME_DIR/notifyd/drawer "1" while the drawer holds the space + $XDG_RUNTIME_DIR/notifyd/snooze an epoch second while snoozing + +`notifyctl` and `notify-snooze.sh` are in `~/bin`; without them the balloons +draw but close and actions do nothing. + +## Blur + +Hyprland blurs a layer surface only when a rule names its namespace. This +component sets `quickshell-notifications`; the rule is in +`~/.config/hypr/sections/decorations.lua`. + +## History rows + +The spec calls history rows closable individually. `notifyctl` has no per-id +history delete, only `clear-history`, so the history page ships a clear all +and inert rows. The daemon verb and the renderer row it needs are tracked in +the `notifyd` repo's `TODO.md`; when that ships, the row's X is wired to +`notifyctl history-remove`. diff --git a/notifications/Theme.qml b/notifications/Theme.qml new file mode 120000 index 0000000..3d2e40f --- /dev/null +++ b/notifications/Theme.qml @@ -0,0 +1 @@ +../shared/Theme.qml
\ No newline at end of file diff --git a/notifications/notify-actions.sh b/notifications/notify-actions.sh new file mode 100755 index 0000000..651275c --- /dev/null +++ b/notifications/notify-actions.sh @@ -0,0 +1,40 @@ +#!/bin/bash +# +# 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. +# +# Pick one of a notification's actions with rofi and invoke it. The renderer +# passes the labels and keys as arguments, so nothing here parses JSON. +# +# notify-actions.sh <id> <label> <key> [label key ...] + +set -u + +[[ $# -ge 3 ]] || { echo "usage: ${0##*/} <id> <label> <key> ..." >&2; exit 2; } + +id="$1"; shift +labels=() +keys=() +while [[ $# -ge 2 ]]; do + labels+=("$1") + keys+=("$2") + shift 2 +done + +choice="$(printf '%s\n' "${labels[@]}" | rofi -dmenu -i -p "Notification")" +[[ -n "$choice" ]] || exit 0 + +for i in "${!labels[@]}"; do + if [[ "${labels[$i]}" == "$choice" ]]; then + notifyctl action "$id" "${keys[$i]}" + exit $? + fi +done diff --git a/notifications/shell.qml b/notifications/shell.qml new file mode 100644 index 0000000..1b353a3 --- /dev/null +++ b/notifications/shell.qml @@ -0,0 +1,30 @@ +// 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. + +import Quickshell +import Quickshell.Wayland + +ShellRoot { + // Quickshell exits once no window is visible, and the balloons are + // hidden whenever the queue is empty, so this keeps the shell alive. See + // AGENTS.md. + PanelWindow { + visible: true + implicitWidth: 1 + implicitHeight: 1 + color: "transparent" + exclusionMode: ExclusionMode.Ignore + mask: Region {} + WlrLayershell.keyboardFocus: WlrKeyboardFocus.None + } + + Balloons {} +} 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" } +} |
