# 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 " ```