diff options
| -rw-r--r-- | AGENTS.md | 50 | ||||
| -rw-r--r-- | README.md | 9 | ||||
| -rw-r--r-- | docs/superpowers/plans/2026-09-13-mail-arrival-notifications.md | 924 | ||||
| -rw-r--r-- | docs/superpowers/specs/2026-09-13-mail-arrival-notifications-design.md | 194 | ||||
| -rw-r--r-- | mail-overview/Accounts.qml | 24 | ||||
| -rw-r--r-- | mail-overview/MailPanel.qml | 78 | ||||
| -rw-r--r-- | mail-overview/README.md | 93 | ||||
| -rwxr-xr-x | mail-overview/mail-notify.sh | 295 | ||||
| -rwxr-xr-x | mail-overview/test-mail-notify.sh | 148 | ||||
| -rw-r--r-- | vm-manager/README.md | 12 | ||||
| -rw-r--r-- | vm-manager/Virsh.qml | 45 | ||||
| -rw-r--r-- | vm-manager/VmPanel.qml | 14 | ||||
| -rw-r--r-- | window-switcher/README.md | 151 |
13 files changed, 1991 insertions, 46 deletions
@@ -8,10 +8,11 @@ Quickshell components for a Hyprland desktop, one per directory, each a complete shell in its own right. They are not modules of a single bar: any of them runs alone, and running one does not require the others. - volume-osd/ volume for output and input, plus what is playing - vm-manager/ libvirt drawer: state, live stats, snapshots - appearance/ wallpaper picker and colour scheme switcher - mail-overview/ notmuch unread counts per account, waybar icon and drawer + volume-osd/ volume for output and input, plus what is playing + vm-manager/ libvirt drawer: state, live stats, snapshots + appearance/ wallpaper picker and colour scheme switcher + mail-overview/ notmuch unread counts per account, waybar icon and drawer + window-switcher/ open windows as live previews in a grid, on ALT+TAB They are started from `~/.config/hypr/sections/autostart.lua` and keep running for the whole session. @@ -102,6 +103,40 @@ changing that component. The ones that generalise: `block.allocation` is qcow2 growth on the host, not usage inside the guest. The real numbers come from qemu-guest-agent, and the panel shows a dash rather than substituting the host-side ones. +- **A managed save makes `virsh start` a restore, and nothing in `domstats` + says so.** A shut-off VM carrying a saved memory image fails to start with + `unable to execute QEMU command 'migrate-incoming'` every time, and the + state reads a plain `shut off`. The shut-off *reason* is no help either: it + reads `failed`, from the failed start, not from the save. `virsh list + --all --managed-save` prints `saved` in the state column but not with + `--name`, which is the form the panel lists with, so detection is `virsh + dominfo <vm>` grepped for `Managed save: yes`, polled per VM the way the + agent rows are. It measured 6ms. +- **Hyprland 0.56.2 evaluates dispatch arguments as Lua.** `dispatch + focuswindow address:0x...` is a syntax error rather than a command, and it + fails silently unless stderr is read. The working form is `dispatch + hl.dsp.focus({ window = "address:0x..." })`. This is why + `~/bin/hypr-windows.sh` is written the way it is, and its syntax was misread + as legacy oddity worth modernising. +- **A focus dispatched while an overlay holds the keyboard is accepted and then + ignored.** A layer surface with `keyboardFocus: Exclusive` grabs the + keyboard, and the compositor will not move window focus out from under that + grab. The dispatch reports `ok`, nothing moves, and no log line says + otherwise. Closing in the same turn does not help either, because a `close()` + that clears a property leaves the surface alive until the frame after. Focus + after the overlay is really gone, which `window-switcher` does with a 60ms + timer. +- **`HyprlandToplevel` has no `focusHistoryID` property.** It reads + `undefined`, so a sort on it compares `NaN` and silently does nothing, + leaving a plausible looking list in arbitrary order. The value is on + `lastIpcObject`. +- **`HyprlandToplevel.address` omits the `0x`** that `hyprctl clients` prints + and that every dispatch requires. +- **`Hyprland.toplevels` reads 0 until `refreshToplevels()` is called.** +- **An uncaptured `ScreencopyView` reports `sourceSize` of `QSize(-1, -1)`**, + not `(0, 0)`. An aspect ratio guard has to test for a positive height: the + obvious rewrites, `!== 0` or a truthiness check, all pass on `-1` and produce + a negative ratio. ## Theme @@ -128,7 +163,7 @@ they never had. A symlink rather than a shared import path because a singleton outside the config directory needs a `qmldir`, which is the same friction that keeps the palette parsed rather than imported; quickshell follows the link and resolves the singleton with no qmldir and no consumer change. Editing any -component's `Theme.qml` edits all four. Do not replace a link with a copy. +component's `Theme.qml` edits all five. Do not replace a link with a copy. ## Blur @@ -144,9 +179,12 @@ A panel that should sit below waybar rather than over it wants exclusive zone without the component knowing the bar's height. Measured with `hyprctl layers`: waybar at `y=-540 h=42`, a `Normal` overlay on the same screen at `y=-498 h=1038`, starting exactly where the bar ends, so the -backdrop never dims it. `mail-overview` does this; the other three use +backdrop never dims it. `mail-overview` does this; the other four use `ExclusionMode.Ignore` and cover the whole screen. +`window-switcher` is the fifth, with namespace `quickshell-window-switcher` and +a `blur-window-switcher` rule of its own. + ## Reloading | | How | @@ -13,10 +13,11 @@ repos stay independent, this one has no build-time dependency on that one. ## Implementations - volume-osd/ on-screen display for output and input volume - vm-manager/ libvirt VM drawer: state, live stats, snapshots - appearance/ wallpaper picker and colour scheme switcher - mail-overview/ unread mail per account, as a waybar count and a drawer + volume-osd/ on-screen display for output and input volume + vm-manager/ libvirt VM drawer: state, live stats, snapshots + appearance/ wallpaper picker and colour scheme switcher + mail-overview/ unread mail per account, as a waybar count and a drawer + window-switcher/ open windows as live previews in a grid, on ALT+TAB Each directory has its own README covering what it does and how to run it. diff --git a/docs/superpowers/plans/2026-09-13-mail-arrival-notifications.md b/docs/superpowers/plans/2026-09-13-mail-arrival-notifications.md new file mode 100644 index 0000000..02f35f8 --- /dev/null +++ b/docs/superpowers/plans/2026-09-13-mail-arrival-notifications.md @@ -0,0 +1,924 @@ +# Mail Arrival Notifications 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:** One desktop notification per mail account per arriving batch, naming the senders and subjects, triggered by notmuch database commits. + +**Architecture:** A standalone bash script, `mail-overview/mail-notify.sh`, watches the notmuch Xapian directory with `inotifywait` and on each commit asks notmuch what changed since the last revision it saw. It notifies per account via `dunstify`, storing its position in a small state file. It runs as its own process from `autostart.lua`, not inside quickshell and not inside the waybar module. + +**Tech Stack:** bash, notmuch 0.39 (`--lastmod`, `lastmod:` queries), `inotifywait` (inotify-tools), `dunstify` (dunst 1.12.2), `jq`. + +**Design spec:** `docs/superpowers/specs/2026-09-13-mail-arrival-notifications-design.md` + +--- + +## Testing approach, and why it is not a framework + +This repo has no test framework and should not gain one. Per the project and +global conventions, non-trivial logic leaves behind **one runnable check**: a +single bash script, `mail-overview/test-mail-notify.sh`, asserting against +fixture data. No pytest, no fixtures directory, no harness. + +The check is real TDD: each task writes its assertion first, watches it fail, +then implements. The assertions run against **pure functions** that take text +on stdin or as arguments and print to stdout. That is the single most +important structural decision in this plan: **no function that this test +touches may call notmuch, dunstify, or inotify.** Those are named in one +place each, at the edges, so everything with logic in it stays testable +without mail arriving. + +`set -u` everywhere, matching `waybar-mail.sh`. Not `set -e`: this script must +survive a failing notmuch call and carry on to the next account, which is the +same reasoning `Accounts.qml` documents for its per-account loop. + +--- + +## File Structure + +| File | Responsibility | +| --- | --- | +| `mail-overview/mail-notify.sh` (create) | Everything: parse accounts, read/write state, query notmuch, build bodies, send notifications, watch loop. One file, ~180 lines. | +| `mail-overview/test-mail-notify.sh` (create) | The one runnable check. Sources the script with `MAIL_NOTIFY_LIB=1` and asserts on the pure functions. | +| `mail-overview/README.md` (modify) | A section documenting the notifier, matching the existing prose style. | +| `~/.config/hypr/sections/autostart.lua` (modify, outside repo) | One `hl.exec_cmd` line. Not committed; this repo holds no home paths. | + +**Why one script rather than several.** It is a single sequential job with no +reusable parts: an abstraction between "parse config" and "send notification" +would have exactly one caller each. The project convention is single-file +scripts where practical, and `waybar-mail.sh` sets the precedent at 69 lines. + +**The library guard.** The script ends with a `main` dispatch that only runs +when `MAIL_NOTIFY_LIB` is unset, so the test can source it for its functions +without starting a watch loop. This is the standard bash equivalent of +Python's `if __name__ == "__main__"`. + +--- + +## Task 1: Skeleton, library guard, and the test harness + +Creates both files, establishes the sourcing contract, and proves the test +runs. No mail logic yet. + +**Files:** +- Create: `mail-overview/mail-notify.sh` +- Create: `mail-overview/test-mail-notify.sh` + +- [ ] **Step 1: Write the failing test** + +Create `mail-overview/test-mail-notify.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 mail-notify.sh. No framework: it sources the +# script as a library and asserts on the pure functions, the ones that take +# text and print text. Nothing here calls notmuch, dunstify or inotify, which +# is why it runs in milliseconds and needs no mail to arrive. +# +# Usage: ./test-mail-notify.sh (exit 0 = all passed) + +set -u + +MAIL_NOTIFY_LIB=1 . "$(dirname "$0")/mail-notify.sh" + +pass=0 +fail=0 + +# Compares two strings and reports. Multi-line values are printed with their +# newlines intact, because half the assertions here are about line structure. +check() { + local name="$1" want="$2" got="$3" + if [[ "$want" == "$got" ]]; then + pass=$((pass + 1)) + else + fail=$((fail + 1)) + printf 'FAIL: %s\n want: %s\n got: %s\n' "$name" "$want" "$got" + fi +} + +check "library guard does not run main" "loaded" "loaded" + +printf '\n%d passed, %d failed\n' "$pass" "$fail" +[[ "$fail" -eq 0 ]] +``` + +- [ ] **Step 2: Run it to verify it fails** + +```bash +chmod +x mail-overview/test-mail-notify.sh +./mail-overview/test-mail-notify.sh +``` + +Expected: FAIL, `mail-notify.sh: No such file or directory`. + +- [ ] **Step 3: Write the minimal implementation** + +Create `mail-overview/mail-notify.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. +# +# Notifies when mail arrives: one notification per account per batch, naming +# the newest senders and subjects. +# +# Watches the notmuch Xapian directory and, on each commit, asks notmuch what +# changed since the revision it last saw. A commit is NOT the same as new +# mail, since reading and tagging also commit, which is why the revision +# counter does the work rather than a count delta. +# +# This is its own process rather than part of waybar-mail.sh, which already +# has the same arrival edge: waybar owns that process, so a bar restart would +# stop notifications with nothing reporting it. +# +# Usage: +# mail-notify.sh watch forever (what autostart runs) +# mail-notify.sh --once process one tick and exit (what the test drives) + +set -u + +STATE="${MAIL_NOTIFY_STATE:-$HOME/.local/state/mail-notify.lastmod}" +CONFIG="${MAIL_NOTIFY_CONFIG:-$HOME/.config/qtmaildir/qtmaildir.conf}" +SCOPE='tag:unread and tag:inbox' + +# How many threads a notification body lists before eliding into "+N more". +# Three matches the drawer's own --limit=3. +ROWS=3 + +main() { + echo "not implemented" +} + +# Sourced by the test with MAIL_NOTIFY_LIB set, which must not start a watch +# loop. The bash equivalent of Python's __name__ == "__main__". +[[ -n "${MAIL_NOTIFY_LIB:-}" ]] || main "$@" +``` + +- [ ] **Step 4: Run it to verify it passes** + +```bash +chmod +x mail-overview/mail-notify.sh +./mail-overview/test-mail-notify.sh +``` + +Expected: PASS, `1 passed, 0 failed`, exit 0. Nothing prints `not implemented`, +which is the proof the guard works. + +- [ ] **Step 5: Commit** + +```bash +git add mail-overview/mail-notify.sh mail-overview/test-mail-notify.sh +git commit -m "feat(mail-overview): skeleton for the arrival notifier + +A library guard so the test can source the script for its pure +functions without starting a watch loop, and the check itself, +which is one bash script rather than a framework." +``` + +--- + +## Task 2: Parse accounts from qtmaildir.conf + +The key-to-bracket and walk-lines traps both live here. They are the two +failure modes that already cost this component a debugging session each, per +`Accounts.qml` and the README. + +**Files:** +- Modify: `mail-overview/mail-notify.sh` +- Modify: `mail-overview/test-mail-notify.sh` + +- [ ] **Step 1: Write the failing test** + +Add to `test-mail-notify.sh`, immediately before the `printf '\n%d passed'` +summary line: + +```bash +# A config with the two traps in it: a key containing dots, and a folder +# value containing a bracket. Both are real shapes from qtmaildir.conf, with +# placeholder names. +read -r -d '' fixture <<'EOF' +[general] +theme = dark + +[account.simple] +label = Simple +color = #112233 + +[account.provider-first.last] +folder = [Gmail]/Bozze +label = Dotted +color = #445566 + +[account.nolabel] +color = #778899 + +[ui] +label = NotAnAccount +EOF + +check "keys run to the bracket, not the first dot" \ + "simple provider-first.last nolabel" \ + "$(parse_accounts <<<"$fixture" | cut -f1 | tr '\n' ' ' | sed 's/ $//')" + +check "a bracket in a value does not end the section" \ + "Dotted" \ + "$(parse_accounts <<<"$fixture" | awk -F'\t' '$1=="provider-first.last"{print $2}')" + +check "a missing label falls back to the key" \ + "nolabel" \ + "$(parse_accounts <<<"$fixture" | awk -F'\t' '$1=="nolabel"{print $2}')" + +check "a non-account section is not an account" \ + "" \ + "$(parse_accounts <<<"$fixture" | awk -F'\t' '$1=="ui"{print $2}')" +``` + +- [ ] **Step 2: Run it to verify it fails** + +```bash +./mail-overview/test-mail-notify.sh +``` + +Expected: FAIL on all four, `parse_accounts: command not found`. + +- [ ] **Step 3: Write the minimal implementation** + +Add to `mail-notify.sh`, after the `ROWS=3` line: + +```bash +# Accounts in file order, one "key<TAB>label" line each, read from stdin. +# +# Two things here are load-bearing, and both have already broken this +# component once: +# +# The key runs to the closing bracket, NOT to the first dot. Real keys +# contain dots, so splitting on the first one yields a notmuch tag matching +# nothing and an account that silently never notifies. +# +# And this walks lines rather than matching a section body as "everything up +# to the next [". Accounts have folders named like [Gmail]/Bozze, which ends +# the body before its label and makes the account display its raw key. +parse_accounts() { + local line key label + key="" + label="" + + while IFS= read -r line || [[ -n "$line" ]]; do + if [[ "$line" =~ ^\[account\.([^]]+)\] ]]; then + [[ -n "$key" ]] && printf '%s\t%s\n' "$key" "${label:-$key}" + key="${BASH_REMATCH[1]}" + label="" + continue + fi + # Any other section ends the current account. + if [[ "$line" =~ ^\[ ]]; then + [[ -n "$key" ]] && printf '%s\t%s\n' "$key" "${label:-$key}" + key="" + label="" + continue + fi + [[ -n "$key" ]] || continue + if [[ "$line" =~ ^[[:space:]]*label[[:space:]]*=[[:space:]]*(.*)$ ]]; then + label="${BASH_REMATCH[1]}" + # Trailing whitespace only; a label may contain spaces. + label="${label%"${label##*[![:space:]]}"}" + fi + done + + [[ -n "$key" ]] && printf '%s\t%s\n' "$key" "${label:-$key}" + return 0 +} +``` + +- [ ] **Step 4: Run it to verify it passes** + +```bash +./mail-overview/test-mail-notify.sh +``` + +Expected: PASS, `5 passed, 0 failed`. + +- [ ] **Step 5: Verify against the real config** + +```bash +MAIL_NOTIFY_LIB=1 . ./mail-overview/mail-notify.sh +parse_accounts < ~/.config/qtmaildir/qtmaildir.conf +``` + +Expected: five lines, each `key<TAB>label`, every label a real label and none +falling back to a raw key. Confirms the fixture matches reality. + +- [ ] **Step 6: Commit** + +```bash +git add mail-overview/mail-notify.sh mail-overview/test-mail-notify.sh +git commit -m "feat(mail-overview): parse accounts for the notifier + +Same source the drawer parses, so adding an account in qtmaildir +notifies with no edit here. Keys run to the closing bracket because +real ones contain dots, and parsing walks lines because a folder +named [Gmail]/Bozze ends a section body early." +``` + +--- + +## Task 3: Build the notification body + +Pure text transformation, the part most worth testing and the part that never +touches notmuch. + +**Files:** +- Modify: `mail-overview/mail-notify.sh` +- Modify: `mail-overview/test-mail-notify.sh` + +- [ ] **Step 1: Write the failing test** + +Add to `test-mail-notify.sh` before the summary: + +```bash +# The shape notmuch search --format=json actually returns, trimmed to the two +# fields the body uses. +rows_json='[ + {"authors":"Alice Example","subject":"First subject"}, + {"authors":"Bob Example","subject":"Second subject"}, + {"authors":"Carol Example","subject":"Third subject"} +]' + +check "a body lists author and subject per row" \ + "Alice Example — First subject +Bob Example — Second subject +Carol Example — Third subject" \ + "$(build_body "$rows_json" 3)" + +check "a batch bigger than the rows shown is elided" \ + "Alice Example — First subject +Bob Example — Second subject +Carol Example — Third subject ++7 more" \ + "$(build_body "$rows_json" 10)" + +check "markup characters are escaped, not rendered" \ + "A & B — <script>" \ + "$(build_body '[{"authors":"A & B","subject":"<script>"}]' 1)" + +check "a missing subject says so rather than printing nothing" \ + "Alice Example — (no subject)" \ + "$(build_body '[{"authors":"Alice Example","subject":null}]' 1)" + +check "malformed json yields an empty body rather than an error" \ + "" \ + "$(build_body 'not json at all' 1 2>/dev/null)" +``` + +- [ ] **Step 2: Run it to verify it fails** + +```bash +./mail-overview/test-mail-notify.sh +``` + +Expected: FAIL on all five, `build_body: command not found`. + +- [ ] **Step 3: Write the minimal implementation** + +Add to `mail-notify.sh` after `parse_accounts`: + +```bash +# Renders notmuch search JSON into notification body text. +# $1 the JSON array from `notmuch search --format=json` +# $2 the true total for this batch, which may exceed the rows present +# +# dunst has body-markup in its capabilities, so a subject containing < or & +# would be parsed as markup and could vanish from the notification. Subjects +# are attacker-controlled text arriving from the internet, so the three XML +# characters are escaped here. This is the one place in this script where +# untrusted text reaches a renderer. +# +# Malformed JSON prints nothing and succeeds. A notification with no body is +# still worth sending: the summary already carries the account and the count. +build_body() { + local json="$1" total="$2" shown + + local body + body="$(printf '%s' "$json" | jq -r ' + .[] | ((.authors // "(unknown)") + " — " + (.subject // "(no subject)")) + | gsub("&"; "&") | gsub("<"; "<") | gsub(">"; ">") + ' 2>/dev/null)" || return 0 + [[ -n "$body" ]] || return 0 + + shown="$(printf '%s\n' "$body" | wc -l)" + printf '%s' "$body" + if [[ "$total" -gt "$shown" ]]; then + printf '\n+%d more' "$((total - shown))" + fi + printf '\n' +} +``` + +Note the `&` escapes: writing `&` directly inside a `jq` replacement +string is fine, but `&` in some `gsub` implementations is a back-reference, so +the escape keeps it literal regardless. + +- [ ] **Step 4: Run it to verify it passes** + +```bash +./mail-overview/test-mail-notify.sh +``` + +Expected: PASS, `10 passed, 0 failed`. + +- [ ] **Step 5: Commit** + +```bash +git add mail-overview/mail-notify.sh mail-overview/test-mail-notify.sh +git commit -m "feat(mail-overview): build notification bodies + +Three rows of author and subject, then +N more, which bounds the +height so a mailing list burst is not a wall. Markup characters are +escaped because dunst renders body markup and a subject is untrusted +text off the internet." +``` + +--- + +## Task 4: Read and write the state file + +The UUID rule and the silent-seed rule both live here. Both exist to prevent a +notification storm. + +**Files:** +- Modify: `mail-overview/mail-notify.sh` +- Modify: `mail-overview/test-mail-notify.sh` + +- [ ] **Step 1: Write the failing test** + +Add to `test-mail-notify.sh` before the summary: + +```bash +# A scratch state file, removed at exit. Never the real one: that is the +# user's live notification position, and a test must not move it. +tmpstate="$(mktemp)" +trap 'rm -f "$tmpstate"' EXIT +STATE="$tmpstate" + +rm -f "$tmpstate" +check "a missing state file reports no previous revision" \ + "" "$(read_prev_rev "uuid-a")" + +write_state "uuid-a" 500 +check "a revision written is a revision read back" \ + "500" "$(read_prev_rev "uuid-a")" + +check "a different database UUID discards the revision" \ + "" "$(read_prev_rev "uuid-b")" + +printf 'garbage not a state file\n' > "$tmpstate" +check "an unparseable state file reports no previous revision" \ + "" "$(read_prev_rev "uuid-a")" + +write_state "uuid-a" 600 +check "a rewrite replaces rather than appends" \ + "1" "$(wc -l < "$tmpstate")" +``` + +- [ ] **Step 2: Run it to verify it fails** + +```bash +./mail-overview/test-mail-notify.sh +``` + +Expected: FAIL, `read_prev_rev: command not found`. + +- [ ] **Step 3: Write the minimal implementation** + +Add to `mail-notify.sh` after `build_body`: + +```bash +# The last revision this script notified up to, or empty when there is none +# to trust. Empty means "seed silently": record where we are now and notify +# nothing. +# +# The stored UUID is checked because notmuch revisions are only comparable +# within one database. A rebuilt database restarts the counter, so an old +# revision would be meaningless, and treating it as a floor would either +# notify nothing forever or notify everything at once. +read_prev_rev() { + local want_uuid="$1" got_uuid rev + + [[ -f "$STATE" ]] || return 0 + read -r got_uuid rev < "$STATE" 2>/dev/null || return 0 + + [[ "$got_uuid" == "$want_uuid" ]] || return 0 + [[ "$rev" =~ ^[0-9]+$ ]] || return 0 + + printf '%s' "$rev" +} + +# Written by atomic replace, the same idiom mail-watcher uses for its +# heartbeat: a reader must never see a half-written file, and mv within a +# directory is atomic where a redirect into the final path is not. +# +# Failure to write is deliberately not fatal. The notifications have already +# been sent; taking the watcher down over a failure to record that would turn +# a bookkeeping problem into a no-mail-notifications problem. +write_state() { + local uuid="$1" rev="$2" tmp + + mkdir -p "$(dirname "$STATE")" 2>/dev/null || return 0 + tmp="$(mktemp "${STATE}.XXXXXX")" || return 0 + printf '%s %s\n' "$uuid" "$rev" > "$tmp" || { rm -f "$tmp"; return 0; } + mv -f "$tmp" "$STATE" 2>/dev/null || rm -f "$tmp" + return 0 +} +``` + +- [ ] **Step 4: Run it to verify it passes** + +```bash +./mail-overview/test-mail-notify.sh +``` + +Expected: PASS, `15 passed, 0 failed`. + +- [ ] **Step 5: Commit** + +```bash +git add mail-overview/mail-notify.sh mail-overview/test-mail-notify.sh +git commit -m "feat(mail-overview): track the notified revision + +Stores the database UUID beside the revision, because notmuch +revisions only compare within one database and a rebuild restarts +the counter. Missing, corrupt or mismatched state reports nothing, +which the caller treats as seed-silently: with no floor, lastmod:0.. +matches every unread message and startup becomes a wall of popups." +``` + +--- + +## Task 5: The tick, the notification, and the watch loop + +Wires the pure functions to notmuch, dunstify and inotify. These are the +edges, each named in exactly one place. + +**Files:** +- Modify: `mail-overview/mail-notify.sh` + +- [ ] **Step 1: Write the implementation** + +Replace the `main() { echo "not implemented"; }` stub in `mail-notify.sh` +with everything below: + +```bash +# Sends one notification for one account. +# +# dunstify rather than notify-send because actions need it. A stack tag per +# account means a second batch for the same account replaces the first rather +# than stacking, which is what "one notification per account" has to mean when +# mail keeps arriving. +# +# Normal urgency and an explicit 10s timeout, deliberately not -u critical: +# on most dunst configurations critical notifications never expire, which +# would leave mail popups on screen until clicked. +# +# The click cannot open the account it belongs to. qtmaildir accepts no +# command line arguments and startup_account is a static config setting, not +# a flag, which is the same limitation the drawer's thread rows already have. +notify_account() { + local label="$1" key="$2" count="$3" body="$4" + + if ! command -v dunstify >/dev/null 2>&1; then + # No actions available, but a notification without a click is still + # worth having. + notify-send -a mail-overview -u normal -t 10000 \ + "$label · $count new" "$body" + return 0 + fi + + # Backgrounded because -b blocks until the notification is dismissed or + # clicked. Without this the loop would stall for the full timeout on + # every account, and a five-account batch would take most of a minute. + ( + if [[ "$(dunstify -a mail-overview -u normal -t 10000 -b \ + -h "string:x-dunst-stack-tag:mail-$key" \ + -A "default,open" \ + "$label · $count new" "$body")" == "default" ]]; then + "$HOME/bin/qtmaildir" & + fi + ) >/dev/null 2>&1 & +} + +# One pass: what has arrived since the revision we last saw. +tick() { + local lastmod uuid cur prev + + # Three tab-separated fields: count, database UUID, revision. Verified on + # notmuch 0.39. + lastmod="$(notmuch count --lastmod "$SCOPE" 2>/dev/null)" || return 0 + uuid="$(printf '%s' "$lastmod" | cut -f2)" + cur="$(printf '%s' "$lastmod" | cut -f3)" + + # The output is the test, not the exit status. notmuch fails two ways and + # only one is detectable: a rejected query prints nothing and exits 1, + # while a query Xapian merely misparses returns a plausible wrong number + # and exits 0. The defence against the second is that SCOPE is a fixed + # string and is never built from anything. + [[ "$cur" =~ ^[0-9]+$ ]] || return 0 + [[ -n "$uuid" ]] || return 0 + + prev="$(read_prev_rev "$uuid")" + + # No trustworthy floor: record where we are and say nothing. This is the + # first run, a rebuilt database, or a corrupt state file. + if [[ -z "$prev" ]]; then + write_state "$uuid" "$cur" + return 0 + fi + + # Nothing committed since last time, or the counter went backwards. + if [[ "$cur" -le "$prev" ]]; then + return 0 + fi + + local key label query count rows + while IFS=$'\t' read -r key label; do + [[ -n "$key" ]] || continue + + query="$SCOPE and tag:account-$key and lastmod:$prev..$cur" + + count="$(notmuch count "$query" 2>/dev/null)" + # Same validation, same reason: an empty string must not become a + # zero, and a failure here skips this account rather than the batch. + [[ "$count" =~ ^[0-9]+$ ]] || continue + [[ "$count" -gt 0 ]] || continue + + rows="$(notmuch search --format=json --limit="$ROWS" \ + --sort=newest-first "$query" 2>/dev/null)" || rows="[]" + + notify_account "$label" "$key" "$count" "$(build_body "$rows" "$count")" + done < <(parse_accounts < "$CONFIG") + + # Written only after every account is done, so a failure part way through + # leaves prev unchanged and the next tick retries rather than dropping a + # batch silently. + write_state "$uuid" "$cur" +} + +main() { + local db + db="$(notmuch config get database.path 2>/dev/null)/xapian" + + if [[ ! -d "$db" ]]; then + echo "mail-notify: no notmuch database at $db" >&2 + exit 1 + fi + + if [[ ! -r "$CONFIG" ]]; then + echo "mail-notify: cannot read $CONFIG" >&2 + exit 1 + fi + + if [[ "${1:-}" == "--once" ]]; then + tick + return 0 + fi + + # Seed before watching, so a first run never notifies the backlog. + tick + + # The watch is on the xapian DIRECTORY, not a file inside it: a commit + # replaces files, and a watch held on a filename dies with the file. + while inotifywait -qq -e close_write,moved_to "$db" 2>/dev/null; do + # One commit touches several files. Without this, a single sync fires + # three or four ticks. + sleep 0.3 + tick + done + + # Falling out means inotifywait itself failed. Say so rather than exiting + # silently, which is indistinguishable from no mail arriving. + echo "mail-notify: inotify watch stopped" >&2 + exit 1 +} +``` + +- [ ] **Step 2: Verify the test still passes** + +```bash +./mail-overview/test-mail-notify.sh +``` + +Expected: PASS, `15 passed, 0 failed`. The pure functions are unchanged, and +this is the check that Task 5 did not break them. + +- [ ] **Step 3: Verify a seed run is silent** + +```bash +export MAIL_NOTIFY_STATE=/tmp/mail-notify-test.lastmod +rm -f "$MAIL_NOTIFY_STATE" +./mail-overview/mail-notify.sh --once +cat "$MAIL_NOTIFY_STATE" +``` + +Expected: **no notifications appear**, and the state file holds one line of +`<uuid> <revision>`. This is the anti-storm rule working: 101 unread messages +and not one popup. + +- [ ] **Step 4: Verify a backdated run notifies** + +This is the verification the spec calls for, and it does not need mail to +arrive: + +```bash +uuid="$(notmuch count --lastmod 'tag:unread and tag:inbox' | cut -f2)" +cur="$(notmuch count --lastmod 'tag:unread and tag:inbox' | cut -f3)" +printf '%s %s\n' "$uuid" "$((cur - 2000))" > "$MAIL_NOTIFY_STATE" +./mail-overview/mail-notify.sh --once +``` + +Expected: one notification per account that had mail in that window, each +summarised `<label> · N new` with up to three author/subject rows and a +`+N more` line when the batch is bigger. Confirm the counts look plausible +against the drawer, that stacking replaced rather than piled up, and that +clicking one opens qtmaildir. + +- [ ] **Step 5: Clean up the scratch state** + +```bash +rm -f /tmp/mail-notify-test.lastmod +unset MAIL_NOTIFY_STATE +``` + +The real state file was never touched: every step above overrode it. + +- [ ] **Step 6: Commit** + +```bash +git add mail-overview/mail-notify.sh +git commit -m "feat(mail-overview): notify per account when mail arrives + +Asks notmuch what changed since the revision last seen, then sends +one notification per account with mail in that window. Counts are +validated as integers because a rejected notmuch query prints nothing +and exits 1, and an empty string must not read as zero. + +dunstify is backgrounded because -b blocks until the notification is +dismissed, which would otherwise stall the loop for the full timeout +on every account. The watch is on the xapian directory rather than a +file inside it, because a commit replaces files and a watch held on a +filename dies with it." +``` + +--- + +## Task 6: Documentation and autostart + +**Files:** +- Modify: `mail-overview/README.md` +- Modify: `~/.config/hypr/sections/autostart.lua` (outside the repo, not committed) + +- [ ] **Step 1: Add the README section** + +Insert into `mail-overview/README.md`, between the `## The waybar module` +section and `## Geometry, and the one property that differs from the other +panels`: + +```markdown +## Notifications on arrival + +`mail-notify.sh` sends one notification per account when mail lands, with the +newest three senders and subjects and a `+N more` line when the batch is +bigger. Started from `autostart.lua`, it runs for the whole session. + + /home/you/Programming/GIT/quickshell/mail-overview/mail-notify.sh + +It watches the same xapian directory the waybar module does, for the same +reason and with the same 0.3s debounce. It is a **separate process rather +than part of `waybar-mail.sh`**, which already has that edge: waybar owns +that script's process, so a bar restart would stop notifications with nothing +reporting it. + +**A commit is not the same as new mail.** Reading a message in qtmaildir drops +its `unread` tag and commits; so does tagging. Arrival is found with notmuch's +revision counter instead: + + notmuch count --lastmod 'tag:unread and tag:inbox' + +which prints count, database UUID and revision, tab separated. Each account is +then asked what it gained in `lastmod:<prev>..<cur>`. A count delta was +rejected because it cannot name senders, and a `date:` watermark because +`date:` is the message's own Date header: backdated mail would never notify +and future-dated mail would notify forever. + +Position is kept in `~/.local/state/mail-notify.lastmod`, written by atomic +replace, holding the **UUID as well as the revision**. Revisions only compare +within one database, so a rebuild restarts the counter and a stored revision +from the old one means nothing. + +**Missing, corrupt or mismatched state seeds silently**, recording the current +revision and notifying nothing. Without that floor, `lastmod:0..` matches every +unread message ever: 101 of them here, which is a wall of popups at every +login. The same applies to a restart, so mail that arrived while it was down is +never notified. The waybar count is still right and the drawer still shows it, +so nothing is lost but a stale popup. + +Clicking a notification opens qtmaildir, but not the account it belongs to: +`qtmaildir` takes no arguments and `startup_account` is a static setting rather +than a flag, the same limitation the thread rows have. + +`test-mail-notify.sh` is the check. It sources the script as a library and +asserts on the functions that only move text around, so it needs no mail and no +notmuch: + + ./test-mail-notify.sh +``` + +- [ ] **Step 2: Verify the README claims are true** + +```bash +./mail-overview/test-mail-notify.sh +``` + +Expected: PASS. The README now promises this command works, so it must. + +- [ ] **Step 3: Commit the README** + +```bash +git add mail-overview/README.md +git commit -m "docs(mail-overview): document the arrival notifier + +Why a revision counter rather than a count delta or a date +watermark, why the state file carries a UUID, and why an unseeded +run stays quiet." +``` + +- [ ] **Step 4: Add the autostart line** + +Edit `~/.config/hypr/sections/autostart.lua`, after the mail-watcher line +(currently line 28, `hl.exec_cmd("/home/you/bin/mail-watcher.sh --ensure")`), +substituting the real home directory for `/home/you`: + +```lua + -- one notification per account when mail lands + hl.exec_cmd("/home/you/Programming/GIT/quickshell/mail-overview/mail-notify.sh") +``` + +The absolute path is required: Hyprland's Lua strings do not expand `~`, the +same reason the `qs -p` lines above are spelled out. This file is outside the +repo and is not committed; no home path enters git. + +- [ ] **Step 5: Reload and confirm it is running** + +```bash +hyprctl reload +pgrep -cf mail-notify.sh +``` + +Expected: `1`. Note this is one of the few safe uses of `pgrep -f` here, since +the script name is distinctive; `pkill -f` remains forbidden in this repo +because it matches the agent's own shell. + +--- + +## Self-Review + +**Spec coverage.** Shape and placement: Task 1 and Task 5's `main`. Arrival +detection via `lastmod`: Task 5's `tick`. State file with UUID and silent +seeding: Task 4. Account parsing with both traps: Task 2. Notification content, +stack tag, urgency, timeout, click action, `notify-send` fallback: Tasks 3 and +5. Failure handling and integer validation: Task 5, tested in Task 3's +malformed-JSON case. Verification by backdated revision: Task 5 Step 4. +Documentation: Task 6. No spec section is unimplemented. + +**Placeholder scan.** No TBDs, no "add error handling", no "similar to Task N". +Every code step carries its code. + +**Type consistency.** `parse_accounts` emits `key<TAB>label` and is consumed +that way in Task 5. `build_body "$json" "$total"` is defined in Task 3 and +called with that signature in Task 5. `read_prev_rev "$uuid"` and +`write_state "$uuid" "$rev"` match between Tasks 4 and 5. `STATE`, `CONFIG`, +`SCOPE` and `ROWS` are declared in Task 1 and used unchanged after. + +**One correction made during review:** Task 4's test needed `STATE` to be +overridable, so Task 1 declares it as `${MAIL_NOTIFY_STATE:-...}` rather than +a bare path. `CONFIG` gained the same treatment for symmetry. Without it the +check would have moved the user's real notification position. diff --git a/docs/superpowers/specs/2026-09-13-mail-arrival-notifications-design.md b/docs/superpowers/specs/2026-09-13-mail-arrival-notifications-design.md new file mode 100644 index 0000000..1423fe9 --- /dev/null +++ b/docs/superpowers/specs/2026-09-13-mail-arrival-notifications-design.md @@ -0,0 +1,194 @@ +# mail-overview: notify on mail arrival + +New mail should announce itself. Today nothing does: `mailsync.sh` syncs and +writes a status file, `waybar-mail.sh` updates a number in the bar, and the +drawer shows detail only when opened. A message that lands while the user is +looking at something else is silent. + +This adds one notification per account per arriving batch, from a new script +in this component. + +## What it is not + +It is not the quickshell notification daemon that would replace dunst. That +remains deferred and is independent of this: notifications here are sent over +the freedesktop DBus spec, so they work with dunst today and keep working +unchanged if the daemon is ever swapped in. Nothing here should wait for it. + +## Shape + +`mail-overview/mail-notify.sh`, bash, sibling to `waybar-mail.sh`, GPLv2 +header like every other source file here. Started from `autostart.lua`, runs +for the whole session. + + resolve db path from `notmuch config get database.path` + guard: db dir missing -> complain on stderr, exit 1 + seed state silently (no startup notification) + while inotifywait -qq -e close_write,moved_to "$db"; do + sleep 0.3 + notify_new + done + complain, exit 1 + +The watch idiom is copied from `waybar-mail.sh` rather than shared. It is +about six lines, and two copies of six lines beat an abstraction spanning a +bar module and a notifier, which have different owners and different +lifetimes. + +**Why a separate process rather than extending `waybar-mail.sh`.** That +script already has the arrival edge, and reusing it would be the shortest +diff. It was rejected because waybar owns that process: a waybar restart or a +`hyprctl reload` would stop mail notifications with nothing reporting it. A +notifier that silently stops is worse than one that costs a second inotify +watch. Running it inside quickshell was also rejected: `FileView` watches +files, not directories, and a watch on a file inside the Xapian directory +dies on commit (the trap already recorded in AGENTS.md), so it would need a +`Process` running `inotifywait` anyway. + +## What counts as new + +A notmuch commit is not the same as new mail. Reading a message in qtmaildir +drops its `unread` tag and commits; so does tagging. Arrival is detected with +notmuch's own revision counter. + + notmuch count --lastmod 'tag:unread and tag:inbox' + +prints three tab-separated fields: count, database UUID, revision. The +revision is field 3. Verified on notmuch 0.39. + +Per tick, if the revision has not advanced, there is nothing to do. Otherwise, +per account: + + notmuch count "tag:unread and tag:inbox and tag:account-<key> and lastmod:<prev+1>..<cur>" + notmuch search --format=json --limit=3 --sort=newest-first "<same query>" + +Two calls, the same count-plus-preview pair `Accounts.qml` already makes, and +only for accounts whose query is non-empty. The count gives the true N for +"+N more"; the search gives the rows. The lower bound is exclusive (`prev+1`) +so the revision just written, which is inclusive at the top end, is not +re-matched on the next tick. + +`search --format=json` returns `authors` and `subject` directly, which is all +the body needs. + +**Alternatives rejected.** A per-account count delta is simpler but cannot +name senders, which was the point of the feature. A timestamp watermark using +`date:@<ts>..` is quietly broken: `date:` is the message's Date header, so +backdated mail never notifies and future-dated mail notifies forever. + +## State + +`~/.local/state/mail-notify.lastmod`, alongside `mail-watcher.heartbeat` and +`mailsync.log`. Written by atomic replace (tmpfile then rename), the same +idiom the heartbeat uses. + +It stores the database UUID as well as the revision. notmuch revisions are +only comparable within one database: a rebuilt database restarts the counter, +and a stored revision from the old one would then be meaningless. A UUID +mismatch is treated exactly like a missing file. + +**Missing, unparseable, or UUID-mismatched state seeds silently:** record the +current revision, notify nothing. Without this, a first run has no floor, +`lastmod:0..` matches every unread inbox message ever, and startup is a wall +of popups. Measured here: 101 unread messages across five accounts. + +The same applies to a restart mid-session. Mail that arrived while the script +was down is never notified. This is the right trade: the waybar count is still +correct and the drawer still shows the mail, so nothing is lost except a +popup that would have been stale anyway. + +The new revision is written **after** every account has been processed. A +single account whose count does not validate is skipped for that tick, and the +revision still advances: holding it back would make every later tick re-notify +the successful accounts' whole range. Only a failure to write the state file +itself is non-fatal and leaves the old revision, so the next tick re-notifies +rather than dropping mail. + +## Accounts + +Parsed from `qtmaildir.conf`, the same file the drawer parses, so adding an +account in qtmaildir makes it notify with no edit here. + +Two details carry over from `Accounts.qml` and are not optional: + +- **The key runs to the closing bracket, not to the first dot.** Real keys + contain dots: a section like `[account.provider-first.last]` maps to the + notmuch tag `account-provider-first.last`. Splitting on the first dot yields + a tag that matches nothing, and an account that never notifies. +- **Walk lines; never match "everything up to the next `[`".** Several + accounts have folders named like `[Gmail]/Bozze`, which ends a section body + before its `label` and makes the account display its raw key. + +The `label` is what the notification summary shows. + +## The notification + +One per account with new mail: + + dunstify -a "New Mail" -u normal -t 10000 -b \ + -i "$MAIL_ICON" \ + -h string:x-dunst-stack-tag:mail-<key> \ + -A default,open \ + "<label> (<count>)" "<body>" + +The heading is split to match the running dunst `format`, which renders `%a` +(the app name) bold on the top line and `%s` (the summary) italic below it: +the app name is `New Mail`, the summary is `<label> (<count>)`. + +Body: up to 3 bullet rows of author and subject, each on its own line with a +blank line between, then `+N more` when N > 3. Three matches the drawer's own +`--limit=3`, and bounds the height so a 20-message mailing list burst does not +become a wall. + +`MAIL_ICON` is an absolute path, not an icon name. The running dunst resolves +a themed icon name only through the `icon_path` in `dunstrc`, which holds no +mail icon, so a name renders nothing; an absolute path under the active theme +resolves, as the machine's other notifiers already do. It points at +`${XDG_DATA_HOME:-$HOME/.local/share}/icons/MB-Blueberry-Suru-GLOW/actions/24/mail-unread-multiple.svg`. + +Because the notification carries `-A`, dunst prepends an `(A)` action +indicator when `show_indicators` is on, so `dunstrc` sets +`show_indicators = no`. + +Normal urgency and an explicit 10s timeout. Deliberately not `-u critical`: +on most dunst configurations critical notifications never expire, which +would leave mail popups stuck on screen. + +**Stack tag per account** means a second batch for the same account replaces +the first rather than stacking, which is what "one notification per account" +has to mean when mail keeps arriving. + +**Click opens qtmaildir.** `-A default,open` plus `-b` makes dunstify block +until the notification is dismissed or actioned and print the action key, so +each notification is launched in a backgrounded subshell that waits and runs +`~/bin/qtmaildir` on `default`. Without backgrounding, the loop would stall +for the full timeout on every account. With the running dunst's +`mouse_middle_click = do_action`, it is a middle click that invokes it. + +## Failure handling + +The existing rule for this component applies unchanged: **the output is the +test, not the exit status.** notmuch fails two ways and only one is +detectable. A rejected query prints nothing and exits 1; a query Xapian merely +misparses returns a plausible wrong number and exits 0. + +So every count is validated as `^[0-9]+$`. An account whose count does not +validate is skipped for that tick with no notification. The defence against +the second failure mode is that the queries are fixed strings with only the +account key and the two revisions interpolated, never built from anything +else. + +Falling out of the `inotifywait` loop means the watch itself died. The script +says so on stderr and exits non-zero rather than exiting silently, which would +look indistinguishable from no mail arriving. + +## Verification + +The script is runnable standalone, which is what makes this testable without +waiting for real mail: write a `prev` revision a few hundred revisions behind +current into the state file, run one tick, and confirm the per-account +notifications appear with the right counts, that no storm occurs, and that the +state file advances to the current revision. + +That single check is enough. It exercises the parse, the query, the body +construction and the state write together, and it fails if any of them break. diff --git a/mail-overview/Accounts.qml b/mail-overview/Accounts.qml index 27cdcf9..0ae6886 100644 --- a/mail-overview/Accounts.qml +++ b/mail-overview/Accounts.qml @@ -184,32 +184,12 @@ Singleton { accounts = next; } - // Launching qtmaildir, and syncing. qtmaildir takes no arguments, so - // there is nothing to tell it about the account or thread clicked. + // Launching qtmaildir. It takes no arguments, so there is nothing to tell + // it about the account or thread clicked. Process { id: openProc; command: [`${Quickshell.env("HOME")}/bin/qtmaildir`] } function openClient() { openProc.running = false; openProc.running = true; } - - property bool syncing: false - - // mailsync.sh is already lock-protected against a concurrent cron run, so - // this does not need its own guard beyond not stacking clicks. - Process { - id: syncProc - command: [`${Quickshell.env("HOME")}/bin/mailsync.sh`] - onExited: { - root.syncing = false; - root.refresh(); - } - } - - function sync() { - if (syncing) return; - syncing = true; - syncProc.running = false; - syncProc.running = true; - } } diff --git a/mail-overview/MailPanel.qml b/mail-overview/MailPanel.qml index 7457fb2..0a7f36a 100644 --- a/mail-overview/MailPanel.qml +++ b/mail-overview/MailPanel.qml @@ -10,6 +10,7 @@ // GNU General Public License for more details. import Quickshell +import Quickshell.Io import Quickshell.Wayland import QtQuick @@ -20,6 +21,45 @@ Scope { property string monitor: "DP-1" property bool open: false + // mail-watcher's heartbeat, written every 60s. Read once per open: the + // drawer is a LazyLoader, so closing and reopening rebuilds the FileView + // and reads the current file. Watch is deliberately not used, because the + // heartbeat is written by atomic replace (tmpfile + rename) and an inotify + // watch held on the old inode dies with it. + property bool watcherAlive: false + property int watcherDead: 0 + property int watcherExpected: 0 + + // The same staleness rule as mail-watcher's heartbeat_is_healthy: dead + // threads or a heartbeat older than 300s mean the watcher needs a look. + // Backoff is healthy, so it never turns the dot. + function readHeartbeat(payload) { + root.watcherAlive = false; + root.watcherDead = 0; + root.watcherExpected = 0; + + let data = null; + try { data = JSON.parse(payload); } catch (e) { return; } + if (!data || typeof data.ts !== "string") return; + + const ts = Date.parse(data.ts); + if (isNaN(ts) || (Date.now() - ts) / 1000 > 300) return; + + root.watcherAlive = true; + root.watcherDead = Number(data.dead) || 0; + root.watcherExpected = Number(data.expected) || 0; + } + + readonly property color watcherColor: + !watcherAlive ? Theme.red + : watcherDead > 0 ? Theme.yellow + : Theme.green + + readonly property string watcherText: + !watcherAlive ? "watcher not running" + : watcherDead > 0 ? `${watcherDead} folder(s) dead, check the log` + : `watcher ok · ${watcherExpected} folders` + readonly property var screenObj: Quickshell.screens.find(s => s.name === root.monitor) ?? Quickshell.screens[0] @@ -95,6 +135,13 @@ Scope { // the drawer. MouseArea { anchors.fill: parent } + FileView { + id: heartbeat + path: `${Quickshell.env("HOME")}/.local/state/mail-watcher.heartbeat` + onLoaded: root.readHeartbeat(text()) + onLoadFailed: root.readHeartbeat("") + } + Column { id: content anchors { left: parent.left; right: parent.right; top: parent.top; margins: 16 } @@ -230,17 +277,36 @@ Scope { Rectangle { width: parent.width; height: 1; color: Qt.alpha(Theme.text, 0.12) } + // Watcher health, above the button. Green when idling, + // yellow when a folder gave up, red when there is no fresh + // heartbeat at all. + Item { + width: parent.width + implicitHeight: 22 + + Rectangle { + id: watcherDot + anchors.verticalCenter: parent.verticalCenter + width: 8; height: 8; radius: 4 + color: root.watcherColor + } + + Text { + anchors { left: watcherDot.right; leftMargin: 10; verticalCenter: parent.verticalCenter } + text: root.watcherText + color: Theme.subtext + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSize - 2 + } + } + + Rectangle { width: parent.width; height: 1; color: Qt.alpha(Theme.text, 0.12) } + Row { anchors.right: parent.right spacing: 10 Button { - text: Accounts.syncing ? "Syncing..." : "Sync now" - enabled: !Accounts.syncing - onClicked: Accounts.sync() - } - - Button { text: "Open qtmaildir" onClicked: { Accounts.openClient(); root.close(); } } diff --git a/mail-overview/README.md b/mail-overview/README.md index daf2bf9..34eaa53 100644 --- a/mail-overview/README.md +++ b/mail-overview/README.md @@ -16,7 +16,9 @@ Clicking the icon opens the drawer; Escape or a click outside closes it. │ ● Account D 0 │ │ ● Account E 0 │ ├──────────────────────────────────────────────────┤ - │ [Sync now] [Open qtmaildir] │ + │ ● watcher ok · 25 folders │ + ├──────────────────────────────────────────────────┤ + │ [Open qtmaildir] │ └──────────────────────────────────────────────────┘ ## Running it @@ -118,6 +120,63 @@ Gmail API with stored credentials, covered three of the five accounts, and opened Thunderbird. Everything it fetched over the network was already in the local index. +## Notifications on arrival + +`mail-notify.sh` sends one notification per account when mail lands, with the +newest three threads' sender and subject and a `+N more` line when the batch is +bigger. Each message is a bullet on its own line with a blank line between, +under a bold `New Mail` heading with the account and count as the second line. +Started from `autostart.lua`, it runs for the whole session. + + /home/you/Programming/GIT/quickshell/mail-overview/mail-notify.sh + +It watches the same xapian directory the waybar module does, for the same +reason and with the same 0.3s debounce. It is a **separate process rather +than part of `waybar-mail.sh`**, which already has that edge: waybar owns +that script's process, so a bar restart would stop notifications with nothing +reporting it. + +**A commit is not the same as new mail.** Reading a message in qtmaildir drops +its `unread` tag and commits; so does tagging. Arrival is found with notmuch's +revision counter instead: + + notmuch count --lastmod 'tag:unread and tag:inbox' + +which prints count, database UUID and revision, tab separated. Each account is +then asked what it gained in `lastmod:<prev+1>..<cur>`, the lower bound one +revision after the stored one so it is exclusive. A count delta was +rejected because it cannot name senders, and a `date:` watermark because +`date:` is the message's own Date header: backdated mail would never notify +and future-dated mail would notify forever. + +Position is kept in `~/.local/state/mail-notify.lastmod`, written by atomic +replace, holding the **UUID as well as the revision**. Revisions only compare +within one database, so a rebuild restarts the counter and a stored revision +from the old one means nothing. + +**Missing, corrupt or mismatched state seeds silently**, recording the current +revision and notifying nothing. Without that floor, `lastmod:0..` matches every +unread message ever: 101 of them here, which is a wall of popups at every +login. The same applies to a restart, so mail that arrived while it was down is +never notified. The waybar count is still right and the drawer still shows it, +so nothing is lost but a stale popup. + +The notification is sent with dunstify. The icon is passed as an **absolute +path** to the active icon theme's `mail-unread-multiple`, because this dunst +resolves an icon *name* only through the `icon_path` in `dunstrc`, and that +path holds no mail icon, so a name renders no icon at all. The click action +(`-A default,open`) makes dunst prepend an `(A)` action indicator when +`show_indicators` is on, so `dunstrc` sets `show_indicators = no`. A middle +click (dunst's `do_action`) opens qtmaildir, but not the account it belongs +to: `qtmaildir` takes no arguments and `startup_account` is a static setting +rather than a flag, the same limitation the thread rows have. + +`test-mail-notify.sh` is the check. It sources the script as a library and +asserts on the functions that only move text around, so it needs no mail and no +notmuch: + + ./test-mail-notify.sh + ## Geometry, and the one property that differs from the other panels The window is a fullscreen overlay with a dimmed backdrop and the drawer itself @@ -145,8 +204,36 @@ open. `startup_account` in its config is a static setting, not a flag, so here was rejected rather than deferred: it would write the database behind a possibly running client. -"Sync now" runs `~/bin/mailsync.sh`, which is already lock-protected against a -concurrent cron run, and the watcher picks up whatever it commits. +"Open qtmaildir" launches the client. There is no "Sync now" button: the +watcher triggers a sync the moment mail lands, the cron tick is the backstop, +and `on-click-right` on the waybar module runs `~/bin/mailsync.sh` for a manual +pull. + +## The watcher status dot + +Below the accounts, above the button, a coloured dot reports whether +`mail-watcher` is alive and sane, read from its heartbeat at +`~/.local/state/mail-watcher.heartbeat`: + +- **green** (`watcher ok · N folders`) heartbeat fresh, no dead threads. +- **yellow** (`N folder(s) dead, check the log`) heartbeat fresh, but a folder + gave up permanently. +- **red** (`watcher not running`) heartbeat missing, unparseable, or older than + 300s. + +Backoff never turns the dot: it is normal recovery from a dropped IDLE +connection, and the watcher's own health check treats it as healthy. The +staleness rule is the same one `mail-watcher` uses (`heartbeat_is_healthy`), +reimplemented here in a few lines rather than shelling out to +`mail-watcher.py --status` on every open. + +The heartbeat is read once per open. The drawer is a `LazyLoader`, so closing +and reopening rebuilds the `FileView` and reads the file current. A file watch +is deliberately not used: the heartbeat is written by atomic replace (tmpfile +then rename), so an inotify watch held on the old inode dies with it, which is +the same trap as watching a file inside the Xapian directory. The cost is that +a drawer left open does not update until reopened, which for 60s heartbeat data +is not worth a timer. ## Theme and blur diff --git a/mail-overview/mail-notify.sh b/mail-overview/mail-notify.sh new file mode 100755 index 0000000..b132775 --- /dev/null +++ b/mail-overview/mail-notify.sh @@ -0,0 +1,295 @@ +#!/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. +# +# Notifies when mail arrives: one notification per account per batch, naming +# the newest senders and subjects. +# +# Watches the notmuch Xapian directory and, on each commit, asks notmuch what +# changed since the revision it last saw. A commit is NOT the same as new +# mail, since reading and tagging also commit, which is why the revision +# counter does the work rather than a count delta. +# +# This is its own process rather than part of waybar-mail.sh, which already +# has the same arrival edge: waybar owns that process, so a bar restart would +# stop notifications with nothing reporting it. +# +# Usage: +# mail-notify.sh watch forever (what autostart runs) +# mail-notify.sh --once process one tick and exit (for manual verification, one tick) + +set -u + +STATE="${MAIL_NOTIFY_STATE:-$HOME/.local/state/mail-notify.lastmod}" +CONFIG="${MAIL_NOTIFY_CONFIG:-$HOME/.config/qtmaildir/qtmaildir.conf}" +SCOPE='tag:unread and tag:inbox' + +# How many threads a notification body lists before eliding into "+N more". +# Three matches the drawer's own --limit=3. +ROWS=3 + +# dunst here resolves a themed icon NAME only through its icon_path, which +# holds no mail icon, so a name renders nothing (dunst stores an empty +# icon_path for it). Pass an absolute path, as this machine's other +# notifiers do. This is the icon the user picked; ${XDG_DATA_HOME} keeps a +# home path out of the committed file. +MAIL_ICON="${XDG_DATA_HOME:-$HOME/.local/share}/icons/MB-Blueberry-Suru-GLOW/actions/24/mail-unread-multiple.svg" + +# Accounts in file order, one "key<TAB>label" line each, read from stdin. +# +# Two things here are load-bearing, and both have already broken this +# component once: +# +# The key runs to the closing bracket, NOT to the first dot. Real keys +# contain dots, so splitting on the first one yields a notmuch tag matching +# nothing and an account that silently never notifies. +# +# And this walks lines rather than matching a section body as "everything up +# to the next [". Accounts have folders named like [Gmail]/Bozze, which ends +# the body before its label and makes the account display its raw key. +parse_accounts() { + local line key label + key="" + label="" + + while IFS= read -r line || [[ -n "$line" ]]; do + if [[ "$line" =~ ^\[account\.([^]]+)\] ]]; then + [[ -n "$key" ]] && printf '%s\t%s\n' "$key" "${label:-$key}" + key="${BASH_REMATCH[1]}" + label="" + continue + fi + # Any other section ends the current account. + if [[ "$line" =~ ^\[ ]]; then + [[ -n "$key" ]] && printf '%s\t%s\n' "$key" "${label:-$key}" + key="" + label="" + continue + fi + [[ -n "$key" ]] || continue + if [[ "$line" =~ ^[[:space:]]*label[[:space:]]*=[[:space:]]*(.*)$ ]]; then + label="${BASH_REMATCH[1]}" + # Trailing whitespace only; a label may contain spaces. + label="${label%"${label##*[![:space:]]}"}" + fi + done + + [[ -n "$key" ]] && printf '%s\t%s\n' "$key" "${label:-$key}" + return 0 +} + +# Renders notmuch search JSON into notification body text. +# $1 the JSON array from `notmuch search --format=json` +# $2 the true total for this batch, which may exceed the rows present +# +# dunst has body-markup in its capabilities, so a subject containing < or & +# would be parsed as markup and could vanish from the notification. Subjects +# are attacker-controlled text arriving from the internet, so the three XML +# characters are escaped here. This is the one place in this script where +# untrusted text reaches a renderer. +# +# Malformed JSON prints nothing and succeeds. A notification with no body is +# still worth sending: the summary already carries the account and the count. +build_body() { + local json="$1" total="$2" shown rowtext body + + rowtext="$(printf '%s' "$json" | jq -r ' + .[] | "• " + ((.authors // "(unknown)") + " — " + (.subject // "(no subject)")) + | gsub("[\r\n]+"; " ") + | gsub("&"; "&") | gsub("<"; "<") | gsub(">"; ">") + ' 2>/dev/null)" || return 0 + [[ -n "$rowtext" ]] || return 0 + + shown="$(printf '%s\n' "$rowtext" | wc -l)" + body="$(printf '%s\n' "$rowtext" | awk 'NR>1{print ""} 1')" + + printf '%s' "$body" + if [[ "$total" -gt "$shown" ]]; then + printf '\n+%d more' "$((total - shown))" + fi + printf '\n' +} + +# The last revision this script notified up to, or empty when there is none +# to trust. Empty means "seed silently": record where we are now and notify +# nothing. +# +# The stored UUID is checked because notmuch revisions are only comparable +# within one database. A rebuilt database restarts the counter, so an old +# revision would be meaningless, and treating it as a floor would either +# notify nothing forever or notify everything at once. +read_prev_rev() { + local want_uuid="$1" got_uuid rev + + [[ -f "$STATE" ]] || return 0 + read -r got_uuid rev < "$STATE" 2>/dev/null || return 0 + + [[ "$got_uuid" == "$want_uuid" ]] || return 0 + [[ "$rev" =~ ^[0-9]+$ ]] || return 0 + + printf '%s' "$rev" +} + +# Written by atomic replace, the same idiom mail-watcher uses for its +# heartbeat: a reader must never see a half-written file, and mv within a +# directory is atomic where a redirect into the final path is not. +# +# Failure to write is deliberately not fatal. The notifications have already +# been sent; taking the watcher down over a failure to record that would turn +# a bookkeeping problem into a no-mail-notifications problem. +write_state() { + local uuid="$1" rev="$2" tmp + + mkdir -p "$(dirname "$STATE")" 2>/dev/null || return 0 + tmp="$(mktemp "${STATE}.XXXXXX")" || return 0 + printf '%s %s\n' "$uuid" "$rev" > "$tmp" || { rm -f "$tmp"; return 0; } + mv -f "$tmp" "$STATE" 2>/dev/null || rm -f "$tmp" + return 0 +} + +# Sends one notification for one account. +# +# dunstify rather than notify-send because actions need it. A stack tag per +# account means a second batch for the same account replaces the first rather +# than stacking, which is what "one notification per account" has to mean when +# mail keeps arriving. +# +# Normal urgency and an explicit 10s timeout, deliberately not -u critical: +# on most dunst configurations critical notifications never expire, which +# would leave mail popups on screen until clicked. +# +# The click cannot open the account it belongs to. qtmaildir accepts no +# command line arguments and startup_account is a static config setting, not +# a flag, which is the same limitation the drawer's thread rows already have. +notify_account() { + local label="$1" key="$2" count="$3" body="$4" + + # -a carries "New Mail" because the user's dunst format renders %a as the + # bold heading line, with %s italic below it. + if ! command -v dunstify >/dev/null 2>&1; then + # No actions available, but a notification without a click is still + # worth having. + notify-send -a "New Mail" -u normal -t 10000 -i "$MAIL_ICON" \ + "$label ($count)" "$body" + return 0 + fi + + # Backgrounded because -b blocks until the notification is dismissed or + # clicked. Without this the loop would stall for the full timeout on + # every account, and a five-account batch would take most of a minute. + ( + if [[ "$(dunstify -a "New Mail" -i "$MAIL_ICON" -u normal -t 10000 -b \ + -h "string:x-dunst-stack-tag:mail-$key" \ + -A "default,open" \ + "$label ($count)" "$body")" == "default" ]]; then + "$HOME/bin/qtmaildir" & + fi + ) >/dev/null 2>&1 & +} + +# One pass: what has arrived since the revision we last saw. +tick() { + local lastmod uuid cur prev + + # Three tab-separated fields: count, database UUID, revision. Verified on + # notmuch 0.39. + lastmod="$(notmuch count --lastmod "$SCOPE" 2>/dev/null)" || return 0 + uuid="$(printf '%s' "$lastmod" | cut -f2)" + cur="$(printf '%s' "$lastmod" | cut -f3)" + + # The output is the test, not the exit status. notmuch fails two ways and + # only one is detectable: a rejected query prints nothing and exits 1, + # while a query Xapian merely misparses returns a plausible wrong number + # and exits 0. The defence against the second is that SCOPE is a fixed + # string and is never built from anything. + [[ "$cur" =~ ^[0-9]+$ ]] || return 0 + [[ -n "$uuid" ]] || return 0 + + prev="$(read_prev_rev "$uuid")" + + # No trustworthy floor: record where we are and say nothing. This is the + # first run, a rebuilt database, or a corrupt state file. + if [[ -z "$prev" ]]; then + write_state "$uuid" "$cur" + return 0 + fi + + # Nothing committed since last time, or the counter went backwards. + if [[ "$cur" -le "$prev" ]]; then + return 0 + fi + + local key label query count rows + while IFS=$'\t' read -r key label; do + [[ -n "$key" ]] || continue + + query="$SCOPE and tag:account-$key and lastmod:$((prev + 1))..$cur" + + count="$(notmuch count "$query" 2>/dev/null)" + # Same validation, same reason: an empty string must not become a + # zero, and a failure here skips this account rather than the batch. + [[ "$count" =~ ^[0-9]+$ ]] || continue + [[ "$count" -gt 0 ]] || continue + + rows="$(notmuch search --format=json --limit="$ROWS" \ + --sort=newest-first "$query" 2>/dev/null)" || rows="[]" + + notify_account "$label" "$key" "$count" "$(build_body "$rows" "$count")" + done < <(parse_accounts < "$CONFIG") + + # Written only after every account is done. A single account whose count + # fails to validate is skipped above but does not hold the revision back; + # leaving it behind would re-notify every successful account's range on + # each later tick. Only a failed state write itself leaves prev unchanged. + write_state "$uuid" "$cur" +} + +main() { + local db + db="$(notmuch config get database.path 2>/dev/null)/xapian" + + if [[ ! -d "$db" ]]; then + echo "mail-notify: no notmuch database at $db" >&2 + exit 1 + fi + + if [[ ! -r "$CONFIG" ]]; then + echo "mail-notify: cannot read $CONFIG" >&2 + exit 1 + fi + + if [[ "${1:-}" == "--once" ]]; then + tick + return 0 + fi + + # Seed before watching, so a first run never notifies the backlog. + tick + + # The watch is on the xapian DIRECTORY, not a file inside it: a commit + # replaces files, and a watch held on a filename dies with the file. + while inotifywait -qq -e close_write,moved_to "$db" 2>/dev/null; do + # One commit touches several files. Without this, a single sync fires + # three or four ticks. + sleep 0.3 + tick + done + + # Falling out means inotifywait itself failed. Say so rather than exiting + # silently, which is indistinguishable from no mail arriving. + echo "mail-notify: inotify watch stopped" >&2 + exit 1 +} + +# Sourced by the test with MAIL_NOTIFY_LIB set, which must not start a watch +# loop. The bash equivalent of Python's __name__ == "__main__". +[[ -n "${MAIL_NOTIFY_LIB:-}" ]] || main "$@" diff --git a/mail-overview/test-mail-notify.sh b/mail-overview/test-mail-notify.sh new file mode 100755 index 0000000..1c3e01a --- /dev/null +++ b/mail-overview/test-mail-notify.sh @@ -0,0 +1,148 @@ +#!/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 mail-notify.sh. No framework: it sources the +# script as a library and asserts on the pure functions, the ones that take +# text and print text. Nothing here calls notmuch, dunstify or inotify, which +# is why it runs in milliseconds and needs no mail to arrive. +# +# Usage: ./test-mail-notify.sh (exit 0 = all passed) + +set -u + +MAIL_NOTIFY_LIB=1 . "$(dirname "$0")/mail-notify.sh" + +pass=0 +fail=0 + +# Compares two strings and reports. Multi-line values are printed with their +# newlines intact, because half the assertions here are about line structure. +check() { + local name="$1" want="$2" got="$3" + if [[ "$want" == "$got" ]]; then + pass=$((pass + 1)) + else + fail=$((fail + 1)) + printf 'FAIL: %s\n want: %s\n got: %s\n' "$name" "$want" "$got" + fi +} + +check "library guard does not run main" "loaded" "loaded" + +# A config with the two traps in it: a key containing dots, and a folder +# value containing a bracket. Both are real shapes from qtmaildir.conf, with +# placeholder names. +read -r -d '' fixture <<'EOF' +[general] +theme = dark + +[account.simple] +label = Simple +color = #112233 + +[account.provider-first.last] +folder = [Gmail]/Bozze +label = Dotted +color = #445566 + +[account.nolabel] +color = #778899 + +[ui] +label = NotAnAccount +EOF + +check "keys run to the bracket, not the first dot" \ + "simple provider-first.last nolabel" \ + "$(parse_accounts <<<"$fixture" | cut -f1 | tr '\n' ' ' | sed 's/ $//')" + +check "a bracket in a value does not end the section" \ + "Dotted" \ + "$(parse_accounts <<<"$fixture" | awk -F'\t' '$1=="provider-first.last"{print $2}')" + +check "a missing label falls back to the key" \ + "nolabel" \ + "$(parse_accounts <<<"$fixture" | awk -F'\t' '$1=="nolabel"{print $2}')" + +check "a non-account section is not an account" \ + "" \ + "$(parse_accounts <<<"$fixture" | awk -F'\t' '$1=="ui"{print $2}')" + +# The shape notmuch search --format=json actually returns, trimmed to the two +# fields the body uses. +rows_json='[ + {"authors":"Alice Example","subject":"First subject"}, + {"authors":"Bob Example","subject":"Second subject"}, + {"authors":"Carol Example","subject":"Third subject"} +]' + +check "a body lists author and subject per row" \ + "• Alice Example — First subject + +• Bob Example — Second subject + +• Carol Example — Third subject" \ + "$(build_body "$rows_json" 3)" + +check "a batch bigger than the rows shown is elided" \ + "• Alice Example — First subject + +• Bob Example — Second subject + +• Carol Example — Third subject ++7 more" \ + "$(build_body "$rows_json" 10)" + +check "markup characters are escaped, not rendered" \ + "• A & B — <script>" \ + "$(build_body '[{"authors":"A & B","subject":"<script>"}]' 1)" + +check "a missing subject says so rather than printing nothing" \ + "• Alice Example — (no subject)" \ + "$(build_body '[{"authors":"Alice Example","subject":null}]' 1)" + +check "malformed json yields an empty body rather than an error" \ + "" \ + "$(build_body 'not json at all' 1 2>/dev/null)" + +check "a newline in a subject stays on one row" \ + "• Alice Example — line one line two" \ + "$(build_body '[{"authors":"Alice Example","subject":"line one\nline two"}]' 1)" + +# A scratch state file, removed at exit. Never the real one: that is the +# user's live notification position, and a test must not move it. +tmpstate="$(mktemp)" +trap 'rm -f "$tmpstate"' EXIT +STATE="$tmpstate" + +rm -f "$tmpstate" +check "a missing state file reports no previous revision" \ + "" "$(read_prev_rev "uuid-a")" + +write_state "uuid-a" 500 +check "a revision written is a revision read back" \ + "500" "$(read_prev_rev "uuid-a")" + +check "a different database UUID discards the revision" \ + "" "$(read_prev_rev "uuid-b")" + +printf 'uuid-a garbage\n' > "$tmpstate" +check "an unparseable state file reports no previous revision" \ + "" "$(read_prev_rev "uuid-a")" + +write_state "uuid-a" 600 +check "a rewrite replaces rather than appends" \ + "1" "$(wc -l < "$tmpstate")" + +printf '\n%d passed, %d failed\n' "$pass" "$fail" +[[ "$fail" -eq 0 ]] diff --git a/vm-manager/README.md b/vm-manager/README.md index 8d817f9..bcf12ac 100644 --- a/vm-manager/README.md +++ b/vm-manager/README.md @@ -85,6 +85,18 @@ in a delete confirmation gives it up. ## Destructive actions +`Discard saved state` appears on a shut-off VM only when one actually exists, +and runs `virsh managedsave-remove`. A VM that was saved rather than shut down +restores that memory image on the next `start`, and when the image cannot be +restored the start fails every time with a QEMU `migrate-incoming` error while +the panel shows an ordinary `shut off`. Discarding it deletes the memory image +and nothing else, so the next start is a cold boot and the disk is untouched. +That is why it takes a confirmation but not a typed name. + +Detection is `virsh dominfo`, grepped for `Managed save: yes`, once per VM on +every list refresh. `domstats` does not carry it and `virsh list --name` drops +the column that would. + `Reset`, `Force stop`, snapshot `Revert` and snapshot `Delete` each take one confirmation click. `Delete VM` requires the machine's name to be typed, because it runs `virsh undefine --remove-all-storage`, which erases the disk diff --git a/vm-manager/Virsh.qml b/vm-manager/Virsh.qml index 309781c..14bd368 100644 --- a/vm-manager/Virsh.qml +++ b/vm-manager/Virsh.qml @@ -21,7 +21,7 @@ import QtQuick Singleton { id: root - // name -> { state, cpu, memUsed, memTotal, fsUsed, fsTotal, ip, vcpus, agent } + // name -> { state, cpu, memUsed, memTotal, fsUsed, fsTotal, ip, vcpus, agent, saved } property var vms: ({}) property var names: [] property var snapshots: ({}) // name -> [{ name, created, state, current }] @@ -33,7 +33,8 @@ Singleton { function _vm(name) { return vms[name] ?? { state: "unknown", cpu: -1, memUsed: -1, memTotal: -1, - fsUsed: -1, fsTotal: -1, ip: "", vcpus: 0, agent: false }; + fsUsed: -1, fsTotal: -1, ip: "", vcpus: 0, agent: false, + saved: false }; } function _set(name, fields) { @@ -52,6 +53,7 @@ Singleton { const found = text.trim().split("\n").map(s => s.trim()).filter(s => s.length); root.names = found; for (const n of found) if (!(n in root.vms)) root._set(n, {}); + root._pollSaved(); root.refresh(); } } @@ -191,6 +193,44 @@ Singleton { onExited: code => { if (code !== 0) { root._set(vm, { ip: "" }); root._nextAgent(); } } } + // --- managed save ---------------------------------------------------- + // + // A shut-off VM can still carry a saved memory image, and `virsh start` + // then restores it rather than booting. When that image cannot be + // restored the start fails every time with a QEMU migrate-incoming + // error, and nothing in the panel said why. `domstats` does not report + // it: the shut-off reason reads "failed", from the failed start, not + // from the save. `dominfo` is the only cheap source, so it is polled per + // VM the way the agent rows are. + + property var _savedQueue: [] + + function _pollSaved() { + _savedQueue = names.slice(); + _nextSaved(); + } + + function _nextSaved() { + if (_savedQueue.length === 0) return; + const vm = _savedQueue[0]; + _savedQueue = _savedQueue.slice(1); + savedProc.vm = vm; + savedProc.command = ["virsh", "dominfo", vm]; + savedProc.running = true; + } + + Process { + id: savedProc + property string vm: "" + stdout: StdioCollector { + onStreamFinished: { + root._set(savedProc.vm, { saved: /^Managed save:\s+yes$/m.test(text) }); + root._nextSaved(); + } + } + onExited: code => { if (code !== 0) { root._set(vm, { saved: false }); root._nextSaved(); } } + } + // --- snapshots ------------------------------------------------------- Process { @@ -227,6 +267,7 @@ Singleton { destroy: v => ["virsh", "destroy", v], suspend: v => ["virsh", "suspend", v], resume: v => ["virsh", "resume", v], + discardsave: v => ["virsh", "managedsave-remove", v], }) function act(vm, action) { diff --git a/vm-manager/VmPanel.qml b/vm-manager/VmPanel.qml index cf224d6..5960a1a 100644 --- a/vm-manager/VmPanel.qml +++ b/vm-manager/VmPanel.qml @@ -62,16 +62,21 @@ Scope { // Which verbs make sense in the current state, mirroring the states the // old rofi script switched on. - function actionsFor(s) { + function actionsFor(s, saved) { if (s === "running") return [["shutdown", "Shutdown"], ["reboot", "Reboot"], ["suspend", "Suspend"], ["reset", "Reset"], ["destroy", "Force stop"]]; if (s === "paused" || s === "suspended") return [["resume", "Resume"], ["shutdown", "Shutdown"], ["destroy", "Force stop"]]; + // Only worth offering when a saved image actually exists: without one + // managedsave-remove fails, and the button would be noise on every + // other VM. + if (saved) + return [["start", "Start"], ["discardsave", "Discard saved state"]]; return [["start", "Start"]]; } - function isDestructive(a) { return a === "reset" || a === "destroy"; } + function isDestructive(a) { return a === "reset" || a === "destroy" || a === "discardsave"; } function run(vm, action) { if (isDestructive(action)) root.confirming = { kind: action, vm: vm, snap: "" }; @@ -265,6 +270,7 @@ Scope { sourceComponent: detail property string vmName: modelData property string vmState: vm.state ?? "" + property bool vmSaved: vm.saved ?? false } } } @@ -282,6 +288,7 @@ Scope { readonly property string vmName: parent.vmName readonly property string vmState: parent.vmState + readonly property bool vmSaved: parent.vmSaved Rectangle { width: parent.width; height: 1; color: Qt.alpha(Theme.text, 0.08) } @@ -299,7 +306,7 @@ Scope { spacing: 8 Repeater { - model: root.actionsFor(vmState) + model: root.actionsFor(vmState, vmSaved) Button { required property var modelData text: modelData[1] @@ -391,6 +398,7 @@ Scope { if (c.kind === "revert") return `Revert ${c.vm} to "${c.snap}"? Changes since that snapshot are lost.`; if (c.kind === "snapdelete") return `Delete snapshot "${c.snap}"?`; if (c.kind === "destroy") return `Force stop ${c.vm}? This is a power cut, not a shutdown.`; + if (c.kind === "discardsave") return `Discard the saved state of ${c.vm}? Its memory image is deleted and the next start boots cold. The disk is untouched.`; if (c.kind === "reset") return `Reset ${c.vm}? This is a hard reset, not a reboot.`; return ""; } diff --git a/window-switcher/README.md b/window-switcher/README.md new file mode 100644 index 0000000..3cfb28b --- /dev/null +++ b/window-switcher/README.md @@ -0,0 +1,151 @@ +# window-switcher + +Every open window as a live preview, in one centred grid over a dimmed screen. +ALT+TAB opens it, a click or Enter picks a window, Escape drops it. It replaces +a rofi list that showed the same windows as text. + + ┌──────────────────────────────────────────────────────────────┐ + │ │ + │ ┌────────────────────┐ ┌────────────────────┐ │ + │ │ [icon] [x] │ │ [icon] [x] │ │ + │ │ │ │ │ │ + │ │ live preview │ │ live preview │ │ + │ │ fitted in a │ │ │ │ + │ │ 16:10 box │ │ │ │ + │ └────────────────────┘ └────────────────────┘ │ + │ firefox kitty │ + │ a page title, elided... ~/Programming/GIT/... │ + │ [1] [4] │ + │ │ + └──────────────────────────────────────────────────────────────┘ + +The cards are ordered most recently used first, so the window you just left is +the first one, the same order ALT+TAB implies. Windows on a `special:` +workspace are the scratchpad, which has its own bind, and are left out. + +With nothing to switch to it says so, because a full-screen dim with nothing in +it reads as a hang. + +## Running it + + qs -p . + +It is started from `autostart.lua` and reached over IPC, so the shell has to be +running for the keybind to do anything: + + qs -p ~/Programming/GIT/quickshell/window-switcher ipc call switcher toggle + +Write that path out in full in the real config: Hyprland's Lua strings have no +shell to expand `~`. The bind lives in `keybindings.lua` on ALT+TAB. `show` and +`close` exist on the same IPC target for anything that wants one direction only. + +## Interaction + +| | | +| --- | --- | +| ALT+TAB | open, or close if already open | +| click a card, or Enter | focus that window and dismiss | +| arrows | move the selection, up and down by a row | +| Tab / Shift+Tab | move the selection by one | +| the `x` on a card | close that window, overlay stays open | +| Escape, or a click on the backdrop | dismiss, focus unchanged | + +Closing leaves the overlay up on purpose. Tidying several windows would +otherwise mean reopening between each one, and the card goes when Hyprland says +the window went, not when the button is pressed. + +## Hyprland dispatches from QML + +**Dispatch arguments are evaluated as Lua on Hyprland 0.56.2.** The form that +reads like the documented one, `dispatch focuswindow address:0x...`, is a +syntax error rather than a command, and it fails silently unless stderr is +read. What works: + + hl.dsp.focus({ window = "address:0x..." }) + hl.dsp.window.close({ window = "address:0x..." }) + +`~/bin/hypr-windows.sh`, the script behind the rofi list this replaces, is +written that way for this reason. While planning, its syntax was read as +legacy oddity worth modernising, which would have broken it. + +**A focus dispatched while the overlay is up is accepted and then ignored.** +The layer surface holds keyboard focus exclusively and the compositor will not +move window focus out from under that grab. The dispatch reports `ok`, nothing +happens, and no log line says otherwise. Closing in the same turn does not help +either: `close()` only clears a property, and the surface survives until the +frame after. So the target is remembered, the overlay closes, and a 60ms timer +does the focus once the grab is really gone. The symptom was picking a window +on the other monitor and seeing nothing happen. + +**Focus alone raises a stacked window.** Workspaces 1 and 8 here are `monocle` +and 4 and 5 are scrolling, so stacking is normal rather than an edge case, and +it was tested with two stacked windows on workspace 1 in both directions. No +`alterzorder` and no batch sequence are needed, which is why the fullscreen +batch in `hypr-windows.sh` was not carried over. + +## Reading the toplevel list + +**`Hyprland.toplevels` reads 0 until `refreshToplevels()` is called.** The +model is refreshed on every opening, which also picks up windows opened since +the last one. + +**`HyprlandToplevel` has no `focusHistoryID` property.** It reads `undefined`, +so a sort on it compares `NaN` and does nothing at all, leaving a plausible +looking list in arbitrary order. The value is on `lastIpcObject` instead, which +is what `focusOrder()` reads, and a missing one sorts last rather than as 0. + +**`HyprlandToplevel.address` omits the `0x`** that `hyprctl clients` prints and +that a dispatch requires, so `addressOf()` puts it back. + +**`toplevel.wayland` can be null.** The Hyprland toplevel and its wlr handle +are separate objects created at different moments, so a window that is +appearing or being destroyed has one and not the other. The card guards every +use of it. + +## Card geometry + +**A card is a fixed 16:10 box with the preview fitted inside**, not a box +shaped to its preview. The previews are not one shape: DP-1 windows are near +21:9 (2556x1034) and DP-3 reports `transform=1`, so its windows are portrait +9:16 (1076x1916). Sizing each card to its own window gives ragged rows and +breaks the alignment of the three text lines under them. + +**Card width is clamped by the height as well as the width.** A centred `Grid` +has no way to scroll, so without the height term a busy desktop pushes rows off +the top and bottom where they cannot be reached. The cards shrink instead. +There is a ceiling on the width too, or a single window becomes a full-screen +mirror of itself. + +**An uncaptured `ScreencopyView` reports `sourceSize` of `QSize(-1, -1)`**, not +`(0, 0)`. The aspect ratio guard therefore tests for a positive height. The +obvious defensive rewrites, `!== 0` or a plain truthiness check, all pass on +`-1` and produce a negative ratio. + +**A `Repeater` delegate with required properties needs both `required property +int index` and `required property var modelData`.** Declaring `modelData` +required makes the implicit `index` unavailable. + +**An app with no themed icon renders a blank scrim square.** +`Quickshell.iconPath("", true)` returns `""`, and an `IconImage` with an empty +source draws nothing. There is no fallback glyph. Both corner overlays carry +their own scrim regardless, because a themed icon on an arbitrary window +preview can otherwise land on a same-coloured region and vanish. + +## Theme and blur + +`Theme.qml` is a symlink to `shared/Theme.qml`: the palette comes from +`~/.cache/wal/udt-palette.qml` and is watched. + +Frosting is Hyprland's, matched on this window's namespace: + + hl.layer_rule({ + name = "blur-window-switcher", + match = { namespace = "^(quickshell-window-switcher)$" }, + blur = true, + xray = false, + ignore_alpha = 0.1, + }) + +Without the rule it still works, rendering flat translucent. The overlay covers +the whole screen, waybar included, so unlike `mail-overview` it uses +`ExclusionMode.Ignore`. |
