diff options
Diffstat (limited to 'docs/superpowers/plans')
| -rw-r--r-- | docs/superpowers/plans/2026-09-13-mail-arrival-notifications.md | 924 | ||||
| -rw-r--r-- | docs/superpowers/plans/2026-09-14-desktop-shell.md | 2823 | ||||
| -rw-r--r-- | docs/superpowers/plans/2026-09-14-kdeconnect.md | 1224 | ||||
| -rw-r--r-- | docs/superpowers/plans/2026-09-14-network-bluetooth.md | 1361 |
4 files changed, 6332 insertions, 0 deletions
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/plans/2026-09-14-desktop-shell.md b/docs/superpowers/plans/2026-09-14-desktop-shell.md new file mode 100644 index 0000000..3476db6 --- /dev/null +++ b/docs/superpowers/plans/2026-09-14-desktop-shell.md @@ -0,0 +1,2823 @@ +# Desktop shell 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 quickshell component, `desktop/`, presenting a left-side drawer that hosts sound, mail, vm and appearance as modules, absorbing three existing components. + +**Architecture:** A `ShellRoot` holds a keepalive window, an `IpcHandler` and a registry list of modules. Each module is a directory under `desktop/modules/` exposing a `Module.qml` that declares a tile, a page, both or neither, plus `alwaysActive` governing whether its background service runs while the drawer is closed. The drawer is a single `PanelWindow` whose content is either the grid or one full-height page. + +**Tech Stack:** Quickshell 0.3.1, Qt 6 QML. `QtQuick.Controls` for `ScrollView`, plain `QtQuick` `Flow` for the tile grid. `Quickshell.Services.Pipewire`, `Quickshell.Services.Mpris`, `Quickshell.Io` for `Process`/`FileView`/`IpcHandler`, `Quickshell.Wayland` for layershell properties. + +**Spec:** `docs/superpowers/specs/2026-09-14-desktop-shell-design.md` + +--- + +## Before you start + +Read `AGENTS.md` at the repo root. Four things there will cost you hours if you +skip them: + +- **A quickshell config with no visible window exits.** No error, no message, it + just quits. Every task that runs the shell depends on the keepalive + `PanelWindow` from Task 2 existing. +- **A detached `qs` does not survive a tool call.** Starting one with `&`, + `nohup` or `setsid -f` and checking `pgrep` later always reports it dead, + whether or not the config is sound. Start it so the harness owns the process, + read the log, and do not conclude anything from a later `pgrep`. +- **The process is `qs`, not `quickshell`.** `pkill -x quickshell` matches + nothing and exits successfully, so every "stopped" is a lie and restarts + stack. Use `pkill -x qs`, then `pgrep -cx qs` and check the number. +- **`pkill -f` kills the caller**, because the agent's own working directory is + in its command line. Always `-x`. + +Verification in this plan is therefore: start the shell in the foreground with a +timeout, read what it printed, and kill by exact name. Anything visual is for +the user to look at, not for a screenshot. + +**Erratum (post-Task-4).** Two corrections found during execution, applying to +every verification block below: + +- The `... 2>&1 | head -20` form races `timeout` and kills the shell early. + Use a file redirect instead: `timeout N qs -p desktop > /tmp/opencode/x.log + 2>&1 &` then `sleep`, probe, `wait`, then read the log. +- Quickshell 0.3.1 IPC requires every declared argument present, so + `ipc call drawer open` with no page name fails. The page-less grid entry is + the zero-argument `ipc call drawer toggle`; `open` always takes a page name. + +## File structure + +``` +desktop/ + shell.qml ShellRoot: keepalive, IpcHandler, module registry + Drawer.qml the PanelWindow: notification area, grid, page stack + Module.qml the contract: name, icon, alwaysActive, tile, page, activate() + Tile.qml one grid tile: icon, label, state line, click + Page.qml page chrome: header, back arrow, content slot + Button.qml moved from mail-overview (byte-identical in vm-manager) + Theme.qml symlink -> ../shared/Theme.qml + README.md + modules/ + sound/ + SoundModule.qml + Service.qml PipeWire bindings, PwObjectTracker, show() logic + Player.qml moved from volume-osd, singleton, unchanged + Osd.qml the transient OSD window, keeps its own namespace + TransportButton.qml moved from volume-osd, unchanged + SoundTile.qml + SoundPage.qml + mail/ + MailModule.qml + Accounts.qml moved from mail-overview, singleton, unchanged + MailTile.qml + MailPage.qml MailPanel's content, rehomed + mail-notify.sh moved + waybar-mail.sh moved + test-mail-notify.sh moved + vm/ + VmModule.qml + Virsh.qml moved from vm-manager, singleton, unchanged + Stat.qml moved from vm-manager, unchanged + VmTile.qml + VmPage.qml VmPanel's content, rehomed + appearance/ + AppearanceModule.qml tile only, activate() calls the external shell +``` + +Deleted when their contents have moved: `volume-osd/`, `mail-overview/`, +`vm-manager/`. + +### A note on singletons + +`Player.qml`, `Accounts.qml`, `Virsh.qml` and `Theme.qml` are all +`pragma Singleton`, and all three of the first group move into +`modules/<name>/` subdirectories. + +**They need no `qmldir`.** An earlier draft of this plan claimed a singleton +outside the config root is invisible until a `qmldir` names it. That was +wrong, and it was tested: a `pragma Singleton` in `modules/sub/`, reached by a +plain `import "modules/sub"`, resolves with no `qmldir` anywhere. The control +for that test was a reference to a genuinely undefined type, which produces a +visible `ReferenceError: <name> is not defined` warning; the singleton case +produced no such warning and the binding evaluated. + +So each module directory gets a plain directory import and nothing else. This +matches what AGENTS.md already says about `Theme.qml`: quickshell follows the +symlink and resolves the singleton with no qmldir and no consumer change. + +--- + +## Task 1: Skeleton that runs + +**Files:** +- Create: `desktop/shell.qml` +- Create: `desktop/Theme.qml` (symlink) + +- [ ] **Step 1: Create the directory and the Theme symlink** + +The symlink, not a copy. `shared/Theme.qml` is the one real file and the other +components link to it; a copy here would be the drift the shared file exists to +prevent. + +```bash +mkdir -p desktop/modules +ln -s ../shared/Theme.qml desktop/Theme.qml +ls -l desktop/Theme.qml +``` + +Expected: `desktop/Theme.qml -> ../shared/Theme.qml` + +- [ ] **Step 2: Write a shell that only holds itself open** + +`desktop/shell.qml`: + +```qml +// Copyright (C) 2026 Danilo M. <danix@danix.xyz> +// +// This program is free software; you can redistribute it and/or modify +// it under the terms of the GNU General Public License version 2 as +// published by the Free Software Foundation. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +import Quickshell +import Quickshell.Wayland + +ShellRoot { + // Quickshell exits once no window is visible, and this shell's drawer is + // closed most of the time. A 1x1 transparent window with an empty mask + // holds the process open without drawing anything or catching a click. + // See AGENTS.md: without it the shell loads, reports no error, and quits. + PanelWindow { + visible: true + implicitWidth: 1 + implicitHeight: 1 + color: "transparent" + exclusionMode: ExclusionMode.Ignore + mask: Region {} + WlrLayershell.keyboardFocus: WlrKeyboardFocus.None + } +} +``` + +- [ ] **Step 3: Verify it loads and stays up** + +Run it in the foreground under a timeout, so the harness owns the process: + +```bash +timeout 5 qs -p desktop 2>&1 | head -20 +``` + +Expected: a line containing `Configuration Loaded`, no `QML` errors, and the +command ending only when the timeout fires (exit 124). If it returns +immediately with no error, the keepalive window is missing or malformed. + +- [ ] **Step 4: Commit** + +```bash +git add desktop/ +git commit -m "feat(desktop): skeleton shell with the keepalive window + +The window draws nothing and catches nothing; it exists because a +quickshell config with no visible window exits silently, and this +shell's drawer is closed most of the time." +``` + +--- + +## Task 2: The module contract + +**Files:** +- Create: `desktop/Module.qml` + +- [ ] **Step 1: Write Module.qml** + +Deliberately thin. Its value is being the one file to read to learn what a +module is, not enforcement. + +`desktop/Module.qml`: + +```qml +// Copyright (C) 2026 Danilo M. <danix@danix.xyz> +// +// This program is free software; you can redistribute it and/or modify +// it under the terms of the GNU General Public License version 2 as +// published by the Free Software Foundation. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +import QtQuick + +// What a module declares to the drawer. +// +// A module provides a tile, a page, both, or neither. A tile with no page +// calls activate() when clicked. A module with neither is a pure background +// service. A module may also own windows outside the drawer entirely, as +// sound does with its OSD. +// +// Nothing here enforces anything: a module is free to do something unusual. +// This file is documentation with defaults. +QtObject { + // Identifies the module to IPC: `ipc call drawer open <name>`. + required property string name + + // Shown on the tile. A Nerd Font glyph. + property string icon: "" + + // Label under the icon. Defaults to the name, capitalised. + property string label: name.charAt(0).toUpperCase() + name.slice(1) + + // Whether this module's background service runs while the drawer is + // closed. The drawer is closed most of the time, so this is what decides + // whether the shell is cheap to run all session. It governs the service + // only: pages are lazily loaded either way. + property bool alwaysActive: false + + // Rendered inside the tile, below the icon: a short state line. Null for + // a tile that says nothing beyond its label. + property Component tileContent: null + + // The full-height page behind the tile. Null means the tile is + // fire-and-forget and activate() is called instead. + property Component page: null + + // What a tile with no page does when clicked. + function activate() {} +} +``` + +- [ ] **Step 2: Verify it parses** + +`Module.qml` is not instantiated yet, so loading the shell will not touch it. +Check it compiles on its own. + +Use the Qt 6 binary by its full path: bare `qmllint` on this machine resolves +to `/usr/lib64/qt5/bin/qmllint`, which rejects Qt 6 syntax and reports errors +that have nothing to do with the file. + +```bash +/usr/lib64/qt6/bin/qmllint desktop/Module.qml 2>&1 | head -20 +``` + +Expected: no output, or warnings only about the unresolved `Theme` import, +which qmllint cannot see without the config's import path. Errors naming a +syntax problem are real failures. + +- [ ] **Step 3: Commit** + +```bash +git add desktop/Module.qml +git commit -m "feat(desktop): the module contract + +A module provides a tile, a page, both or neither, plus alwaysActive, +which governs the background service rather than the page: the drawer is +closed most of the time and three of four modules have background work." +``` + +--- + +## Task 3: Tile and Page chrome + +**Files:** +- Create: `desktop/Tile.qml` +- Create: `desktop/Page.qml` + +- [ ] **Step 1: Write Tile.qml** + +Sized by the `Flow` that holds it, so the width comes from outside. The minimum +tile width of 180px lives in `Drawer.qml`, not here. + +`desktop/Tile.qml`: + +```qml +// Copyright (C) 2026 Danilo M. <danix@danix.xyz> +// +// This program is free software; you can redistribute it and/or modify +// it under the terms of the GNU General Public License version 2 as +// published by the Free Software Foundation. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +import QtQuick + +Rectangle { + id: tile + + property string icon: "" + property string label: "" + property Component content: null + property bool active: false + signal clicked + + implicitHeight: 96 + radius: 12 + color: area.containsMouse + ? Qt.alpha(Theme.accent, 0.22) + : Qt.alpha(Theme.surface, active ? 0.7 : 0.35) + border.width: 1 + border.color: active ? Qt.alpha(Theme.accent, 0.5) : Qt.alpha(Theme.text, 0.08) + + Behavior on color { ColorAnimation { duration: 120 } } + + Column { + anchors { + left: parent.left; right: parent.right + verticalCenter: parent.verticalCenter + leftMargin: 14; rightMargin: 14 + } + spacing: 6 + + Text { + text: tile.icon + font { family: Theme.fontFamily; pixelSize: 22 } + color: tile.active ? Theme.accent : Theme.text + } + + Text { + width: parent.width + elide: Text.ElideRight + text: tile.label + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2; bold: true } + color: Theme.text + } + + // The state line. A module with nothing to say leaves this null and + // the tile is icon and label only. + Loader { + width: parent.width + active: tile.content !== null + sourceComponent: tile.content + } + } + + MouseArea { + id: area + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: tile.clicked() + } +} +``` + +- [ ] **Step 2: Write Page.qml** + +The header and back arrow, with the module's own content below. The content +scrolls: mail with several accounts and vm with several VMs both exceed the +drawer height. + +`desktop/Page.qml`: + +```qml +// Copyright (C) 2026 Danilo M. <danix@danix.xyz> +// +// This program is free software; you can redistribute it and/or modify +// it under the terms of the GNU General Public License version 2 as +// published by the Free Software Foundation. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +import QtQuick +import QtQuick.Controls + +Item { + id: page + + property string title: "" + default property alias content: holder.data + signal back + + Item { + id: header + anchors { top: parent.top; left: parent.left; right: parent.right } + height: 44 + + Rectangle { + id: backBtn + anchors { left: parent.left; verticalCenter: parent.verticalCenter } + width: 32; height: 32; radius: 16 + color: backArea.containsMouse ? Qt.alpha(Theme.accent, 0.22) : "transparent" + + Text { + anchors.centerIn: parent + text: "" + font { family: Theme.fontFamily; pixelSize: 14 } + color: Theme.text + } + + MouseArea { + id: backArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: page.back() + } + } + + Text { + anchors { left: backBtn.right; leftMargin: 10; verticalCenter: parent.verticalCenter } + text: page.title + font { family: Theme.fontFamily; pixelSize: Theme.fontSize + 2; bold: true } + color: Theme.text + } + } + + Rectangle { + id: rule + anchors { top: header.bottom; left: parent.left; right: parent.right } + height: 1 + color: Qt.alpha(Theme.text, 0.12) + } + + ScrollView { + anchors { top: rule.bottom; left: parent.left; right: parent.right; bottom: parent.bottom } + anchors.topMargin: 12 + clip: true + contentWidth: availableWidth + + Item { + id: holder + width: parent.width + implicitHeight: childrenRect.height + } + } +} +``` + +- [ ] **Step 3: Verify both parse by instantiating them** + +Temporarily add to `desktop/shell.qml`, inside `ShellRoot`, after the keepalive +window: + +```qml + // scratch: remove before committing + property Component _t: Tile { icon: "x"; label: "Test" } + property Component _p: Page { title: "Test" } +``` + +Then: + +```bash +timeout 5 qs -p desktop 2>&1 | head -20 +``` + +Expected: `Configuration Loaded`, no errors naming `Tile.qml` or `Page.qml`. +Remove the two scratch lines afterwards. + +- [ ] **Step 4: Commit** + +```bash +git add desktop/Tile.qml desktop/Page.qml +git commit -m "feat(desktop): tile and page chrome + +The page body scrolls because mail with several accounts and vm with +several VMs both exceed the drawer height; the tile grid deliberately +does not, being fixed at a 3x3 ceiling." +``` + +--- + +## Task 4: The drawer + +**Files:** +- Create: `desktop/Drawer.qml` +- Modify: `desktop/shell.qml` + +- [ ] **Step 1: Write Drawer.qml** + +The layout decisions from the spec are all here: left-anchored on DP-1, 600px, +`ExclusionMode.Normal`, reserved top, `Flow` grid with a 180px minimum tile +width, page replacing the whole content. + +`desktop/Drawer.qml`: + +```qml +// Copyright (C) 2026 Danilo M. <danix@danix.xyz> +// +// This program is free software; you can redistribute it and/or modify +// it under the terms of the GNU General Public License version 2 as +// published by the Free Software Foundation. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +import Quickshell +import Quickshell.Wayland +import QtQuick + +Scope { + id: root + + // The modules the drawer hosts, in grid order. Set from shell.qml. + property list<QtObject> modules + + // Waybar runs on this screen and the launcher sits at its left end, so + // the drawer belongs here. Falls back to the first screen when this + // monitor is not connected, so the drawer is never invisible. + property string monitor: "DP-1" + + property bool open: false + + // Which module's page is showing. Empty means the grid. + property string page: "" + + readonly property var screenObj: + Quickshell.screens.find(s => s.name === root.monitor) ?? Quickshell.screens[0] + + // A plain `list<QtObject>` is indexed directly; it is not an + // ObjectModel, so there is no `.values` to go through. + readonly property QtObject current: { + for (let i = 0; i < root.modules.length; i++) + if (root.modules[i].name === root.page) return root.modules[i]; + return null; + } + + function show(name) { + root.page = name ?? ""; + root.open = true; + } + + function close() { + root.open = false; + // Reset to the grid: a panel that reopens somewhere unexpected is + // worse than one extra click. + root.page = ""; + } + + function toggle(name) { + if (root.open && (name ?? "") === root.page) root.close(); + else root.show(name); + } + + // Clicking a tile: a module with a page opens it, one without acts. + function activate(mod) { + if (mod.page) root.page = mod.name; + else { mod.activate(); root.close(); } + } + + LazyLoader { + active: root.open + + PanelWindow { + id: win + screen: root.screenObj + + anchors { top: true; left: true; right: true; bottom: true } + color: "transparent" + + // Normal, not Ignore: waybar claims an exclusive zone at the top + // of this screen, so respecting it puts the drawer below the bar + // without this file knowing the bar's height. The drawer is + // reached from the bar, so the bar must stay visible and + // clickable while it is open. + exclusionMode: ExclusionMode.Normal + + WlrLayershell.layer: WlrLayer.Overlay + WlrLayershell.namespace: "quickshell-desktop" + WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive + + // The click-outside catcher. It covers the whole surface, and the + // drawer sits on top of it swallowing its own clicks. + MouseArea { + anchors.fill: parent + onClicked: root.close() + } + + // Keys reach a focused item, never the window: setting + // keyboardFocus above is necessary but not sufficient, and + // Keys.onEscapePressed on a PanelWindow never fires. See AGENTS.md. + Item { + anchors.fill: parent + focus: true + Keys.onEscapePressed: { + if (root.page) root.page = ""; + else root.close(); + } + } + + Rectangle { + id: panel + anchors { top: parent.top; left: parent.left; bottom: parent.bottom } + width: 600 + color: Qt.alpha(Theme.base, 0.72) + topRightRadius: 14 + bottomRightRadius: 14 + border.width: 1 + border.color: Qt.alpha(Theme.text, 0.12) + + // Clicks on the panel must not reach the catcher behind it. + MouseArea { anchors.fill: parent } + + // --- grid view --- + + Item { + anchors.fill: parent + anchors.margins: 16 + visible: root.page === "" + + // Reserved for the notification engine. An empty Item that + // claims the space rather than a placeholder graphic: the + // grid has to sit where it will sit once notifications + // arrive, or the layout is tuned against a position that + // does not survive. + Item { + id: notifications + anchors { top: parent.top; left: parent.left; right: parent.right } + anchors.bottom: grid.top + anchors.bottomMargin: 16 + } + + // Fixed, never scrolled. Three columns at 600px with a + // 180px minimum; tiles wrap and add rows, ceiling 3x3. + Flow { + id: grid + anchors { left: parent.left; right: parent.right; bottom: parent.bottom } + spacing: 10 + + Repeater { + model: root.modules + + Tile { + required property QtObject modelData + // Three columns, or fewer if the panel is + // narrower than three 180px tiles allow. + width: (grid.width - 2 * grid.spacing) / 3 + icon: modelData.icon + label: modelData.label + content: modelData.tileContent + onClicked: root.activate(modelData) + } + } + } + } + + // --- page view --- + + Loader { + anchors.fill: parent + anchors.margins: 16 + active: root.current !== null + sourceComponent: root.current?.page ?? null + + // The page enters from the right: the one piece of motion + // in the design, and what makes the drawer read as one + // surface rather than a window swapping contents. + opacity: active ? 1 : 0 + x: active ? 16 : 60 + Behavior on x { NumberAnimation { duration: 160; easing.type: Easing.OutCubic } } + Behavior on opacity { NumberAnimation { duration: 160 } } + } + } + } + } +} +``` + +- [ ] **Step 2: Wire it into the shell with no modules yet** + +Replace `desktop/shell.qml` with: + +```qml +// Copyright (C) 2026 Danilo M. <danix@danix.xyz> +// +// This program is free software; you can redistribute it and/or modify +// it under the terms of the GNU General Public License version 2 as +// published by the Free Software Foundation. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +import Quickshell +import Quickshell.Io +import Quickshell.Wayland + +ShellRoot { + // Quickshell exits once no window is visible, and the drawer is closed + // most of the time. See AGENTS.md. + PanelWindow { + visible: true + implicitWidth: 1 + implicitHeight: 1 + color: "transparent" + exclusionMode: ExclusionMode.Ignore + mask: Region {} + WlrLayershell.keyboardFocus: WlrKeyboardFocus.None + } + + Drawer { + id: drawer + modules: [] + } + + // The waybar launcher and the deep-link keybinds all reach this: + // qs -p <this dir> ipc call drawer toggle -> the grid + // qs -p <this dir> ipc call drawer open mail -> the mail page + // Quickshell 0.3.1 IPC requires every declared argument to be present, + // and a parameter with a JS default registers as QVariant, which IPC + // rejects. So the page name cannot be optional: `open` always takes a + // page, and the page-less grid is `toggle`. + IpcHandler { + target: "drawer" + function open(page: string) { drawer.show(page); } + function toggle() { drawer.toggle(""); } + function close() { drawer.close(); } + } +} +``` + +- [ ] **Step 3: Verify the drawer opens** + +```bash +timeout 8 qs -p desktop 2>&1 | head -20 & +sleep 3 +qs -p desktop ipc call drawer toggle +sleep 1 +qs -p desktop ipc call drawer close +wait +``` + +Expected: `Configuration Loaded`, both `ipc call` commands exiting 0, and no +QML errors. An empty 600px panel appearing on the left of DP-1 for one second +is the visible result; ask the user to confirm it rather than screenshotting. + +- [ ] **Step 4: Commit** + +```bash +git add desktop/Drawer.qml desktop/shell.qml +git commit -m "feat(desktop): the drawer, with the top reserved + +Left of DP-1 because conky holds the right; ExclusionMode.Normal so the +bar the drawer is reached from stays visible and clickable. The top is an +empty Item claiming the space the notification engine will fill, so the +grid already sits where it will sit once that lands." +``` + +--- + +## Task 5: The appearance module + +The simplest module, and the one that proves a tile needs no page. Done first +so the grid has something in it before the harder migrations. + +**Files:** +- Create: `desktop/modules/appearance/AppearanceModule.qml` +- Modify: `desktop/shell.qml` + +- [ ] **Step 1: Write the module** + +`desktop/modules/appearance/AppearanceModule.qml`: + +```qml +// Copyright (C) 2026 Danilo M. <danix@danix.xyz> +// +// This program is free software; you can redistribute it and/or modify +// it under the terms of the GNU General Public License version 2 as +// published by the Free Software Foundation. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +import Quickshell +import Quickshell.Io +import QtQuick + +// Fire and forget. The appearance shell stays a separate process: a wallpaper +// picker needs more room than a 600px drawer, so this tile only opens it. +Module { + id: mod + + name: "appearance" + icon: "" + label: "Appearance" + + function activate() { + proc.running = false; + proc.running = true; + } + + property Process proc: Process { + command: ["qs", "-p", `${Quickshell.env("HOME")}/Programming/GIT/quickshell/appearance`, + "ipc", "call", "appearance", "wallpaper"] + } +} +``` + +- [ ] **Step 2: Register it in the shell** + +A QML type is named by its file, so each module's file carries its own name +rather than all four being `Module.qml`: four files of the same name in four +directories would collide the moment two are imported together. Write the file +from Step 1 as `desktop/modules/appearance/AppearanceModule.qml`, and the type +is `AppearanceModule`. + +In `desktop/shell.qml`, add the directory import near the top, after the other +imports: + +```qml +import "modules/appearance" +``` + +and replace the `Drawer` block with: + +```qml + Drawer { + id: drawer + modules: [ + AppearanceModule {}, + ] + } +``` + +- [ ] **Step 3: Verify the tile appears and fires** + +```bash +timeout 10 qs -p desktop 2>&1 | head -20 & +sleep 3 +qs -p desktop ipc call drawer toggle +sleep 5 +wait +pgrep -cx qs +``` + +Expected: one tile labelled "Appearance" in the grid. Ask the user to click it +and confirm the wallpaper picker opens and the drawer closes. `pgrep -cx qs` +should report the number of shells actually running, which during development +is the existing five plus this one. + +- [ ] **Step 4: Commit** + +```bash +git add desktop/modules/appearance/ desktop/shell.qml +git commit -m "feat(desktop): the appearance tile + +A tile with no page: appearance stays its own process because a wallpaper +picker needs more room than a 600px drawer, so the tile only fires its +existing IPC. This is the case the contract's activate() exists for." +``` + +--- + +## Task 6: Move the sound module + +Three jobs currently live in `VolumeOsd.qml`: PipeWire tracking, the OSD +surface, and the player transport. They split into `Service.qml`, `Osd.qml` and +the page. + +**Files:** +- Create: `desktop/modules/sound/SoundModule.qml` +- Create: `desktop/modules/sound/Service.qml` +- Create: `desktop/modules/sound/Osd.qml` +- Create: `desktop/modules/sound/SoundTile.qml` +- Create: `desktop/modules/sound/SoundPage.qml` +- Move: `volume-osd/Player.qml` -> `desktop/modules/sound/Player.qml` +- Move: `volume-osd/TransportButton.qml` -> `desktop/modules/sound/TransportButton.qml` + +- [ ] **Step 1: Move the two files that need no change** + +```bash +git mv volume-osd/Player.qml desktop/modules/sound/Player.qml +git mv volume-osd/TransportButton.qml desktop/modules/sound/TransportButton.qml +``` + +`Player.qml` is `pragma Singleton` and needs no registration: the directory +import in the shell resolves it. See "A note on singletons" above. + +- [ ] **Step 2: Write the service** + +The PipeWire half of the old `VolumeOsd.qml`, with the two traps preserved +verbatim in comment and code. + +`desktop/modules/sound/Service.qml`: + +```qml +// Copyright (C) 2026 Danilo M. <danix@danix.xyz> +// +// This program is free software; you can redistribute it and/or modify +// it under the terms of the GNU General Public License version 2 as +// published by the Free Software Foundation. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +import Quickshell +import Quickshell.Services.Pipewire +import QtQuick + +// The PipeWire half of what used to be VolumeOsd.qml. Always active: the OSD +// has to react to a volume keypress with no drawer open. +Scope { + id: root + + readonly property PwNode sink: Pipewire.defaultAudioSink + readonly property PwNode source: Pipewire.defaultAudioSource + + readonly property real volume: sink?.audio?.volume ?? 0 + readonly property bool muted: sink?.audio?.muted ?? false + + // Which node changed last, and whether it was the input. The OSD draws + // this one; null means nothing to show. + property PwNode active: null + property bool isInput: false + + // Keeping the nodes bound is what makes volume/muted actually update. + // Without the tracker the value reads once and goes stale. + PwObjectTracker { objects: [root.sink, root.source].filter(n => n !== null) } + + signal changed() + + // A node reports its initial volume while binding, before `ready` goes + // true, so the `ready` check alone suppresses the startup values. Nothing + // else may be swallowed: the next signal after that is the user's first + // keypress, and eating it costs the OSD its first appearance. + function show(node, input) { + if (!node?.ready || !node.audio) return; + root.active = node; + root.isInput = input; + root.changed(); + } + + function showTrack() { + if (!Player.active) return; + root.active = root.sink; + root.isInput = false; + root.changed(); + } + + Connections { + target: root.sink?.audio ?? null + function onVolumeChanged() { root.show(root.sink, false); } + function onMutedChanged() { root.show(root.sink, false); } + } + + Connections { + target: root.source?.audio ?? null + function onVolumeChanged() { root.show(root.source, true); } + function onMutedChanged() { root.show(root.source, true); } + } + + // A track change shows the OSD as well, so the row is not something you + // only see when you happen to touch the volume. + Connections { + target: Player.current ?? null + function onTrackTitleChanged() { if (Player.title) root.showTrack(); } + function onPlaybackStateChanged() { root.showTrack(); } + } +} +``` + +- [ ] **Step 3: Write the OSD** + +The window half, keeping its namespace so the existing Hyprland blur rule needs +no edit. The body is the old `VolumeOsd.qml` from its first `PanelWindow` +onward, with `root.` reads redirected to the injected service. + +`desktop/modules/sound/Osd.qml`: + +```qml +// Copyright (C) 2026 Danilo M. <danix@danix.xyz> +// +// This program is free software; you can redistribute it and/or modify +// it under the terms of the GNU General Public License version 2 as +// published by the Free Software Foundation. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +import Quickshell +import Quickshell.Wayland +import Quickshell.Services.Pipewire +import QtQuick + +// The transient on-screen display. Unchanged in behaviour from volume-osd, +// including its namespace, so the existing Hyprland blur rule still matches. +Scope { + id: root + + required property var service + + // Milliseconds the OSD stays up after the last change. + property int timeout: 1500 + + property bool visibleNow: false + + // Hovering freezes the countdown so the transport buttons can be clicked; + // leaving starts it again. + property bool hovered: false + + Connections { + target: root.service + function onChanged() { + root.visibleNow = true; + hideTimer.restart(); + } + } + + Timer { + id: hideTimer + running: root.visibleNow && !root.hovered + interval: root.timeout + onTriggered: root.visibleNow = false + } + + PanelWindow { + id: win + + visible: root.visibleNow + + readonly property PwNode node: root.service.active + readonly property real volume: node?.audio?.volume ?? 0 + readonly property bool muted: node?.audio?.muted ?? false + readonly property bool isInput: root.service.isInput + + // Bottom centre. Move the anchor to relocate. + anchors.bottom: true + margins.bottom: 120 + + // Grows to fit the track row; the volume-only size is unchanged. + implicitWidth: 360 + implicitHeight: Player.active ? 150 : 72 + color: "transparent" + + exclusionMode: ExclusionMode.Ignore + WlrLayershell.layer: WlrLayer.Overlay + WlrLayershell.namespace: "quickshell-volume-osd" + // Still no keyboard focus: the transport buttons are pointer targets, + // and the OSD must never take keys from the window being typed in. + WlrLayershell.keyboardFocus: WlrKeyboardFocus.None + + Rectangle { + anchors.fill: parent + radius: 12 + // Translucent so the compositor's blur shows through. The frosting + // itself is Hyprland's, applied by layerrule to this window's + // namespace: see the README. + color: Qt.alpha(Theme.base, 0.65) + border.width: 1 + border.color: Qt.alpha(Theme.text, 0.12) + + HoverHandler { + onHoveredChanged: root.hovered = hovered + } + + Column { + anchors.fill: parent + anchors.margins: 16 + spacing: 12 + + Loader { + active: Player.active + width: parent.width + sourceComponent: trackRow + } + + Rectangle { + visible: Player.active + width: parent.width + height: 1 + color: Qt.alpha(Theme.text, 0.1) + } + + Row { + width: parent.width + spacing: 14 + + Text { + anchors.verticalCenter: parent.verticalCenter + width: 30 + horizontalAlignment: Text.AlignHCenter + font.family: Theme.fontFamily + font.pixelSize: 24 + color: win.muted ? Theme.red : Theme.accent + text: { + if (win.isInput) return win.muted ? "" : ""; + if (win.muted || win.volume <= 0) return ""; + return win.volume < 0.5 ? "" : ""; + } + } + + Column { + anchors.verticalCenter: parent.verticalCenter + width: parent.width - 30 - parent.spacing + spacing: 8 + + Item { + width: parent.width + height: label.implicitHeight + + Text { + id: label + anchors.left: parent.left + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSize + color: Theme.subtext + text: win.isInput ? "Input" : "Output" + } + + Text { + anchors.right: parent.right + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSize + color: Theme.text + text: win.muted ? "muted" : Math.round(win.volume * 100) + "%" + } + } + + Rectangle { + width: parent.width + height: 6 + radius: 3 + color: Theme.surface + + Rectangle { + height: parent.height + radius: parent.radius + // Volume can exceed 1.0; the bar stops at full. + width: parent.width * Math.min(win.volume, 1) + color: win.muted ? Theme.red : Theme.accent + opacity: win.muted ? 0.5 : 1 + Behavior on width { NumberAnimation { duration: 100 } } + } + } + } + } + } + } + } + + Component { + id: trackRow + + Row { + id: trackLine + // A Row sizes to its children, so the panel width has to be + // pushed in: the text column below subtracts from it. + width: parent ? parent.width : 0 + spacing: 12 + + // Players that extract embedded art reuse one temp path, so the + // source carries a per-track suffix and caching is off. + Rectangle { + width: 46; height: 46; radius: 6 + color: Qt.alpha(Theme.surface, 0.8) + clip: true + + Image { + anchors.fill: parent + source: Player.artUrl + cache: false + asynchronous: true + fillMode: Image.PreserveAspectCrop + visible: status === Image.Ready + } + Text { + anchors.centerIn: parent + visible: Player.artUrl === "" || parent.children[0].status !== Image.Ready + text: "" + font { family: Theme.fontFamily; pixelSize: 20 } + color: Theme.overlay + } + } + + Column { + anchors.verticalCenter: parent.verticalCenter + // Whatever the art and transport buttons leave: a fixed width + // here overflowed the panel and pushed `next` past its edge. + width: trackLine.width - 46 - transport.width - 2 * trackLine.spacing + spacing: 3 + + Text { + width: parent.width + elide: Text.ElideRight + text: Player.title || "Nothing playing" + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 1; bold: true } + color: Theme.text + } + Text { + width: parent.width + elide: Text.ElideRight + visible: Player.artist !== "" + text: Player.artist + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 } + color: Theme.subtext + } + } + + Row { + id: transport + anchors.verticalCenter: parent.verticalCenter + spacing: 2 + TransportButton { + glyph: "" + enabled: Player.current?.canGoPrevious ?? false + onClicked: Player.current?.previous() + } + TransportButton { + glyph: Player.playing ? "" : "" + enabled: Player.current?.canTogglePlaying ?? false + onClicked: Player.current?.togglePlaying() + } + TransportButton { + glyph: "" + enabled: Player.current?.canGoNext ?? false + onClicked: Player.current?.next() + } + } + } + } +} +``` + +Note: the glyphs above are written as escapes because the originals are Nerd +Font private-use characters that do not survive copying through a plan +document. When moving the file, take the glyph bytes from the original +`volume-osd/VolumeOsd.qml` rather than retyping them, and check +`git diff` shows no change to those literals. + +- [ ] **Step 4: Write the tile content and the page** + +`desktop/modules/sound/SoundTile.qml`: + +```qml +// Copyright (C) 2026 Danilo M. <danix@danix.xyz> +// +// This program is free software; you can redistribute it and/or modify +// it under the terms of the GNU General Public License version 2 as +// published by the Free Software Foundation. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +import QtQuick + +// The tile's state line: volume, or what is playing. +Text { + required property var service + + elide: Text.ElideRight + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } + color: Theme.subtext + text: { + if (service.muted) return "muted"; + const pct = Math.round(service.volume * 100) + "%"; + return Player.active && Player.title ? `${pct} · ${Player.title}` : pct; + } +} +``` + +`desktop/modules/sound/SoundPage.qml`: + +```qml +// Copyright (C) 2026 Danilo M. <danix@danix.xyz> +// +// This program is free software; you can redistribute it and/or modify +// it under the terms of the GNU General Public License version 2 as +// published by the Free Software Foundation. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +import Quickshell +import Quickshell.Services.Pipewire +import QtQuick + +Column { + id: page + + required property var service + signal back + + spacing: 16 + + // Output and input, each with its own slider. + Repeater { + model: [ + { label: "Output", node: page.service.sink }, + { label: "Input", node: page.service.source }, + ] + + Column { + required property var modelData + readonly property var audio: modelData.node?.audio ?? null + + width: page.width + spacing: 6 + + Item { + width: parent.width + implicitHeight: name.implicitHeight + + Text { + id: name + anchors.left: parent.left + text: modelData.label + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 1; bold: true } + color: Theme.text + } + + Text { + anchors.right: parent.right + text: !audio ? "—" : audio.muted ? "muted" : Math.round(audio.volume * 100) + "%" + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 1 } + color: audio?.muted ? Theme.red : Theme.subtext + } + } + + Text { + width: parent.width + elide: Text.ElideRight + text: modelData.node?.description ?? modelData.node?.name ?? "" + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } + color: Theme.overlay + } + + // Click or drag anywhere on the bar to set the level. + Rectangle { + width: parent.width + height: 8 + radius: 4 + color: Theme.surface + + Rectangle { + height: parent.height + radius: parent.radius + width: parent.width * Math.min(audio?.volume ?? 0, 1) + color: audio?.muted ? Theme.red : Theme.accent + opacity: audio?.muted ? 0.5 : 1 + } + + MouseArea { + anchors.fill: parent + enabled: audio !== null + onPositionChanged: mouse => set(mouse.x) + onPressed: mouse => set(mouse.x) + function set(x) { + if (audio) audio.volume = Math.max(0, Math.min(1, x / width)); + } + } + } + } + } + + Rectangle { width: parent.width; height: 1; color: Qt.alpha(Theme.text, 0.12) } + + // What is playing, with transport. Same Player singleton the OSD uses, + // so playerctld's duplicate is already filtered out by dbusName. + Column { + width: parent.width + spacing: 8 + visible: Player.active + + Text { + width: parent.width + elide: Text.ElideRight + text: Player.title || "Nothing playing" + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 1; bold: true } + color: Theme.text + } + + Text { + width: parent.width + elide: Text.ElideRight + visible: Player.artist !== "" + text: Player.artist + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 } + color: Theme.subtext + } + + Row { + spacing: 4 + TransportButton { + glyph: "" + enabled: Player.current?.canGoPrevious ?? false + onClicked: Player.current?.previous() + } + TransportButton { + glyph: Player.playing ? "" : "" + enabled: Player.current?.canTogglePlaying ?? false + onClicked: Player.current?.togglePlaying() + } + TransportButton { + glyph: "" + enabled: Player.current?.canGoNext ?? false + onClicked: Player.current?.next() + } + } + } +} +``` + +- [ ] **Step 5: Write the module** + +`desktop/modules/sound/SoundModule.qml`: + +```qml +// Copyright (C) 2026 Danilo M. <danix@danix.xyz> +// +// This program is free software; you can redistribute it and/or modify +// it under the terms of the GNU General Public License version 2 as +// published by the Free Software Foundation. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +import QtQuick + +// Always active: the OSD must react to a volume keypress with no drawer open, +// which is the whole reason this module's service cannot be lazy. +Module { + id: mod + + name: "sound" + icon: "" + label: "Sound" + alwaysActive: true + + readonly property Service service: Service {} + + // The OSD is this module's own window, outside the drawer entirely. + readonly property Osd osd: Osd { service: mod.service } + + tileContent: Component { + SoundTile { service: mod.service } + } + + page: Component { + Page { + title: "Sound" + SoundPage { width: parent.width; service: mod.service } + } + } +} +``` + +- [ ] **Step 6: Register it and delete the old component** + +In `desktop/shell.qml`, add `import "modules/sound"` and put `SoundModule {}` +first in the `modules` list, before `AppearanceModule {}`. + +Then remove what is now duplicated: + +```bash +git rm volume-osd/VolumeOsd.qml volume-osd/shell.qml volume-osd/Theme.qml +git mv volume-osd/README.md desktop/modules/sound/README.md +rmdir volume-osd +``` + +- [ ] **Step 7: Verify the OSD still works and the page renders** + +```bash +pkill -x qs +pgrep -cx qs +``` + +Expected: `0`. If it is not zero, something is still running and later readings +will be wrong. + +Then start only the new shell: + +```bash +timeout 20 qs -p desktop 2>&1 | head -30 +``` + +While it runs, ask the user to: +1. Press a volume key and confirm the OSD appears bottom-centre as before. +2. Run `qs -p desktop ipc call drawer open sound` and confirm the page shows + output and input with working sliders. + +Afterwards restart the other components the user still needs: + +```bash +qs -p vm-manager & +qs -p mail-overview & +qs -p appearance & +qs -p window-switcher & +``` + +Note these are detached and will not survive the tool call; they are for the +user's session, so have the user start them, or leave them for the next login. + +- [ ] **Step 8: Commit** + +```bash +git add -A desktop/modules/sound volume-osd desktop/shell.qml +git commit -m "feat(desktop): move volume-osd in as the sound module + +VolumeOsd.qml did three jobs in one file: PipeWire tracking, the OSD +surface and the player transport. They become Service, Osd and the page. +The OSD keeps its namespace so the existing Hyprland blur rule still +matches, and the service stays always-active because the OSD has to +answer a keypress with no drawer open." +``` + +--- + +## Task 7: Move the mail module + +The gentlest move: `Accounts.qml` is unchanged, `MailPanel.qml`'s body becomes +the page, and the three scripts move with it. + +**Files:** +- Move: `mail-overview/Accounts.qml` -> `desktop/modules/mail/Accounts.qml` +- Move: `mail-overview/Button.qml` -> `desktop/Button.qml` +- Move: the three scripts -> `desktop/modules/mail/` +- Create: `desktop/modules/mail/MailModule.qml` +- Create: `desktop/modules/mail/MailTile.qml` +- Create: `desktop/modules/mail/MailPage.qml` + +- [ ] **Step 1: Move the files that need no change** + +`Button.qml` is byte-identical in `mail-overview` and `vm-manager`; one copy +moves to the shell root and the other is deleted in Task 8. + +```bash +git mv mail-overview/Accounts.qml desktop/modules/mail/Accounts.qml +git mv mail-overview/Button.qml desktop/Button.qml +git mv mail-overview/mail-notify.sh desktop/modules/mail/mail-notify.sh +git mv mail-overview/waybar-mail.sh desktop/modules/mail/waybar-mail.sh +git mv mail-overview/test-mail-notify.sh desktop/modules/mail/test-mail-notify.sh +``` + +`Accounts.qml` is `pragma Singleton` and needs no registration: the directory +import in the shell resolves it. See "A note on singletons" above. + +- [ ] **Step 2: Check the scripts for self-referential paths** + +The scripts may locate siblings relative to their own directory. Check before +assuming the move is transparent: + +```bash +grep -n 'dirname\|BASH_SOURCE\|\$0\|mail-overview' desktop/modules/mail/*.sh +``` + +If any line hardcodes `mail-overview`, update it to the new path. If they use +`$(dirname "$0")` they are already correct. + +- [ ] **Step 3: Verify the test suite still passes** + +This is the only automated oracle in the whole project. + +```bash +./desktop/modules/mail/test-mail-notify.sh +``` + +Expected: `16 passed, 0 failed`. If the count differs, the move broke +something; fix before continuing. + +- [ ] **Step 4: Write the page** + +`MailPanel.qml`'s content, with the window chrome dropped and `root.` reads +pointing at the page. The heartbeat `FileView` moves in unchanged, including +its deliberate lack of `watchChanges`. + +`desktop/modules/mail/MailPage.qml`: + +```qml +// Copyright (C) 2026 Danilo M. <danix@danix.xyz> +// +// This program is free software; you can redistribute it and/or modify +// it under the terms of the GNU General Public License version 2 as +// published by the Free Software Foundation. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +import Quickshell +import Quickshell.Io +import QtQuick + +Column { + id: page + + signal close + + // mail-watcher's heartbeat, written every 60s. Read once per open: the + // page is behind a Loader, so opening it 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) { + page.watcherAlive = false; + page.watcherDead = 0; + page.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; + + page.watcherAlive = true; + page.watcherDead = Number(data.dead) || 0; + page.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` + + spacing: 10 + + FileView { + id: heartbeat + path: `${Quickshell.env("HOME")}/.local/state/mail-watcher.heartbeat` + onLoaded: page.readHeartbeat(text()) + onLoadFailed: page.readHeartbeat("") + } + + Item { + width: parent.width + implicitHeight: totalText.implicitHeight + + Text { + id: totalText + anchors.right: parent.right + // A dash rather than a possibly-wrong number while any account is + // still unknown. + text: Accounts.anyUnknown ? "—" : `${Accounts.total} unread` + color: Theme.subtext + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSize + } + } + + Text { + visible: Accounts.error !== "" + width: parent.width + text: Accounts.error + color: Theme.red + wrapMode: Text.WordWrap + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSize - 2 + } + + Repeater { + model: Accounts.accounts + + Column { + required property var modelData + width: page.width + spacing: 4 + + Item { + width: parent.width + implicitHeight: 26 + + Rectangle { + id: dot + anchors.verticalCenter: parent.verticalCenter + width: 8; height: 8; radius: 4 + // The account's own colour from the config. Per-account + // identity, not a palette. + color: modelData.color || Theme.accent + } + + Text { + anchors { left: dot.right; leftMargin: 10; verticalCenter: parent.verticalCenter } + text: modelData.label + color: Theme.text + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSize + } + + Text { + anchors { right: parent.right; verticalCenter: parent.verticalCenter } + text: modelData.count < 0 ? "—" : String(modelData.count) + color: modelData.count > 0 ? Theme.text : Theme.subtext + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSize + font.bold: modelData.count > 0 + } + } + + // The newest three unread threads. Read-only: qtmaildir takes no + // arguments, so there is no way to ask it for a particular thread. + Repeater { + model: modelData.threads + + Column { + required property var modelData + width: page.width - 18 + x: 18 + spacing: 1 + bottomPadding: 4 + + Item { + width: parent.width + implicitHeight: who.implicitHeight + + Text { + id: who + anchors.left: parent.left + width: parent.width - when.implicitWidth - 10 + text: modelData.authors + elide: Text.ElideRight + color: Theme.subtext + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSize - 3 + } + + Text { + id: when + anchors.right: parent.right + text: modelData.date + color: Theme.overlay + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSize - 3 + } + } + + Text { + width: parent.width + text: modelData.subject + elide: Text.ElideRight + color: Theme.text + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSize - 2 + } + } + } + } + } + + Rectangle { width: parent.width; height: 1; color: Qt.alpha(Theme.text, 0.12) } + + // Watcher health. 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: page.watcherColor + } + + Text { + anchors { left: watcherDot.right; leftMargin: 10; verticalCenter: parent.verticalCenter } + text: page.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: "Open qtmaildir" + onClicked: { Accounts.openClient(); page.close(); } + } + } +} +``` + +- [ ] **Step 5: Write the tile and the module** + +`desktop/modules/mail/MailTile.qml`: + +```qml +// Copyright (C) 2026 Danilo M. <danix@danix.xyz> +// +// This program is free software; you can redistribute it and/or modify +// it under the terms of the GNU General Public License version 2 as +// published by the Free Software Foundation. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +import QtQuick + +Text { + elide: Text.ElideRight + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } + color: Accounts.total > 0 ? Theme.text : Theme.subtext + // A dash rather than a possibly-wrong number while any account is unknown, + // the same rule the page header uses. + text: Accounts.anyUnknown ? "—" + : Accounts.total === 0 ? "no unread" + : `${Accounts.total} unread` +} +``` + +`desktop/modules/mail/MailModule.qml`: + +```qml +// Copyright (C) 2026 Danilo M. <danix@danix.xyz> +// +// This program is free software; you can redistribute it and/or modify +// it under the terms of the GNU General Public License version 2 as +// published by the Free Software Foundation. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +import QtQuick + +// Always active: the unread count outlives the drawer, and Accounts watches +// qtmaildir.conf so a new account appears without a restart. +Module { + id: mod + + name: "mail" + icon: "" + label: "Mail" + alwaysActive: true + + tileContent: Component { MailTile {} } + + page: Component { + Page { + title: "Mail" + // Mail has almost certainly arrived since this was last opened, + // and for an autostarted shell that is the whole session. + Component.onCompleted: Accounts.refresh() + MailPage { width: parent.width } + } + } +} +``` + +- [ ] **Step 6: Register it and delete the old component** + +Add `import "modules/mail"` to `desktop/shell.qml` and put `MailModule {}` in +the `modules` list, after sound. + +```bash +git rm mail-overview/MailPanel.qml mail-overview/shell.qml mail-overview/Theme.qml +git mv mail-overview/README.md desktop/modules/mail/README.md +rmdir mail-overview +``` + +- [ ] **Step 7: Verify** + +```bash +pkill -x qs +pgrep -cx qs +``` + +Expected: `0`. + +```bash +./desktop/modules/mail/test-mail-notify.sh +timeout 15 qs -p desktop 2>&1 | head -30 +``` + +Expected: the test reporting `16 passed, 0 failed`, then +`Configuration Loaded` with no QML errors. Ask the user to run +`qs -p desktop ipc call drawer open mail` and confirm the account rows, thread +previews and watcher dot all render as they did in the old drawer. + +- [ ] **Step 8: Commit** + +```bash +git add -A desktop mail-overview +git commit -m "feat(desktop): move mail-overview in as the mail module + +Accounts.qml is unchanged and MailPanel's body becomes the page. The +three scripts move with it, which changes the absolute paths in autostart +and in the waybar module; both are outside this repo and listed in the +plan's final task. Button.qml, byte-identical here and in vm-manager, +lands at the shell root as the single copy." +``` + +--- + +## Task 8: Move the vm module + +The largest move. `Virsh.qml` becomes the module's service unchanged; +`VmPanel.qml`'s body becomes the page. + +**Files:** +- Move: `vm-manager/Virsh.qml` -> `desktop/modules/vm/Virsh.qml` +- Move: `vm-manager/Stat.qml` -> `desktop/modules/vm/Stat.qml` +- Create: `desktop/modules/vm/VmModule.qml` +- Create: `desktop/modules/vm/VmTile.qml` +- Create: `desktop/modules/vm/VmPage.qml` +- Modify: `desktop/shell.qml` + +- [ ] **Step 1: Move the two files that need no change** + +```bash +git mv vm-manager/Virsh.qml desktop/modules/vm/Virsh.qml +git mv vm-manager/Stat.qml desktop/modules/vm/Stat.qml +git rm vm-manager/Button.qml +``` + +`Button.qml` is deleted rather than moved: it was byte-identical to +`mail-overview`'s, which became `desktop/Button.qml` in Task 7. Confirm before +deleting: + +```bash +git show HEAD~1:vm-manager/Button.qml | diff - desktop/Button.qml && echo IDENTICAL +``` + +`Virsh.qml` is `pragma Singleton` and needs no registration: the directory +import in the shell resolves it. See "A note on singletons" above. + +- [ ] **Step 2: Write the page** + +`VmPanel.qml`'s content with the window chrome dropped. The confirm-step logic, +the per-VM rows and the snapshot list all move unchanged; only the enclosing +`Scope`/`PanelWindow` and the keyboard handling go away, the latter because the +drawer owns Escape now. + +`desktop/modules/vm/VmPage.qml`: + +```qml +// Copyright (C) 2026 Danilo M. <danix@danix.xyz> +// +// This program is free software; you can redistribute it and/or modify +// it under the terms of the GNU General Public License version 2 as +// published by the Free Software Foundation. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +import QtQuick + +Column { + id: page + + property string selected: "" + + // A pending destructive action, shown as a confirm step instead of the + // action list: { kind, vm, snap }. Null when nothing is being confirmed. + property var confirming: null + property string typed: "" + + spacing: 16 + + Component.onCompleted: { + // The stats timer only runs while this page is up, so start it here + // and stop it in Component.onDestruction. The lifecycle event stream + // in Virsh keeps running regardless, which is what makes the list + // correct the moment the page appears. + Virsh.sampling = true; + Virsh.refreshList(); + if (!page.selected && Virsh.names.length) page.selected = Virsh.names[0]; + if (page.selected) Virsh.loadSnapshots(page.selected); + } + + Component.onDestruction: Virsh.sampling = false + + onSelectedChanged: if (selected) Virsh.loadSnapshots(selected) + + function fmtBytes(b) { + if (b < 0) return "—"; + const g = b / (1024 * 1024 * 1024); + return g >= 10 ? g.toFixed(0) + " GB" : g.toFixed(1) + " GB"; + } + + function stateColor(s) { + if (s === "running") return Theme.green; + if (s === "paused" || s === "suspended" || s === "shutting down") return Theme.yellow; + if (s === "crashed") return Theme.red; + return Theme.overlay; + } + + // Which verbs make sense in the current state, mirroring the states the + // old rofi script switched on. + 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" || a === "discardsave"; } + + function run(vm, action) { + if (isDestructive(action)) page.confirming = { kind: action, vm: vm, snap: "" }; + else Virsh.act(vm, action); + } + + // One row per VM, so several VMs stay readable at a glance. + Repeater { + model: Virsh.names + + Rectangle { + required property string modelData + readonly property var vm: Virsh.vms[modelData] ?? ({}) + readonly property bool isSel: page.selected === modelData + + width: page.width + implicitHeight: vmCol.implicitHeight + 24 + radius: 10 + color: isSel ? Qt.alpha(Theme.surface, 0.7) : Qt.alpha(Theme.surface, 0.35) + border.width: 1 + border.color: isSel ? Qt.alpha(Theme.accent, 0.5) : "transparent" + + MouseArea { + anchors.fill: parent + onClicked: page.selected = modelData + } + + Column { + id: vmCol + anchors { left: parent.left; right: parent.right; top: parent.top; margins: 12 } + spacing: 10 + + Row { + spacing: 10 + Rectangle { + anchors.verticalCenter: parent.verticalCenter + width: 9; height: 9; radius: 5 + color: page.stateColor(vm.state ?? "") + } + Text { + text: modelData + font { family: Theme.fontFamily; pixelSize: Theme.fontSize; bold: true } + color: Theme.text + } + Text { + anchors.verticalCenter: parent.verticalCenter + text: vm.state ?? "" + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 } + color: Theme.subtext + } + Text { + anchors.verticalCenter: parent.verticalCenter + text: (vm.vcpus ?? 0) + " vCPU" + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 } + color: Theme.overlay + } + Text { + anchors.verticalCenter: parent.verticalCenter + visible: vm.state === "running" && !(vm.agent ?? false) + text: "agent starting" + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 } + color: Theme.overlay + } + } + + // Stats only mean anything while the VM runs. The figures are + // guest-agent only: libvirt's balloon.current reads full + // forever and block.allocation is host-side qcow2 growth, so + // a dash is correct where the agent is silent. + Flow { + visible: vm.state === "running" + width: parent.width + spacing: 20 + + Stat { + label: "CPU" + value: (vm.cpu ?? -1) < 0 ? "—" : (vm.cpu).toFixed(0) + "%" + fraction: (vm.cpu ?? 0) / 100 + } + Stat { + label: "RAM" + value: (vm.memUsed ?? -1) < 0 ? "—" + : page.fmtBytes(vm.memUsed) + " / " + page.fmtBytes(vm.memTotal) + fraction: (vm.memUsed ?? -1) < 0 ? -1 : vm.memUsed / vm.memTotal + } + Stat { + label: "Disk" + value: (vm.fsUsed ?? -1) < 0 ? "—" + : page.fmtBytes(vm.fsUsed) + " / " + page.fmtBytes(vm.fsTotal) + fraction: (vm.fsUsed ?? -1) < 0 ? -1 : vm.fsUsed / vm.fsTotal + } + Stat { + label: "Address" + value: (vm.ip ?? "") === "" ? "—" : vm.ip + fraction: -1 + } + } + + // Actions and snapshots, for the selected VM only. + Loader { + active: isSel + width: parent.width + sourceComponent: detail + property string vmName: modelData + property string vmState: vm.state ?? "" + property bool vmSaved: vm.saved ?? false + } + } + } + } + + Component { + id: detail + + Column { + spacing: 12 + + 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) } + + // Confirm step replaces the buttons, so the action cannot be + // clicked again while it is being confirmed. + Loader { + active: page.confirming !== null && page.confirming.vm === vmName + width: parent.width + sourceComponent: confirmUi + } + + Flow { + visible: !(page.confirming !== null && page.confirming.vm === vmName) + width: parent.width + spacing: 8 + + Repeater { + model: page.actionsFor(vmState, vmSaved) + Button { + required property var modelData + text: modelData[1] + danger: page.isDestructive(modelData[0]) + onClicked: page.run(vmName, modelData[0]) + } + } + Button { + text: "Snapshot" + onClicked: Virsh.snapshotCreate(vmName) + } + Button { + text: "Delete VM" + danger: true + onClicked: page.confirming = { kind: "delete", vm: vmName, snap: "" } + } + } + + Text { + visible: (Virsh.snapshots[vmName] ?? []).length > 0 + text: "Snapshots" + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2; bold: true } + color: Theme.subtext + } + + Repeater { + model: Virsh.snapshots[vmName] ?? [] + + Column { + required property var modelData + width: parent.width + spacing: 4 + + Text { + width: parent.width + elide: Text.ElideRight + text: modelData.name + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 } + color: Theme.text + } + + Row { + width: parent.width + spacing: 10 + + Text { + anchors.verticalCenter: parent.verticalCenter + text: modelData.created + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 } + color: Theme.overlay + } + Text { + anchors.verticalCenter: parent.verticalCenter + text: modelData.state + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 } + color: Theme.overlay + } + Button { + text: "Revert" + danger: true + onClicked: page.confirming = { kind: "revert", vm: vmName, snap: modelData.name } + } + Button { + text: "Delete" + danger: true + onClicked: page.confirming = { kind: "snapdelete", vm: vmName, snap: modelData.name } + } + } + } + } + } + } + + Component { + id: confirmUi + + Column { + spacing: 10 + readonly property var c: page.confirming + // Deleting a VM erases its disk image, so that one asks for the + // name to be typed. The rest are recoverable enough for a click. + readonly property bool needsTyping: c && c.kind === "delete" + + Text { + width: parent.width + wrapMode: Text.Wrap + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 1 } + color: Theme.red + text: { + if (!c) return ""; + if (c.kind === "delete") return `Delete ${c.vm}? This erases its disk image and cannot be undone.`; + 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 ""; + } + } + + TextInput { + id: nameField + visible: needsTyping + width: 260 + text: page.typed + onTextChanged: page.typed = text + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 1 } + color: Theme.text + focus: needsTyping + Component.onCompleted: if (needsTyping) forceActiveFocus() + + Rectangle { + anchors.fill: parent + anchors.margins: -6 + z: -1 + radius: 6 + color: Qt.alpha(Theme.surface, 0.8) + border.width: 1 + border.color: Qt.alpha(Theme.text, 0.15) + } + Text { + visible: !nameField.text + text: "type the VM name" + font: nameField.font + color: Theme.overlay + } + } + + Row { + spacing: 8 + Button { + text: "Confirm" + danger: true + enabled: !needsTyping || page.typed === c.vm + onClicked: { + if (c.kind === "delete") Virsh.deleteVm(c.vm); + else if (c.kind === "revert") Virsh.snapshotRevert(c.vm, c.snap); + else if (c.kind === "snapdelete") Virsh.snapshotDelete(c.vm, c.snap); + else Virsh.act(c.vm, c.kind); + page.confirming = null; + page.typed = ""; + } + } + Button { + text: "Cancel" + onClicked: { page.confirming = null; page.typed = ""; } + } + } + } + } +} +``` + +Note the stat row changed from `Row` to `Flow`: four stats at 150px bars do not +fit in a 600px drawer as one row, where they did in the old full-width panel. + +- [ ] **Step 3: Write the tile and the module** + +The tile shows one dot per VM. `Virsh` runs `virsh event --all --loop` +unconditionally and refreshes the list on every lifecycle event, so +`Virsh.names` and each VM's state are current even while `sampling` is false; +what `sampling` gates is only the 2s stats poll. + +`desktop/modules/vm/VmTile.qml`: + +```qml +// Copyright (C) 2026 Danilo M. <danix@danix.xyz> +// +// This program is free software; you can redistribute it and/or modify +// it under the terms of the GNU General Public License version 2 as +// published by the Free Software Foundation. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +import QtQuick + +// One dot per VM, green for running. The state behind these comes from the +// lifecycle event stream, which runs whether or not the page is open, so the +// dots are current without the stats poll. +Row { + spacing: 4 + + Repeater { + model: Virsh.names + + Rectangle { + required property string modelData + readonly property string state: Virsh.vms[modelData]?.state ?? "" + + anchors.verticalCenter: parent.verticalCenter + width: 7; height: 7; radius: 4 + color: state === "running" ? Theme.green + : state === "paused" || state === "suspended" ? Theme.yellow + : Theme.overlay + } + } + + Text { + anchors.verticalCenter: parent.verticalCenter + visible: Virsh.names.length === 0 + text: "no VMs" + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } + color: Theme.subtext + } +} +``` + +`desktop/modules/vm/VmModule.qml`: + +```qml +// Copyright (C) 2026 Danilo M. <danix@danix.xyz> +// +// This program is free software; you can redistribute it and/or modify +// it under the terms of the GNU General Public License version 2 as +// published by the Free Software Foundation. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +import QtQuick + +// Not always active: the 2s stats poll exists only to paint a page nobody is +// looking at, so the page starts and stops it. The lifecycle event stream in +// Virsh is separate and always runs, which is what keeps the tile's dots and +// the VM list correct without polling. +Module { + id: mod + + name: "vm" + icon: "" + label: "Machines" + alwaysActive: false + + tileContent: Component { VmTile {} } + + page: Component { + Page { + title: "Virtual machines" + VmPage { width: parent.width } + } + } +} +``` + +- [ ] **Step 4: Keep the failure notification** + +`vm-manager/shell.qml` turned `Virsh.actionFailed` into a `notify-send`. That +belongs in the module now. Add to `VmModule.qml`, inside the `Module` block: + +```qml + // An action that fails (libvirt refusing, a disk in use) has to say so: + // the notification is the only feedback, since virsh output goes nowhere. + property Process notifyProc: Process {} + + property Connections conn: Connections { + target: Virsh + function onActionFailed(vm, action, message) { + mod.notifyProc.command = ["notify-send", "--app-name=vm-manager", + "--urgency=critical", "--icon=error", + `${action} failed: ${vm}`, message]; + mod.notifyProc.running = false; + mod.notifyProc.running = true; + } + } +``` + +and add `import Quickshell.Io` at the top of the file. + +- [ ] **Step 5: Register it and delete the old component** + +Add `import "modules/vm"` to `desktop/shell.qml` and put `VmModule {}` in the +`modules` list, after mail. + +```bash +git rm vm-manager/VmPanel.qml vm-manager/shell.qml vm-manager/Theme.qml +git mv vm-manager/README.md desktop/modules/vm/README.md +rmdir vm-manager +``` + +- [ ] **Step 6: Verify** + +```bash +pkill -x qs +pgrep -cx qs +``` + +Expected: `0`. + +```bash +timeout 20 qs -p desktop 2>&1 | head -30 +``` + +Expected: `Configuration Loaded`, no QML errors. Ask the user to: +1. Confirm the grid shows four tiles, with the VM tile showing a dot per VM. +2. Run `qs -p desktop ipc call drawer open vm` and confirm the VM rows, stats + and snapshot list render, and that the back arrow returns to the grid. +3. Confirm the stats update while the page is open and that the tile dots are + still right after closing it. + +`Virsh.qml` moved unchanged, so the two libvirt findings it encodes should +still hold. Confirm they survived the move rather than assuming it: + +```bash +grep -c 'Managed save' desktop/modules/vm/Virsh.qml +grep -c 'balloon\|block.allocation\|guest-get-fsinfo' desktop/modules/vm/Virsh.qml +``` + +Expected: `1` and at least `1`. The first is the managed-save detection, which +`domstats` cannot report and which makes `virsh start` fail every time on an +affected VM; the second is the guest-agent path that exists because libvirt's +own memory and disk figures measure something else. If either reads `0`, the +file was edited when it should have been moved verbatim. + +A VM carrying a managed save shows "Discard saved state" among its actions; if +the user has one, that button appearing is the end-to-end check. + +- [ ] **Step 7: Commit** + +```bash +git add -A desktop vm-manager +git commit -m "feat(desktop): move vm-manager in as the vm module + +Virsh.qml is unchanged; VmPanel's body becomes the page and the stat row +becomes a Flow, because four stats with 150px bars do not fit a 600px +drawer as one row. The page starts and stops Virsh.sampling, so the 2s +poll runs only while something is looking at it; the lifecycle event +stream still runs always, which is what keeps the tile's dots current." +``` + +--- + +## Task 9: Documentation + +**Files:** +- Create: `desktop/README.md` +- Modify: `README.md` +- Modify: `AGENTS.md` + +- [ ] **Step 1: Write the component README** + +`desktop/README.md` covers what the drawer is, how a module is written, and the +outside-repo configuration. The three moved READMEs stay where Tasks 6-8 put +them, under their module directories, and this one links to them. + +Write it with these sections: + +```markdown +# desktop + +One drawer, left of DP-1, hosting the things a desktop lets you adjust. +Reached from a launcher at the left end of waybar. + +## Running it + + qs -p ./desktop + + qs -p ./desktop ipc call drawer toggle # the grid + qs -p ./desktop ipc call drawer open mail # straight to a page + +## The modules + + modules/sound/ output and input volume, the OSD, the player + modules/mail/ unread per account, threads, the watcher dot + modules/vm/ libvirt state, live stats, snapshots + modules/appearance/ a tile that opens the separate appearance shell + +Each has its own README. + +## Writing a module + +[Describe Module.qml's properties: name, icon, label, alwaysActive, +tileContent, page, activate(). Explain that a module provides a tile, a +page, both or neither, and that alwaysActive governs the service rather +than the page. Show the appearance module as the smallest complete +example, since it is nine lines of substance.] + +## alwaysActive + +[Explain why sound and mail are true and vm is false: the OSD must answer +a keypress with no drawer open, the unread count outlives the drawer, and +vm's stats poll only paints a page nobody is looking at. Note that vm's +lifecycle event stream still runs always, so the property governs the +poll, not everything the module does.] + +## Geometry + +[600px, left of DP-1 because conky holds the right, ExclusionMode.Normal +so waybar stays visible and clickable, the reserved notification area at +the top, the fixed 3x3 tile grid at the bottom.] + +## Hyprland and waybar + +[The blur rule, the launcher module, the deep-link click targets. Refer +to the config table in the plan's final task.] + +## Theme + +[One sentence: Theme.qml is a symlink to shared/Theme.qml, as in every +component here.] +``` + +Fill each bracketed section with real prose; the brackets are instructions to +you, not content to keep. + +- [ ] **Step 2: Update the top-level README** + +In `README.md`, replace the "Implementations" block with: + +``` + desktop/ the drawer: sound, mail, VMs, appearance + appearance/ wallpaper picker and colour scheme switcher + window-switcher/ open windows as live previews in a grid, on ALT+TAB +``` + +and adjust the paragraph above it, which currently says the components are +"separate shells, not modules of a single bar". That is still true of the three +directories, but `desktop/` is itself a host for modules, so say so: three +shells, one of which hosts modules. + +Update the `qs -p ./volume-osd` example to `qs -p ./desktop`. + +- [ ] **Step 3: Update AGENTS.md** + +Two sections are now wrong: + +- The component list at the top still names five directories. +- The "Theme" section says there is one `Theme.qml` "symlinked five ways"; it + is now three. + +Add to the per-component notes, since both cost time to rediscover: + +```markdown +- **A `pragma Singleton` in a subdirectory needs no `qmldir`.** A plain + directory import resolves it, the same way quickshell resolves the + `Theme.qml` symlink. Tested while merging the components: the control was a + reference to an undefined type, which warns `ReferenceError: <name> is not + defined`, and the singleton case produced no such warning. +- **`Virsh.sampling` gates the 2s stats poll, not the whole service.** The + lifecycle event stream runs unconditionally, which is what keeps the VM list + and the tile's dots current while the page is closed. +``` + +- [ ] **Step 4: Verify the docs match the code** + +```bash +ls desktop/modules/ +grep -c 'volume-osd\|mail-overview\|vm-manager' README.md AGENTS.md +``` + +Expected: the four module directories listed, and the grep reporting `0` for +`README.md`. `AGENTS.md` legitimately still mentions the old names in its +historical notes, so read its matches rather than requiring zero. + +- [ ] **Step 5: Commit** + +```bash +git add README.md AGENTS.md desktop/README.md +git commit -m "docs: the desktop shell and what the merge changed + +Five components become three, and the Theme symlink count with them. Two +new notes: a pragma Singleton in a subdirectory resolves through a plain +directory import with no qmldir, and Virsh.sampling gates only the stats +poll, not the lifecycle stream that keeps the tile correct while the page +is closed." +``` + +--- + +## Task 10: The configuration outside this repo + +These six edits are in the user's live Hyprland and waybar configuration, not +in this repository. **Do not apply them without asking.** Present the list, make +the edits the user approves, and let the user restart the session. + +**Paths in this task are written with a tilde, and must be typed absolute.** +This file is committed, and the repo forbids home paths in committed files, so +the tilde is what you read here. But neither Hyprland's `exec_cmd` nor waybar's +`exec` expands `~`: they do not go through a shell, so a tilde there fails +silently, which is exactly the kind of failure that looks like a broken +keybind. Expand every `~/Programming/...` below to the real absolute path when +you write it into the live config. The `~/.config/...` paths naming the files +to edit are ordinary prose and need no expansion. + +- [ ] **Step 1: Show the user what needs changing** + +| file | change | +|---|---| +| `~/.config/hypr/sections/autostart.lua` | five `qs` lines to three; `mail-notify.sh` path | +| `~/.config/hypr/sections/keybindings.lua` | `SUPER+v` to the drawer deep-link | +| `~/.config/hypr/sections/decorations.lua` | add `blur-desktop`; drop `blur-mail`, `blur-vm-manager`; keep `blur-volume-osd` | +| `~/.config/waybar/config.jsonc` | add the launcher at the left end | +| `~/.config/waybar/modules/custom/mail.jsonc` | `exec` path and `on-click` | +| `~/.config/waybar/modules/custom/launcher.jsonc` | new file | + +- [ ] **Step 2: autostart.lua** + +Replace the five `qs` lines with: + +```lua + hl.exec_cmd("qs -p ~/Programming/GIT/quickshell/desktop") + hl.exec_cmd("qs -p ~/Programming/GIT/quickshell/appearance") + hl.exec_cmd("qs -p ~/Programming/GIT/quickshell/window-switcher") +``` + +and change the notifier line to its new path: + +```lua + hl.exec_cmd("~/Programming/GIT/quickshell/desktop/modules/mail/mail-notify.sh") +``` + +- [ ] **Step 3: keybindings.lua** + +Replace the `SUPER + v` bind: + +```lua +hl.bind(mainMod .. " + v", hl.dsp.exec_cmd("qs -p ~/Programming/GIT/quickshell/desktop ipc call drawer open vm")) +``` + +ALT+TAB and `SUPER+Return` are unchanged: both still point at shells that still +exist. + +- [ ] **Step 4: decorations.lua** + +Delete the `blur-mail` and `blur-vm-manager` rules, whose namespaces no longer +exist. Keep `blur-volume-osd`, which the OSD still uses. Add: + +```lua +-- Frosted glass for the quickshell desktop drawer. +hl.layer_rule({ + name = "blur-desktop", + match = { namespace = "^(quickshell-desktop)$" }, + blur = true, + xray = false, + ignore_alpha = 0.1, +}) +``` + +- [ ] **Step 5: The waybar launcher** + +Create `~/.config/waybar/modules/custom/launcher.jsonc`: + +```jsonc +{ + // Opens the quickshell desktop drawer. A static button: no "exec", so it + // cannot show whether the drawer is open, which would need the shell to + // feed waybar. + "custom/launcher": { + "format": "<span font='18px'></span>", + "tooltip": false, + "on-click": "qs -p ~/Programming/GIT/quickshell/desktop ipc call drawer toggle" + } +} +``` + +The glyph between the `span` tags must be the Slackware icon from the user's +Nerd Font; ask the user which codepoint they want rather than guessing. + +In `~/.config/waybar/config.jsonc`, add the include and put the module first in +`modules-left`: + +```jsonc + "~/.config/waybar/modules/custom/launcher.jsonc", +``` + +```jsonc + "modules-left": [ + "custom/launcher", + "clock#date", +``` + +- [ ] **Step 6: The mail module's paths** + +In `~/.config/waybar/modules/custom/mail.jsonc`: + +```jsonc + "exec": "~/Programming/GIT/quickshell/desktop/modules/mail/waybar-mail.sh", + "on-click": "qs -p ~/Programming/GIT/quickshell/desktop ipc call drawer open mail", +``` + +- [ ] **Step 7: Apply and restart** + +```bash +hyprctl reload +``` + +That picks up the binds and the layer rules. It does not start processes: +`hl.exec_cmd` is exec-once, so the three-shell autostart takes effect at the +next login. Have the user either log out and back in, or start the shells +manually for this session. + +Restart waybar for the launcher and the mail module's new paths: + +```bash +pkill -x waybar && waybar & +``` + +- [ ] **Step 8: Confirm the end state** + +After the user has logged back in: + +```bash +pgrep -ax qs +``` + +Expected: exactly three, running `desktop`, `appearance` and `window-switcher`. + +Ask the user to confirm: the launcher opens the drawer, `SUPER+v` lands on the +VM page, the waybar mail count still updates and its click opens the mail page, +the volume OSD still appears on a keypress with the drawer closed, and the +drawer is frosted rather than flat. + +--- + +## Notes for whoever executes this + +**Commit after every task.** Each task leaves the repo working; several delete +a component, and a half-finished deletion is painful to unpick. + +**The three deleted components stay in git history.** If a page turns out to +have lost something, `git show HEAD~n:vm-manager/VmPanel.qml` has the original. + +**Glyphs.** Several files carry Nerd Font private-use characters. This plan +writes them as `\uXXXX` escapes because they do not survive copying through a +document. When moving a file, take the real bytes from the original with +`git mv` or `git show`, and check `git diff` reports no change to them. Where a +new file needs a glyph, the escape form is correct and renders identically. + +**What the spec deliberately leaves out.** Notifications, DND, breaktimer, +wifi, bluetooth and kdeconnect are later projects. The reserved area at the top +of the drawer is the only accommodation this project makes for the first of +them. Resist filling it. diff --git a/docs/superpowers/plans/2026-09-14-kdeconnect.md b/docs/superpowers/plans/2026-09-14-kdeconnect.md new file mode 100644 index 0000000..82b0ba5 --- /dev/null +++ b/docs/superpowers/plans/2026-09-14-kdeconnect.md @@ -0,0 +1,1224 @@ +# KDE Connect Module 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:** Add a `kdeconnect` module to the `desktop/` drawer, absorbing the `kdeconnect-indicator` tray icon: device status and battery, pairing, ping, clipboard send, file share, refresh and filesystem mount. + +**Architecture:** One `Module` under `desktop/modules/kdeconnect/` with a tile and a full-height page, following the existing contract. Quickshell 0.3.1 has no generic D-Bus module, so all state and actions go through two binaries: `qdbus6` for reading the daemon and for pair accept/reject, and `kdeconnect-cli` for the user actions. State is a helper script emitting tab-separated lines, polled every 5s while the drawer is open and not while it is closed. + +**Tech Stack:** Quickshell 0.3.1, Qt6 QML, the `org.kde.kdeconnect` D-Bus daemon via `qdbus6`, `kdeconnect-cli`, `QtQuick.Dialogs` for the share picker. + +**Spec:** `docs/superpowers/specs/2026-09-14-kdeconnect-design.md` + +## Global Constraints + +- Quickshell 0.3.1, Qt6 QML. Run configs with `qs -p ./desktop`. The running process is `qs`: `pkill -x qs`, `pgrep -cx qs`, never `pkill -f` (it matches the calling shell and kills it). +- GPLv2 only. Every new `.qml` file begins with this exact header, no exceptions: + +```qml +// Copyright (C) 2026 Danilo M. <danix@danix.xyz> +// +// This program is free software; you can redistribute it and/or modify +// it under the terms of the GNU General Public License version 2 as +// published by the Free Software Foundation. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +``` + +- Every new `.sh` file begins with this exact header, no exceptions: + +```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. +``` + +- Module files live under `desktop/modules/kdeconnect/` and reference root types (`Module`, `Page`, `Button`, `Theme`), so each uses `import "../.."`. +- Shell-outs only through `qdbus6` and `kdeconnect-cli`. Quickshell 0.3.1 has no generic D-Bus module (only `Quickshell.DBusMenu`); do not look for a native binding. +- `alwaysActive: false`. Polling runs only while the drawer is open. +- Grid order in `shell.qml`: `Sound, Network, Bluetooth, KDE Connect, Mail, Appearance, Machines`. +- No em dashes anywhere. No home paths in committed files. Nerd Font glyphs are written as `\uXXXX` and their bytes verified with `git diff`. +- Reusing a `Process` needs `running = false` immediately before `running = true`. +- Hot reload does not pick up a new component file. After a task that adds a `.qml` file, ask the user to restart the drawer shell, or make a content change to `desktop/shell.qml`; touching a file is not enough. +- The phone-to-PC clipboard is the daemon's own plugin and is not built here. Do not add a code path for it. + +## Verified API Facts (probed on this machine, 2026-09-14) + +Do not re-probe these; they are measured, not assumed. + +- `qdbus6 org.kde.kdeconnect /modules/kdeconnect org.kde.kdeconnect.daemon.devices` prints one device id per line, and only an id. Zero devices prints nothing and exits 0. A daemon that is down exits non-zero. +- Per device, `qdbus6 org.kde.kdeconnect /modules/kdeconnect/devices/<id> org.kde.kdeconnect.device.<prop>` returns the scalar: `name`, `type` (`phone` or `desktop`), `isPaired`, `isReachable`, `isPairRequested`, `isPairRequestedByPeer` (all lowercase `true`/`false`). `verificationKey` is a method with the same call shape. +- The battery object is not present on every device. `qdbus6 .../devices/<id>/battery org.freedesktop.DBus.Properties.Get org.kde.kdeconnect.device.battery charge` prints an error to stderr and exits non-zero on a device without it (observed on `kalilaptop`, a desktop with no battery). Suppress stderr and treat empty stdout as absent, never as zero. +- Incoming pairing requests: `qdbus6 org.kde.kdeconnect /modules/kdeconnect org.freedesktop.DBus.Properties.Get org.kde.kdeconnect.daemon pairingRequests`, a list of ids, empty when there are none, exit 0. +- A full read over the two paired devices measured about 5ms. +- `Quickshell.shellDir` is a string holding the full path to the shell root (the directory of `shell.qml`), so a script path is `` `${Quickshell.shellDir}/modules/kdeconnect/kdeconnect-state.sh` ``. +- Glyph codepoints confirmed present in the Inconsolata Nerd Font cmap (the family `Theme.iconFamily` resolves to): phone `\uf10b`, laptop `\uf109`. +- Smoke-check command, harness owns the process and the log is read, never a later `pgrep`: + +```bash +timeout 8 qs -p ./desktop 2>&1 | grep -E 'ERROR|TypeError|ReferenceError|is not defined|Cannot assign|Unable to assign' && echo "ERRORS ABOVE" || echo "clean" +``` + +Expected: `clean`. The trailing `|| echo clean` is deliberate: grep exits 1 when it finds nothing. This briefly starts a second drawer instance; it dies with the timeout. + +--- + +### Task 1: State script and its test + +**Files:** +- Create: `desktop/modules/kdeconnect/kdeconnect-state.sh` +- Create: `desktop/modules/kdeconnect/test-kdeconnect-state.sh` + +**Interfaces:** +- Consumes: nothing from other tasks. +- Produces: `kdeconnect-state.sh`, invoked as `bash kdeconnect-state.sh`, printing to stdout one `request<TAB><id>` line per incoming pairing request, then one line per device: + `device<TAB><id><TAB><name><TAB><type><TAB><paired 0|1><TAB><reachable 0|1><TAB><pairRequested 0|1><TAB><pairRequestedByPeer 0|1><TAB><verificationKey or empty><TAB><charge or empty><TAB><charging 0|1>` + Eleven tab-separated fields on a device line. Exit 0 on success, non-zero with no output when the daemon query fails. Task 2 parses this exact shape. + +- [ ] **Step 1: Write the failing test** + +Create `desktop/modules/kdeconnect/test-kdeconnect-state.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 kdeconnect-state.sh. It puts a stub qdbus6 on +# PATH and runs the real script against it, so nothing here touches the live +# daemon and the whole thing runs in milliseconds. +# +# Fixture: idA a reachable phone with a battery, idB a laptop whose name +# contains a tab and which has no battery plugin, idC an unpaired phone with +# an incoming pairing request and a verification key. +# +# Usage: ./test-kdeconnect-state.sh (exit 0 = all passed) + +set -u + +here="$(cd "$(dirname "$0")" && pwd)" +stub="$(mktemp -d)" +trap 'rm -rf "$stub"' EXIT + +cat > "$stub/qdbus6" <<'STUB' +#!/bin/bash +path="$2"; m="$3"; a="$4"; b="$5" + +if [[ "${FAIL_DEVICES:-}" == 1 && "$m" == "org.kde.kdeconnect.daemon.devices" ]]; then + exit 1 +fi +if [[ "${ZERO_DEVICES:-}" == 1 && "$m" == "org.kde.kdeconnect.daemon.devices" ]]; then + exit 0 +fi + +case "$path:$m" in + "/modules/kdeconnect:org.kde.kdeconnect.daemon.devices") + printf '%s\n' idA idB idC ;; + "/modules/kdeconnect:org.freedesktop.DBus.Properties.Get") + if [[ "$a" == "org.kde.kdeconnect.daemon" && "$b" == "pairingRequests" ]]; then + [[ "${ZERO_DEVICES:-}" == 1 ]] || printf '%s\n' idC + fi ;; + "/modules/kdeconnect/devices/idA:org.kde.kdeconnect.device.name") printf 'Pixel 6 Pro\n' ;; + "/modules/kdeconnect/devices/idA:org.kde.kdeconnect.device.type") printf 'phone\n' ;; + "/modules/kdeconnect/devices/idA:org.kde.kdeconnect.device.isPaired") printf 'true\n' ;; + "/modules/kdeconnect/devices/idA:org.kde.kdeconnect.device.isReachable") printf 'true\n' ;; + "/modules/kdeconnect/devices/idA:org.kde.kdeconnect.device.isPairRequested") printf 'false\n' ;; + "/modules/kdeconnect/devices/idA:org.kde.kdeconnect.device.isPairRequestedByPeer") printf 'false\n' ;; + "/modules/kdeconnect/devices/idA/battery:org.freedesktop.DBus.Properties.Get") + [[ "$b" == charge ]] && printf '50\n' || printf 'true\n' ;; + "/modules/kdeconnect/devices/idB:org.kde.kdeconnect.device.name") printf 'kali\tlaptop\n' ;; + "/modules/kdeconnect/devices/idB:org.kde.kdeconnect.device.type") printf 'desktop\n' ;; + "/modules/kdeconnect/devices/idB:org.kde.kdeconnect.device.isPaired") printf 'true\n' ;; + "/modules/kdeconnect/devices/idB:org.kde.kdeconnect.device.isReachable") printf 'false\n' ;; + "/modules/kdeconnect/devices/idB:org.kde.kdeconnect.device.isPairRequested") printf 'false\n' ;; + "/modules/kdeconnect/devices/idB:org.kde.kdeconnect.device.isPairRequestedByPeer") printf 'false\n' ;; + "/modules/kdeconnect/devices/idB/battery:org.freedesktop.DBus.Properties.Get") exit 1 ;; + "/modules/kdeconnect/devices/idC:org.kde.kdeconnect.device.name") printf 'New Phone\n' ;; + "/modules/kdeconnect/devices/idC:org.kde.kdeconnect.device.type") printf 'phone\n' ;; + "/modules/kdeconnect/devices/idC:org.kde.kdeconnect.device.isPaired") printf 'false\n' ;; + "/modules/kdeconnect/devices/idC:org.kde.kdeconnect.device.isReachable") printf 'true\n' ;; + "/modules/kdeconnect/devices/idC:org.kde.kdeconnect.device.isPairRequested") printf 'false\n' ;; + "/modules/kdeconnect/devices/idC:org.kde.kdeconnect.device.isPairRequestedByPeer") printf 'true\n' ;; + "/modules/kdeconnect/devices/idC:org.kde.kdeconnect.device.verificationKey") printf '1826C6D4\n' ;; + "/modules/kdeconnect/devices/idC/battery:org.freedesktop.DBus.Properties.Get") exit 1 ;; +esac +STUB +chmod +x "$stub/qdbus6" + +pass=0 +fail=0 + +check() { + local name="$1" want="$2" got="$3" + if [[ "$want" == "$got" ]]; then + pass=$((pass + 1)) + else + fail=$((fail + 1)) + printf 'FAIL: %s\n want: %q\n got: %q\n' "$name" "$want" "$got" + fi +} + +# The full line protocol for the fixture. The name with a tab is one field +# after sanitizing; the two battery-less devices leave the charge and +# charging fields empty and zero respectively. +want="$(printf 'request\tidC') +$(printf 'device\tidA\tPixel 6 Pro\tphone\t1\t1\t0\t0\t\t50\t1') +$(printf 'device\tidB\tkali laptop\tdesktop\t1\t0\t0\t0\t\t\t0') +$(printf 'device\tidC\tNew Phone\tphone\t0\t1\t0\t1\t1826C6D4\t\t0')" + +got="$(PATH="$stub:$PATH" bash "$here/kdeconnect-state.sh")" +check "the line protocol" "$want" "$got" + +# A daemon that is down is not an empty device list. +got="$(PATH="$stub:$PATH" FAIL_DEVICES=1 bash "$here/kdeconnect-state.sh")" +rc=$? +check "a failed query prints nothing" "" "$got" +check "a failed query exits non-zero" "1" "$([[ $rc -ne 0 ]] && echo 1 || echo 0)" + +# A reachable daemon with no devices is a valid empty list. +got="$(PATH="$stub:$PATH" ZERO_DEVICES=1 bash "$here/kdeconnect-state.sh")" +rc=$? +check "zero devices prints nothing" "" "$got" +check "zero devices exits zero" "0" "$rc" + +printf '\n%d passed, %d failed\n' "$pass" "$fail" +[[ "$fail" -eq 0 ]] +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `bash desktop/modules/kdeconnect/test-kdeconnect-state.sh` +Expected: FAIL, the script under test does not exist yet. + +- [ ] **Step 3: Write `kdeconnect-state.sh`** + +Create `desktop/modules/kdeconnect/kdeconnect-state.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. +# +# One state dump for the KDE Connect module: the daemon's devices and any +# pending pairing requests, as tab-separated lines on stdout. +# +# device<TAB>id<TAB>name<TAB>type<TAB>paired<TAB>reachable<TAB>pairRequested<TAB>pairRequestedByPeer<TAB>key<TAB>charge<TAB>charging +# request<TAB>id +# +# request lines come first. A daemon query that fails exits non-zero with no +# output, so the caller keeps its last list rather than showing an empty one; +# a query that succeeds with no devices exits zero and also prints nothing. +# +# The test stubs qdbus6 on PATH; see test-kdeconnect-state.sh. + +set -u + +BUS=org.kde.kdeconnect +ROOT=/modules/kdeconnect +QDBUS=qdbus6 + +# A scalar property or method value for a device, empty on any failure. +prop() { + "$QDBUS" "$BUS" "$ROOT/devices/$1" "org.kde.kdeconnect.device.$2" 2>/dev/null +} + +bool() { + case "$(prop "$1" "$2")" in + true) printf 1 ;; + *) printf 0 ;; + esac +} + +# A device name is attacker-adjacent text; a tab or newline in it must not +# break the line protocol. +sanitize() { + printf '%s' "$1" | tr '\t\n' ' ' +} + +emit_device() { + local id="$1" name type paired reachable pr pbp key charge charging + name="$(sanitize "$(prop "$id" name)")" + type="$(sanitize "$(prop "$id" type)")" + paired="$(bool "$id" isPaired)" + reachable="$(bool "$id" isReachable)" + pr="$(bool "$id" isPairRequested)" + pbp="$(bool "$id" isPairRequestedByPeer)" + + # The key only means anything while a pairing is in flight. + key="" + if [[ "$pr" == 1 || "$pbp" == 1 ]]; then + key="$(sanitize "$(prop "$id" verificationKey)")" + fi + + # The battery plugin is not on every device: a desktop without a battery + # object already returned qdbus an error. An absent query is an empty + # field, never a zero. + charge="$("$QDBUS" "$BUS" "$ROOT/devices/$id/battery" \ + org.freedesktop.DBus.Properties.Get \ + org.kde.kdeconnect.device.battery charge 2>/dev/null)" + [[ "$charge" =~ ^[0-9]+$ ]] || charge="" + + charging=0 + if [[ -n "$charge" ]]; then + case "$("$QDBUS" "$BUS" "$ROOT/devices/$id/battery" \ + org.freedesktop.DBus.Properties.Get \ + org.kde.kdeconnect.device.battery isCharging 2>/dev/null)" in + true) charging=1 ;; + esac + fi + + printf 'device\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \ + "$id" "$name" "$type" "$paired" "$reachable" "$pr" "$pbp" \ + "$key" "$charge" "$charging" +} + +main() { + local ids + # Command substitution carries qdbus's exit status; a failed list is the + # error case, not an empty device list. + ids="$("$QDBUS" "$BUS" "$ROOT" org.kde.kdeconnect.daemon.devices 2>/dev/null)" || exit 1 + + local reqs r + reqs="$("$QDBUS" "$BUS" "$ROOT" org.freedesktop.DBus.Properties.Get \ + org.kde.kdeconnect.daemon pairingRequests 2>/dev/null)" || exit 1 + for r in $reqs; do + printf 'request\t%s\n' "$r" + done + + local id + for id in $ids; do + [[ -n "$id" ]] && emit_device "$id" + done +} + +main "$@" +``` + +- [ ] **Step 4: Make both scripts executable** + +Run: `chmod +x desktop/modules/kdeconnect/kdeconnect-state.sh desktop/modules/kdeconnect/test-kdeconnect-state.sh` + +- [ ] **Step 5: Run the test to verify it passes** + +Run: `bash desktop/modules/kdeconnect/test-kdeconnect-state.sh` +Expected: `5 passed, 0 failed`. + +- [ ] **Step 6: Commit** + +```bash +git add desktop/modules/kdeconnect/kdeconnect-state.sh desktop/modules/kdeconnect/test-kdeconnect-state.sh +git commit -m "feat(desktop): KDE Connect state script + +Quickshell 0.3.1 has no generic D-Bus module, so device state comes from +qdbus6 shell-outs. The script emits a tab line protocol for the QML side +and is checked by a stub-daemon test, since the live daemon is not an +oracle an agent can rely on. + +A failed daemon query exits non-zero with no output so the module keeps +its last list, which is not the same as a valid empty device list." +``` + +--- + +### Task 2: Module, tile, and a read-only page + +**Files:** +- Create: `desktop/modules/kdeconnect/KdeConnectModule.qml` +- Create: `desktop/modules/kdeconnect/KdeConnectTile.qml` +- Create: `desktop/modules/kdeconnect/KdeConnectPage.qml` +- Create: `desktop/modules/kdeconnect/KdeConnectRow.qml` +- Modify: `desktop/shell.qml` (import and module registry) + +**Interfaces:** +- Consumes: `kdeconnect-state.sh` from Task 1; the `Module`, `Page`, `Button`, `Theme` types. +- Produces: `KdeConnectModule` exposing `devices` (array of `{id, name, type, paired, reachable, pairRequested, pairRequestedByPeer, key, charge, charging}`), `requests` (array of id strings), `error`, `paired`, `unpaired`, `reachable`, `anyReachable`, `polling`, `refresh()`, `notify(title, body)`, `discover()`, `deviceName(id)`, `run(label, cmd)`, and (Task 4) `pair`, `unpair`, `ring`, `clipboard`, `mount`, `accept`, `reject`, and (Task 5) `share`. Tile type `KdeConnectTile { mod: ... }`, page type `KdeConnectPage { mod: ... }`, row type `KdeConnectRow { mod: ..., device: ... }`. + +- [ ] **Step 1: Create `KdeConnectModule.qml`** + +In this task the action functions are present but only `discover()` is called by the page; Tasks 3 and 4 add the buttons that use the rest. + +```qml +// <GPLv2 header> + +import Quickshell +import Quickshell.Io +import QtQuick +import "../.." + +// Not always active: there is no push to listen to without a generic D-Bus +// module, so the state is polled while the drawer is open and not otherwise. +// The poll's lifetime is the tile's lifetime; see KdeConnectTile.qml. +Module { + id: mod + + name: "kdeconnect" + label: "KDE Connect" + alwaysActive: false + + // Rebuilt from kdeconnect-state.sh on every poll. See the script for the + // line protocol; the field order here must match it. + property var devices: [] + property var requests: [] + property string error: "" + + readonly property var paired: devices.filter(d => d.paired) + readonly property var unpaired: devices.filter(d => !d.paired) + readonly property var reachable: devices.filter(d => d.reachable) + readonly property bool anyReachable: reachable.length > 0 + + icon: "\uf10b" + active: mod.anyReachable + + function deviceName(id) { + const d = devices.find(x => x.id === id); + return d ? d.name : id; + } + + // The tile is created when the drawer panel loads, grid or page, and + // destroyed when it unloads, so its presence is the poll's on/off switch. + property bool polling: false + onPollingChanged: if (polling) refresh() + + function refresh() { + stateProc.devices = []; + stateProc.reqs = []; + stateProc.running = false; + stateProc.running = true; + } + + property Process stateProc: Process { + property var devices: [] + property var reqs: [] + command: ["bash", `${Quickshell.shellDir}/modules/kdeconnect/kdeconnect-state.sh`] + stdout: SplitParser { + onRead: line => { + const f = line.split("\t"); + if (f[0] === "request" && f[1]) + stateProc.reqs.push(f[1]); + else if (f[0] === "device" && f.length === 11) + stateProc.devices.push({ + id: f[1], name: f[2], type: f[3], + paired: f[4] === "1", reachable: f[5] === "1", + pairRequested: f[6] === "1", + pairRequestedByPeer: f[7] === "1", + key: f[8], charge: f[9], charging: f[10] === "1", + }); + } + } + onExited: code => { + if (code !== 0) { mod.error = "kdeconnectd is not responding"; return; } + mod.error = ""; + mod.devices = stateProc.devices; + mod.requests = stateProc.reqs; + } + } + + property Timer pollTimer: Timer { + interval: 5000 + repeat: true + running: mod.polling + onTriggered: mod.refresh() + } + + // --- actions --- + + // A failure after the drawer closed would otherwise go unseen, so it also + // raises a notification. Same cached-Process shape as the other modules. + function notify(title, body) { + notifyProc.command = ["notify-send", "--app-name=kdeconnect", + "--urgency=critical", "--icon=error", title, body]; + notifyProc.running = false; + notifyProc.running = true; + } + + property Process notifyProc: Process {} + + // One reusable action process. Actions are serialized; a second click + // while one runs replaces it rather than racing. A failure notifies. + function run(label, cmd) { + actProc.label = label; + actProc.command = cmd; + actProc.running = false; + actProc.running = true; + } + + property Process actProc: Process { + property string label: "" + stderr: StdioCollector { id: actErr } + onExited: code => { + if (code !== 0) + mod.notify(actProc.label + " failed", + actErr.text.trim() || ("exited " + code)); + mod.refresh(); + } + } + + function discover() { run("Refresh", ["kdeconnect-cli", "--refresh"]); } + function ring(id) { run("Ring", ["kdeconnect-cli", "--ring", "-d", id]); } + function clipboard(id) { + run("Clipboard", ["kdeconnect-cli", "--send-clipboard", "-d", id]); + } + function pair(id) { run("Pair", ["kdeconnect-cli", "--pair", "-d", id]); } + function unpair(id) { run("Unpair", ["kdeconnect-cli", "--unpair", "-d", id]); } + + // Accept and reject are the two actions kdeconnect-cli does not offer, so + // they go straight to the daemon. + function accept(id) { + run("Accept", ["qdbus6", "org.kde.kdeconnect", + `/modules/kdeconnect/devices/${id}`, + "org.kde.kdeconnect.device.acceptPairing"]); + } + function reject(id) { + run("Reject", ["qdbus6", "org.kde.kdeconnect", + `/modules/kdeconnect/devices/${id}`, + "org.kde.kdeconnect.device.cancelPairing"]); + } + + // --mount prints nothing and the mount point comes from --get-mount-point, + // so both steps run in one shell. The id is machine-generated hex; quoting + // it through JSON.stringify keeps the shell from being the injection path. + function mount(id) { + run("Mount", ["sh", "-c", + `kdeconnect-cli --mount -d ${JSON.stringify(id)} && ` + + `xdg-open "$(kdeconnect-cli --get-mount-point -d ${JSON.stringify(id)})"`]); + } + + tileContent: Component { KdeConnectTile { mod: mod } } + + page: Component { + Page { + title: "KDE Connect" + KdeConnectPage { width: parent.width; mod: mod } + } + } +} +``` + +- [ ] **Step 2: Create `KdeConnectTile.qml`** + +```qml +// <GPLv2 header> + +import QtQuick +import "../.." + +// The state line under the tile label, and the poll's lifetime: the tile +// exists exactly while the drawer panel is loaded, so it turns polling on and +// off. Without this the module would poll while the drawer is closed, which +// nothing needs. +Item { + id: tile + + required property var mod + + width: parent ? parent.width : implicitWidth + implicitHeight: state.implicitHeight + + Component.onCompleted: mod.polling = true + Component.onDestruction: mod.polling = false + + Text { + id: state + width: parent.width + elide: Text.ElideRight + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } + color: tile.mod.anyReachable ? Theme.text : Theme.subtext + text: { + const r = tile.mod.reachable; + if (r.length === 1) + return r[0].charge !== "" ? r[0].name + " " + r[0].charge + "%" : r[0].name; + if (r.length > 1) return r.length + " connected"; + if (tile.mod.paired.length > 0) return "Offline"; + return "No devices"; + } + } +} +``` + +- [ ] **Step 3: Create `KdeConnectRow.qml` (read-only identity and status; actions are Task 3)** + +```qml +// <GPLv2 header> + +import QtQuick +import "../.." + +// One KDE Connect device. The type glyph is the phone for anything that is +// not a laptop or desktop, since the daemon's type strings are not a closed +// set. +Rectangle { + id: row + + required property var mod + required property var device + + implicitHeight: col.implicitHeight + 16 + radius: 8 + color: row.device.reachable ? Qt.alpha(Theme.accent, 0.10) : Qt.alpha(Theme.surface, 0.35) + + Column { + id: col + anchors { left: parent.left; right: parent.right; top: parent.top; margins: 10 } + spacing: 8 + + Item { + width: parent.width + implicitHeight: Math.max(name.implicitHeight, glyph.implicitHeight) + + Text { + id: glyph + anchors.verticalCenter: parent.verticalCenter + text: row.device.type === "desktop" ? "\uf109" : "\uf10b" + font { family: Theme.iconFamily; pixelSize: Theme.fontSize - 2 } + color: Theme.subtext + } + + Text { + id: name + anchors { left: glyph.right; leftMargin: 8; right: parent.right; verticalCenter: parent.verticalCenter } + elide: Text.ElideRight + text: row.device.name || row.device.id + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2; bold: row.device.reachable } + color: row.device.reachable ? Theme.accent : Theme.text + } + } + + Text { + id: status + width: parent.width + text: { + let s = row.device.paired + ? (row.device.reachable ? "Reachable" : "Not reachable") + : (row.device.reachable ? "Found" : "Not reachable"); + if (row.device.charge !== "") + s += " " + row.device.charge + "%" + (row.device.charging ? " charging" : ""); + return s; + } + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } + color: Theme.subtext + elide: Text.ElideRight + } + } +} +``` + +- [ ] **Step 4: Create `KdeConnectPage.qml` (list only; actions and pairing land in Tasks 3 and 4)** + +```qml +// <GPLv2 header> + +import QtQuick +import "../.." + +Column { + id: page + + required property var mod + + spacing: 14 + + Row { + width: page.width + spacing: 8 + + Button { + text: "Refresh" + onClicked: page.mod.discover() + } + } + + Text { + width: page.width + visible: page.mod.error !== "" + wrapMode: Text.Wrap + text: page.mod.error + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 } + color: Theme.red + } + + Text { + width: page.width + visible: page.mod.paired.length > 0 + text: "Paired" + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2; bold: true } + color: Theme.subtext + } + + Repeater { + model: page.mod.paired + + KdeConnectRow { + required property var modelData + mod: page.mod + device: modelData + width: page.width + } + } + + Text { + width: page.width + visible: page.mod.unpaired.length > 0 + text: "Available" + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2; bold: true } + color: Theme.subtext + } + + Repeater { + model: page.mod.unpaired + + KdeConnectRow { + required property var modelData + mod: page.mod + device: modelData + width: page.width + } + } +} +``` + +- [ ] **Step 5: Register the module in `shell.qml`** + +Add the import beside the others: + +```qml +import "modules/kdeconnect" +``` + +Add the module to the registry between Bluetooth and Mail: + +```qml + modules: [ + SoundModule {}, + NetworkModule {}, + BluetoothModule {}, + KdeConnectModule {}, + MailModule {}, + AppearanceModule {}, + VmModule {}, + ] +``` + +- [ ] **Step 6: Smoke-check the config loads** + +Run the Global Constraints smoke-check. Expected: `clean`. + +- [ ] **Step 7: Ask the user to confirm visually** + +Because hot reload does not pick up a new component file, ask the user to restart the drawer shell first. Then: the grid shows a seventh tile, a phone glyph labelled KDE Connect, whose line reads `Pixel 6 Pro 50%`, or `Offline` when the phone is away. Clicking it opens a page listing the paired devices and any discovered ones. The battery-less laptop shows no percentage rather than a zero. Confirm the glyph is a phone, not a box, and the laptop glyph is a laptop. + +- [ ] **Step 8: Commit** + +```bash +git add desktop/modules/kdeconnect/KdeConnectModule.qml desktop/modules/kdeconnect/KdeConnectTile.qml desktop/modules/kdeconnect/KdeConnectPage.qml desktop/modules/kdeconnect/KdeConnectRow.qml desktop/shell.qml +git commit -m "feat(desktop): KDE Connect module, tile and page + +State is polled from kdeconnect-state.sh because there is no D-Bus module +to subscribe with. The poll runs only while the drawer is open: the tile +is created when the panel loads and destroyed when it unloads, and it +flips the module's polling flag. + +The laptop reports no battery plugin, so an absent charge is an empty +field and renders as nothing, never as a zero." +``` + +--- + +### Task 3: Row actions + +**Files:** +- Modify: `desktop/modules/kdeconnect/KdeConnectRow.qml` (add the action row) + +**Interfaces:** +- Consumes: `KdeConnectModule.run`, `KdeConnectModule.ring`, `clipboard`, `mount`, `unpair`, `discover`, `refresh` (all Task 2). +- Produces: nothing new for later tasks except the populated row; Task 4 adds the Pair button and the verification key line to the same file. + +- [ ] **Step 1: Append the action row to `KdeConnectRow.qml`** + +Inside the `Column`, after the `status` Text, add: + +```qml + Row { + width: parent.width + spacing: 6 + + Button { + visible: row.device.reachable + text: "Ring" + onClicked: row.mod.ring(row.device.id) + } + + Button { + visible: row.device.reachable + text: "Clipboard" + onClicked: row.mod.clipboard(row.device.id) + } + + Button { + visible: row.device.reachable + text: "Mount" + onClicked: row.mod.mount(row.device.id) + } + + Button { + visible: row.device.paired + text: "Unpair" + danger: true + onClicked: row.mod.unpair(row.device.id) + } + } +``` + +- [ ] **Step 2: Smoke-check the config loads** + +Run the Global Constraints smoke-check. Expected: `clean`. This edits an existing file, so hot reload picks it up; no restart needed. + +- [ ] **Step 3: Ask the user to confirm visually** + +Ask the user: on the reachable phone row, Ring makes the phone ring, Clipboard pushes the PC clipboard to the phone, Mount opens the phone's filesystem in the file manager, and Unpair removes the device after confirmation on the phone. Trigger one failure (for example Ring a device then immediately switch the phone's wifi off) and confirm it raises a notification. The buttons are absent on the offline laptop except Unpair. + +- [ ] **Step 4: Commit** + +```bash +git add desktop/modules/kdeconnect/KdeConnectRow.qml +git commit -m "feat(desktop): KDE Connect device actions + +Ring, clipboard, mount and unpair, all shell-outs since there is no D-Bus +module. Actions share one serialized Process and a failure notifies, for +the case where the drawer has closed by the time the command returns. + +Mount runs --mount and then --get-mount-point in one shell, because +--mount prints nothing and the path is a second call." +``` + +--- + +### Task 4: Pairing + +**Files:** +- Modify: `desktop/modules/kdeconnect/KdeConnectRow.qml` (Pair button and the verification key line) +- Modify: `desktop/modules/kdeconnect/KdeConnectPage.qml` (incoming request banner) + +**Interfaces:** +- Consumes: `KdeConnectModule.pair`, `unpair`, `accept`, `reject`, `requests`, `deviceName`, `run` (Tasks 2 and 3). +- Produces: nothing new; completes the pairing flow. + +- [ ] **Step 1: Add the Pair button to `KdeConnectRow.qml`** + +In the action `Row`, after the Unpair button, add: + +```qml + Button { + visible: !row.device.paired && row.device.reachable + text: "Pair" + onClicked: row.mod.pair(row.device.id) + } +``` + +- [ ] **Step 2: Add the verification key to the status line in `KdeConnectRow.qml`** + +Replace the `status` Text's `text` binding with: + +```qml + text: { + let s = row.device.paired + ? (row.device.reachable ? "Reachable" : "Not reachable") + : (row.device.reachable ? "Found" : "Not reachable"); + if (row.device.charge !== "") + s += " " + row.device.charge + "%" + (row.device.charging ? " charging" : ""); + if (row.device.key !== "") + s += " key " + row.device.key; + return s; + } +``` + +- [ ] **Step 3: Add the incoming request banner to `KdeConnectPage.qml`** + +Insert after the error Text and before the Paired heading: + +```qml + // Incoming pairing requests. The property persists while the drawer is + // closed, so a request raised then is still here when it opens. + Repeater { + model: page.mod.requests + + Rectangle { + required property var modelData + + width: page.width + implicitHeight: reqCol.implicitHeight + 16 + radius: 8 + color: Qt.alpha(Theme.accent, 0.12) + border.width: 1 + border.color: Qt.alpha(Theme.accent, 0.4) + + Column { + id: reqCol + anchors { left: parent.left; right: parent.right; top: parent.top; margins: 10 } + spacing: 8 + + Text { + width: parent.width + elide: Text.ElideRight + text: page.mod.deviceName(modelData) + " wants to pair" + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2; bold: true } + color: Theme.text + } + + Row { + spacing: 8 + + Button { + text: "Accept" + onClicked: page.mod.accept(modelData) + } + + Button { + text: "Reject" + danger: true + onClicked: page.mod.reject(modelData) + } + } + } + } + } +``` + +- [ ] **Step 4: Smoke-check the config loads** + +Run the Global Constraints smoke-check. Expected: `clean`. + +- [ ] **Step 5: Ask the user to confirm visually** + +Ask the user: on the laptop (paired but offline, shown under Paired) press Unpair, then Refresh and Pair on it while it is in range; confirm the laptop's own confirmation prompt appears and the device returns to Paired and reachable. Then from the phone, ask to pair with this PC, and confirm the drawer shows a `wants to pair` banner with Accept and Reject, that Accept completes it, and that during the flow the row shows a `key` you can compare with the phone. + +- [ ] **Step 6: Commit** + +```bash +git add desktop/modules/kdeconnect/KdeConnectRow.qml desktop/modules/kdeconnect/KdeConnectPage.qml +git commit -m "feat(desktop): KDE Connect pairing + +Outgoing pair and unpair go through kdeconnect-cli; accepting and +rejecting an incoming request go straight to the daemon, since the CLI +has no verb for them. The verification key is shown on the row while a +pairing is in flight so the two ends can be compared. + +An incoming request is not noticed while the drawer is closed, because +the poll only runs when it is open; the daemon keeps the request, so it +is there on the next open." +``` + +--- + +### Task 5: File share + +**Files:** +- Modify: `desktop/modules/kdeconnect/KdeConnectModule.qml` (the share dialog and `share(id)`) +- Modify: `desktop/modules/kdeconnect/KdeConnectRow.qml` (the Share button) + +**Interfaces:** +- Consumes: `KdeConnectModule.run` (Task 2). +- Produces: `KdeConnectModule.share(id)`, `shareTarget`, `pathOf(url)`. + +- [ ] **Step 1: Add the share dialog to `KdeConnectModule.qml`** + +Add the import beside the others: + +```qml +import QtQuick.Dialogs +``` + +Add before `tileContent`: + +```qml + // --- share --- + + // The dialog is held on the module, not the page, so closing the drawer + // mid-pick does not destroy it under the user. + property string shareTarget: "" + + function share(id) { + mod.shareTarget = id; + shareDialog.open(); + } + + // A FileDialog hands back a file:// URL; kdeconnect-cli wants a path. + function pathOf(u) { + return decodeURIComponent(u.toString().replace(/^file:\/\//, "")); + } + + property FileDialog shareDialog: FileDialog { + title: "Share with device" + fileMode: FileDialog.OpenFile + onAccepted: mod.run("Share", + ["kdeconnect-cli", "--share", mod.pathOf(selectedFile), "-d", mod.shareTarget]) + } +``` + +- [ ] **Step 2: Add the Share button to `KdeConnectRow.qml`** + +In the action `Row`, after Clipboard, add: + +```qml + Button { + visible: row.device.reachable + text: "Share" + onClicked: row.mod.share(row.device.id) + } +``` + +- [ ] **Step 3: Smoke-check the config loads** + +Run the Global Constraints smoke-check. Expected: `clean`. A load failure here means the `FileDialog` type is not usable on a `QtObject`; if that happens, say so and use the fallback in the next step instead of debugging it. + +- [ ] **Step 4: Ask the user to confirm the dialog works** + +Ask the user: press Share on the reachable phone. Confirm a file picker appears, takes focus, a file chosen is sent, and the phone receives it. + +If the picker does not appear or cannot take focus while the drawer holds the keyboard, replace the dialog with the fallback and re-check. Fallback, a text field in the page or an inline row field, modelled on `NetworkRow.qml`'s PSK prompt: + +```qml + // Replace the FileDialog with this in KdeConnectModule.qml: a path or URL + // the user pastes. The drawer's Exclusive keyboard grab is the reason the + // native dialog was dropped. + function sharePath(id, path) { + if (path === "") return; + mod.run("Share", ["kdeconnect-cli", "--share", path, "-d", id]); + } +``` + +and in `KdeConnectRow.qml`, a `TextInput` revealed by the Share button, whose `Keys.onReturnPressed` calls `row.mod.sharePath(row.device.id, text)`. Record the outcome in the commit message either way. + +- [ ] **Step 5: Commit** + +```bash +git add desktop/modules/kdeconnect/KdeConnectModule.qml desktop/modules/kdeconnect/KdeConnectRow.qml +git commit -m "feat(desktop): share a file to a KDE Connect device + +A QtQuick.Dialogs FileDialog held on the module, not the page, so a +closed drawer does not destroy it mid-pick. The path is decoded from the +file:// URL before it reaches kdeconnect-cli." +``` + +--- + +### Task 6: Documentation + +**Files:** +- Create: `desktop/modules/kdeconnect/README.md` +- Modify: `desktop/README.md` (module list, grid order, alwaysActive enumeration) + +**Interfaces:** +- Consumes: nothing. Documentation only. +- Produces: nothing executable. + +- [ ] **Step 1: Create `desktop/modules/kdeconnect/README.md`** + +```markdown +# kdeconnect + +KDE Connect devices in the drawer, replacing the `kdeconnect-indicator` tray +icon. Status, battery, pairing, ping, clipboard send, file share, refresh and +filesystem mount. + +The tile names the reachable device and its battery when the battery plugin +reports one, a count when several are reachable, `Offline` when paired devices +exist but none is reachable, and `No devices` otherwise. + +The page lists paired devices, then any discovered unpaired ones, with a +refresh control at the top and an incoming-pairing banner. A reachable row +offers Ring, Clipboard, Share and Mount; a paired row offers Unpair; an +unpaired reachable one offers Pair. + +## The daemon does the clipboard + +Quickshell 0.3.1 has no generic D-Bus module, only `Quickshell.DBusMenu`, so +nothing here binds to `org.kde.kdeconnect`. State is read by `qdbus6` and +actions run through `kdeconnect-cli`, with `qdbus6` for pair accept and reject, +which the CLI does not expose. + +The phone-to-PC clipboard is not this module's. `kdeconnectd` loads the +clipboard plugin and writes the system clipboard itself (through +`KSystemClipboard`), and the indicator was never part of that path, so removing +the indicator does not break it. If it fails on Hyprland it is the compositor +refusing `set_selection` from a daemon with no keyboard focus, which no code +here can fix. + +## Service lifetime + +`alwaysActive: false`. There is no push to listen to, so the state is polled +every 5s while the drawer is open. The tile is created when the drawer panel +loads and destroyed when it unloads, and it is the tile that turns polling on +and off, so nothing polls while the drawer is closed. A consequence: an +incoming pairing request that arrives while the drawer is closed is not noticed +until it is opened, though the daemon keeps the request. + +## Not built + +SMS, notification forwarding, remote input, media control, presenter mode, +find-this-device and remote commands. The daemon supports them and the +indicator surfaced some; this module does not. Pairing failure comes from the +`kdeconnect-cli` exit code, not the `pairingFailed` signal, since there is no +D-Bus module to subscribe with. +``` + +- [ ] **Step 2: Update `desktop/README.md`** + +Add the module line in grid order, after the bluetooth line: + +```markdown + modules/kdeconnect/ devices, battery, pairing, ping, clipboard, share, mount +``` + +Change the geometry paragraph's grid order to include it: + +```markdown +The bottom is a fixed, never-scrolled `Flow` grid: three columns at 180px +minimum, wrapping and adding rows up to a 3x3 ceiling for the modules that +exist, in the order Sound, Network, Bluetooth, KDE Connect, Mail, Appearance, +Machines. +``` + +Then fix the sentences this module makes stale, in the same file: + +- the line "Sound, mail, vm, network and bluetooth each carry their own README" becomes "Sound, mail, vm, network, bluetooth and kdeconnect each carry their own README" +- the line "Sound, mail, vm, network and bluetooth each add a service and a page on top of that same shape" becomes "Sound, mail, vm, network, bluetooth and kdeconnect each add a service and a page on top of that same shape" + +Leave the `alwaysActive` list line as it stands: kdeconnect is false, like vm, so the existing "Sound, mail, network and bluetooth are `alwaysActive: true`; vm is false." gains a companion. Change it to: + +```markdown +Sound, mail, network and bluetooth are `alwaysActive: true`; vm and kdeconnect +are false. +``` + +- [ ] **Step 3: Commit** + +```bash +git add desktop/modules/kdeconnect/README.md desktop/README.md +git commit -m "docs(desktop): document the kdeconnect module + +Records that the phone-to-PC clipboard is the daemon's plugin and not the +indicator's, so removing the indicator does not break it, and that polling +stops when the drawer closes." +``` + +--- + +### Task 7: Remove the indicator, record the traps, final check + +**Files:** +- Modify: `desktop/README.md` (the indicator removal note) +- Modify: `AGENTS.md` (per-component notes) +- Modify outside the repo: `~/.config/hypr/sections/autostart.lua` (user applies) + +**Interfaces:** +- Consumes: the finished module. +- Produces: nothing executable in the repo. + +- [ ] **Step 1: Ask the user to remove the indicator** + +The live Hyprland config is not in this repo. Ask the user to remove the `kdeconnect-indicator` line from `~/.config/hypr/sections/autostart.lua` and kill the running indicator. The system-wide `kdeconnectd` autostart stays, so the daemon and the phone-to-PC clipboard keep working. Record in the task report what was removed and from which file. + +- [ ] **Step 2: Add the removal note to `desktop/README.md`** + +Append to the "Hyprland and waybar" section: + +```markdown +The drawer is the only KDE Connect surface, so the `kdeconnect-indicator` +autostart line was removed in the same change. The `kdeconnectd` daemon is a +separate system autostart and stays; the phone-to-PC clipboard is the daemon's, +not the indicator's. +``` + +- [ ] **Step 3: Add the traps to `AGENTS.md`** + +Append these bullets to the per-component notes list: + +```markdown +- **Quickshell 0.3.1 has no generic D-Bus module**, only `Quickshell.DBusMenu`. + KDE Connect state therefore comes from `qdbus6` shell-outs, not a binding, + which means there is no push and the drawer polls 5s while it is open. The + poll is gated on the tile's lifetime, since the tile exists exactly while the + drawer panel is loaded. +- **`kdeconnectd` and `kdeconnect-indicator` are separate.** The daemon runs + from a system autostart; the indicator was started from the live Hyprland + config. The phone-to-PC clipboard is the daemon's clipboard plugin + (`KSystemClipboard`), so removing the indicator does not touch it. +- **The KDE Connect battery plugin is not on every device.** `qdbus6` errors on + `.../devices/<id>/battery` for a device without it, so an absent battery is an + empty field, never a zero. +- **`qdbus` resolves a bare `org.kde.kdeconnect.device.<name>` as either a + property or a method**, so `name`, `isReachable` and the `verificationKey()` + method all use the same call shape. +``` + +- [ ] **Step 4: Full smoke check and process count** + +```bash +timeout 8 qs -p ./desktop 2>&1 | grep -E 'ERROR|TypeError|ReferenceError|is not defined|Cannot assign|Unable to assign' ; echo "rc=$?" +pgrep -cx qs +``` + +Expected: no error lines; `pgrep` reports the shells the user has running, checked only after the smoke check returns. Never conclude anything from a `pgrep` issued in a later tool call against a detached process. + +- [ ] **Step 5: Run both script tests** + +```bash +bash desktop/modules/kdeconnect/test-kdeconnect-state.sh +bash desktop/modules/mail/test-mail-notify.sh +``` + +Expected: `5 passed, 0 failed`, then the mail suite unchanged at 16 of 16. + +- [ ] **Step 6: Verify the glyph bytes** + +```bash +grep -nP '[\x{E000}-\x{F8FF}]' desktop/modules/kdeconnect/*.qml ; echo "rc=$?" +grep -n '\\u' desktop/modules/kdeconnect/*.qml +``` + +Expected: the first grep finds nothing (`rc=1`), so no raw private-use byte landed in the source; the second shows the two ASCII escapes `\uf10b` and `\uf109`. The committed source must contain the escape, not the codepoint. + +- [ ] **Step 7: Ask the user for the final visual pass** + +Ask the user to confirm: seven tiles in order with KDE Connect fourth; the tile shows the phone and battery, `Offline` when it is away; the page lists devices, refreshes, rings, sends the clipboard, shares a file and mounts the filesystem; pairing works in both directions with the key shown; and the phone-to-PC clipboard direction, tested once by sending from the phone and pasting on the PC. + +- [ ] **Step 8: Commit** + +```bash +git add desktop/README.md AGENTS.md +git commit -m "docs: record the KDE Connect traps + +No D-Bus module means shell-outs and no push; the daemon and the +indicator are separate; the battery plugin is not on every device; and +qdbus reads properties and methods through one call shape." +``` diff --git a/docs/superpowers/plans/2026-09-14-network-bluetooth.md b/docs/superpowers/plans/2026-09-14-network-bluetooth.md new file mode 100644 index 0000000..f5e314d --- /dev/null +++ b/docs/superpowers/plans/2026-09-14-network-bluetooth.md @@ -0,0 +1,1361 @@ +# Network and Bluetooth Modules 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:** Add a `network` module (wired + wifi) and a `bluetooth` module to the `desktop/` drawer, using the native Quickshell backends. + +**Architecture:** Two `Module` objects under `desktop/modules/`, each with a live tile and a full-height page, following the existing contract exactly. All state and actions bind to the `Quickshell.Networking` and `Quickshell.Bluetooth` singletons. The only non-native piece is pairing, which shells out to `bluetoothctl` in `Pairing.qml` because Quickshell ships no BlueZ agent. + +**Tech Stack:** Quickshell 0.3.1, Qt6 QML, NetworkManager D-Bus, BlueZ D-Bus, `bluetoothctl` for pairing only. + +**Spec:** `docs/superpowers/specs/2026-09-14-network-bluetooth-design.md` + +## Global Constraints + +- Quickshell 0.3.1, Qt6 QML. Run configs with `qs -p ./desktop`. The running process is `qs`: `pkill -x qs`, `pgrep -cx qs`, never `pkill -f` (it kills the calling shell). +- GPLv2 only. Every new `.qml` file begins with this exact header, no exceptions: + +```qml +// Copyright (C) 2026 Danilo M. <danix@danix.xyz> +// +// This program is free software; you can redistribute it and/or modify +// it under the terms of the GNU General Public License version 2 as +// published by the Free Software Foundation. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +``` + +- Module files live under `desktop/modules/<name>/` and reference root types (`Module`, `Page`, `Button`, `Theme`), so each uses `import "../.."`. +- Native backends only. `bluetoothctl` appears in `Pairing.qml` and nowhere else. +- Both modules `alwaysActive: true`. +- Grid order in `shell.qml`: `Sound, Network, Bluetooth, Mail, Appearance, Machines`. +- Hidden-network join is dropped: `typeof NMSettings === "undefined"` in QML scope, so no profile can be built from scratch. Do not add a code path for it. +- No em dashes anywhere. No home paths in committed files. Nerd Font glyphs are written as `\uXXXX` and their bytes verified with `git diff`. +- QML's JS engine has no `String.matchAll`; use `exec` loops if parsing is ever needed. +- Reusing a `Process` needs `running = false` immediately before `running = true`. + +## Verified API Facts (probed on this machine, 2026-09-14) + +Do not re-probe these; they are measured, not assumed. + +- Both singletons are empty/false for the first ~2s after launch, then populate. Bindings self-correct; never cache a first paint. +- `Networking.devices` and `Bluetooth.devices` are `ObjectModel`s. Iterate `.values`, which is a plain JS array. `Repeater { model: <ObjectModel> }` also works directly. +- `DeviceType.Wifi === 1`, `DeviceType.Wired === 2`. `ConnectionState.Connected === 2`, `ConnectionState.Disconnected === 4`. `WifiSecurityType.Open === 10`, `Opaque` = `Owe`. +- `signalStrength` is `0..1`, not a percentage. +- `WifiDevice.scannerEnabled` and `BluetoothAdapter.discovering` are writable and start a scan. +- Network methods present: `connect()`, `connectWithPsk(psk)`, `connectWithSettings()`, `disconnect()`, `forget()`, signal `connectionFailed`. Bluetooth methods present: `pair()`, `cancelPair()`, `connect()`, `disconnect()`, `forget()`. +- Live state used by the smoke checks: `eth0` wired connected, `wlan0` wifi disconnected, adapter on with four paired devices. +- Smoke-check command, harness owns the process and the log is read, never a later `pgrep`: + +```bash +timeout 8 qs -p ./desktop 2>&1 | grep -E 'ERROR|TypeError|ReferenceError|is not defined|Cannot assign|Unable to assign' && echo "ERRORS ABOVE" || echo "clean" +``` + +Expected: `clean`. This briefly starts a second drawer instance; it dies with the timeout. + +--- + +### Task 1: Network module, tile, and wired page + +**Files:** +- Create: `desktop/modules/network/NetworkModule.qml` +- Create: `desktop/modules/network/NetworkTile.qml` +- Create: `desktop/modules/network/NetworkPage.qml` +- Modify: `desktop/shell.qml` (imports and module registry) + +**Interfaces:** +- Consumes: the `Module`, `Page`, `Button`, `Theme` types, `Quickshell.Networking`. +- Produces: `NetworkModule` exposing `wiredDevices`, `wiredUp`, `wifiDevice`, `wifiUp`, `wifiNetworks`, `wifiNetworksSorted`, `wifiSsid`, `wiredName`, `wifiOn`, `error`, `connect(net)`, `connectWithPsk(net, psk)`, `disconnectNetwork(net)`, `forget(net)`, `notify(title, body)`. Task 2 uses all of these. The page type is `NetworkPage { net: ... }`; the tile type is `NetworkTile { net: ... }`. + +- [ ] **Step 1: Create `NetworkModule.qml`** + +```qml +// <GPLv2 header> + +import Quickshell +import Quickshell.Networking +import Quickshell.Io +import QtQuick +import "../.." + +// Always active: the backends push and there is no poll to gate, so the +// state the tile shows is live from launch. Referencing Networking here is +// also what instantiates the singleton at shell start. +Module { + id: mod + + name: "network" + label: "Network" + alwaysActive: true + + // .values on the device ObjectModel; empty until the backend is ready, + // roughly two seconds in. Bindings repaint when it arrives. + readonly property var devices: Networking.devices ? Networking.devices.values : [] + readonly property var wiredDevices: devices.filter(d => d.type === DeviceType.Wired) + readonly property var wifiDevice: devices.find(d => d.type === DeviceType.Wifi) ?? null + + readonly property bool wiredUp: wiredDevices.some(d => d.connected) + readonly property bool wifiUp: wifiDevice ? wifiDevice.connected : false + readonly property bool wifiOn: Networking.wifiEnabled + readonly property var wifiNetworks: wifiDevice && wifiDevice.networks ? wifiDevice.networks.values : [] + + readonly property string wifiSsid: { + const n = wifiNetworks.find(x => x.connected); + return n ? n.name : ""; + } + readonly property string wiredName: { + const d = wiredDevices.find(x => x.connected); + return d ? d.name : ""; + } + + // Connected first, then known, then strongest. Kept here so both the + // tile and the page read the same order. + readonly property var wifiNetworksSorted: { + const arr = wifiNetworks.slice(); + arr.sort((a, b) => + ((b.connected ? 1 : 0) - (a.connected ? 1 : 0)) || + ((b.known ? 1 : 0) - (a.known ? 1 : 0)) || + ((b.signalStrength ?? 0) - (a.signalStrength ?? 0))); + return arr; + } + + // The icon follows the active link, live, with no contract change: the + // drawer reads this property like any other. + icon: mod.wiredUp ? "\uf796" : (mod.wifiUp ? "\uf1eb" : "\uf05e") + + // The network a connect is pending on, so connectionFailed can be caught. + property var pendingNetwork: null + property string error: "" + + function connect(net) { + mod.pendingNetwork = net; + mod.error = ""; + net.connect(); + } + + function connectWithPsk(net, psk) { + mod.pendingNetwork = net; + mod.error = ""; + net.connectWithPsk(psk); + } + + function disconnectNetwork(net) { if (net) net.disconnect(); } + function forget(net) { if (net) net.forget(); } + + // A failure after the drawer closed would otherwise go unseen, so it also + // raises a notification. Same cached-Process shape as VmModule. + function notify(title, body) { + notifyProc.command = ["notify-send", "--app-name=network", + "--urgency=critical", "--icon=error", title, body]; + notifyProc.running = false; + notifyProc.running = true; + } + + property Process notifyProc: Process {} + + // A typed property, not a bare child: Module is a QtObject with no default + // property, so a bare child object fails to load. Same as VmModule. + property Connections conn: Connections { + target: mod.pendingNetwork + function onConnectionFailed(reason) { + mod.error = "Connection failed: " + reason; + mod.notify("Network", mod.error); + mod.pendingNetwork = null; + } + } + + tileContent: Component { NetworkTile { net: mod } } + + page: Component { + Page { + title: "Network" + NetworkPage { width: parent.width; net: mod } + } + } +} +``` + +- [ ] **Step 2: Create `NetworkTile.qml`** + +```qml +// <GPLv2 header> + +import QtQuick +import "../.." + +// The state line under the tile label. One line per active link: the API +// exposes no route metric, so when both wired and wifi are up the tile shows +// both rather than guessing which one carries traffic. +Column { + required property var net + + width: parent ? parent.width : implicitWidth + spacing: 2 + + Text { + width: parent.width + elide: Text.ElideRight + visible: net.wiredUp + text: net.wiredName + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } + color: Theme.text + } + + Text { + width: parent.width + elide: Text.ElideRight + visible: net.wifiUp + text: net.wifiSsid + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } + color: Theme.text + } + + Text { + width: parent.width + elide: Text.ElideRight + visible: !net.wiredUp && !net.wifiUp + text: net.wifiOn ? "Disconnected" : "Off" + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } + color: Theme.subtext + } +} +``` + +- [ ] **Step 3: Create `NetworkPage.qml` with the wired section only** + +```qml +// <GPLv2 header> + +import Quickshell.Networking +import QtQuick +import "../.." + +// The wired half. Sibling of NetworkPage's wifi half, added in Task 2. +Column { + id: page + + required property var net + signal back + + spacing: 14 + + function stateLabel(s) { + return s === ConnectionState.Connected ? "connected" + : s === ConnectionState.Connecting ? "connecting" + : s === ConnectionState.Disconnecting ? "disconnecting" + : "disconnected"; + } + + Text { + visible: page.net.wiredDevices.length > 0 + text: "Wired" + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2; bold: true } + color: Theme.subtext + } + + Repeater { + model: page.net.wiredDevices + + Rectangle { + required property var modelData + + width: page.width + implicitHeight: wiredText.implicitHeight + 16 + radius: 8 + color: Qt.alpha(Theme.surface, 0.35) + + Text { + id: wiredText + anchors { left: parent.left; leftMargin: 10; verticalCenter: parent.verticalCenter } + text: modelData.name + " " + page.stateLabel(modelData.state) + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 } + color: modelData.connected ? Theme.text : Theme.subtext + } + + Button { + anchors { right: parent.right; rightMargin: 8; verticalCenter: parent.verticalCenter } + visible: modelData.connected + text: "Disconnect" + onClicked: page.net.disconnectNetwork(modelData) + } + } + } +} +``` + +- [ ] **Step 4: Register the module in `shell.qml`** + +Add the import beside the others (after `import "modules/mail"`): + +```qml +import "modules/network" +``` + +Add the module to the registry between Sound and Mail: + +```qml + modules: [ + SoundModule {}, + NetworkModule {}, + MailModule {}, + AppearanceModule {}, + VmModule {}, + ] +``` + +- [ ] **Step 5: Smoke-check the config loads** + +Run the smoke-check command from Global Constraints. Expected: `clean`. The clean marker is deliberate: grep exits 1 when it finds nothing, so do not read that as failure. + +- [ ] **Step 6: Ask the user to confirm visually** + +The running shell hot-reloads on save. Ask the user: the drawer shows a Network tile whose line reads `eth0` (or the SSID), and clicking it opens a Network page listing the wired device as connected with a Disconnect button. Confirm the icon renders as a glyph, not a box. + +- [ ] **Step 7: Commit** + +```bash +git add desktop/modules/network/NetworkModule.qml desktop/modules/network/NetworkTile.qml desktop/modules/network/NetworkPage.qml desktop/shell.qml +git commit -m "feat(desktop): network module, live tile and wired page + +Wired first because it is the simpler half and proves the module wiring. +Native Quickshell.Networking, no polling: the singletons push, and +alwaysActive is true with no Service.qml because there is nothing to gate. + +The tile shows one line per active link rather than picking a primary, +since the API exposes no route metric and guessing wrong would be worse +than showing both." +``` + +--- + +### Task 2: Network wifi section + +**Files:** +- Create: `desktop/modules/network/NetworkRow.qml` +- Modify: `desktop/modules/network/NetworkPage.qml` (replace with wired + wifi) + +**Interfaces:** +- Consumes: everything Task 1's `NetworkModule` produces. +- Produces: `NetworkRow { net: ..., entry: ... }`, a wifi list row that owns its own password-prompt state. + +- [ ] **Step 1: Create `NetworkRow.qml`** + +```qml +// <GPLv2 header> + +import Quickshell.Networking +import QtQuick +import "../.." + +// One wifi network. A row click connects; a secured unknown network reveals +// an inline password field instead. The click area is declared first so the +// buttons and the field, declared later, sit above it. +Rectangle { + id: row + + required property var net + required property var entry + property bool prompting: false + property string psk: "" + + implicitHeight: col.implicitHeight + 16 + radius: 8 + color: entry.connected ? Qt.alpha(Theme.accent, 0.10) : Qt.alpha(Theme.surface, 0.35) + + MouseArea { + anchors.fill: parent + enabled: !row.prompting + cursorShape: Qt.PointingHandCursor + onClicked: row.activate() + } + + Column { + id: col + anchors { left: parent.left; right: parent.right; top: parent.top; margins: 10 } + spacing: 8 + + Item { + width: parent.width + implicitHeight: Math.max(nameText.implicitHeight, actions.implicitHeight) + + Text { + id: nameText + anchors { left: parent.left; right: actions.left; rightMargin: 8; verticalCenter: parent.verticalCenter } + elide: Text.ElideRight + text: row.entry.name + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2; bold: row.entry.connected } + color: row.entry.connected ? Theme.accent : Theme.text + } + + Row { + id: actions + anchors { right: parent.right; verticalCenter: parent.verticalCenter } + spacing: 6 + + Text { + anchors.verticalCenter: parent.verticalCenter + visible: row.secured() + text: "\uf023" + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } + color: Theme.overlay + } + + Text { + anchors.verticalCenter: parent.verticalCenter + text: Math.round((row.entry.signalStrength ?? 0) * 100) + "%" + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } + color: Theme.overlay + } + + Button { + visible: row.entry.connected + text: "Disconnect" + onClicked: row.net.disconnectNetwork(row.entry) + } + + Button { + visible: !row.entry.connected && row.entry.known + text: "Forget" + danger: true + onClicked: row.net.forget(row.entry) + } + } + } + + Row { + width: parent.width + spacing: 8 + visible: row.prompting + + TextInput { + id: pskField + width: parent.width - connectBtn.width - parent.spacing + echoMode: TextInput.Password + text: row.psk + onTextChanged: row.psk = text + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 } + color: Theme.text + Component.onCompleted: forceActiveFocus() + Keys.onReturnPressed: row.submit() + Keys.onEscapePressed: { row.prompting = false; row.psk = ""; } + + Rectangle { + anchors.fill: parent + anchors.margins: -5 + z: -1 + radius: 6 + color: Qt.alpha(Theme.surface, 0.9) + border.width: 1 + border.color: Qt.alpha(Theme.text, 0.15) + } + } + + Button { + id: connectBtn + text: "Connect" + onClicked: row.submit() + } + } + } + + function secured() { + return entry.security !== WifiSecurityType.Open && entry.security !== WifiSecurityType.Owe; + } + + function activate() { + if (entry.connected) { net.disconnectNetwork(entry); return; } + if (entry.known || !secured()) { net.connect(entry); return; } + row.prompting = true; + pskField.forceActiveFocus(); + } + + function submit() { + if (row.psk === "") return; + net.connectWithPsk(entry, row.psk); + row.psk = ""; + row.prompting = false; + } +} +``` + +- [ ] **Step 2: Replace `NetworkPage.qml` with wired + wifi** + +```qml +// <GPLv2 header> + +import Quickshell.Networking +import QtQuick +import QtQuick.Controls +import "../.." + +Column { + id: page + + required property var net + signal back + + spacing: 14 + + function stateLabel(s) { + return s === ConnectionState.Connected ? "connected" + : s === ConnectionState.Connecting ? "connecting" + : s === ConnectionState.Disconnecting ? "disconnecting" + : "disconnected"; + } + + // --- Wired --- + + Text { + visible: page.net.wiredDevices.length > 0 + text: "Wired" + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2; bold: true } + color: Theme.subtext + } + + Repeater { + model: page.net.wiredDevices + + Rectangle { + required property var modelData + + width: page.width + implicitHeight: wiredText.implicitHeight + 16 + radius: 8 + color: Qt.alpha(Theme.surface, 0.35) + + Text { + id: wiredText + anchors { left: parent.left; leftMargin: 10; verticalCenter: parent.verticalCenter } + text: modelData.name + " " + page.stateLabel(modelData.state) + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 } + color: modelData.connected ? Theme.text : Theme.subtext + } + + Button { + anchors { right: parent.right; rightMargin: 8; verticalCenter: parent.verticalCenter } + visible: modelData.connected + text: "Disconnect" + onClicked: page.net.disconnectNetwork(modelData) + } + } + } + + Rectangle { width: page.width; height: 1; color: Qt.alpha(Theme.text, 0.12) } + + // --- Wifi --- + + Item { + width: page.width + implicitHeight: Math.max(wifiLabel.implicitHeight, wifiSwitch.implicitHeight) + + Text { + id: wifiLabel + anchors.verticalCenter: parent.verticalCenter + text: "Wi-Fi" + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 1; bold: true } + color: Theme.text + } + + Switch { + id: wifiSwitch + anchors { right: parent.right; verticalCenter: parent.verticalCenter } + enabled: Networking.wifiHardwareEnabled + checked: page.net.wifiOn + onToggled: Networking.wifiEnabled = checked + } + } + + Text { + width: page.width + visible: !page.net.wifiOn + text: "Wi-Fi off" + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 } + color: Theme.subtext + } + + Row { + width: page.width + visible: page.net.wifiOn + spacing: 8 + + Button { + text: page.net.wifiDevice && page.net.wifiDevice.scannerEnabled ? "Scanning…" : "Scan" + onClicked: if (page.net.wifiDevice) page.net.wifiDevice.scannerEnabled = true + } + } + + Repeater { + model: page.net.wifiOn ? page.net.wifiNetworksSorted : [] + + NetworkRow { + required property var modelData + net: page.net + entry: modelData + width: page.width + } + } + + Text { + width: page.width + visible: page.net.error !== "" + wrapMode: Text.Wrap + text: page.net.error + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 } + color: Theme.red + } +} +``` + +- [ ] **Step 3: Smoke-check the config loads** + +Run the Global Constraints smoke-check. Expected: `clean`. The clean marker is deliberate: grep exits 1 when it finds nothing, so do not read that as failure. + +- [ ] **Step 4: Ask the user to confirm visually** + +Ask the user: open the Network page, toggle Wi-Fi off and on, press Scan and watch the list grow. Click a known network to connect and a secured unknown one to see the password field. Confirm a wrong password shows the red error line and a right one connects. Confirm the tile shows the SSID after connecting. + +- [ ] **Step 5: Commit** + +```bash +git add desktop/modules/network/NetworkRow.qml desktop/modules/network/NetworkPage.qml +git commit -m "feat(desktop): network wifi page + +Radio switch, scan, and a list ordered connected, known, strongest. A +secured unknown network reveals an inline password field rather than +opening a popup, since a QtQuick.Controls popup is a separate window that +positions badly on this layer surface, the same reason the sound page +expands in place. + +Connection failures come through the Network.connectionFailed signal and +show inline; the same failure also notifies, for the case where the +drawer has closed by the time it lands." +``` + +--- + +### Task 3: Network README and desktop README + +**Files:** +- Create: `desktop/modules/network/README.md` +- Modify: `desktop/README.md` (module list and grid order) + +**Interfaces:** +- Consumes: nothing. Pure documentation. +- Produces: nothing executable. + +- [ ] **Step 1: Create `desktop/modules/network/README.md`** + +```markdown +# network + +Wired and wifi in one module, because a connected `eth0` must not mask the +wifi state. + +The tile shows one line per active link. When both wired and wifi are up it +shows both: the API exposes no route metric, so it does not guess which link +carries traffic. + +The page lists managed wired devices, then a wifi radio switch and list. A +row click connects; a secured network that is not yet known reveals an inline +password field. Known networks offer Forget, the connected one Disconnect. + +## Service lifetime + +`alwaysActive: true`, and truthfully: the `Quickshell.Networking` backend +pushes and there is no poll to gate. There is no `Service.qml`, unlike +sound's `Service.qml` or vm's `Virsh.qml`, because there is no loop to own. +Referencing `Networking` in `NetworkModule.qml` is what instantiates it at +shell start. + +The backend is empty for about two seconds after launch and then fills. Every +binding repaints when it arrives; nothing caches the first paint. + +## Not built + +Joining a hidden network. It would need a NetworkManager settings profile +built from scratch, and `NMSettings` is not in QML scope (`typeof` is +`undefined`), so there is no way to construct one from QML. Enterprise and +802.1x networks are out for the same class of reason. + +Disconnect and forget failures. Neither backend exposes a failure signal for +them, so a failure shows only as the list not changing. Noted rather than +faked. +``` + +- [ ] **Step 2: Update `desktop/README.md` (network only; Bluetooth lines land in Task 6)** + +In the module list, add the one line for the module that exists at this commit: + +```markdown + modules/network/ wired and wifi, radio, scan, join, forget +``` + +Change the geometry paragraph's grid sentence from "three columns at 180px minimum, wrapping and adding rows up to a 3x3 ceiling for the modules that exist" to name the current order: + +```markdown +The bottom is a fixed, never-scrolled `Flow` grid: three columns at 180px +minimum, wrapping and adding rows up to a 3x3 ceiling for the modules that +exist, in the order Sound, Network, Mail, Appearance, Machines. +``` + +Then fix the three sentences this module makes stale, in the same file: + +- the line "Sound, mail and vm each carry their own README" becomes "Sound, mail, vm and network each carry their own README" +- the line "Sound, mail and vm each add a service and a page on top of that same shape" becomes "Sound, mail, vm and network each add a service and a page on top of that same shape" +- the line "Sound and mail are `alwaysActive: true`; vm is false." becomes "Sound, mail and network are `alwaysActive: true`; vm is false." + +- [ ] **Step 3: Commit** + +```bash +git add desktop/modules/network/README.md desktop/README.md +git commit -m "docs(desktop): document the network module" +``` + +--- + +### Task 4: Bluetooth module, tile, and page + +**Files:** +- Create: `desktop/modules/bluetooth/BluetoothModule.qml` +- Create: `desktop/modules/bluetooth/BluetoothTile.qml` +- Create: `desktop/modules/bluetooth/BluetoothRow.qml` +- Create: `desktop/modules/bluetooth/BluetoothPage.qml` +- Modify: `desktop/shell.qml` (imports and module registry) + +**Interfaces:** +- Consumes: `Module`, `Page`, `Button`, `Theme`, `Quickshell.Bluetooth`. +- Produces: `BluetoothModule` exposing `adapter`, `devices`, `connectedDevices`, `pairedDevices`, `availableDevices`, `anyConnected`, `notify(title, body)`. Task 5 adds `pairing` and the available-devices section. Tile type `BluetoothTile { bt: ... }`, row type `BluetoothRow { bt: ..., device: ... }`. + +- [ ] **Step 1: Create `BluetoothModule.qml`** + +```qml +// <GPLv2 header> + +import Quickshell +import Quickshell.Bluetooth +import Quickshell.Io +import QtQuick +import "../.." + +// Always active: the BlueZ backend pushes and there is no poll to gate. +// Referencing Bluetooth here instantiates it at shell start. +Module { + id: mod + + name: "bluetooth" + label: "Bluetooth" + alwaysActive: true + + readonly property var adapter: Bluetooth.defaultAdapter + readonly property var devices: Bluetooth.devices ? Bluetooth.devices.values : [] + readonly property var connectedDevices: devices.filter(d => d.connected) + readonly property var pairedDevices: devices.filter(d => d.paired && !d.connected) + readonly property var availableDevices: devices.filter(d => !d.paired) + + readonly property bool anyConnected: connectedDevices.length > 0 + + // Null for the first ~2s, then filled. Safe navigation everywhere. + icon: mod.adapter && mod.adapter.enabled ? "\uf293" : "\uf05e" + + function notify(title, body) { + notifyProc.command = ["notify-send", "--app-name=bluetooth", + "--urgency=critical", "--icon=error", title, body]; + notifyProc.running = false; + notifyProc.running = true; + } + + property Process notifyProc: Process {} + + tileContent: Component { BluetoothTile { bt: mod } } + + page: Component { + Page { + title: "Bluetooth" + BluetoothPage { width: parent.width; bt: mod } + } + } +} +``` + +- [ ] **Step 2: Create `BluetoothTile.qml`** + +```qml +// <GPLv2 header> + +import QtQuick +import "../.." + +Text { + required property var bt + + width: parent ? parent.width : implicitWidth + elide: Text.ElideRight + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } + color: bt.anyConnected ? Theme.text : Theme.subtext + text: !bt.adapter || !bt.adapter.enabled ? "Off" + : bt.connectedDevices.length === 0 ? "No devices" + : bt.connectedDevices.length === 1 ? bt.connectedDevices[0].name + : bt.connectedDevices.length + " connected" +} +``` + +- [ ] **Step 3: Create `BluetoothRow.qml` (paired and connected actions only; Pair is Task 5)** + +```qml +// <GPLv2 header> + +import QtQuick +import "../.." + +// One tracked device. The actions shown depend on its state: connected, +// paired-but-idle, or found and not yet paired. +Rectangle { + id: row + + required property var bt + required property var device + + implicitHeight: col.implicitHeight + 16 + radius: 8 + color: device.connected ? Qt.alpha(Theme.accent, 0.10) : Qt.alpha(Theme.surface, 0.35) + + Column { + id: col + anchors { left: parent.left; right: parent.right; top: parent.top; margins: 10 } + spacing: 8 + + Item { + width: parent.width + implicitHeight: Math.max(nameText.implicitHeight, actions.implicitHeight) + + Text { + id: nameText + anchors { left: parent.left; right: actions.left; rightMargin: 8; verticalCenter: parent.verticalCenter } + elide: Text.ElideRight + text: row.device.name || row.device.deviceName || row.device.address + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2; bold: row.device.connected } + color: row.device.connected ? Theme.accent : Theme.text + } + + Row { + id: actions + anchors { right: parent.right; verticalCenter: parent.verticalCenter } + spacing: 6 + + Text { + anchors.verticalCenter: parent.verticalCenter + visible: row.device.batteryAvailable ?? false + text: Math.round((row.device.battery ?? 0) * 100) + "%" + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } + color: Theme.overlay + } + + Button { + visible: !row.device.connected && row.device.paired + text: "Connect" + onClicked: row.device.connect() + } + + Button { + visible: row.device.connected + text: "Disconnect" + onClicked: row.device.disconnect() + } + + Button { + visible: row.device.paired + text: row.device.trusted ? "Untrust" : "Trust" + onClicked: row.device.trusted = !row.device.trusted + } + + Button { + visible: row.device.paired + text: "Forget" + danger: true + onClicked: row.device.forget() + } + } + } + } +} +``` + +- [ ] **Step 4: Create `BluetoothPage.qml` (adapter, connected, paired; available is Task 5)** + +```qml +// <GPLv2 header> + +import QtQuick +import QtQuick.Controls +import "../.." + +Column { + id: page + + required property var bt + signal back + + spacing: 14 + + // --- Adapter --- + + Item { + width: page.width + implicitHeight: Math.max(btLabel.implicitHeight, powerSwitch.implicitHeight) + + Text { + id: btLabel + anchors.verticalCenter: parent.verticalCenter + text: "Bluetooth" + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 1; bold: true } + color: Theme.text + } + + Switch { + id: powerSwitch + anchors { right: parent.right; verticalCenter: parent.verticalCenter } + checked: page.bt.adapter ? page.bt.adapter.enabled : false + onToggled: if (page.bt.adapter) page.bt.adapter.enabled = checked + } + } + + Text { + width: page.width + visible: !(page.bt.adapter && page.bt.adapter.enabled) + text: "Bluetooth off" + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 } + color: Theme.subtext + } + + Item { + width: page.width + visible: page.bt.adapter && page.bt.adapter.enabled + implicitHeight: Math.max(visLabel.implicitHeight, visSwitch.implicitHeight) + + Text { + id: visLabel + anchors.verticalCenter: parent.verticalCenter + text: "Visible" + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 } + color: Theme.text + } + + Switch { + id: visSwitch + anchors { right: parent.right; verticalCenter: parent.verticalCenter } + checked: page.bt.adapter ? page.bt.adapter.discoverable : false + onToggled: if (page.bt.adapter) page.bt.adapter.discoverable = checked + } + } + + Row { + width: page.width + visible: page.bt.adapter && page.bt.adapter.enabled + spacing: 8 + + Button { + text: page.bt.adapter && page.bt.adapter.discovering ? "Stop scan" : "Scan" + onClicked: if (page.bt.adapter) page.bt.adapter.discovering = !page.bt.adapter.discovering + } + } + + // --- Connected --- + + Text { + width: page.width + visible: page.bt.connectedDevices.length > 0 + text: "Connected" + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2; bold: true } + color: Theme.subtext + } + + Repeater { + model: page.bt.connectedDevices + + BluetoothRow { + required property var modelData + bt: page.bt + device: modelData + width: page.width + } + } + + // --- Paired --- + + Text { + width: page.width + visible: page.bt.pairedDevices.length > 0 + text: "Paired" + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2; bold: true } + color: Theme.subtext + } + + Repeater { + model: page.bt.pairedDevices + + BluetoothRow { + required property var modelData + bt: page.bt + device: modelData + width: page.width + } + } +} +``` + +- [ ] **Step 5: Register the module in `shell.qml`** + +Add `import "modules/bluetooth"` beside the network import, and add `BluetoothModule {}` after `NetworkModule {}`. Final registry: + +```qml + modules: [ + SoundModule {}, + NetworkModule {}, + BluetoothModule {}, + MailModule {}, + AppearanceModule {}, + VmModule {}, + ] +``` + +- [ ] **Step 6: Smoke-check the config loads** + +Run the Global Constraints smoke-check. Expected: `clean`. The clean marker is deliberate: grep exits 1 when it finds nothing, so do not read that as failure. + +- [ ] **Step 7: Ask the user to confirm visually** + +Ask the user: the Bluetooth tile shows `Off` or a device name, its icon is a glyph. The page toggles the adapter, shows the four paired devices with Connect/Trust/Forget, and the Scan button starts discovery. Confirm trust toggling round-trips. + +- [ ] **Step 8: Commit** + +```bash +git add desktop/modules/bluetooth/BluetoothModule.qml desktop/modules/bluetooth/BluetoothTile.qml desktop/modules/bluetooth/BluetoothRow.qml desktop/modules/bluetooth/BluetoothPage.qml desktop/shell.qml +git commit -m "feat(desktop): bluetooth module, tile and page + +Native Quickshell.Bluetooth for adapter power, discoverable, scan, +connect, disconnect, trust and forget. Pairing is separate, because +quickshell ships no BlueZ agent. + +The adapter is null for the first ~2s, so every access is navigated +safely rather than assuming a first paint." +``` + +--- + +### Task 5: Bluetooth pairing + +**Files:** +- Create: `desktop/modules/bluetooth/Pairing.qml` +- Modify: `desktop/modules/bluetooth/BluetoothModule.qml` (add `pairing`, handle completion) +- Modify: `desktop/modules/bluetooth/BluetoothPage.qml` (add the Available section and the pair status line) + +**Interfaces:** +- Consumes: `BluetoothModule.devices`, `BluetoothModule.notify`. +- Produces: `Pairing` with `address`, `error`, `busy`, signal `finished(address, ok)`, and `pair(address)`. Progress is shown from `busy` plus `address`; there is no separate `status` property. + +- [ ] **Step 1: Create `Pairing.qml`** + +```qml +// <GPLv2 header> + +import Quickshell.Io +import QtQuick + +// The one action that is not native. Quickshell ships no BlueZ agent and no +// generic D-Bus module, so a device that needs a passkey or PIN confirmed +// cannot be paired from QML. bluetoothctl registers its own agent and handles +// the prompt, so pairing goes through it and everything else stays native. +// +// ponytail: one-shot bluetoothctl with a timeout, keyed on the exit code. If +// a device needs input bluetoothctl cannot auto-confirm, pairing times out +// and the error surfaces; upgrade to driving interactive bluetoothctl only +// when a real device needs it. +QtObject { + id: root + + property string address: "" + property string error: "" + readonly property bool busy: proc.running + + signal finished(string address, bool ok) + + function pair(address) { + root.address = address; + root.error = ""; + proc.command = ["bluetoothctl", "--timeout", "20", "pair", address]; + proc.running = false; + proc.running = true; + } + + property Process proc: Process { + stdout: StdioCollector { id: out } + stderr: StdioCollector { id: err } + onExited: code => { + const ok = code === 0; + if (!ok) root.error = (err.text.trim() || out.text.trim() || ("bluetoothctl exited " + code)); + root.finished(root.address, ok); + } + } +} +``` + +- [ ] **Step 2: Add `pairing` and completion handling to `BluetoothModule.qml`** + +Add the property beside `adapter`: + +```qml + readonly property Pairing pairing: Pairing {} +``` + +Add the completion handler beside `notifyProc`, as a typed property (a bare `Connections` child does not load on a `QtObject`; same reason as `NetworkModule`): + +```qml + // Pairing runs through bluetoothctl; on success connect natively, on + // failure notify, since the drawer may have closed by then. + property Connections pairingConn: Connections { + target: mod.pairing + function onFinished(address, ok) { + if (!ok) { + mod.notify("Bluetooth", "Pairing failed: " + mod.pairing.error); + return; + } + const d = mod.devices.find(x => x.address === address); + if (d) d.connect(); + } + } +``` + +- [ ] **Step 3: Add the Available section to `BluetoothPage.qml`** + +Append after the Paired repeater, before the closing brace of the Column: + +```qml + // --- Available (found while scanning, not yet paired) --- + + Text { + width: page.width + visible: page.bt.availableDevices.length > 0 + text: "Available" + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2; bold: true } + color: Theme.subtext + } + + Repeater { + model: page.bt.availableDevices + + Rectangle { + required property var modelData + + width: page.width + implicitHeight: availText.implicitHeight + 16 + radius: 8 + color: Qt.alpha(Theme.surface, 0.35) + + Text { + id: availText + anchors { left: parent.left; leftMargin: 10; verticalCenter: parent.verticalCenter } + width: parent.width - pairBtn.width - 28 + elide: Text.ElideRight + text: page.bt.pairing.busy && page.bt.pairing.address === modelData.address + ? "Pairing…" + : (modelData.name || modelData.deviceName || modelData.address) + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 } + color: Theme.text + } + + Button { + id: pairBtn + anchors { right: parent.right; rightMargin: 8; verticalCenter: parent.verticalCenter } + text: "Pair" + enabled: !page.bt.pairing.busy + onClicked: page.bt.pairing.pair(modelData.address) + } + } + } + + Text { + width: page.width + visible: page.bt.pairing.error !== "" + wrapMode: Text.Wrap + text: page.bt.pairing.error + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 } + color: Theme.red + } +``` + +- [ ] **Step 4: Smoke-check the config loads** + +Run the Global Constraints smoke-check. Expected: `clean`. The clean marker is deliberate: grep exits 1 when it finds nothing, so do not read that as failure. + +- [ ] **Step 5: Ask the user to confirm visually** + +Ask the user: put a Bluetooth device in pairing mode, press Scan, and Pair on it. Confirm pairing completes and the device moves to Connected. If a device needs a passkey, confirm the failure surfaces rather than hanging past the 20s timeout. + +- [ ] **Step 6: Commit** + +```bash +git add desktop/modules/bluetooth/Pairing.qml desktop/modules/bluetooth/BluetoothModule.qml desktop/modules/bluetooth/BluetoothPage.qml +git commit -m "feat(desktop): pair bluetooth devices + +Quickshell has no BlueZ agent and no generic D-Bus module, so a pairing +that needs a passkey confirmed cannot be done from QML. bluetoothctl +registers its own agent, so pairing is the one shell-out; adapter state, +scanning, connecting and forgetting all stay native. + +The timeout is the ceiling: a device bluetoothctl cannot auto-confirm +fails visibly rather than hanging the page." +``` + +--- + +### Task 6: Bluetooth README + +**Files:** +- Create: `desktop/modules/bluetooth/README.md` + +**Interfaces:** +- Consumes: nothing. Documentation only. + +- [ ] **Step 1: Create the README** + +```markdown +# bluetooth + +The adapter, its devices, and pairing, in one module. + +The tile names the connected device, or a count when several, `No devices` +when the adapter is on and idle, `Off` when it is down. + +The page carries the adapter power switch, a visible toggle, a scan toggle, +then the connected devices, the paired ones, and anything found while +scanning. A paired device offers Connect, Trust and Forget; an unpaired one +offers Pair. + +## Pairing is the one shell-out + +Quickshell ships no BlueZ pairing agent and no generic D-Bus module, so a +device that requires a passkey or PIN confirmation has no way to prompt from +QML. `Pairing.qml` runs `bluetoothctl --timeout 20 pair <address>`, whose own +agent handles the prompt. + +Everything else is native `Quickshell.Bluetooth`. The cost is that +`bluetoothctl` is the ceiling: a device it cannot auto-confirm times out and +the error shows, rather than pairing. `Pairing.qml` carries a `ponytail:` +comment naming that and the upgrade path. + +## Not built + +Connecting and forgetting report no failure signal in the BlueZ binding; only +pairing does, through the `bluetoothctl` exit code. A connect that fails +shows only as the device staying unconnected. + +## Service lifetime + +`alwaysActive: true`, and truthfully: the BlueZ backend pushes, there is no +poll to gate, and there is no `Service.qml`. Referencing `Bluetooth` in +`BluetoothModule.qml` instantiates it at shell start. + +The adapter is null and the device list empty for about two seconds after +launch. Every access navigates safely; nothing caches the first paint. +``` + +- [ ] **Step 2: Add the Bluetooth lines to `desktop/README.md`** + +Task 3 deliberately left these out so every commit stays self-consistent, since the module did not exist then. Now it does: + +- module list: add ` modules/bluetooth/ adapter, scan, pair, connect, forget, trust` +- grid sentence: extend the order to `Sound, Network, Bluetooth, Mail, Appearance, Machines` +- the README enumeration becomes "Sound, mail, vm, network and bluetooth each carry their own README" +- the service enumeration becomes "Sound, mail, vm, network and bluetooth each add a service and a page on top of that same shape" +- the alwaysActive line becomes "Sound, mail, network and bluetooth are `alwaysActive: true`; vm is false." + +- [ ] **Step 3: Commit** + +```bash +git add desktop/modules/bluetooth/README.md desktop/README.md +git commit -m "docs(desktop): document the bluetooth module" +``` + +--- + +### Task 7: Remove the waybar indicators, record the traps, final check + +**Files:** +- Modify: `desktop/README.md` (add a line that waybar's indicators were removed) +- Modify: `AGENTS.md` (add the new traps under Per-component notes) +- Modify outside the repo: the live waybar configuration (user applies) + +**Interfaces:** +- Consumes: the finished modules. +- Produces: nothing executable in the repo. + +- [ ] **Step 1: Ask the user to remove the waybar indicators** + +The live waybar config is not in this repo. Ask the user to remove waybar's own network and Bluetooth modules, since the drawer replaces them, and to reload waybar. Record in the task report which modules were removed and from which file, so the change is traceable. + +- [ ] **Step 2: Add the removal note to `desktop/README.md`** + +Append to the "Hyprland and waybar" section: + +```markdown +The drawer is the only place network and Bluetooth are managed, so waybar's +own network and Bluetooth indicators were removed in the same change. +``` + +- [ ] **Step 3: Add the traps to `AGENTS.md`** + +Append these bullets to the per-component notes list: + +```markdown +- **`Quickshell.Networking` and `Quickshell.Bluetooth` start empty.** Devices, + the adapter and the wifi radio all read empty or false for roughly two + seconds after launch, then populate. Bindings repaint when they arrive; + nothing may cache the first paint. Both device lists are `ObjectModel`s, so + iterate `.values`, which is a plain array. +- **`NMSettings` is not in QML scope.** `typeof NMSettings` is `undefined`, so + a NetworkManager profile cannot be built from scratch in QML. This is why + the network module cannot join a hidden network, and the same class of + limit blocks enterprise 802.1x. +- **Quickshell has no BlueZ pairing agent and no generic D-Bus module.** A + device needing a passkey or PIN confirmed cannot be paired from QML; + `bluetoothctl` registers its own agent, so pairing is the one shell-out in + the bluetooth module. +- **`WifiNetwork.signalStrength` is `0..1`, not a percentage.** The known + networks a scan has not refreshed report a cached `1`. +``` + +- [ ] **Step 4: Full smoke check and process count** + +```bash +timeout 8 qs -p ./desktop 2>&1 | grep -E 'ERROR|TypeError|ReferenceError|is not defined|Cannot assign|Unable to assign' ; echo "rc=$?" +pgrep -cx qs +``` + +Expected: `clean`; `pgrep` reports the shells the user has running (three on a normal session, plus the transient test instance only during the timeout window, so run the count after the smoke check returns). Never conclude anything from a `pgrep` issued in a later tool call against a detached process. + +- [ ] **Step 5: Confirm the mail oracle still passes** + +```bash +bash desktop/modules/mail/test-mail-notify.sh +``` + +Expected: all tests pass, unchanged from before this work. + +- [ ] **Step 6: Ask the user for the final visual pass** + +Ask the user to confirm: six tiles in order (Sound, Network, Bluetooth, Mail, Appearance, Machines); the network page handles wifi off, scan, join with a wrong then right password; both wired and wifi up makes the network tile show two lines; the bluetooth page powers on, scans, pairs, connects, trusts and forgets; failed actions notify when the drawer is closed. + +- [ ] **Step 7: Commit** + +```bash +git add desktop/README.md AGENTS.md +git commit -m "docs: record the network and bluetooth traps + +The native backends start empty for about two seconds, device lists are +ObjectModels, NMSettings is not in QML scope, and quickshell has no BlueZ +agent. Each one cost a probe to learn; writing them down is cheaper than +re-learning them." +``` |
