diff options
Diffstat (limited to 'docs/superpowers/plans/2026-09-15-notification-image-renderers.md')
| -rw-r--r-- | docs/superpowers/plans/2026-09-15-notification-image-renderers.md | 218 |
1 files changed, 218 insertions, 0 deletions
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. |
