From 274d1f02dfa0ac7a28f5318fe8c4eb6a28702bbe Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Fri, 16 Oct 2026 09:54:42 +0200 Subject: 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 Sonnet 5.5 --- desktop/modules/user/test-userctl.sh | 143 +++++++++++++++++++++++++++++++++++ desktop/modules/user/userctl | 115 ++++++++++++++++++++++++++++ 2 files changed, 258 insertions(+) create mode 100755 desktop/modules/user/test-userctl.sh create mode 100755 desktop/modules/user/userctl (limited to 'desktop/modules') diff --git a/desktop/modules/user/test-userctl.sh b/desktop/modules/user/test-userctl.sh new file mode 100755 index 0000000..cc90550 --- /dev/null +++ b/desktop/modules/user/test-userctl.sh @@ -0,0 +1,143 @@ +#!/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 ]] diff --git a/desktop/modules/user/userctl b/desktop/modules/user/userctl new file mode 100755 index 0000000..07d3a63 --- /dev/null +++ b/desktop/modules/user/userctl @@ -0,0 +1,115 @@ +#!/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 -- cgit v1.2.3