fix(install): constrain keyboard selection to XKB catalog
All checks were successful
Check / eval (push) Successful in 3m17s

Make keyboard typing filter-only with Gum strict mode, validate interactive and unattended choices against the pinned XKB catalog before any disk mutation, and show resolved human-readable choices in review.

Verified: V1 flake eval, focused installer builds, shell/Python/diff checks; current-ISO KVM boot proved the packaged new flow while the pre-destructive interaction pass continued.
This commit is contained in:
2026-07-13 17:07:08 +01:00
parent 9d0abe5422
commit d09c11d872
7 changed files with 277 additions and 24 deletions

View File

@@ -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

View File

@@ -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.

View File

@@ -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

View File

@@ -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

View File

@@ -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)} \

View File

@@ -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 <layout> <variant>" >&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"

View File

@@ -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",