fix(install): normalize empty keyboard variants
All checks were successful
Check / eval (push) Successful in 3m18s
All checks were successful
Check / eval (push) Successful in 3m18s
Treat the picker-only (none) row as an empty XKB variant at the shared input boundary and again in the template patcher. Guard the real downstream output and console keymap, and carry the sentinel through the offline installer VM. Verified: V0 full flake evaluation plus shell/Python/Nix/diff checks; V1 installer-keyboard, installer-safety, and template-SoT builds; V2 full KVM offline LUKS+swap install and themed first boot. No V3 required.
This commit is contained in:
@@ -30,22 +30,17 @@ These are separate queue items from one real install/session pass. Preserve
|
|||||||
that separation when fixing them: the installer blocker, unclear installer
|
that separation when fixing them: the installer blocker, unclear installer
|
||||||
copy, and post-install desktop failures have different verification paths.
|
copy, and post-install desktop failures have different verification paths.
|
||||||
|
|
||||||
### 91. Installer: empty keyboard variant becomes literal `(none)` and blocks installation
|
|
||||||
|
|
||||||
The generated system fails at `xkb-console-keymap.drv` with
|
|
||||||
`WARNING: Can not find "(none)" in "us"` followed by ckbcomp's `bad include
|
|
||||||
list`; every later system derivation is only a cascading dependency failure.
|
|
||||||
Normalize the picker's “no variant” value to the empty string before writing
|
|
||||||
Nix, and add an installer regression case for `us` + no variant. Pass = the
|
|
||||||
generated console/XKB configuration builds and the install proceeds.
|
|
||||||
|
|
||||||
### 92. Installer: keyboard selection is confusing
|
### 92. Installer: keyboard selection is confusing
|
||||||
|
|
||||||
Make the layout/variant relationship and the “no variant” choice explicit;
|
Make the layout/variant relationship and the “no variant” choice explicit;
|
||||||
show a human-readable preview/current choice before confirmation. Keep the
|
show a human-readable preview/current choice before confirmation. Typing may
|
||||||
simple default path short. Pass = a user can deliberately choose US with no
|
filter the installed XKB catalog, but it must never become a free-form value:
|
||||||
variant without knowing XKB terminology, and the generated values are shown
|
only an enumerated layout/variant (or the explicit no-variant choice) can be
|
||||||
before installation.
|
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
|
### 93. Installer: swap-size field lacks a clear prompt/explanation
|
||||||
|
|
||||||
|
|||||||
@@ -124,10 +124,16 @@ the **T14s** (webcam case).
|
|||||||
(SUPER+? / root menu › Keybindings) now ends in ↩ Back and
|
(SUPER+? / root menu › Keybindings) now ends in ↩ Back and
|
||||||
returns to the root picker; spot a couple of submenus (Display,
|
returns to the root picker; spot a couple of submenus (Display,
|
||||||
VPN › Tailscale) still Back correctly after the audit pass.
|
VPN › Tailscale) still Back correctly after the audit pass.
|
||||||
- [ ] **Keyboard hotplug picker (re-verify after in-flake graduation)** —
|
- [ ] **Per-device keyboard menu + hotplug restore (re-verify after in-flake
|
||||||
plug an external keyboard post-login, pick a layout in rofi, confirm
|
graduation)** — update to the current `main`, run `nomarchy-home`, and
|
||||||
it applies per-device only, persists in `settings.keyboard.devices`,
|
relogin with an external keyboard already connected. Open System ›
|
||||||
and graduates into a `device{}` block on the next rebuild.
|
Keyboard: both the built-in and external boards must be listed with
|
||||||
|
their current layouts. Pick a different layout for the external board;
|
||||||
|
it must apply only there and persist in `settings.keyboard.devices`.
|
||||||
|
Unplug/replug it: the saved layout must restore without another prompt
|
||||||
|
and graduate into a `device{}` block on the next rebuild. Finally plug
|
||||||
|
a previously unseen keyboard in after login: the automatic picker must
|
||||||
|
appear once and remember that choice too.
|
||||||
- [ ] **Snapshots restore + rollback exercise** (residual — the
|
- [ ] **Snapshots restore + rollback exercise** (residual — the
|
||||||
GUI/polkit half passed on hardware 2026-07-04, a47aa3a): in
|
GUI/polkit half passed on hardware 2026-07-04, a47aa3a): in
|
||||||
`sudo nomarchy-snapshots`, restore a single file (`undochange`)
|
`sudo nomarchy-snapshots`, restore a single file (`undochange`)
|
||||||
|
|||||||
@@ -19,6 +19,21 @@ Template:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
- **Did:** Normalize at the shared input boundary and defensively in the real
|
||||||
|
template patcher. Added a `us` + sentinel fixture, actual console-keymap
|
||||||
|
build, and made the offline installer VM carry the literal sentinel.
|
||||||
|
- **Verified:** V2 — full flake eval; focused installer-keyboard/safety/
|
||||||
|
template builds; shell/Python/Nix syntax and diff checks; KVM
|
||||||
|
`tools/test-install.sh` installed offline with LUKS + swap, powered off after
|
||||||
|
~400s, unlocked, and booted the themed desktop. Inspected install progress,
|
||||||
|
LUKS, and first-boot captures under `/tmp/nomarchy-vm/`.
|
||||||
|
- **Pending:** #92 now explicitly forbids free-form XKB values. Expanded the
|
||||||
|
existing per-device keyboard hardware check for the connected-at-login menu.
|
||||||
|
- **Next suggestion:** #92 installer keyboard selection clarity/safety.
|
||||||
|
|
||||||
## 2026-07-13 — #101 charge-limit AC-event burst recovery (this commit)
|
## 2026-07-13 — #101 charge-limit AC-event burst recovery (this commit)
|
||||||
- **Task:** Fix `nomarchy-battery-charge-limit.service` failing when USB-C
|
- **Task:** Fix `nomarchy-battery-charge-limit.service` failing when USB-C
|
||||||
dock power events arrive while its settling write is still active.
|
dock power events arrive while its settling write is still active.
|
||||||
|
|||||||
@@ -217,6 +217,13 @@ Design/decision records and a running log of shipped work (items marked
|
|||||||
v1: `nomarchy.system.plymouth.*`, background tinted from the state file)
|
v1: `nomarchy.system.plymouth.*`, background tinted from the state file)
|
||||||
- Installer round 2: multi-disk BTRFS RAID, impermanence, BIOS/legacy
|
- Installer round 2: multi-disk BTRFS RAID, impermanence, BIOS/legacy
|
||||||
boot (v1 `nomarchy-install` is single-disk UEFI — see `pkgs/nomarchy-install`)
|
boot (v1 `nomarchy-install` is single-disk UEFI — see `pkgs/nomarchy-install`)
|
||||||
|
- ✓ **Installer no-keyboard-variant safety (#91):** gum's display-only
|
||||||
|
`(none)` row is normalized at the common interactive/unattended boundary,
|
||||||
|
and the template patcher independently refuses to serialize that sentinel
|
||||||
|
into either Home Manager or system XKB configuration. A permanent check
|
||||||
|
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.
|
||||||
- launch-or-focus UX scripts (swayosd volume/brightness OSD ships since v1:
|
- launch-or-focus UX scripts (swayosd volume/brightness OSD ships since v1:
|
||||||
`nomarchy.osd.*`, media keys drive `swayosd-client`, themed from the JSON)
|
`nomarchy.osd.*`, media keys drive `swayosd-client`, themed from the JSON)
|
||||||
- **Distro branding, round 2:** `distroName = "Nomarchy"` ships
|
- **Distro branding, round 2:** `distroName = "Nomarchy"` ships
|
||||||
|
|||||||
31
flake.nix
31
flake.nix
@@ -349,10 +349,39 @@
|
|||||||
{ nativeBuildInputs = [ pkgs.python3 ]; }
|
{ nativeBuildInputs = [ pkgs.python3 ]; }
|
||||||
''
|
''
|
||||||
python3 ${./tools/check-install-safety.py} \
|
python3 ${./tools/check-install-safety.py} \
|
||||||
${./pkgs/nomarchy-install/nomarchy-install.sh}
|
${./pkgs/nomarchy-install/nomarchy-install.sh} \
|
||||||
|
${./pkgs/nomarchy-install/patch-template.py} \
|
||||||
|
${./templates/downstream}
|
||||||
touch $out
|
touch $out
|
||||||
'';
|
'';
|
||||||
|
|
||||||
|
# Installer keyboard no-variant regression (item 91): gum's `(none)`
|
||||||
|
# row is display-only. Exercise it through the real template
|
||||||
|
# patcher, then build the exact us + empty-variant console keymap
|
||||||
|
# which previously failed as xkb-console-keymap.drv.
|
||||||
|
installer-keyboard =
|
||||||
|
let
|
||||||
|
keyboardSystem = nixpkgs.lib.nixosSystem {
|
||||||
|
inherit system;
|
||||||
|
modules = [{
|
||||||
|
console.useXkbConfig = true;
|
||||||
|
services.xserver.xkb.layout = "us";
|
||||||
|
services.xserver.xkb.variant = "";
|
||||||
|
system.stateVersion = "26.05";
|
||||||
|
}];
|
||||||
|
};
|
||||||
|
in
|
||||||
|
pkgs.runCommand "nomarchy-installer-keyboard"
|
||||||
|
{ nativeBuildInputs = [ pkgs.python3 ]; }
|
||||||
|
''
|
||||||
|
python3 ${./tools/check-install-safety.py} \
|
||||||
|
${./pkgs/nomarchy-install/nomarchy-install.sh} \
|
||||||
|
${./pkgs/nomarchy-install/patch-template.py} \
|
||||||
|
${./templates/downstream}
|
||||||
|
test -s ${keyboardSystem.config.console.keyMap}
|
||||||
|
touch $out
|
||||||
|
'';
|
||||||
|
|
||||||
# Installer share ↔ templates/downstream SoT (item 72): the four
|
# Installer share ↔ templates/downstream SoT (item 72): the four
|
||||||
# files default.nix copies into share/.../template/ must stay
|
# files default.nix copies into share/.../template/ must stay
|
||||||
# byte-identical to the checkout template (single machine-seed
|
# byte-identical to the checkout template (single machine-seed
|
||||||
|
|||||||
@@ -222,9 +222,14 @@ else
|
|||||||
KB_VARIANT=$( { echo "(none)"; localectl list-x11-keymap-variants "$KB_LAYOUT" 2>/dev/null; } \
|
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)" \
|
| gum filter --placeholder "variant for $KB_LAYOUT (pick '(none)' for the default)" \
|
||||||
|| echo "(none)")
|
|| echo "(none)")
|
||||||
[[ "$KB_VARIANT" == "(none)" ]] && KB_VARIANT=""
|
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
# `(none)` is gum's display-only sentinel, never an XKB variant. Keep the
|
||||||
|
# normalization at the common boundary so interactive and unattended installs
|
||||||
|
# cannot write it into either generated Nix file.
|
||||||
|
if [[ "$KB_VARIANT" == "(none)" ]]; then
|
||||||
|
KB_VARIANT=""
|
||||||
|
fi
|
||||||
[[ "$USERNAME" =~ ^[a-z_][a-z0-9_-]*$ ]] || fail "Invalid username '$USERNAME'."
|
[[ "$USERNAME" =~ ^[a-z_][a-z0-9_-]*$ ]] || fail "Invalid username '$USERNAME'."
|
||||||
[[ -f "/usr/share/zoneinfo/$TIMEZONE" || -e "/etc/zoneinfo/$TIMEZONE" ]] \
|
[[ -f "/usr/share/zoneinfo/$TIMEZONE" || -e "/etc/zoneinfo/$TIMEZONE" ]] \
|
||||||
|| timedatectl list-timezones 2>/dev/null | grep -qx "$TIMEZONE" \
|
|| timedatectl list-timezones 2>/dev/null | grep -qx "$TIMEZONE" \
|
||||||
|
|||||||
@@ -32,6 +32,12 @@ def nix_str(s: str) -> str:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def keyboard_variant(v: dict) -> str:
|
||||||
|
"""Return the XKB value, never the picker's display-only sentinel."""
|
||||||
|
variant = v.get("keyboardVariant") or ""
|
||||||
|
return "" if variant == "(none)" else variant
|
||||||
|
|
||||||
|
|
||||||
def replace_once(text: str, old: str, new: str, label: str) -> str:
|
def replace_once(text: str, old: str, new: str, label: str) -> str:
|
||||||
if old not in text:
|
if old not in text:
|
||||||
sys.exit(f"patch-template: missing placeholder for {label}: {old!r}")
|
sys.exit(f"patch-template: missing placeholder for {label}: {old!r}")
|
||||||
@@ -75,7 +81,7 @@ def patch_flake(text: str, v: dict) -> str:
|
|||||||
|
|
||||||
def patch_home(text: str, v: dict) -> str:
|
def patch_home(text: str, v: dict) -> str:
|
||||||
layout = nix_str(v["keyboardLayout"])
|
layout = nix_str(v["keyboardLayout"])
|
||||||
variant = nix_str(v.get("keyboardVariant") or "")
|
variant = nix_str(keyboard_variant(v))
|
||||||
text = replace_once(
|
text = replace_once(
|
||||||
text,
|
text,
|
||||||
' nomarchy.keyboard.layout = "us";',
|
' nomarchy.keyboard.layout = "us";',
|
||||||
@@ -213,7 +219,7 @@ def patch_system(text: str, v: dict) -> str:
|
|||||||
text = replace_once(
|
text = replace_once(
|
||||||
text,
|
text,
|
||||||
' services.xserver.xkb.variant = "";',
|
' services.xserver.xkb.variant = "";',
|
||||||
f' services.xserver.xkb.variant = "{nix_str(v.get("keyboardVariant") or "")}";',
|
f' services.xserver.xkb.variant = "{nix_str(keyboard_variant(v))}";',
|
||||||
"xkb variant",
|
"xkb variant",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Installer safety guards contract (flake check `installer-safety`).
|
"""Installer safety guards contract (flake check `installer-safety`).
|
||||||
|
|
||||||
Usage: check-install-safety.py <nomarchy-install.sh>
|
Usage: check-install-safety.py <nomarchy-install.sh> [<patch-template.py> <template>]
|
||||||
|
|
||||||
Locks the BACKLOG #54 safety guards (+ #61 review Source line) against
|
Locks the BACKLOG #54 safety guards (+ #61 review Source line) against
|
||||||
silent regression:
|
silent regression:
|
||||||
@@ -11,16 +11,27 @@ silent regression:
|
|||||||
3. HM pre-activate failure drops NOMARCHY-DESKTOP-NOT-THEMED.txt.
|
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.
|
4. Review panel Source line tracks the same OFFLINE probe as the install path.
|
||||||
|
|
||||||
Also re-checks the password ≥8 interactive floors (V0-complete).
|
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.
|
Pure — no VM, no ISO.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
import re
|
import re
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
import tempfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
if len(sys.argv) not in (2, 4):
|
||||||
|
sys.exit(
|
||||||
|
f"usage: {sys.argv[0]} <nomarchy-install.sh> "
|
||||||
|
"[<patch-template.py> <template>]"
|
||||||
|
)
|
||||||
|
|
||||||
script = Path(sys.argv[1]).read_text(encoding="utf-8")
|
script = Path(sys.argv[1]).read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
@@ -165,4 +176,84 @@ check(
|
|||||||
"offline Source phrase should appear exactly once (SOURCE_NET assign)",
|
"offline Source phrase should appear exactly once (SOURCE_NET assign)",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# ── 5. Keyboard no-variant normalization (BACKLOG #91) ────────────────
|
||||||
|
if len(sys.argv) == 4:
|
||||||
|
# The picker sentinel must be normalized after both input paths converge,
|
||||||
|
# not only inside the interactive gum branch.
|
||||||
|
normalize_re = re.compile(
|
||||||
|
r'if \[\[ "\$KB_VARIANT" == "\(none\)" \]\]; then\s+'
|
||||||
|
r'KB_VARIANT=""\s+fi'
|
||||||
|
)
|
||||||
|
normalizations = list(normalize_re.finditer(script))
|
||||||
|
check(
|
||||||
|
len(normalizations) == 1,
|
||||||
|
"expected one set -e-safe keyboard `(none)` normalization",
|
||||||
|
)
|
||||||
|
normalize_at = normalizations[0].start() if normalizations else -1
|
||||||
|
input_branch_end = script.find(
|
||||||
|
'[[ "$USERNAME" =~ ^[a-z_][a-z0-9_-]*$ ]]', normalize_at
|
||||||
|
)
|
||||||
|
unattended_at = script.find('KB_VARIANT="${NOMARCHY_KB_VARIANT:-}"')
|
||||||
|
check(
|
||||||
|
unattended_at < normalize_at < input_branch_end,
|
||||||
|
"keyboard variant normalization is not on the shared input boundary",
|
||||||
|
)
|
||||||
|
|
||||||
|
values = {
|
||||||
|
"hostname": "keyboard-test",
|
||||||
|
"username": "nomarchy",
|
||||||
|
"timezone": "UTC",
|
||||||
|
"locale": "en_US.UTF-8",
|
||||||
|
"keyboardLayout": "us",
|
||||||
|
# This is the no-variant row as gum presents it. Even if a caller
|
||||||
|
# reaches the patcher before shell normalization, it must stay data,
|
||||||
|
# never become an XKB include name.
|
||||||
|
"keyboardVariant": "(none)",
|
||||||
|
"hashedPassword": "$6$test$hash",
|
||||||
|
"autoLogin": False,
|
||||||
|
"laptop": False,
|
||||||
|
"thermald": False,
|
||||||
|
"hardwareProfiles": [],
|
||||||
|
"hardware": {},
|
||||||
|
"resumeOffset": None,
|
||||||
|
"rootUuid": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
patcher = Path(sys.argv[2])
|
||||||
|
template = Path(sys.argv[3])
|
||||||
|
with tempfile.TemporaryDirectory(prefix="nomarchy-keyboard-") as tmp:
|
||||||
|
generated = Path(tmp) / "flake"
|
||||||
|
shutil.copytree(template, generated)
|
||||||
|
# Nix-store templates are read-only; the real installer copy is
|
||||||
|
# writable, so reproduce that property in the build sandbox.
|
||||||
|
for path in generated.iterdir():
|
||||||
|
if path.is_file():
|
||||||
|
path.chmod(path.stat().st_mode | 0o200)
|
||||||
|
result = subprocess.run(
|
||||||
|
[sys.executable, str(patcher), str(generated)],
|
||||||
|
input=json.dumps(values),
|
||||||
|
text=True,
|
||||||
|
capture_output=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
check(
|
||||||
|
result.returncode == 0,
|
||||||
|
f"keyboard fixture patch failed: {result.stderr.strip()}",
|
||||||
|
)
|
||||||
|
|
||||||
|
home = (generated / "home.nix").read_text(encoding="utf-8")
|
||||||
|
system = (generated / "system.nix").read_text(encoding="utf-8")
|
||||||
|
expected = (
|
||||||
|
(home, 'nomarchy.keyboard.layout = "us";'),
|
||||||
|
(home, 'nomarchy.keyboard.variant = "";'),
|
||||||
|
(system, 'services.xserver.xkb.layout = "us";'),
|
||||||
|
(system, 'services.xserver.xkb.variant = "";'),
|
||||||
|
)
|
||||||
|
for text, line in expected:
|
||||||
|
check(line in text, f"generated configuration missing {line!r}")
|
||||||
|
check(
|
||||||
|
"(none)" not in home and "(none)" not in system,
|
||||||
|
"keyboard picker sentinel leaked into generated Nix",
|
||||||
|
)
|
||||||
|
|
||||||
print("installer-safety: all contracts hold")
|
print("installer-safety: all contracts hold")
|
||||||
|
|||||||
@@ -95,6 +95,8 @@ echo " matrix: SWAP_GB=$TEST_SWAP_GB NO_LUKS=$TEST_NO_LUKS"
|
|||||||
echo 'export NOMARCHY_USERNAME=ada'
|
echo 'export NOMARCHY_USERNAME=ada'
|
||||||
echo 'export NOMARCHY_PASSWORD=nomarchy'
|
echo 'export NOMARCHY_PASSWORD=nomarchy'
|
||||||
echo 'export NOMARCHY_HOSTNAME=testbox'
|
echo 'export NOMARCHY_HOSTNAME=testbox'
|
||||||
|
echo 'export NOMARCHY_KB_LAYOUT=us'
|
||||||
|
echo 'export NOMARCHY_KB_VARIANT="(none)"'
|
||||||
echo "export NOMARCHY_SWAP_GB=$TEST_SWAP_GB"
|
echo "export NOMARCHY_SWAP_GB=$TEST_SWAP_GB"
|
||||||
echo 'export NOMARCHY_FINISH=poweroff'
|
echo 'export NOMARCHY_FINISH=poweroff'
|
||||||
if [[ "$TEST_NO_LUKS" == "1" ]]; then
|
if [[ "$TEST_NO_LUKS" == "1" ]]; then
|
||||||
|
|||||||
Reference in New Issue
Block a user