diff --git a/bin/omarchy-iso-test b/bin/omarchy-iso-test index 4d7e781..7824473 100755 --- a/bin/omarchy-iso-test +++ b/bin/omarchy-iso-test @@ -13,6 +13,9 @@ # # Usage: omarchy-iso-test [release/omarchy.iso] [options] # +# --keyboard LABEL English (US/UK), Japanese, Japanese (US keyboard), Korean, +# or Chinese (Simplified, Pinyin)/(Traditional, Zhuyin). +# OMARCHY_PATH must be the source tree used to build the ISO. # --encrypt Drive the encrypted install flow (default: unencrypted) # --provision Drive the deferred-provisioning install flow: no user during install, the # first boot runs omarchy-provision-owner and creates it there @@ -47,12 +50,14 @@ NO_PREVIEW=false SYNC_DIR="" SYNC_ALL=false +KEYBOARD_LABEL="English (US)" GUEST_USER="omarchy" GUEST_PASSWORD="omarchy" GUEST_HOSTNAME="omarchy-test" while (($#)); do case "$1" in + --keyboard) KEYBOARD_LABEL="$2"; shift ;; --encrypt) ENCRYPT=true ;; --provision) PROVISION=true ;; --reuse-base) REUSE_BASE=true ;; @@ -84,12 +89,20 @@ if [[ ! -f ${ISO:-} ]]; then exit 1 fi +case "$KEYBOARD_LABEL" in + "English (US)" | "English (UK)" | "Japanese" | "Japanese (US keyboard)" | "Korean" | "Chinese (Simplified, Pinyin)" | "Chinese (Traditional, Zhuyin)") ;; + *) echo "The VM harness cannot type credentials with keyboard: $KEYBOARD_LABEL" >&2; exit 1 ;; +esac + omarchy-pkg-add qemu-full edk2-ovmf socat imagemagick tesseract tesseract-data-eng OVMF_CODE="/usr/share/edk2/x64/OVMF_CODE.4m.fd" OVMF_VARS_TEMPLATE="/usr/share/edk2/x64/OVMF_VARS.4m.fd" BASE_NAME="$(basename "$ISO" .iso)" +if [[ $KEYBOARD_LABEL != "English (US)" ]]; then + BASE_NAME+="-$(printf '%s' "$KEYBOARD_LABEL" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g')" +fi $ENCRYPT && BASE_NAME+="-encrypted" $PROVISION && BASE_NAME+="-provision" BASE_DIR="$REPO_DIR/test-runs/$BASE_NAME" @@ -610,6 +623,35 @@ audio-panel|meta_l-ctrl-a|omarchy-keyboard-panel|omarchy.audio' # ----------------------------------------------------- driving the installer +select_keyboard() { + local label="$1" index direction="down" + # gum choose uses arrows, not a searchable text field. Read the same ordered + # choices the ISO vendors from the runtime rather than hard-code row numbers. + index=$(OMARCHY_TEST_KEYBOARD="$label" python - <<'PYTHON' +import os, re +from pathlib import Path +source = Path(os.environ.get("OMARCHY_PATH", "")) / "install/provisioning/setup-form.sh" +if not source.is_file(): + raise SystemExit("Set OMARCHY_PATH to the source tree used to build this ISO") +text = source.read_text() +rows = re.search(r"OMARCHY_KEYBOARD_LAYOUTS=\$'(.*?)'\n", text, re.S).group(1).splitlines() +labels = [row.split("|")[0] for row in rows] +label = os.environ["OMARCHY_TEST_KEYBOARD"] +if label not in labels: + raise SystemExit(f"Keyboard choice is absent from the ISO source: {label}") +print(labels.index(label) - labels.index("English (US)")) +PYTHON + ) || return 1 + if (( index < 0 )); then + direction="up" + index=$((-index)) + fi + for (( i = 0; i < index; i++ )); do + press "$direction" + sleep 0.1 + done +} + drive_configurator() { log "Driving the installer wizard" @@ -651,7 +693,11 @@ drive_configurator() { return 0 fi - press ret # English (US) is preselected + if [[ $KEYBOARD_LABEL != "English (US)" ]]; then + select_keyboard "$KEYBOARD_LABEL" + capture_console "success-installer-01-selected-keyboard" + fi + press ret # Normal flow: user step comes before disk selection. wait_for_screen "Username" 120 @@ -675,7 +721,15 @@ drive_configurator() { press ret wait_for_screen "Email address" 60 - type_text "test@omarchy.org" + type_text "test" + if [[ $KEYBOARD_LABEL == "Japanese" ]]; then + press bracket_left + elif [[ $KEYBOARD_LABEL == "English (UK)" ]]; then + press shift-apostrophe + else + press shift-2 + fi + type_text "omarchy.org" capture_console "success-installer-06-email" press ret @@ -736,8 +790,12 @@ drive_provision_owner() { press ret wait_for_screen "keyboard layout" 120 - press down - capture_console "success-provision-01-keyboard-layout-uk" + if [[ $KEYBOARD_LABEL == "English (US)" ]]; then + press down # preserve the harness's default UK provisioning coverage + else + select_keyboard "$KEYBOARD_LABEL" + fi + capture_console "success-provision-01-keyboard-layout" press ret wait_for_screen "Username" 120 @@ -763,7 +821,13 @@ drive_provision_owner() { # Shift+2 as on US); send that keycode so the typed email is correct under # the new layout — itself further proof the deferred keymap took effect. type_text "test" - press shift-apostrophe + if [[ $KEYBOARD_LABEL == "English (US)" || $KEYBOARD_LABEL == "English (UK)" ]]; then + press shift-apostrophe + elif [[ $KEYBOARD_LABEL == "Japanese" ]]; then + press bracket_left + else + press shift-2 + fi type_text "omarchy.org" capture_console "success-provision-04-email" press ret diff --git a/configs/airootfs/root/configurator b/configs/airootfs/root/configurator index 2960c06..8e18b59 100644 --- a/configs/airootfs/root/configurator +++ b/configs/airootfs/root/configurator @@ -800,6 +800,8 @@ run_partition_execute() { "bootloader_config": { "bootloader": "Limine", "uki": false, "removable": false }, "custom_commands": [], "omarchy_install": { + "input_method": "${keyboard_input_method:-none}", + "input_xkb_layout": "${keyboard_xkb_layout:-}", "mode": "protected", "target_mount": "/mnt", "boot": { @@ -1056,6 +1058,8 @@ if $defer_provisioning; then # their real layout in the OOBE, which re-keys the console and, on encrypted # installs, rebuilds the initramfs so the LUKS prompt matches. keyboard="us" + keyboard_input_method="none" + keyboard_xkb_layout="" username="" password="" password_hash="" @@ -1138,6 +1142,8 @@ cat <<-_EOF_ >user_configuration.json "bootloader_config": { "bootloader": "Limine", "uki": false, "removable": false }, "custom_commands": [], "omarchy_install": { + "input_method": "${keyboard_input_method:-none}", + "input_xkb_layout": "${keyboard_xkb_layout:-}", "mode": "full_disk", "defer_provisioning": $defer_provisioning, "target_mount": "/mnt", diff --git a/configs/airootfs/usr/share/omarchy-iso/orchestrator/archinstall_adapter.py b/configs/airootfs/usr/share/omarchy-iso/orchestrator/archinstall_adapter.py index c6a279b..4d9b20d 100644 --- a/configs/airootfs/usr/share/omarchy-iso/orchestrator/archinstall_adapter.py +++ b/configs/airootfs/usr/share/omarchy-iso/orchestrator/archinstall_adapter.py @@ -13,7 +13,7 @@ The canonical call sequence (mirrored from archinstall.scripts.guided.py) is: # configs do their own mounting before the Installer context opens. if disk_config.config_type != DiskLayoutType.Pre_mount: inst.mount_ordered_layout() - inst.sanity_check(offline=, skip_ntp=, skip_wkd=) + sanity_check(inst) inst.generate_key_files() # encrypted only inst.set_mirrors(handler, mirror_config, on_target=False) inst.minimal_installation(...) # base + linux pacstrap @@ -214,6 +214,14 @@ def _method_accepts(method, name: str) -> bool: return name in (*positional, *kwonly) +def sanity_check(installer) -> None: + """Archinstall 4.5 removed offline; retain it for older releases.""" + options = {"skip_ntp": True, "skip_wkd": True} + if _method_accepts(installer.sanity_check, "offline"): + options["offline"] = True + installer.sanity_check(**options) + + def _method_accepts_users(method) -> bool: return _method_accepts(method, "users") diff --git a/configs/airootfs/usr/share/omarchy-iso/orchestrator/context.py b/configs/airootfs/usr/share/omarchy-iso/orchestrator/context.py index 02494b0..436bfa8 100644 --- a/configs/airootfs/usr/share/omarchy-iso/orchestrator/context.py +++ b/configs/airootfs/usr/share/omarchy-iso/orchestrator/context.py @@ -11,6 +11,8 @@ from dataclasses import dataclass, field from pathlib import Path from typing import Any +from .keyboard import validate_input_selection + @dataclass class InstallContext: @@ -57,6 +59,8 @@ class InstallContext: kernel = (omarchy_install.get("storage") or {}).get("kernel") or _default_kernel() user_configuration["kernels"] = [kernel] + validate_input_selection(omarchy_install.get("input_method"), omarchy_install.get("input_xkb_layout", "")) + # Deferred provisioning: the whole system installs but user creation is deferred to # first boot. Selected by the configurator (omarchy_install.defer_provisioning) or by # an `defer-provisioning` marker file on an autoinstall drive, which also replaces the diff --git a/configs/airootfs/usr/share/omarchy-iso/orchestrator/keyboard.py b/configs/airootfs/usr/share/omarchy-iso/orchestrator/keyboard.py index 48c457c..969ae2f 100644 --- a/configs/airootfs/usr/share/omarchy-iso/orchestrator/keyboard.py +++ b/configs/airootfs/usr/share/omarchy-iso/orchestrator/keyboard.py @@ -16,21 +16,31 @@ from pathlib import Path from .command import capture -def configure_keyboard(target: Path, language: str) -> bool: +def configure_keyboard(target: Path, language: str, input_method: str | None = None, xkb_layout: str = "") -> bool: """Write the console keymap into a mounted target without booting it. 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. """ + validate_input_selection(input_method, xkb_layout) if not language.strip(): - return True + if input_method is None: + return True + language = "us" + + # Input engines are separate from real console keymaps. The shared picker + # uses US for Chinese/Korean and carries its preference in omarchy_install. + if input_method is None: + input_method = "mozc" if language == "jp106" else "none" result = capture(["localectl", "--no-pager", "list-keymaps"]) if result.returncode != 0: detail = result.stderr.strip() or "localectl returned an error" raise RuntimeError(f"Unable to list keyboard layouts: {detail}") if language.lower() not in {layout.lower() for layout in result.stdout.splitlines()}: + if input_method != "none": + raise ValueError(f"Unknown keyboard language: {language}") return False # systemd-firstboot --force rewrites vconsole.conf wholesale, dropping the @@ -51,4 +61,15 @@ def configure_keyboard(target: Path, language: str) -> bool: if font: vconsole_path.write_text(vconsole_path.read_text() + font + "\n") + preference = target / "etc/omarchy/input-method" + preference.parent.mkdir(parents=True, exist_ok=True) + preference.write_text(f"INPUT_METHOD={input_method}\nXKB_LAYOUT={xkb_layout}\n") + return True + + +def validate_input_selection(input_method: str | None, xkb_layout: str) -> None: + if input_method is not None and input_method not in {"none", "mozc", "hangul", "pinyin", "chewing"}: + raise ValueError(f"Unknown input method: {input_method}") + if xkb_layout not in {"", "kr"}: + raise ValueError(f"Unknown input keyboard layout: {xkb_layout}") diff --git a/configs/airootfs/usr/share/omarchy-iso/orchestrator/phases_impl.py b/configs/airootfs/usr/share/omarchy-iso/orchestrator/phases_impl.py index 1ebd4ce..e30e8c7 100644 --- a/configs/airootfs/usr/share/omarchy-iso/orchestrator/phases_impl.py +++ b/configs/airootfs/usr/share/omarchy-iso/orchestrator/phases_impl.py @@ -235,11 +235,7 @@ def arch_install_system(ctx: InstallContext) -> None: if not pre_mounted: installer.mount_ordered_layout() - installer.sanity_check( - offline=True, - skip_ntp=True, - skip_wkd=True, - ) + arch.sanity_check(installer) if not pre_mounted and arch.is_encrypted(config): installer.generate_key_files() @@ -274,7 +270,11 @@ def arch_install_system(ctx: InstallContext) -> None: # use the target kernel instead of the live ISO's kernel. installer.add_additional_packages([f"{kernel}-headers" for kernel in config.kernels]) - if not configure_keyboard(installer.target, kb_layout): + if not configure_keyboard( + installer.target, kb_layout, + ctx.omarchy_install.get("input_method"), + ctx.omarchy_install.get("input_xkb_layout", ""), + ): error(f"Invalid keyboard language specified: {kb_layout}") if config.mirror_config: diff --git a/test/unit/keyboard-selection-test.sh b/test/unit/keyboard-selection-test.sh new file mode 100644 index 0000000..674a3ad --- /dev/null +++ b/test/unit/keyboard-selection-test.sh @@ -0,0 +1,32 @@ +#!/bin/bash +set -euo pipefail + +ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd) +work=$(mktemp -d) +trap 'rm -rf "$work"' EXIT +mkdir -p "$work/install/provisioning" +cat > "$work/install/provisioning/setup-form.sh" <<'FORM' +OMARCHY_KEYBOARD_LAYOUTS=$'Chinese (Simplified, Pinyin)|us|pinyin +English (US)|us +Japanese|jp106|mozc' +FORM +export OMARCHY_PATH="$work" +eval "$(sed -n '/^select_keyboard() {$/,/^}$/p' "$ROOT/bin/omarchy-iso-test")" +press() { pressed+=("$1"); } +sleep() { :; } + +pressed=() +select_keyboard "Japanese" +[[ ${pressed[*]} == "down" ]] +pressed=() +select_keyboard "Chinese (Simplified, Pinyin)" +[[ ${pressed[*]} == "up" ]] +pressed=() +select_keyboard "English (US)" +(( ${#pressed[@]} == 0 )) +if (set -e; select_keyboard "Missing") 2>"$work/error"; then + echo "Unknown keyboard unexpectedly succeeded" >&2 + exit 1 +fi +grep -q 'Keyboard choice is absent' "$work/error" +echo "ok - VM keyboard choices are relative to the default and reject absent rows" diff --git a/test/unit/test_archinstall_sanity.py b/test/unit/test_archinstall_sanity.py new file mode 100644 index 0000000..ddf63b7 --- /dev/null +++ b/test/unit/test_archinstall_sanity.py @@ -0,0 +1,29 @@ +"""Exercise the compatibility boundary without importing host archinstall.""" +import ast +from pathlib import Path +import types +import unittest + +ROOT = Path(__file__).resolve().parents[2] +source = ROOT / "configs/airootfs/usr/share/omarchy-iso/orchestrator/archinstall_adapter.py" +tree = ast.parse(source.read_text()) +namespace = {} +functions = [node for node in tree.body if isinstance(node, ast.FunctionDef) and node.name in {"_method_accepts", "sanity_check"}] +exec(compile(ast.Module(body=functions, type_ignores=[]), str(source), "exec"), namespace) + + +class SanityCheckTest(unittest.TestCase): + def test_legacy_archinstall_stays_offline(self): + calls = [] + def check(offline=False, skip_ntp=False, skip_wkd=False): + calls.append((offline, skip_ntp, skip_wkd)) + namespace["sanity_check"](types.SimpleNamespace(sanity_check=check)) + self.assertEqual(calls, [(True, True, True)]) + + def test_archinstall_45_does_not_receive_removed_argument(self): + calls = [] + class Installer: + def sanity_check(self, skip_ntp=False, skip_wkd=False): + calls.append((skip_ntp, skip_wkd)) + namespace["sanity_check"](Installer()) + self.assertEqual(calls, [(True, True)]) diff --git a/test/unit/test_kernel_selection.py b/test/unit/test_kernel_selection.py index 5e01f73..a366a2e 100644 --- a/test/unit/test_kernel_selection.py +++ b/test/unit/test_kernel_selection.py @@ -107,7 +107,7 @@ class KernelSelectionTest(unittest.TestCase): ) ctx = types.SimpleNamespace( state={"arch_config_handler": types.SimpleNamespace(config=config), "mirror_handler": None}, - target=Path("/unused"), tailscale_authkey_path=None, + target=Path("/unused"), tailscale_authkey_path=None, omarchy_install={}, ) for name in ("_mount_offline_package_cache", "_mask_mkinitcpio_pacman_hooks", "_configure_limine_boot", "_write_pre_mounted_fstab"): @@ -119,6 +119,7 @@ class KernelSelectionTest(unittest.TestCase): stack.enter_context(mock.patch.object(phases_impl, "_runtime_package_list", return_value=["omarchy"])) stack.enter_context(mock.patch.object(phases_impl.arch, "is_pre_mount", return_value=True, create=True)) stack.enter_context(mock.patch.object(phases_impl.arch, "root_user", return_value=None, create=True)) + stack.enter_context(mock.patch.object(phases_impl.arch, "sanity_check", create=True)) opened = stack.enter_context(mock.patch.object(phases_impl.arch, "open_installer", create=True)) opened.return_value.__enter__.return_value = installer if fail_headers: diff --git a/test/unit/test_keyboard.py b/test/unit/test_keyboard.py index 16c3c41..1269b1c 100644 --- a/test/unit/test_keyboard.py +++ b/test/unit/test_keyboard.py @@ -1,6 +1,7 @@ #!/usr/bin/python import re +import os import sys import tempfile import unittest @@ -17,6 +18,7 @@ from orchestrator import keyboard as KEYBOARD # noqa: E402 # 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(os.environ.get("OMARCHY_PATH", "/nonexistent")) / "install/provisioning/setup-form.sh", 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"), @@ -31,7 +33,7 @@ def supported_keymaps(): 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 [line.split("|")[1] for line in block.group(1).splitlines()] return None @@ -79,6 +81,36 @@ class KeyboardConfigurationTest(unittest.TestCase): "KEYMAP=us\nFONT=default8x16\n", ) + def test_input_choice_survives_while_console_keymap_stays_real(self): + for method, keymap, layout in (("mozc", "jp106", ""), ("mozc", "us", ""), ("hangul", "us", "kr"), ("pinyin", "us", ""), ("chewing", "us", "")): + with self.subTest(method=method), tempfile.TemporaryDirectory() as directory: + target = self.target(directory) + self.assertTrue(KEYBOARD.configure_keyboard(target, keymap, method, layout)) + self.assertIn(f"KEYMAP={keymap}", (target / "etc/vconsole.conf").read_text()) + if keymap == "jp106": + self.assertIn("XKBLAYOUT=jp", (target / "etc/vconsole.conf").read_text()) + self.assertEqual((target / "etc/omarchy/input-method").read_text(), f"INPUT_METHOD={method}\nXKB_LAYOUT={layout}\n") + + def test_input_choice_with_no_console_map_defaults_to_us(self): + with tempfile.TemporaryDirectory() as directory: + target = self.target(directory) + self.assertTrue(KEYBOARD.configure_keyboard(target, "", "pinyin")) + self.assertIn("INPUT_METHOD=pinyin", (target / "etc/omarchy/input-method").read_text()) + + def test_unknown_keyboard_does_not_silently_drop_an_input_choice(self): + with tempfile.TemporaryDirectory() as directory: + target = self.target(directory) + with self.assertRaises(ValueError): + KEYBOARD.configure_keyboard(target, "unknown", "pinyin") + + def test_invalid_input_preferences_are_rejected_without_writes(self): + for method, layout in (("unknown", ""), ("hangul", "kr\nKEYMAP=ru")): + with tempfile.TemporaryDirectory() as directory: + target = self.target(directory) + with self.assertRaises(ValueError): + KEYBOARD.configure_keyboard(target, "us", method, layout) + self.assertFalse((target / "etc/omarchy/input-method").exists()) + if __name__ == "__main__": unittest.main()