From dc8c94052e491f7cb1d1f8095afbcee574510d42 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Fri, 16 Oct 2026 09:45:41 +0200 Subject: docs(plan): user details implementation plan Co-Authored-By: Claude Opus 5.5 --- docs/superpowers/plans/2026-10-16-user-details.md | 1054 +++++++++++++++++++++ 1 file changed, 1054 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-16-user-details.md (limited to 'docs/superpowers') diff --git a/docs/superpowers/plans/2026-10-16-user-details.md b/docs/superpowers/plans/2026-10-16-user-details.md new file mode 100644 index 0000000..5b13b90 --- /dev/null +++ b/docs/superpowers/plans/2026-10-16-user-details.md @@ -0,0 +1,1054 @@ +# User Details Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** A drawer row greeting the login user, opening a page that edits name, email, avatar, language, location and git signing key, written where other programs read them. + +**Architecture:** `userctl` (bash) is the only reader and writer of the system stores: AccountsService over `busctl`, `~/.config/emaildefaults` via `kwriteconfig6`, git via `git config --global`. The QML side (`desktop/modules/user/`) calls it through one-shot `Process`es. `User.js` holds the pure helpers (parse, locale list, git name preview), node-tested. The drawer gains a header row and a `Module.tiled` flag so the module has a page and no tile. + +**Tech Stack:** bash, jq, busctl (elogind), kwriteconfig6, gpg, Quickshell 0.3.1 QML, node for the JS test. + +Spec: `docs/superpowers/specs/2026-10-16-user-details-design.md`. + +--- + +## Before starting + +- The working tree may hold uncommitted work from other tasks (the perf page). Stage only the files each task names. +- No real personal data in any committed file, test fixture or message: use `Jane Doèr`, `jane@example.org`. A gitleaks hook blocks the real surname. +- Never run `qs` from a tool call; the user's running shell hot-reloads on save. Read its log with `timeout 5 qs log -p desktop | tail`. + +## File map + +| File | Role | +| --- | --- | +| `desktop/modules/user/userctl` | create: get, keys, set | +| `desktop/modules/user/test-userctl.sh` | create: stubbed check for userctl | +| `desktop/modules/user/User.js` | create: parse, locales, gitName | +| `desktop/modules/user/test_user.js` | create: node check for User.js | +| `desktop/modules/user/UserRow.qml` | create: the drawer greeting | +| `desktop/modules/user/UserPage.qml` | create: the form | +| `desktop/modules/user/UserModule.qml` | create: registers the page | +| `desktop/modules/user/README.md` | create | +| `desktop/Module.qml` | modify: `tiled` | +| `desktop/Drawer.qml` | modify: tile filter, header row | +| `desktop/shell.qml` | modify: import and list the module | +| `desktop/README.md`, `AGENTS.md` | modify: module list, busctl note | + +--- + +### Task 1: userctl + +**Files:** +- Create: `desktop/modules/user/test-userctl.sh` +- Create: `desktop/modules/user/userctl` + +- [ ] **Step 1: Write the failing test** + +`desktop/modules/user/test-userctl.sh`: + +```bash +#!/bin/bash +# +# Copyright (C) 2026 Danilo M. +# +# 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 userctl. busctl, kwriteconfig6, git and gpg are +# stubs on PATH that log their arguments, so nothing here reaches the real +# AccountsService, emaildefaults or git config. jq is the real one. +# +# Usage: ./test-userctl.sh (exit 0 = all passed) + +set -u + +here="$(cd "$(dirname "$0")" && pwd)" +ctl="$here/userctl" +tmp="$(mktemp -d)" +trap 'rm -rf "$tmp"' EXIT + +mkdir "$tmp/bin" +cat > "$tmp/bin/stub" <<'EOF' +#!/bin/bash +n="${0##*/}" +{ printf '%s' "$n"; printf ' [%s]' "$@"; echo; } >> "$LOG" +[[ "${FAIL:-}" == "$n" ]] && { echo boom >&2; exit 1; } +case "$n" in + busctl) [[ " $* " == *" get-property "* ]] && printf '{"type":"s","data":"%s"}\n' "${@: -1}" ;; + git) [[ " $* " == *" --get "* ]] && { [[ -n "${GIT_VALUE:-}" ]] && echo "$GIT_VALUE" || exit 1; } ;; + gpg) cat "$GPG_FIXTURE" ;; +esac +exit 0 +EOF +chmod +x "$tmp/bin/stub" +for n in busctl kwriteconfig6 git gpg; do ln -s stub "$tmp/bin/$n"; done +export PATH="$tmp/bin:$PATH" HOME="$tmp" LOG="$tmp/log" + +obj="/org/freedesktop/Accounts/User$(id -u)" +acc="busctl [--system] [call] [org.freedesktop.Accounts] [$obj] [org.freedesktop.Accounts.User]" + +pass=0 +fail=0 +check() { + local label="$1" want="$2" got="$3" + if [[ "$want" == "$got" ]]; then + printf 'ok %s\n' "$label"; pass=$((pass + 1)) + else + printf 'FAIL %s:\n want %q\n got %q\n' "$label" "$want" "$got"; fail=$((fail + 1)) + fi +} +reset() { : > "$LOG"; unset FAIL GIT_VALUE; } + +# get: one line per field, AccountsService through --json, the key from git. +reset +export GIT_VALUE=ABCDEF +check "get" "name=RealName +email=Email +avatar=IconFile +language=Language +location=Location +signingkey=ABCDEF" "$("$ctl" get)" + +# keys: the primary fingerprint and first uid of each secret key; subkey +# fingerprints and later uids are skipped. +reset +cat > "$tmp/gpg.txt" <<'EOF' +sec:u:255:22:AAAA1111:1700000000:::u:::scESC:::+:::23::0: +fpr:::::::::1111222233334444555566667777AAAA1111: +grp:::::::::0000: +uid:u::::1700000000::HASH::Jane Doèr ::::::::::0: +uid:u::::1700000000::HASH::Jane D. ::::::::::0: +ssb:u:255:18:SUB1:1700000000::::::e:::+:::23: +fpr:::::::::9999888877776666555544443333SUB1: +sec:u:255:22:BBBB2222:1700000000:::u:::scESC:::#:::23::0: +fpr:::::::::AAAABBBBCCCCDDDDEEEEFFFF0000BBBB2222: +uid:u::::1700000000::HASH::Work Key ::::::::::0: +EOF +export GPG_FIXTURE="$tmp/gpg.txt" +check "keys" "1111222233334444555566667777AAAA1111 Jane Doèr +AAAABBBBCCCCDDDDEEEEFFFF0000BBBB2222 Work Key " "$("$ctl" keys)" + +# set name: AccountsService, emaildefaults, git with the surname shortened. +reset +out="$("$ctl" set name "Jane Doèr")" +check "set name prints nothing" "" "$out" +check "set name writes" "$acc [SetRealName] [s] [Jane Doèr] +kwriteconfig6 [--file] [emaildefaults] [--group] [PROFILE_Default] [--key] [FullName] [Jane Doèr] +git [config] [--global] [user.name] [Jane D.]" "$(cat "$LOG")" + +gitname() { reset; "$ctl" set name "$1" >/dev/null; grep '^git' "$LOG"; } +check "three words" "git [config] [--global] [user.name] [Anna Maria R.]" "$(gitname "Anna Maria Rossi")" +check "one word" "git [config] [--global] [user.name] [Cher]" "$(gitname "Cher")" +check "accented initial" "git [config] [--global] [user.name] [Ève À.]" "$(gitname "Ève Àlba")" +check "extra spaces" "git [config] [--global] [user.name] [Jane D.]" "$(gitname " Jane Doèr ")" + +# set email: the same three stores. +reset +"$ctl" set email jane@example.org >/dev/null +check "set email writes" "$acc [SetEmail] [s] [jane@example.org] +kwriteconfig6 [--file] [emaildefaults] [--group] [PROFILE_Default] [--key] [EmailAddress] [jane@example.org] +git [config] [--global] [user.email] [jane@example.org]" "$(cat "$LOG")" + +# AccountsService only. +for pair in avatar:SetIconFile language:SetLanguage location:SetLocation; do + reset + "$ctl" set "${pair%%:*}" v >/dev/null + check "set ${pair%%:*} writes" "$acc [${pair#*:}] [s] [v]" "$(cat "$LOG")" +done + +# signingkey: a fingerprint is written; empty unsets, but only if one is set. +reset +"$ctl" set signingkey ABCD >/dev/null +check "set signingkey" "git [config] [--global] [user.signingkey] [ABCD]" "$(cat "$LOG")" +reset +export GIT_VALUE=OLD +"$ctl" set signingkey "" >/dev/null +check "unset signingkey" "git [config] [--global] [--get] [user.signingkey] +git [config] [--global] [--unset] [user.signingkey]" "$(cat "$LOG")" +reset +"$ctl" set signingkey "" >/dev/null +check "unset when none is set" "git [config] [--global] [--get] [user.signingkey]" "$(cat "$LOG")" + +# A failing write stops there, says why on stdout, and exits 1. +reset +export FAIL=busctl +out="$("$ctl" set name "Jane Doèr")"; rc=$? +check "failure exit" "1" "$rc" +check "failure message" "busctl: boom" "$out" +check "failure stops" "1" "$(wc -l < "$LOG" | tr -d ' ')" + +# An unknown field is refused. +reset +"$ctl" set shoesize 42 >/dev/null 2>&1; rc=$? +check "unknown field" "2" "$rc" + +printf '\n%d passed, %d failed\n' "$pass" "$fail" +[[ $fail -eq 0 ]] +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `chmod +x desktop/modules/user/test-userctl.sh && desktop/modules/user/test-userctl.sh` +Expected: FAIL lines (userctl does not exist), last line `0 passed, N failed`, exit 1. + +- [ ] **Step 3: Write userctl** + +`desktop/modules/user/userctl`: + +```bash +#!/bin/bash +# +# Copyright (C) 2026 Danilo M. +# +# 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 user's details, read and written where other programs look for them. +# +# userctl get key=value: name email avatar language location signingkey +# userctl keys fingerprintuid per gpg secret key +# userctl set write one field to every store holding it +# +# Stores: AccountsService (and through SetRealName the passwd GECOS that +# LibreOffice reads), KDE's ~/.config/emaildefaults, the global git config. +# Polkit lets a user change their own AccountsService entry without a prompt. +# +# set prints nothing on success. On a failing write it prints the command and +# its error on stdout, for the drawer page, and exits 1 without trying the +# remaining stores. A new field is one case below and one entry in the +# page's field list. + +set -u +# Character-wise ${x:0:1}, so an accented initial is not cut in half. +export LC_ALL=C.UTF-8 + +ACC=org.freedesktop.Accounts +OBJ="/org/freedesktop/Accounts/User$(id -u)" +IFACE=org.freedesktop.Accounts.User + +usage() { + printf 'usage: %s get | keys | set \n' \ + "${0##*/}" >&2 + exit 2 +} + +run() { + local err + if ! err="$("$@" 2>&1 >/dev/null)"; then + printf '%s: %s\n' "$1" "${err:-failed}" + exit 1 + fi +} + +# --json, because plain get-property prints non-ASCII as octal escapes. +prop() { + busctl --system --json=short get-property "$ACC" "$OBJ" "$IFACE" "$1" | jq -r .data +} + +acc() { run busctl --system call "$ACC" "$OBJ" "$IFACE" "$1" s "$2"; } +kde() { run kwriteconfig6 --file emaildefaults --group PROFILE_Default --key "$1" "$2"; } + +# The surname as an initial, for commits: "Jane Doèr" -> "Jane D.". +gitname() { + local -a w + read -ra w <<< "$1" + local n=${#w[@]} + if (( n < 2 )); then printf '%s' "${w[*]}"; return; fi + printf '%s %s.' "${w[*]:0:n-1}" "${w[n-1]:0:1}" +} + +[[ $# -ge 1 ]] || usage + +case "$1" in + get) + printf 'name=%s\n' "$(prop RealName)" + printf 'email=%s\n' "$(prop Email)" + printf 'avatar=%s\n' "$(prop IconFile)" + printf 'language=%s\n' "$(prop Language)" + printf 'location=%s\n' "$(prop Location)" + printf 'signingkey=%s\n' "$(git config --global --get user.signingkey)" + ;; + keys) + # The first fpr after a sec is the primary key's; the first uid + # after that names it. ssb fingerprints come later and are skipped. + gpg --list-secret-keys --with-colons 2>/dev/null | awk -F: ' + $1 == "sec" { want = 1; next } + $1 == "fpr" && want == 1 { fpr = $10; want = 2; next } + $1 == "uid" && want == 2 { print fpr "\t" $10; want = 0 }' + ;; + set) + [[ $# -eq 3 ]] || usage + v="$3" + case "$2" in + name) + acc SetRealName "$v" + kde FullName "$v" + run git config --global user.name "$(gitname "$v")" + ;; + email) + acc SetEmail "$v" + kde EmailAddress "$v" + run git config --global user.email "$v" + ;; + avatar) acc SetIconFile "$v" ;; + language) acc SetLanguage "$v" ;; + location) acc SetLocation "$v" ;; + signingkey) + if [[ -n "$v" ]]; then + run git config --global user.signingkey "$v" + elif git config --global --get user.signingkey >/dev/null; then + run git config --global --unset user.signingkey + fi + ;; + *) usage ;; + esac + ;; + *) usage ;; +esac +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `chmod +x desktop/modules/user/userctl && desktop/modules/user/test-userctl.sh` +Expected: every line `ok`, last line `19 passed, 0 failed`, exit 0. + +- [ ] **Step 5: Read-only check against the real stores** + +Run: `desktop/modules/user/userctl get && desktop/modules/user/userctl keys | cut -c1-50` +Expected: six `key=value` lines with the real values (accented letters intact, not `\303`), and one line per secret key. Do not run `set` here; the user tries writes from the page. + +- [ ] **Step 6: Commit** + +```bash +git add desktop/modules/user/userctl desktop/modules/user/test-userctl.sh +git commit -m "feat(user): userctl reads and writes the user's details + +One writer for the stores other programs read: AccountsService (and with +it the passwd GECOS LibreOffice prefills from), KDE's emaildefaults and +the global git config, where the surname goes in as an initial. busctl +is asked for JSON because its plain output escapes non-ASCII as octal. + +Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 2: User.js + +**Files:** +- Create: `desktop/modules/user/test_user.js` +- Create: `desktop/modules/user/User.js` + +- [ ] **Step 1: Write the failing test** + +`desktop/modules/user/test_user.js`: + +```js +// Copyright (C) 2026 Danilo M. +// +// 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. + +// Run: node desktop/modules/user/test_user.js +// User.js is a QML library, so its .pragma line is stripped before node +// evaluates it. + +const fs = require("fs"); +const assert = require("assert"); + +const src = fs.readFileSync(__dirname + "/User.js", "utf8") + .replace(/^\.pragma library$/m, ""); +const U = new Function(src + "\nreturn { parse, keys, locales, gitName };")(); + +// parse: key=value lines; a value may itself hold "=". +assert.deepStrictEqual(U.parse("name=Jane Doèr\nlocation=a=b\n\n"), + { name: "Jane Doèr", location: "a=b" }); +assert.deepStrictEqual(U.parse(""), {}); + +// keys: userctl keys lines into {fpr, uid}. +assert.deepStrictEqual(U.keys("AAAA\tJane \nBBBB\tWork\n"), + [{ fpr: "AAAA", uid: "Jane " }, { fpr: "BBBB", uid: "Work" }]); + +// locales: UTF-8 entries of locale -a, spelled as AccountsService stores +// them; C and POSIX left out; sorted. +assert.deepStrictEqual(U.locales("C\nC.utf8\nPOSIX\nit_IT.utf8\nen_US.UTF-8\nde_DE\nfr_FR.utf8\n"), + ["en_US.UTF-8", "fr_FR.UTF-8", "it_IT.UTF-8"]); + +// gitName: the same rule as userctl's gitname. +assert.strictEqual(U.gitName("Jane Doèr"), "Jane D."); +assert.strictEqual(U.gitName("Anna Maria Rossi"), "Anna Maria R."); +assert.strictEqual(U.gitName("Cher"), "Cher"); +assert.strictEqual(U.gitName("Ève Àlba"), "Ève À."); +assert.strictEqual(U.gitName(" Jane Doèr "), "Jane D."); +assert.strictEqual(U.gitName(""), ""); + +console.log("test_user: ok"); +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `node desktop/modules/user/test_user.js` +Expected: FAIL with `ENOENT: no such file or directory` for `User.js`. + +- [ ] **Step 3: Write User.js** + +`desktop/modules/user/User.js`: + +```js +// Copyright (C) 2026 Danilo M. +// +// This program is free software; you can redistribute it and/or modify +// it under the terms of the GNU General Public License version 2 as +// published by the Free Software Foundation. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +.pragma library + +// Pure helpers for the user page, tested by test_user.js under node. + +// `userctl get` output into an object. +function parse(text) { + const out = {}; + for (const line of text.split("\n")) { + const i = line.indexOf("="); + if (i > 0) out[line.slice(0, i)] = line.slice(i + 1); + } + return out; +} + +// `userctl keys` output into [{fpr, uid}]. +function keys(text) { + return text.split("\n").filter(l => l.includes("\t")) + .map(l => ({ fpr: l.slice(0, l.indexOf("\t")), uid: l.slice(l.indexOf("\t") + 1) })); +} + +// `locale -a` prints it_IT.utf8; AccountsService stores it_IT.UTF-8. +function locales(text) { + return text.split("\n") + .filter(l => /\.utf-?8$/i.test(l) && !/^C\./.test(l)) + .map(l => l.replace(/\.utf-?8$/i, ".UTF-8")) + .sort(); +} + +// The name git gets, for the page's preview. ponytail: duplicates userctl's +// gitname; both tests pin the same cases, keep them in step. +function gitName(name) { + const w = name.trim().split(/\s+/).filter(s => s !== ""); + if (w.length < 2) return w.join(" "); + return w.slice(0, -1).join(" ") + " " + Array.from(w[w.length - 1])[0] + "."; +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `node desktop/modules/user/test_user.js` +Expected: `test_user: ok` + +- [ ] **Step 5: Commit** + +```bash +git add desktop/modules/user/User.js desktop/modules/user/test_user.js +git commit -m "feat(user): helpers for the user page + +Parsing userctl's output and locale -a, and the git name preview, kept +out of QML so node can test them. + +Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 3: Module.tiled and the drawer row + +**Files:** +- Modify: `desktop/Module.qml` (after `property bool active`) +- Modify: `desktop/Drawer.qml` (imports; grid view `NotificationList` and tile `Repeater`) +- Create: `desktop/modules/user/UserRow.qml` + +- [ ] **Step 1: Check the glyph exists in the icon font** + +Run: `fc-list ':charset=f007' family | grep -i inconsolata` +Expected: at least one `Inconsolata Nerd Font` line (nf-fa-user). If none, pick another user glyph that does match and use it in Steps 3 and Task 4. + +- [ ] **Step 2: Add `tiled` to Module.qml** + +In `desktop/Module.qml`, after the `active` property: + +```qml + // Whether the drawer's grid shows a tile. False for a module reached + // some other way: the user page opens from the drawer's header row. + property bool tiled: true +``` + +- [ ] **Step 3: Write UserRow.qml** + +`desktop/modules/user/UserRow.qml`: + +```qml +// Copyright (C) 2026 Danilo M. +// +// 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.Widgets +import QtQuick +import "../.." +import "User.js" as U + +// The drawer's greeting: avatar, login name, real name. It re-reads each time +// it becomes visible, which is every drawer open and every return from a +// page, so a save on the user page shows here on the way back. +Rectangle { + id: row + + signal clicked + property var info: ({}) + + implicitHeight: 64 + radius: 12 + color: area.containsMouse ? Qt.alpha(Theme.accent, 0.22) : Qt.alpha(Theme.surface, 0.35) + border.width: 1 + border.color: Qt.alpha(Theme.text, 0.08) + + function read() { + reader.running = false; + reader.running = true; + } + onVisibleChanged: if (visible) read() + Component.onCompleted: read() + + Process { + id: reader + command: [Qt.resolvedUrl("userctl").toString().replace(/^file:\/\//, ""), "get"] + // Cleared first: the icon path never changes, so only a source going + // through "" makes an uncached Image load the new picture. + stdout: StdioCollector { + onStreamFinished: { row.info = ({}); row.info = U.parse(text); } + } + } + + ClippingRectangle { + id: face + anchors { left: parent.left; leftMargin: 12; verticalCenter: parent.verticalCenter } + width: 44 + height: 44 + radius: 22 + color: Theme.surfaceAlt + + Image { + id: pic + anchors.fill: parent + source: row.info.avatar ? "file://" + row.info.avatar : "" + fillMode: Image.PreserveAspectCrop + sourceSize: Qt.size(88, 88) + cache: false + } + Text { + anchors.centerIn: parent + visible: pic.status !== Image.Ready + text: "\uf007" + font { family: Theme.iconFamily; pixelSize: 20 } + color: Theme.subtext + } + } + + Column { + anchors { left: face.right; leftMargin: 12; right: parent.right; rightMargin: 12 + verticalCenter: parent.verticalCenter } + spacing: 2 + Text { + text: "Hello " + Quickshell.env("USER") + font { family: Theme.fontFamily; pixelSize: Theme.fontSize + 2; bold: true } + color: Theme.text + } + Text { + width: parent.width + visible: text !== "" + text: row.info.name ?? "" + elide: Text.ElideRight + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 } + color: Theme.subtext + } + } + + MouseArea { + id: area + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: row.clicked() + } +} +``` + +- [ ] **Step 4: Place the row and filter the tiles in Drawer.qml** + +Add `import "modules/user"` after the existing imports at the top of `desktop/Drawer.qml`. + +In the grid view `Item` (the one with `visible: root.page === "" && !root.history`), insert before `NotificationList`: + +```qml + UserRow { + id: user + anchors { top: parent.top; left: parent.left; right: parent.right } + onClicked: root.show("user") + } +``` + +and change the `NotificationList` top anchor so it starts under the row: + +```qml + NotificationList { + id: notifications + anchors { top: user.bottom; topMargin: 12; left: parent.left; right: parent.right } +``` + +(keep its existing `anchors.bottom`, `anchors.bottomMargin` and handlers). In the tile `Repeater`, change `model: root.modules` to: + +```qml + model: root.modules.filter(m => m.tiled) +``` + +- [ ] **Step 5: Check the shell reloaded** + +Run: `sleep 2; timeout 5 qs log -p desktop 2>&1 | grep -E "ERROR|Configuration Loaded" | tail -3` +Expected: last line `Configuration Loaded`, no `ERROR` after it. `show("user")` resolves to the grid until Task 4 adds the module; that is expected. + +- [ ] **Step 6: Commit** + +```bash +git add desktop/Module.qml desktop/Drawer.qml desktop/modules/user/UserRow.qml +git commit -m "feat(desktop): a greeting row above the notifications + +Avatar, Hello and the real name, re-read whenever the grid view +shows again so an edit is visible on the way back from the page. Module +gains tiled, so a module can own a page without a tile. + +Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 4: The user page and module + +**Files:** +- Create: `desktop/modules/user/UserPage.qml` +- Create: `desktop/modules/user/UserModule.qml` +- Modify: `desktop/shell.qml` (imports, `modules` list) + +- [ ] **Step 1: Write UserPage.qml** + +`desktop/modules/user/UserPage.qml`: + +```qml +// Copyright (C) 2026 Danilo M. +// +// 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.Widgets +import QtQuick +import QtQuick.Controls +import QtQuick.Dialogs +import "../.." +import "User.js" as U + +// The form. `saved` is what userctl read, `draft` the edits; Save sends each +// changed key to `userctl set` in turn. A failed key keeps its edit and shows +// the error under the field, so the next Save retries it. +Column { + id: form + + spacing: 14 + + // A new detail is one entry here and one case in userctl. Avatar is not + // listed: it is the picture at the top, not a row. + readonly property var fields: [ + { key: "name", label: "Name", kind: "text" }, + { key: "email", label: "Email", kind: "text" }, + { key: "location", label: "Location", kind: "text" }, + { key: "language", label: "Language", kind: "locale" }, + { key: "signingkey", label: "Signing key", kind: "key" }, + ] + + readonly property string ctl: Qt.resolvedUrl("userctl").toString().replace(/^file:\/\//, "") + property var saved: ({}) + property var draft: ({}) + property var errors: ({}) + property var keys: [] + property var locales: [] + property bool busy: false + readonly property var changed: Object.keys(draft).filter(k => draft[k] !== saved[k]) + + function set(key, value) { + const d = Object.assign({}, form.draft); + d[key] = value; + form.draft = d; + } + + // ComboBox entries per kind. The current language is kept even if + // locale -a no longer lists it, so the combo never shows blank. + function options(kind) { + if (kind === "locale") { + const l = form.locales.slice(); + if (form.saved.language && !l.includes(form.saved.language)) l.unshift(form.saved.language); + return l.map(v => ({ text: v, value: v })); + } + return [{ text: "None", value: "" }] + .concat(form.keys.map(k => ({ text: k.uid + " · " + k.fpr.slice(-16), value: k.fpr }))); + } + + // One Process per call, so reads and writes never share one (a running + // Process ignores a second start, AGENTS.md). cb gets stdout. + function run(cmd, cb) { + op.createObject(form, { command: cmd, cb: cb }).running = true; + } + Component { + id: op + Process { + id: proc + property var cb: null + stdout: StdioCollector { + onStreamFinished: { + try { proc.cb(text); } + finally { proc.destroy(); } + } + } + } + } + + // keep: draft values to carry over the reload (the keys that failed). + function load(keep) { + form.run([form.ctl, "get"], out => { + const s = U.parse(out); + form.saved = ({}); + form.saved = s; + form.draft = Object.assign({}, s, keep); + }); + } + + function save() { + if (form.busy || form.changed.length === 0) return; + form.busy = true; + form.errors = ({}); + form.step(form.changed.slice(), {}); + } + function step(queue, failed) { + if (queue.length === 0) { + form.busy = false; + form.load(failed); + return; + } + const k = queue.shift(); + form.run([form.ctl, "set", k, form.draft[k]], out => { + if (out.trim() !== "") { + const e = Object.assign({}, form.errors); + e[k] = out.trim(); + form.errors = e; + failed[k] = form.draft[k]; + } + form.step(queue, failed); + }); + } + + Component.onCompleted: { + form.load({}); + form.run([form.ctl, "keys"], out => form.keys = U.keys(out)); + form.run(["locale", "-a"], out => form.locales = U.locales(out)); + } + + // A FileDialog hands back a file:// URL; AccountsService wants a path. + FileDialog { + id: picker + title: "Choose a picture" + fileMode: FileDialog.OpenFile + nameFilters: ["Images (*.png *.jpg *.jpeg)"] + onAccepted: form.set("avatar", decodeURIComponent(selectedFile.toString().replace(/^file:\/\//, ""))) + } + + ClippingRectangle { + anchors.horizontalCenter: parent.horizontalCenter + width: 96 + height: 96 + radius: 48 + color: Theme.surfaceAlt + + Image { + id: pic + anchors.fill: parent + source: form.draft.avatar ? "file://" + form.draft.avatar : "" + fillMode: Image.PreserveAspectCrop + sourceSize: Qt.size(192, 192) + cache: false + } + Text { + anchors.centerIn: parent + visible: pic.status !== Image.Ready + text: "\uf007" + font { family: Theme.iconFamily; pixelSize: 40 } + color: Theme.subtext + } + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: picker.open() + } + } + + Text { + anchors.horizontalCenter: parent.horizontalCenter + visible: !!form.errors.avatar + text: form.errors.avatar ?? "" + color: Theme.red + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } + } + + Repeater { + model: form.fields + + delegate: Column { + id: field + required property var modelData + width: form.width + spacing: 4 + + Text { + text: field.modelData.label + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 } + color: Theme.subtext + } + + Rectangle { + visible: field.modelData.kind === "text" + width: parent.width + height: 34 + radius: 8 + color: Theme.surface + TextInput { + anchors { fill: parent; leftMargin: 10; rightMargin: 10 } + verticalAlignment: TextInput.AlignVCenter + clip: true + selectByMouse: true + color: Theme.text + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 } + text: form.draft[field.modelData.key] ?? "" + onTextEdited: form.set(field.modelData.key, text) + } + } + + ComboBox { + visible: field.modelData.kind !== "text" + width: parent.width + textRole: "text" + valueRole: "value" + model: field.modelData.kind === "text" ? [] : form.options(field.modelData.kind) + // count is read so the index is recomputed once the list + // arrives, which is after the draft. + currentIndex: count >= 0 ? indexOfValue(form.draft[field.modelData.key] ?? "") : -1 + onActivated: form.set(field.modelData.key, currentValue) + } + + Text { + width: parent.width + visible: !!form.errors[field.modelData.key] + text: form.errors[field.modelData.key] ?? "" + wrapMode: Text.Wrap + color: Theme.red + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } + } + } + } + + Text { + width: parent.width + text: "git: " + U.gitName(form.draft.name ?? "") + " <" + (form.draft.email ?? "") + ">" + elide: Text.ElideRight + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } + color: Theme.overlay + } + + Button { + anchors.right: parent.right + text: form.busy ? "Saving…" : "Save" + enabled: !form.busy && form.changed.length > 0 + onClicked: form.save() + } +} +``` + +- [ ] **Step 2: Write UserModule.qml** + +`desktop/modules/user/UserModule.qml`: + +```qml +// Copyright (C) 2026 Danilo M. +// +// 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 "../.." + +// The user's details. No tile: the drawer's greeting row opens the page. +Module { + name: "user" + label: "User" + // nf-fa-user, present in Inconsolata Nerd Font. + icon: "\uf007" + tiled: false + + page: Component { + Page { + title: "User" + UserPage { width: parent.width } + } + } +} +``` + +- [ ] **Step 3: Register the module in shell.qml** + +In `desktop/shell.qml`, add `import "modules/user"` after `import "modules/updates"`, and add `UserModule {},` as the last entry of the `modules: [...]` list in `Drawer`. + +- [ ] **Step 4: Check the shell reloaded** + +Run: `sleep 2; timeout 5 qs log -p desktop 2>&1 | grep -E "ERROR|Configuration Loaded" | tail -3` +Expected: last line `Configuration Loaded`, no `ERROR` after it. If `UserModule` is reported unknown, the directory index was not rescanned (AGENTS.md, Reloading): ask the user to run `./reload.sh desktop`. + +- [ ] **Step 5: Ask the user to check it by hand** + +Ask the user to: open the drawer and see the row (avatar, `Hello `, real name); click it; change the location to something harmless and Save; go back and reopen the page to see it persisted; pick a new avatar and check the row shows it on return; check the git preview line; set the location back. Anything visual is the user's call (AGENTS.md). + +- [ ] **Step 6: Commit** + +```bash +git add desktop/modules/user/UserPage.qml desktop/modules/user/UserModule.qml desktop/shell.qml +git commit -m "feat(user): a drawer page for the user's details + +Name, email, avatar, language, location and the git signing key, each +saved through userctl to every store that holds it. Only changed fields +are sent; a field whose write failed keeps its edit and its error, so +the next Save retries it. + +Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 5: Documentation + +**Files:** +- Create: `desktop/modules/user/README.md` +- Modify: `desktop/README.md` (module list near `modules/perf/`) +- Modify: `AGENTS.md` (the `desktop/` line in "What this is"; a note in "Per-component notes") + +- [ ] **Step 1: Write the module README** + +`desktop/modules/user/README.md`: + +```markdown +# user + +The drawer's greeting row (avatar, `Hello `, real name) and the page +it opens, which edits the user's details where other programs read them. + +| Field | Written to | +| --- | --- | +| name | AccountsService `RealName` (which also rewrites the passwd GECOS that LibreOffice prefills from), `~/.config/emaildefaults` `FullName`, git `user.name` with the surname as an initial | +| email | AccountsService `Email`, emaildefaults `EmailAddress`, git `user.email` | +| avatar | AccountsService `IconFile`; the daemon copies the file in and SDDM shows it | +| language, location | AccountsService | +| signing key | git `user.signingkey`; None unsets it | + +`userctl` does all reading and writing; the page only calls it. Polkit lets a +user edit their own AccountsService entry without a prompt. A new detail is +one entry in `UserPage.qml`'s `fields` and one `case` in `userctl`. + +Checks: `./test-userctl.sh` (stubs every store) and +`node desktop/modules/user/test_user.js`. + +Not here: the password (`passwd` in a terminal; changing it also leaves the +login keyring on the old one), `~/.face.icon` (SDDM reads the +AccountsService icon), social links (nothing reads them). Values are read +when the page opens, not watched. +``` + +- [ ] **Step 2: Update the module lists** + +In `desktop/README.md`, after the `modules/perf/` line add: + +``` + modules/user/ the greeting row and the user's details page +``` + +In `AGENTS.md`, change the `desktop/` line of the tree to: + +``` + desktop/ the drawer (SUPER alone): user details, sound, mail, VMs, status, + performance, clipboard, updates, appearance +``` + +- [ ] **Step 3: Add the busctl note to AGENTS.md** + +At the end of "Per-component notes" in `AGENTS.md`: + +```markdown +- **`busctl get-property` escapes non-ASCII as octal.** A name with an + accented letter reads `s "Do\303\250r"`-style, which no shell builtin + decodes. `busctl --json=short` returns real UTF-8; `userctl` pipes it + through `jq -r .data`. +``` + +- [ ] **Step 4: Commit** + +```bash +git add desktop/modules/user/README.md desktop/README.md AGENTS.md +git commit -m "docs(user): the module's README and the busctl escaping note + +Co-Authored-By: Claude Opus 5.5 " +``` -- cgit v1.2.3