diff --git a/bin/omarchy-iso-test b/bin/omarchy-iso-test index 3e670a8..281cf56 100755 --- a/bin/omarchy-iso-test +++ b/bin/omarchy-iso-test @@ -711,17 +711,17 @@ drive_provision_owner() { log "Driving the deferred-provisioning first-boot setup" # Deferred provisioning defers the keyboard step to first boot; it comes before the user form. - # Pick English (UK) (one row above the preselected English (US)) so the - # verifier can prove the deferred step applied a non-default layout — UK - # leaves every letter identical to US, so the typed password (sent as raw - # keycodes) still matches the literal one the harness pipes to sudo. + # Pick English (UK) (one row below the preselected English (US), which leads + # the list) so the verifier can prove the deferred step applied a non-default + # layout — UK leaves every letter identical to US, so the typed password (sent + # as raw keycodes) still matches the literal one the harness pipes to sudo. # A greeter (animated logo + tagline) opens first boot; Return begins. wait_for_screen "Opinionated" 600 capture_console "success-provision-00-greeter" press ret wait_for_screen "keyboard layout" 120 - press up + press down capture_console "success-provision-01-keyboard-layout-uk" press ret diff --git a/builder/build-iso.sh b/builder/build-iso.sh index ae7719b..6f94ff4 100755 --- a/builder/build-iso.sh +++ b/builder/build-iso.sh @@ -139,6 +139,7 @@ sed -i -E '/^(linux|broadcom-wl)$/d' "$build_cache_dir/packages.x86_64" # pulls the published omarchy* from the network mirror like any other package. if [[ -d /omarchy-source ]]; then base_pkg_lists=(/omarchy-source/install/omarchy-base.packages /omarchy-source/install/omarchy-other.packages) + setup_form=/omarchy-source/install/provisioning/setup-form.sh else # Pull the same package lists out of the freshly-downloaded Omarchy runtime # package so we don't need a local checkout in the non-local-source path. @@ -154,12 +155,32 @@ else mkdir -p /tmp/omarchy-pkglists bsdtar -xf "$omarchy_pkg" -C /tmp/omarchy-pkglists usr/share/omarchy/install/omarchy-base.packages usr/share/omarchy/install/omarchy-other.packages base_pkg_lists=(/tmp/omarchy-pkglists/usr/share/omarchy/install/omarchy-base.packages /tmp/omarchy-pkglists/usr/share/omarchy/install/omarchy-other.packages) + # Extracted on its own, tolerating a miss: bsdtar exits non-zero for a member + # it can't find, so asking for this alongside the package lists would abort the + # build here (set -e) with a bare "Not found in archive" instead of the + # actionable error below. + bsdtar -xf "$omarchy_pkg" -C /tmp/omarchy-pkglists usr/share/omarchy/install/provisioning/setup-form.sh 2>/dev/null || true + setup_form=/tmp/omarchy-pkglists/usr/share/omarchy/install/provisioning/setup-form.sh fi mkdir -p "$build_cache_dir/airootfs/usr/share/omarchy-iso" cp "${base_pkg_lists[0]}" "$build_cache_dir/airootfs/usr/share/omarchy-iso/omarchy-base.packages" cp "${base_pkg_lists[1]}" "$build_cache_dir/airootfs/usr/share/omarchy-iso/omarchy-other.packages" +# The configurator's setup form comes from the runtime this ISO bundles, so the +# installer and the first-boot setup that finishes a deferred install can never +# disagree. A runtime predating the split ships no such file, which would leave +# the configurator with no prompts at all. +if [[ ! -f $setup_form ]]; then + echo "ERROR: $OMARCHY_RUNTIME_PACKAGE does not ship install/provisioning/setup-form.sh" >&2 + echo " The configurator sources its prompts from that file, so this ISO" >&2 + echo " would boot into an installer with no questions to ask." >&2 + echo " Publish a runtime carrying the shared setup form, or build with" >&2 + echo " --local-source against a checkout that has it." >&2 + exit 1 +fi +cp "$setup_form" "$build_cache_dir/airootfs/usr/share/omarchy-iso/setup-form.sh" + # Collect every package we want available in the offline mirror. declare -a all_packages mapfile -t all_packages < <( diff --git a/configs/airootfs/root/configurator b/configs/airootfs/root/configurator index 3a1ca9e..ec94f53 100644 --- a/configs/airootfs/root/configurator +++ b/configs/airootfs/root/configurator @@ -9,6 +9,21 @@ OMARCHY_SETTINGS_PACKAGE="${OMARCHY_SETTINGS_PACKAGE:-omarchy-settings}" LOGO_PATH="$OMARCHY_PATH/logo.txt" +# The setup form — keyboard, account, hostname, and timezone questions, plus the +# rules their answers are checked against — shared verbatim with the first-boot +# owner setup that finishes a deferred install. build-iso.sh vendors it out of +# the runtime package this ISO bundles, so the two can never offer different +# layouts or accept different usernames. On the ISO that vendored copy is what +# loads; a dry run outside the ISO falls back to the omarchy checkout +# bin/omarchy-iso-configurator already points OMARCHY_PATH at. +SETUP_FORM="${SETUP_FORM:-/usr/share/omarchy-iso/setup-form.sh}" +[[ -r $SETUP_FORM ]] || SETUP_FORM="$OMARCHY_PATH/install/provisioning/setup-form.sh" +if [[ ! -r $SETUP_FORM ]]; then + echo "ERROR: shared setup form not found at $SETUP_FORM" >&2 + exit 1 +fi +source "$SETUP_FORM" + LOGO_WIDTH=$(awk '{ if (length > max) max = length } END { print max+0 }' "$LOGO_PATH") export GUM_CONFIRM_PROMPT_FOREGROUND="6" @@ -177,76 +192,32 @@ keyboard_form() { # screen is the hidden entry into deferred provisioning; $1 is "true" only for that first # invocation, so the review-loop re-edit can't flip a user install into it. local allow_defer_provisioning="${1:-false}" - clear_logo - echo - say "Let's setup your machine..." - if $allow_defer_provisioning; then - say --foreground 8 "Press Ctrl+C to prepare this machine for another owner." - fi - echo + local status - keyboards=$'Azerbaijani|azerty -Belarusian|by -Belgian|be-latin1 -Bulgarian|bg-cp1251 -Croatian|croat -Czech|cz -Danish|dk-latin1 -Dutch|nl -English (UK)|uk -English (US)|us -English (US, Dvorak)|dvorak -English (US, Colemak)|colemak -Estonian|et -Finnish|fi -French|fr -French (Canada)|cf -French (Switzerland)|fr_CH -Georgian|ge -German|de -German (Switzerland)|de_CH-latin1 -Greek|gr -Hebrew|il -Hungarian|hu -Icelandic|is-latin1 -Irish|ie -Italian|it -Japanese|jp106 -Kazakh|kazakh -Kyrgyz|kyrgyz -Lao|la-latin1 -Latvian|lv -Lithuanian|lt -Macedonian|mk-utf -Norwegian|no-latin1 -Polish|pl -Portuguese|pt-latin1 -Portuguese (Brazil)|br-abnt2 -Romanian|ro -Russian|ru -Serbian|sr-latin -Slovak|sk-qwertz -Slovenian|slovene -Spanish|es -Spanish (Latin American)|la-latin1 -Swedish|sv-latin1 -Tajik|tj_alt-UTF8 -Turkish|trq -Ukrainian|ua' - choice=$(printf '%s\n' "$keyboards" | cut -d'|' -f1 | gum choose --height 10 --selected "English (US)" --header "Select keyboard layout") - local status=$? - - if $allow_defer_provisioning && (( status == 130 )); then - if confirm_prepare_for_another_owner; then - defer_provisioning=true - return 0 + while true; do + clear_logo + echo + say "Let's setup your machine..." + if $allow_defer_provisioning; then + say --foreground 8 "Press Ctrl+C to prepare this machine for another owner." fi - keyboard_form true # declined: back to the keyboard picker - return - fi - (( status == 0 )) || abort + echo - keyboard=$(printf '%s\n' "$keyboards" | awk -F'|' -v c="$choice" '$1==c{print $2; exit}') + omarchy_prompt_keyboard && status=0 || status=$? + ((status == 0)) && break + + # Esc means "back", and nothing precedes the first screen, so re-ask. + ((status == OMARCHY_FORM_BACK)) && continue + + if $allow_defer_provisioning && ((status == OMARCHY_FORM_SIGNAL)); then + if confirm_prepare_for_another_owner; then + defer_provisioning=true + return 0 + fi + continue # declined: back to the keyboard picker + fi + abort + done # Only attempt to load keyboard layout if we're on a real console # loadkeys only works on Linux virtual consoles (tty), not in terminal emulators @@ -275,66 +246,34 @@ confirm_prepare_for_another_owner() { user_form() { step "Let's setup your user account..." - while true; do - username=$(gum input --placeholder "Alphanumeric without spaces (like dhh)" --prompt.foreground="#845DF9" --prompt "Username> ") || abort - - if [[ "$username" =~ ^[a-z_][a-z0-9_-]*[$]?$ ]]; then - if [[ "$username" =~ ^(root|bin|daemon|mail|ftp|http|nobody|dbus|systemd-coredump|systemd-network|systemd-oom|systemd-journal-remote|systemd-resolve|systemd-timesync|tss|uuidd|alpm|git|avahi|cups|lp|_talkd|polkitd|rtkit|qemu|brltty|gluster|rpc|libvirt-qemu|pcscd|nvidia-persistenced|sddm)$ ]] ; then - notice "Username is reserved for system" 1 - else - break - fi - else - notice "Username must be alphanumeric with no spaces" 1 - fi - done - - while true; do - password=$(gum input --placeholder "Used for user + root, and disk encryption when enabled" --prompt.foreground="#845DF9" --password --prompt "Password> ") || abort - password_confirmation=$(gum input --placeholder "Must match the password you just typed" --prompt.foreground="#845DF9" --password --prompt "Confirm> ") || abort - - if [[ -n "$password" && "$password" == "$password_confirmation" ]]; then - break - elif [[ -z "$password" ]]; then - notice "Your password can't be blank!" 1 - else - notice "Passwords didn't match!" 1 - fi - done + # Each prompt reports 0 (set), OMARCHY_FORM_BACK (Esc), or OMARCHY_FORM_SIGNAL + # (Ctrl+C). Both non-zero cases unwind to user_step, which decides what they + # mean here; the form itself never aborts. + omarchy_prompt_username || return $? + omarchy_prompt_password || return $? # Hash the password using yescrypt password_hash=$(printf '%s' "$password" | openssl passwd -6 -stdin) - full_name=$(gum input --placeholder "Used for git authentication (hit return to skip)" --prompt.foreground="#845DF9" --prompt "Full name> ") - email_address=$(gum input --placeholder "Used for git authentication (hit return to skip)" --prompt.foreground="#845DF9" --prompt "Email address> ") - - while true; do - hostname=$(gum input --placeholder "Letters, digits, and dashes (or return for 'omarchy')" --prompt.foreground="#845DF9" --prompt "Hostname> ") - - if [[ "$hostname" =~ ^[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?$ ]]; then - break - elif [[ -z $hostname ]]; then - hostname="omarchy" - break - else - notice "Hostname must be 1-63 letters, digits, or dashes, and cannot start or end with a dash" 1 - fi - done - - # Pick timezone - geo_guessed_timezone=$(tzupdate -p 2>/dev/null) - - if [[ -n $geo_guessed_timezone ]]; then - timezone=$(timedatectl list-timezones | gum choose --height 10 --selected "$geo_guessed_timezone" --header "Timezone") || abort - else - timezone=$(timedatectl list-timezones | gum filter --height 10 --header "Timezone") || abort - fi + omarchy_prompt_identity || return $? + omarchy_prompt_hostname || return $? + omarchy_prompt_timezone || return $? } user_step() { - user_form + local status while true; do + user_form && status=0 || status=$? + if ((status != 0)); then + # Esc unwinds to the start of the form. Ctrl+C has no side channel in the + # user step (only the keyboard and disk screens give it a meaning), so it + # keeps its long-standing behaviour of ending the install. + ((status == OMARCHY_FORM_BACK)) || abort + keyboard_form + continue + fi + # Add manual padding since gum table -p doesn't respect padding echo -e "Field,Value Username,$username @@ -351,7 +290,6 @@ Keyboard,$keyboard" | break else keyboard_form - user_form fi done } diff --git a/configs/airootfs/usr/share/omarchy-iso/orchestrator/keyboard.py b/configs/airootfs/usr/share/omarchy-iso/orchestrator/keyboard.py index 3286241..48f9431 100644 --- a/configs/airootfs/usr/share/omarchy-iso/orchestrator/keyboard.py +++ b/configs/airootfs/usr/share/omarchy-iso/orchestrator/keyboard.py @@ -18,8 +18,9 @@ from pathlib import Path def configure_keyboard(target: Path, language: str) -> bool: """Write the console keymap into a mounted target without booting it. - Returns False for layouts localectl doesn't know (the configurator offers - two, ba and khmer), matching archinstall: warn and keep the default. + Returns False for layouts localectl doesn't know, matching archinstall: + warn and keep the default. Every layout the configurator offers is known; + the guard is for the kb_layout an autoinstall drive can name freely. """ if not language.strip(): return True diff --git a/test/test_keyboard.py b/test/test_keyboard.py index a0103b4..c8b2abf 100644 --- a/test/test_keyboard.py +++ b/test/test_keyboard.py @@ -14,14 +14,30 @@ KEYBOARD = importlib.util.module_from_spec(SPEC) assert SPEC.loader SPEC.loader.exec_module(KEYBOARD) -CONFIGURATOR = (ROOT / "configs/airootfs/root/configurator").read_text() -KEYBOARD_BLOCK = re.search(r"keyboards=\$'(.*?)'\n choice=", CONFIGURATOR, re.DOTALL) -assert KEYBOARD_BLOCK -SUPPORTED_KEYMAPS = [ - line.split("|", 1)[1] - for line in KEYBOARD_BLOCK.group(1).splitlines() -] -UNSUPPORTED_KEYMAPS = {"ba", "khmer"} +# The layout list lives in the Omarchy runtime now, shared verbatim with the +# first-boot owner setup; build-iso.sh vendors it onto the ISO. Read it from +# wherever this checkout can see a runtime, and skip the coverage test rather +# than fail when none is around (a bare CI checkout of just this repo). +SETUP_FORM_CANDIDATES = ( + Path("/omarchy-source/install/provisioning/setup-form.sh"), + ROOT.parent / "omarchy/install/provisioning/setup-form.sh", + Path("/usr/share/omarchy/install/provisioning/setup-form.sh"), +) + + +def supported_keymaps(): + for candidate in SETUP_FORM_CANDIDATES: + if not candidate.is_file(): + continue + block = re.search( + r"OMARCHY_KEYBOARD_LAYOUTS=\$'(.*?)'\n", candidate.read_text(), re.DOTALL + ) + assert block, f"no layout list in {candidate}" + return [line.split("|", 1)[1] for line in block.group(1).splitlines()] + return None + + +SUPPORTED_KEYMAPS = supported_keymaps() class KeyboardConfigurationTest(unittest.TestCase): @@ -45,15 +61,15 @@ class KeyboardConfigurationTest(unittest.TestCase): self.assertEqual(lines.count("FONT=default8x16"), 1) def test_all_configurator_keymaps_are_known_to_localectl(self): + if SUPPORTED_KEYMAPS is None: + self.skipTest("no Omarchy runtime checkout to read the layout list from") for keymap in SUPPORTED_KEYMAPS: with self.subTest(keymap=keymap), tempfile.TemporaryDirectory() as directory: target = self.target(directory) - configured = KEYBOARD.configure_keyboard(target, keymap) - self.assertEqual(configured, keymap not in UNSUPPORTED_KEYMAPS) - if configured: - lines = (target / "etc/vconsole.conf").read_text().splitlines() - self.assertIn(f"KEYMAP={keymap}", lines) - self.assertEqual(lines.count("FONT=default8x16"), 1) + self.assertTrue(KEYBOARD.configure_keyboard(target, keymap)) + lines = (target / "etc/vconsole.conf").read_text().splitlines() + self.assertIn(f"KEYMAP={keymap}", lines) + self.assertEqual(lines.count("FONT=default8x16"), 1) def test_unknown_and_empty_keymaps_match_archinstall_behavior(self): for keymap, expected in (("definitely-not-a-keymap", False), ("", True)):