fix(install): constrain keyboard selection to XKB catalog
All checks were successful
Check / eval (push) Successful in 3m17s
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:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)} \
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user