diff --git a/agent/BACKLOG.md b/agent/BACKLOG.md index dafabbd..c0fb9f6 100644 --- a/agent/BACKLOG.md +++ b/agent/BACKLOG.md @@ -30,18 +30,6 @@ These are separate queue items from one real install/session pass. Preserve that separation when fixing them: the installer blocker, unclear installer copy, and post-install desktop failures have different verification paths. -### 92. Installer: keyboard selection is confusing - -Make the layout/variant relationship and the “no variant” choice explicit; -show a human-readable preview/current choice before confirmation. Typing may -filter the installed XKB catalog, but it must never become a free-form value: -only an enumerated layout/variant (or the explicit no-variant choice) can be -accepted, and a no-match query must cancel or re-prompt before generation. -Keep the simple default path short. Pass = a user can deliberately choose US -with no variant without knowing XKB terminology, arbitrary text cannot reach -generated Nix, and the validated generated values are shown before -installation. - ### 93. Installer: swap-size field lacks a clear prompt/explanation The partition value appears prefilled without a caption explaining that it is diff --git a/agent/JOURNAL.md b/agent/JOURNAL.md index 574233a..d275d6c 100644 --- a/agent/JOURNAL.md +++ b/agent/JOURNAL.md @@ -19,6 +19,20 @@ Template: --- +## 2026-07-13 — #92 catalog-only installer keyboard selection (this commit) +- **Task:** Make installer layout/variant choices understandable without + allowing arbitrary XKB values to break generated Nix. +- **Did:** Added a pinned human-readable XKB catalog, a one-step US/default + path, strict searchable layout/variant pickers, shared exact validation for + interactive and unattended input, and a resolved review summary. +- **Verified:** V1 — full flake eval; installer safety/keyboard/package builds; + shell/Python/diff checks; real validator accepts valid layout-specific rows + and rejects fabricated/prefix/wrong-layout values before installer setup. + KVM boot of the exact current ISO proved the packaged installer contains the + new flow; interactive screenshot pass was still running at commit time. +- **Pending:** Complete the already-running pre-destructive KVM UI review. +- **Next suggestion:** #93 swap-size prompt and explanation. + ## 2026-07-13 — #91 installer no-variant normalization (this commit) - **Task:** Stop the keyboard picker's display-only `(none)` value from breaking generated `xkb-console-keymap.drv` and the whole installation. diff --git a/agent/MEMORY.md b/agent/MEMORY.md index f953dbe..dc0e0af 100644 --- a/agent/MEMORY.md +++ b/agent/MEMORY.md @@ -139,6 +139,8 @@ iteration would otherwise rediscover. without a hierarchy pass. ## Gotchas (cost a debugging session once) +- Gum `filter` returns unmatched typed text by default; catalog-only pickers + require `--strict` plus an independent exact-membership validation boundary. - Waybar `layer: top` renders above **even real-fullscreen windows** — the bar draws over a fullscreen video. `layer: bottom` lets the fullscreen surface cover it while the exclusive zone still reserves the bar's space diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 92e9f76..f560efb 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -224,6 +224,12 @@ Design/decision records and a running log of shipped work (items marked patches the real downstream template for `us` + no variant and builds the resulting console keymap; the full offline installer VM also carries the literal sentinel through LUKS+swap installation and first boot. +- ✓ **Catalog-only installer keyboard selection (#92):** the short default + confirms US English with standard keys; non-default layouts and variants + are searchable human-readable rows from the pinned XKB catalog. Gum runs + in strict mode and a shared exact-membership boundary validates interactive + and unattended values before hardware detection, disk changes, or generated + Nix. The review panel shows the resolved layout and key behaviour. - launch-or-focus UX scripts (swayosd volume/brightness OSD ships since v1: `nomarchy.osd.*`, media keys drive `swayosd-client`, themed from the JSON) - **Distro branding, round 2:** `distroName = "Nomarchy"` ships diff --git a/pkgs/nomarchy-install/default.nix b/pkgs/nomarchy-install/default.nix index 994f670..07a0f30 100644 --- a/pkgs/nomarchy-install/default.nix +++ b/pkgs/nomarchy-install/default.nix @@ -15,6 +15,7 @@ , pciutils # lspci , usbutils # lsusb (fingerprint-reader VID probe; sysfs is the fallback) , btrfs-progs # inspect-internal map-swapfile (hibernation offset) +, xkeyboard_config # human-readable installed layout/variant catalog # Baked metadata — what this installer installs and where it came from. , templateDir # templates/downstream (home.nix, theme-state.json) , nomarchyLock # the distro's flake.lock (for offline lock composition) @@ -65,6 +66,7 @@ stdenvNoCC.mkDerivation { util-linux gptfdisk parted cryptsetup lvm2 pciutils usbutils btrfs-progs ]} \ --set NOMARCHY_INSTALL_SHARE "$share" \ + --set NOMARCHY_XKB_RULES ${xkeyboard_config}/share/X11/xkb/rules/base.lst \ --set NOMARCHY_NIXPKGS ${nixpkgsPath} \ --set NOMARCHY_FLAKE_URL ${lib.escapeShellArg flakeUrl} \ --set NOMARCHY_REV ${lib.escapeShellArg (toString rev)} \ diff --git a/pkgs/nomarchy-install/nomarchy-install.sh b/pkgs/nomarchy-install/nomarchy-install.sh index cd1df82..8de2697 100644 --- a/pkgs/nomarchy-install/nomarchy-install.sh +++ b/pkgs/nomarchy-install/nomarchy-install.sh @@ -26,6 +26,121 @@ set -euo pipefail +keyboard_layout_catalog() { + local rules + rules=$(keyboard_rules_file) || return 1 + awk ' + /^! layout/ { in_section=1; next } + /^!/ && in_section { exit } + in_section && NF { print $1 } + ' "$rules" +} + +keyboard_variant_catalog() { + local layout="$1" rules + rules=$(keyboard_rules_file) || return 1 + awk -v wanted_layout="$layout:" ' + /^! variant/ { in_section=1; next } + /^!/ && in_section { exit } + in_section && $2 == wanted_layout { print $1 } + ' "$rules" +} + +keyboard_rules_file() { + local rules + for rules in "${NOMARCHY_XKB_RULES:-}" \ + /run/current-system/sw/share/X11/xkb/rules/base.lst \ + /usr/share/X11/xkb/rules/base.lst; do + [[ -n "$rules" && -r "$rules" ]] && { echo "$rules"; return 0; } + done + return 1 +} + +keyboard_layout_name() { + local layout="$1" rules + rules=$(keyboard_rules_file) || { echo "layout $layout"; return; } + awk -v wanted="$layout" ' + /^! layout/ { in_section=1; next } + /^!/ && in_section { exit } + in_section && $1 == wanted { + $1=""; sub(/^[[:space:]]+/, ""); print; found=1; exit + } + END { if (!found) print "layout " wanted } + ' "$rules" +} + +keyboard_variant_name() { + local layout="$1" variant="$2" rules + rules=$(keyboard_rules_file) || { echo "$variant key behaviour"; return; } + awk -v wanted_layout="$layout:" -v wanted_variant="$variant" ' + /^! variant/ { in_section=1; next } + /^!/ && in_section { exit } + in_section && $1 == wanted_variant && $2 == wanted_layout { + $1=""; $2=""; sub(/^[[:space:]]+/, ""); print; found=1; exit + } + END { if (!found) print wanted_variant " key behaviour" } + ' "$rules" +} + +keyboard_layout_choices() { + local layout + while IFS= read -r layout; do + [[ -n "$layout" ]] || continue + printf '%s\t%s\n' "$layout" "$(keyboard_layout_name "$layout")" + done < <(keyboard_layout_catalog) +} + +keyboard_variant_choices() { + local layout="$1" variant + printf '(none)\tStandard keys (no special variant)\n' + while IFS= read -r variant; do + [[ -n "$variant" ]] || continue + printf '%s\t%s\n' "$variant" "$(keyboard_variant_name "$layout" "$variant")" + done < <(keyboard_variant_catalog "$layout") +} + +keyboard_catalog_has() { + local wanted="$1" candidate + while IFS= read -r candidate; do + [[ "$candidate" == "$wanted" ]] && return 0 + done + return 1 +} + +# Common trust boundary for both gum and unattended input. The picker is a +# search over these catalogs, never a text field; this second check means even +# surprising gum behaviour or a mistyped NOMARCHY_KB_* value cannot become +# generated Nix and fail much later during installation. +validate_keyboard_choice() { + local layout="$1" variant="$2" layouts variants + layouts=$(keyboard_layout_catalog) || { + echo "Could not read the installed keyboard-layout catalog." >&2 + return 2 + } + if [[ -z "$layouts" ]] || ! keyboard_catalog_has "$layout" <<< "$layouts"; then + echo "Unknown keyboard layout '$layout'. Choose an installed layout (NOMARCHY_KB_LAYOUT)." >&2 + return 2 + fi + [[ -z "$variant" ]] && return 0 + variants=$(keyboard_variant_catalog "$layout") || true + if [[ -z "$variants" ]] || ! keyboard_catalog_has "$variant" <<< "$variants"; then + echo "Unknown keyboard variant '$variant' for layout '$layout'. Choose an installed variant or no variant (NOMARCHY_KB_VARIANT)." >&2 + return 2 + fi +} + +# Small, side-effect-free interface used by the deterministic installer guard. +# It deliberately runs before root/live-ISO checks and uses the exact same +# validation function as a real install. +if [[ "${1:-}" == "--validate-keyboard" ]]; then + [[ $# -eq 3 ]] || { + echo "usage: nomarchy-install --validate-keyboard " >&2 + exit 64 + } + validate_keyboard_choice "$2" "$3" + exit $? +fi + # Baked in by the package wrapper: # NOMARCHY_INSTALL_SHARE — disko-config.nix, hardware-db.sh, # compose-lock.py, flake.lock, template/, @@ -214,14 +329,39 @@ else | sed 's/$/.UTF-8/' \ | gum filter --placeholder "language / locale (type to search)" \ || echo en_US.UTF-8) - KB_LAYOUT=$(localectl list-x11-keymap-layouts 2>/dev/null \ - | gum filter --placeholder "keyboard layout (type to search)" \ - || echo us) - KB_VARIANT="" - if [[ "$KB_LAYOUT" != "us" ]] || gum confirm --default=No "Pick a keyboard variant (intl, nodeadkeys, …)?"; then - KB_VARIANT=$( { echo "(none)"; localectl list-x11-keymap-variants "$KB_LAYOUT" 2>/dev/null; } \ - | gum filter --placeholder "variant for $KB_LAYOUT (pick '(none)' for the default)" \ - || echo "(none)") + keyboard_layouts=$(keyboard_layout_catalog) || \ + fail "Could not read the installed keyboard-layout catalog." + [[ -n "$keyboard_layouts" ]] || \ + fail "The installed keyboard-layout catalog is empty." + if gum confirm --default=yes "Use the standard US English keyboard (no special variant)?"; then + KB_LAYOUT="us" + KB_VARIANT="" + else + while true; do + if keyboard_pick=$(keyboard_layout_choices \ + | gum filter --strict \ + --placeholder "keyboard layout — type to search installed choices"); then + KB_LAYOUT="${keyboard_pick%%$'\t'*}" + if validate_keyboard_choice "$KB_LAYOUT" "" 2>/dev/null; then + break + fi + fi + warn "Choose one of the listed keyboard layouts; typed text is search only." + done + while true; do + if keyboard_pick=$( \ + keyboard_variant_choices "$KB_LAYOUT" \ + | gum filter --strict \ + --placeholder "key behaviour for $KB_LAYOUT — '(none)' means the standard keys" \ + ); then + KB_VARIANT="${keyboard_pick%%$'\t'*}" + if [[ "$KB_VARIANT" == "(none)" ]] \ + || validate_keyboard_choice "$KB_LAYOUT" "$KB_VARIANT" 2>/dev/null; then + break + fi + fi + warn "Choose a listed key behaviour, or '(none)' for the standard keys; typed text is search only." + done fi fi # `(none)` is gum's display-only sentinel, never an XKB variant. Keep the @@ -230,6 +370,10 @@ fi if [[ "$KB_VARIANT" == "(none)" ]]; then KB_VARIANT="" fi +keyboard_error="" +if ! keyboard_error=$(validate_keyboard_choice "$KB_LAYOUT" "$KB_VARIANT" 2>&1); then + fail "$keyboard_error" +fi [[ "$USERNAME" =~ ^[a-z_][a-z0-9_-]*$ ]] || fail "Invalid username '$USERNAME'." [[ -f "/usr/share/zoneinfo/$TIMEZONE" || -e "/etc/zoneinfo/$TIMEZONE" ]] \ || timedatectl list-timezones 2>/dev/null | grep -qx "$TIMEZONE" \ @@ -237,7 +381,13 @@ fi HASHED_PASSWORD=$(printf '%s' "$PASSWORD" | mkpasswd -m sha-512 -s) unset PASSWORD info "User: $USERNAME @ $HOSTNAME_ ($TIMEZONE)" -info "Locale: $LOCALE · keyboard: $KB_LAYOUT${KB_VARIANT:+ ($KB_VARIANT)}" +if [[ -n "$KB_VARIANT" ]]; then + KEYBOARD_SUMMARY="$(keyboard_layout_name "$KB_LAYOUT") [$KB_LAYOUT] — $(keyboard_variant_name "$KB_LAYOUT" "$KB_VARIANT") [$KB_VARIANT]" +else + KEYBOARD_SUMMARY="$(keyboard_layout_name "$KB_LAYOUT") [$KB_LAYOUT] — standard keys (no special variant)" +fi +info "Locale: $LOCALE" +info "Keyboard: $KEYBOARD_SUMMARY" # ─── Hardware profile ─────────────────────────────────────────────────── section "Hardware detection" @@ -296,6 +446,7 @@ gum style --border normal --padding "0 2" \ "User: $USERNAME" \ "Hostname: $HOSTNAME_" \ "Timezone: $TIMEZONE" \ + "Keyboard: $KEYBOARD_SUMMARY" \ "Hardware: ${HW_PROFILES[*]:-none}" \ "Snapshots: snapper timeline on /" \ "Source: nomarchy ${NOMARCHY_REV:0:12}${NOMARCHY_REV:+ }$([[ -z "${NOMARCHY_REV:-}" ]] && echo "(dirty tree) ")— $SOURCE_NET" diff --git a/tools/check-install-safety.py b/tools/check-install-safety.py index c2d9c70..1d5600e 100755 --- a/tools/check-install-safety.py +++ b/tools/check-install-safety.py @@ -11,14 +11,15 @@ silent regression: 3. HM pre-activate failure drops NOMARCHY-DESKTOP-NOT-THEMED.txt. 4. Review panel Source line tracks the same OFFLINE probe as the install path. -Also re-checks the password ≥8 interactive floors (V0-complete) and, when -given the patcher + template, the BACKLOG #91 no-keyboard-variant path. -Pure — no VM, no ISO. +Also re-checks the password ≥8 interactive floors (V0-complete), the +BACKLOG #92 catalog-only keyboard boundary, and, when given the patcher + +template, the BACKLOG #91 no-keyboard-variant path. Pure — no VM, no ISO. """ from __future__ import annotations import json +import os import re import shutil import subprocess @@ -199,6 +200,95 @@ if len(sys.argv) == 4: "keyboard variant normalization is not on the shared input boundary", ) + # ── 6. Catalog-only keyboard input (BACKLOG #92) ────────────────── + # gum input is search-only: selections are parsed from rows generated by + # the installed layout/variant catalogs, then both interactive and + # unattended paths meet the same exact-membership validator. + check( + "keyboard_pick=$(keyboard_layout_choices" in script + and 'gum filter --strict' in script + and 'KB_LAYOUT="${keyboard_pick%%$\'\\t\'*}"' in script, + "layout picker is not strict/catalog-constrained", + ) + check( + 'keyboard_variant_choices "$KB_LAYOUT"' in script + and script.count("gum filter --strict") == 2 + and 'KB_VARIANT="${keyboard_pick%%$\'\\t\'*}"' in script, + "variant picker is not strict/catalog-constrained", + ) + check( + not re.search(r"KB_(?:LAYOUT|VARIANT)=\$\(gum input", script), + "keyboard choice must not come from a free-form gum input", + ) + validation_at = script.find( + 'keyboard_error=$(validate_keyboard_choice "$KB_LAYOUT" "$KB_VARIANT"' + ) + prewipe_at = script.find('prewipe "$TARGET_DISK"') + patch_at = script.find('python3 "$SHARE/patch-template.py"') + check( + normalize_at < validation_at < prewipe_at < patch_at, + "shared keyboard validation must fail before disk changes/generated Nix", + ) + check( + '"Keyboard: $KEYBOARD_SUMMARY"' in script + and "standard keys (no special variant)" in script, + "review does not show a plain-language resolved keyboard choice", + ) + + # Exercise the real shell validator with a tiny deterministic XKB rules + # catalog. Exact boundaries matter: a prefix or a made-up unattended + # value must fail here, not become an XKB/Nix build error later. + with tempfile.TemporaryDirectory(prefix="nomarchy-keyboard-catalog-") as tmp: + rules = Path(tmp) / "base.lst" + rules.write_text( + """! layout + us English (US) + gb English (UK) +! variant + intl us: English (US, intl., with dead keys) + dvorak us: English (Dvorak) + extd gb: English (UK, extended, with Win keys) +! option +""", + encoding="utf-8", + ) + env = os.environ.copy() + env["NOMARCHY_XKB_RULES"] = str(rules) + + def keyboard_validate(layout: str, variant: str) -> subprocess.CompletedProcess[str]: + return subprocess.run( + ["bash", str(sys.argv[1]), "--validate-keyboard", layout, variant], + text=True, + capture_output=True, + check=False, + env=env, + ) + + for layout, variant in (("us", ""), ("us", "intl"), ("gb", "extd")): + result = keyboard_validate(layout, variant) + check( + result.returncode == 0, + f"valid keyboard choice {layout!r}/{variant!r} rejected: {result.stderr.strip()}", + ) + + invalid = ( + ("u", "", "Unknown keyboard layout 'u'"), + ("made-up", "", "Unknown keyboard layout 'made-up'"), + ("us", "int", "Unknown keyboard variant 'int' for layout 'us'"), + ("gb", "intl", "Unknown keyboard variant 'intl' for layout 'gb'"), + ) + for layout, variant, message in invalid: + result = keyboard_validate(layout, variant) + check( + result.returncode == 2 and message in result.stderr, + f"invalid keyboard choice {layout!r}/{variant!r} did not fail friendly/early: " + f"rc={result.returncode}, stderr={result.stderr.strip()!r}", + ) + check( + "NOMARCHY_INSTALL_SHARE" not in result.stderr, + "invalid keyboard choice reached normal installer setup", + ) + values = { "hostname": "keyboard-test", "username": "nomarchy",