diff --git a/README.md b/README.md index 6ac625c..8d3400a 100644 --- a/README.md +++ b/README.md @@ -179,6 +179,8 @@ When `authorized_keys` is present, autoinstall installs it as the user's `~/.ssh When `tailscale_authkey` is present (one key, blank lines and `#` comments ignored), the install adds the `tailscale` package from the ISO's bundled mirror — nothing is fetched from the network at install or boot — and stages the join for first boot: the key lands at `/etc/tailscale/authkey` (root-only), `tailscaled` is enabled, ufw allows traffic in on `tailscale0`, and a background unit runs `tailscale up` once the network is actually up, retrying until it succeeds without holding up the boot. After a successful join the key is deleted and the unit disables itself; until then both survive reboots, so a machine installed offline joins whenever it first gets connectivity. The node appears on the tailnet under the configured hostname. Use a reusable, pre-authorized (tagged) key so one drive image serves many machines — or an ephemeral key for disposable VMs. +The region follows `timezone`, exactly as in an interactive install: a timezone listed in one of the runtime's region profiles (`default/regions/*/timezones`, such as `Asia/Shanghai` for `cn`) selects that profile's mirrors and repositories, and anything else is global. Set `omarchy_install.region` in `user_configuration.json` to pick one explicitly, for example `"cn"` on a deferred-provisioning install, which carries `UTC` until first boot. Every ISO bundles every profile's packages, so a regional install still needs no network. + ### Building the drive ```bash @@ -219,6 +221,8 @@ For local development, build the ISO from sibling checkouts: Despite the local folder name, the first argument is the Omarchy source checkout (runtime commands, configs, setup scripts, themes, shell, migrations). The installer itself lives in this ISO repo. +The configurator asks for the language on its first screen: English or Simplified Chinese. Chinese reruns the installer in cage + foot, a console that draws Chinese, with Noto Sans Mono CJK unpacked from the offline mirror's `noto-fonts-cjk`. It opens on the keyboard screen, offers only keyboards with a US keymap (cage keeps a US layout) and preselects Chinese (Simplified, Pinyin); every later screen and the install progress read in Chinese. Esc there goes back to a bilingual language screen, where English returns to the VT, which applies the chosen keymap. Without a working display or the font, Chinese continues in English on the VT. Either way a Chinese install sets the target's `LANG` to `zh_CN.UTF-8` through archinstall's `locale_config`. The language never decides the region: that follows the timezone. The Chinese text lives in `OMARCHY_ZH` tables beside the English it replaces. + Use `--dev` or `--rc` to build against those package channels. Both `--dev` and `--edge` select the dev packages from the edge mirror. ## Testing the ISO diff --git a/bin/omarchy-iso-test b/bin/omarchy-iso-test index 7824473..0f95b2d 100755 --- a/bin/omarchy-iso-test +++ b/bin/omarchy-iso-test @@ -17,6 +17,7 @@ # 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) +# --language LANG Pick en (default) or zh; zh keeps the preselected Pinyin keyboard # --provision Drive the deferred-provisioning install flow: no user during install, the # first boot runs omarchy-provision-owner and creates it there # --reuse-base Skip the install phase if a base image already exists @@ -42,6 +43,7 @@ MEMORY=8192 INSTALL_TIMEOUT=2400 BOOT_TIMEOUT=600 ENCRYPT=false +INSTALL_LANGUAGE=en PROVISION=false REUSE_BASE=false INSTALL_ONLY=false @@ -59,6 +61,7 @@ while (($#)); do case "$1" in --keyboard) KEYBOARD_LABEL="$2"; shift ;; --encrypt) ENCRYPT=true ;; + --language) INSTALL_LANGUAGE="$2"; shift ;; --provision) PROVISION=true ;; --reuse-base) REUSE_BASE=true ;; --install-only) INSTALL_ONLY=true ;; @@ -89,12 +92,32 @@ if [[ ! -f ${ISO:-} ]]; then exit 1 fi +if [[ $INSTALL_LANGUAGE != "en" && $INSTALL_LANGUAGE != "zh" ]]; then + echo "--language must be en or zh, not $INSTALL_LANGUAGE" >&2 + exit 1 +fi +if [[ $INSTALL_LANGUAGE == "zh" && $KEYBOARD_LABEL != "English (US)" ]]; then + echo "--language zh takes the preselected Pinyin keyboard; drop --keyboard" >&2 + 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 +omarchy-pkg-add qemu-full edk2-ovmf socat imagemagick tesseract tesseract-data-eng tesseract-data-chi_sim + +# After the language screen a Chinese install reads in Chinese. +OCR_LANGS=eng +[[ $INSTALL_LANGUAGE == "zh" ]] && OCR_LANGS=eng+chi_sim +# lang_text picks the text a screen shows in this run. +lang_text() { + if [[ $INSTALL_LANGUAGE == "zh" ]]; then + printf '%s' "$2" + else + printf '%s' "$1" + fi +} OVMF_CODE="/usr/share/edk2/x64/OVMF_CODE.4m.fd" OVMF_VARS_TEMPLATE="/usr/share/edk2/x64/OVMF_VARS.4m.fd" @@ -104,6 +127,7 @@ 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" +[[ $INSTALL_LANGUAGE == "zh" ]] && BASE_NAME+="-zh" $PROVISION && BASE_NAME+="-provision" BASE_DIR="$REPO_DIR/test-runs/$BASE_NAME" RUN_DIR="$BASE_DIR/runs/$(date +%Y%m%d-%H%M%S)" @@ -253,7 +277,13 @@ ocr_screen() { screendump "$shot" [[ -s $shot ]] || return 0 magick "$shot" -colorspace gray -negate -resize 150% "$prepped" 2>/dev/null || return 0 - tesseract "$prepped" - --psm 6 2>/dev/null || true + # The logo throws tesseract off CJK, so Chinese runs also read below it, without spaces. + if [[ $INSTALL_LANGUAGE == "zh" ]]; then + magick "$shot" -gravity north -chop 0x35% -colorspace gray -negate -resize 150% "$prepped.text.png" 2>/dev/null || return 0 + { tesseract "$prepped" - --psm 6 -l "$OCR_LANGS"; tesseract "$prepped.text.png" - --psm 6 -l "$OCR_LANGS"; } 2>/dev/null | sed 'p; s/ //g' || true + else + tesseract "$prepped" - --psm 6 -l "$OCR_LANGS" 2>/dev/null || true + fi } # Wait until the console shows the given text. Saves the offending screen as @@ -660,23 +690,28 @@ drive_configurator() { capture_console "success-installer-00-greeter" press ret - # Keyboard is the first screen. Ctrl+C there is the hidden entry into deferred provisioning - # mode; a normal install just selects the layout. - wait_for_screen "keyboard layout" 300 + # Language comes first: English on top, Chinese below. + wait_for_screen "Select language" 300 + capture_console "success-installer-01-language" + if [[ $INSTALL_LANGUAGE == "zh" ]]; then press end; else press home; fi + press ret + + # The keyboard follows, for Chinese in cage + foot; Ctrl+C there defers provisioning. + wait_for_screen "$(lang_text "keyboard layout" "键盘布局")" 300 capture_console "success-installer-01-keyboard-layout" if $PROVISION; then press ctrl-c # arm "prepare for another owner" - wait_for_screen "another owner" 30 + wait_for_screen "$(lang_text "another owner" "另一位")" 30 capture_console "success-installer-02-prepare-confirm" press ret # "Yes, prepare for another owner" is the affirmative default # Jumps straight to disk selection, then the overwrite confirm. - wait_for_screen "install disk" 60 + wait_for_screen "$(lang_text "install disk" "安装磁盘")" 60 capture_console "success-installer-03-prepare-install-disk" press ret - wait_for_screen "recovery possible" 60 + wait_for_screen "$(lang_text "recovery possible" "无法恢复")" 60 capture_console "success-installer-04-prepare-disk-warning-encrypted" if ! $ENCRYPT; then press ctrl-c @@ -700,27 +735,27 @@ drive_configurator() { press ret # Normal flow: user step comes before disk selection. - wait_for_screen "Username" 120 + wait_for_screen "$(lang_text "Username" "用户名")" 120 type_text "$GUEST_USER" capture_console "success-installer-02-username" press ret - wait_for_screen "Password" 60 + wait_for_screen "$(lang_text "Password" "密码")" 60 type_text "$GUEST_PASSWORD" capture_console "success-installer-03-password" press ret - wait_for_screen "Must match" 60 + wait_for_screen "$(lang_text "Must match" "确认")" 60 type_text "$GUEST_PASSWORD" capture_console "success-installer-04-password-confirmation" press ret - wait_for_screen "Full name" 60 + wait_for_screen "$(lang_text "Full name" "姓名")" 60 type_text "Omarchy Test" capture_console "success-installer-05-full-name" press ret - wait_for_screen "Email address" 60 + wait_for_screen "$(lang_text "Email address" "邮箱")" 60 type_text "test" if [[ $KEYBOARD_LABEL == "Japanese" ]]; then press bracket_left @@ -733,30 +768,30 @@ drive_configurator() { capture_console "success-installer-06-email" press ret - wait_for_screen "Hostname" 60 + wait_for_screen "$(lang_text "Hostname" "主机名")" 60 type_text "$GUEST_HOSTNAME" capture_console "success-installer-07-hostname" press ret - wait_for_screen "Timezone" 180 # tzupdate geo-guess can take a moment + wait_for_screen "$(lang_text "Timezone" "时区")" 180 # tzupdate geo-guess can take a moment capture_console "success-installer-08-timezone" press ret - wait_for_screen "look right" 60 + wait_for_screen "$(lang_text "look right" "正确")" 60 capture_console "success-installer-09-review" press ret - wait_for_screen "install disk" 60 + wait_for_screen "$(lang_text "install disk" "安装磁盘")" 60 capture_console "success-installer-10-install-disk" press ret # the single virtio disk - wait_for_screen "installation mode\|recovery possible" 60 - if ocr_screen | grep -qi "installation mode"; then + wait_for_screen "$(lang_text "installation mode\|recovery possible" "安装方式\|无法恢复")" 60 + if ocr_screen | grep -qi "$(lang_text "installation mode" "安装方式")"; then capture_console "success-installer-11-install-mode" press ret # "Full disk install" is preselected fi - wait_for_screen "recovery possible" 60 + wait_for_screen "$(lang_text "recovery possible" "无法恢复")" 60 capture_console "success-installer-12-disk-warning-encrypted" if ! $ENCRYPT; then press ctrl-c # toggles the confirm to the unencrypted flow @@ -860,7 +895,8 @@ wait_for_install() { while true; do text=$(ocr_screen) - if grep -qi "Reboot Now" <<<"$text"; then + # OCR cannot read the Chinese reboot button, so Chinese runs match the title above it. + if grep -qi "$(lang_text "Reboot Now" "安装完成")" <<<"$text"; then log "Install finished. Confirming the reboot prompt." capture_console "success-installer-15-reboot" press ret @@ -876,7 +912,7 @@ wait_for_install() { return 0 fi - if grep -qi "installation stopped" <<<"$text"; then + if grep -qi "$(lang_text "installation stopped" "安装已停止")" <<<"$text"; then capture_console "failure-installer-installation-stopped" echo "Install failed — screenshot saved to $RUN_DIR" >&2 return 1 diff --git a/builder/build-iso.sh b/builder/build-iso.sh index 13a90d9..f4c57db 100755 --- a/builder/build-iso.sh +++ b/builder/build-iso.sh @@ -134,7 +134,8 @@ cp "/tmp/$NODE_FILENAME" "$build_cache_dir/airootfs/opt/packages/" # The selected omarchy-settings package is needed here so its post_install hook # drops Omarchy's plymouthd.conf into /etc/plymouth before mkarchiso builds the # live initramfs. -arch_packages=(linux-t2 git gum jq openssl plymouth ttfx tzupdate omarchy-keyring "$OMARCHY_SETTINGS_PACKAGE" lvm2 cryptsetup parted) +# cage and foot give Chinese installs a console that draws Chinese. +arch_packages=(linux-t2 git gum jq openssl plymouth ttfx tzupdate omarchy-keyring "$OMARCHY_SETTINGS_PACKAGE" lvm2 cryptsetup parted cage foot) printf '%s\n' "${arch_packages[@]}" >> "$build_cache_dir/packages.x86_64" # The live ISO boots linux-t2 (see airootfs/etc/mkinitcpio.d/linux-t2.preset), so @@ -155,6 +156,7 @@ sed -i -E '/^(linux|broadcom-wl)$/d' "$build_cache_dir/packages.x86_64" # already in the mirror and we filter them out below. Without it, pacman -Syw # pulls the published omarchy* from the network mirror like any other package. if [[ -d /omarchy-source ]]; then + regions_source=/omarchy-source/default/regions 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 @@ -177,6 +179,17 @@ else # 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 + # Region profiles live under default/, which the settings package ships, + # not the runtime. Its own cache dir keeps the runtime lookup above from + # ever matching it. + settings_cache_dir="$bootstrap_cache_dir/settings" + mkdir -p "$settings_cache_dir" + pacman --config /configs/pacman-online-${OMARCHY_MIRROR}.conf --noconfirm -Syw "$OMARCHY_SETTINGS_PACKAGE" --cachedir "$settings_cache_dir" --dbpath /tmp/offlinedb-bootstrap >/dev/null + regions_source=$(find "$settings_cache_dir" -maxdepth 1 -type f -name "$OMARCHY_SETTINGS_PACKAGE-*.pkg.tar.zst" | sort | head -1) + if [[ -z $regions_source ]]; then + echo "ERROR: downloaded package for $OMARCHY_SETTINGS_PACKAGE not found in $settings_cache_dir" >&2 + exit 1 + fi setup_form=/tmp/omarchy-pkglists/usr/share/omarchy/install/provisioning/setup-form.sh fi @@ -203,6 +216,24 @@ if [[ ! -f $setup_form ]]; then fi cp "$setup_form" "$build_cache_dir/airootfs/usr/share/omarchy-iso/setup-form.sh" +# Every region's packages ride in the offline mirror, and its community +# repositories join a writable copy of the online config to download them. +online_config=/tmp/omarchy-pacman-online.conf +cp "/configs/pacman-online-${OMARCHY_MIRROR}.conf" "$online_config" +bash /builder/prepare-regions.sh "$regions_source" \ + "$build_cache_dir/airootfs/usr/share/omarchy-iso" "$online_config" + +# Profiles ship in the settings package, but the finalizer that applies them +# ships in the runtime. A runtime without it would ignore the region marker, +# leaving regional targets with the keyring but none of the repositories. +if [[ -n ${omarchy_pkg:-} && -d $build_cache_dir/airootfs/usr/share/omarchy-iso/regions ]]; then + runtime_listing=$(bsdtar -tf "$omarchy_pkg") + if ! grep -qx 'usr/bin/omarchy-apply-pacman' <<<"$runtime_listing"; then + echo "ERROR: $OMARCHY_SETTINGS_PACKAGE ships region profiles, but $OMARCHY_RUNTIME_PACKAGE lacks omarchy-apply-pacman to apply them." >&2 + exit 1 + fi +fi + # Collect every package we want available in the offline mirror. declare -a all_packages mapfile -t all_packages < <( @@ -210,6 +241,9 @@ mapfile -t all_packages < <( cat "$build_cache_dir/packages.x86_64" grep -hv '^#\|^$' "${base_pkg_lists[@]}" grep -hv '^#\|^$' /builder/archinstall.packages + # Downloaded but not installed: the installer adds a region's packages + # only to targets in that region. + grep -hsv '^#\|^$' "$build_cache_dir"/airootfs/usr/share/omarchy-iso/regions/*/packages || true # Always include the selected Omarchy packages so the target install can # find the runtime and companion packages in the offline mirror. printf '%s\n' "$OMARCHY_RUNTIME_PACKAGE" "$OMARCHY_SETTINGS_PACKAGE" "$OMARCHY_NVIM_PACKAGE" @@ -248,7 +282,7 @@ fi mkdir -p /tmp/offlinedb download_offline_packages() { - pacman --config /configs/pacman-online-${OMARCHY_MIRROR}.conf --noconfirm -Syw \ + pacman --config "$online_config" --noconfirm -Syw \ "${all_packages[@]}" --cachedir "$offline_mirror_dir/" --dbpath /tmp/offlinedb --needed } @@ -266,7 +300,7 @@ fi # newest version of every cached package name) removes packages that have left # the lists or dependency closure, such as an old Electron major version. if ! resolved_package_files="$( - pacman --config "/configs/pacman-online-${OMARCHY_MIRROR}.conf" --noconfirm \ + pacman --config "$online_config" --noconfirm \ --dbpath /tmp/offlinedb -S --print --print-format '%f' "${all_packages[@]}" )"; then echo "ERROR: could not resolve the package files required by the offline mirror" >&2 diff --git a/builder/prepare-regions.sh b/builder/prepare-regions.sh new file mode 100644 index 0000000..c668048 --- /dev/null +++ b/builder/prepare-regions.sh @@ -0,0 +1,50 @@ +#!/bin/bash + +# Ship every region profile from the bundled runtime, so one ISO serves every +# region. The installer picks a profile from the target's timezone; this only +# stages the profiles and makes their packages downloadable. Repository +# defaults stay in the runtime, not in a second set of ISO-specific templates. +set -euo pipefail + +regions_source="$1" +iso_payload="$2" +online_config="$3" + +# The source is a checkout's default/regions, or the published settings +# package that ships default/. One predating region profiles ships none; the +# ISO then installs global targets only, exactly as before. Anything else that +# fails here, such as an unreadable package, aborts the build. +regions="$regions_source" +if [[ -f $regions_source ]]; then + member=usr/share/omarchy/default/regions + listing=$(bsdtar -tf "$regions_source") + if grep -q "^$member/" <<<"$listing"; then + extracted=$(mktemp -d) + trap 'rm -rf "$extracted"' EXIT + bsdtar -xf "$regions_source" -C "$extracted" "$member" + regions="$extracted/$member" + fi +fi +if [[ ! -d $regions ]]; then + echo "Runtime ships no region profiles; every install will be global." + exit 0 +fi + +cp -a "$regions" "$iso_payload/regions" + +for profile in "$regions"/*/; do + # Only the community repositories are needed to download regional packages. + # Keep the channel's build mirrors intact; target mirrors are set later by + # the runtime's own pacman finalizer. + printf '\n' >> "$online_config" + cat "$profile/pacman/pacman.conf.append" >> "$online_config" + + # Each keyring package must be signed by a key Arch already trusts. Do not + # bootstrap trust from an unchecked keyserver response or disable signatures. + # https://github.com/archlinuxcn/repo#usage + mapfile -t keyring_packages < <(grep -hv '^#\|^$' "$profile/packages" | grep -- '-keyring$' || true) + if (( ${#keyring_packages[@]} > 0 )); then + pacman --config "$online_config" --noconfirm -Sy --needed "${keyring_packages[@]}" + pacman-key --populate "${keyring_packages[@]%-keyring}" + fi +done diff --git a/configs/airootfs/root/.automated_script.sh b/configs/airootfs/root/.automated_script.sh index 16904f8..90d1ad3 100644 --- a/configs/airootfs/root/.automated_script.sh +++ b/configs/airootfs/root/.automated_script.sh @@ -10,7 +10,10 @@ # - COLUMNS/LINES so gum picks up real terminal size set -euo pipefail -[[ $(tty) == /dev/tty1 ]] || exit 0 +[[ $(tty) == /dev/tty1 || -n ${OMARCHY_CJK_CONSOLE:-} ]] || exit 0 +if [[ -n ${OMARCHY_CJK_CONSOLE:-} ]]; then + touch "$XDG_RUNTIME_DIR/cjk-console-started" +fi export OMARCHY_MIRROR="$(cat /root/omarchy_mirror)" if [[ -f /root/omarchy_iso_ref ]]; then @@ -39,7 +42,8 @@ set_tokyo_night_colors() { echo -en "\033[0m" clear } -set_tokyo_night_colors +# foot sets the same palette from foot.ini; these escapes are VT-only. +[[ -n ${OMARCHY_CJK_CONSOLE:-} ]] || set_tokyo_night_colors mkdir -p /var/log touch "$OMARCHY_INSTALL_LOG_FILE" @@ -90,6 +94,16 @@ warm_offline_mirror & warm_pid=$! trap 'kill "$warm_pid" 2>/dev/null' EXIT +# The CJK console's font comes from the offline mirror's noto-fonts-cjk, not the live image. +unpack_cjk_font() { + local packages=(/var/cache/omarchy/mirror/offline/noto-fonts-cjk-*.pkg.tar.zst) + if ((${#packages[@]} != 1)) || [[ ! -f ${packages[0]} ]]; then + echo "Expected one noto-fonts-cjk package in the offline mirror, found: ${packages[*]}" >&2 + return 1 + fi + bsdtar -xf "${packages[0]}" -C / usr/share/fonts/noto-cjk/NotoSansCJK-Regular.ttc +} + cd /root # Autoinstall: a cidata drive carrying the configurator's own output files # stands in for the wizard. omarchy-cidata-load copies them into /root and @@ -98,7 +112,43 @@ if /usr/local/bin/omarchy-cidata-load; then echo "Autoinstall configuration found on cidata drive; skipping the configurator." export OMARCHY_UI_INTERACTIVE=no else - ./configurator + # Chinese moves to cage + foot, which draw Chinese; English moves back to the VT, which applies keymaps. + cjk_relaunch=10 + vt_return=11 + if [[ -n ${OMARCHY_CJK_CONSOLE:-} ]]; then + export OMARCHY_VT_RETURN=$vt_return + elif command -v cage >/dev/null; then + export OMARCHY_CJK_RELAUNCH=$cjk_relaunch + fi + ./configurator && status=0 || status=$? + while ((status == cjk_relaunch)); do + cjk_runtime=/run/omarchy-cjk-console + mkdir -p -m 700 "$cjk_runtime" + rm -f "$cjk_runtime/cjk-console-started" "$cjk_runtime/return-to-vt" + if ! unpack_cjk_font; then + echo "The Chinese font could not be unpacked from the offline mirror; continuing in English." + unset OMARCHY_CJK_RELAUNCH + OMARCHY_INSTALL_LANGUAGE=zh ./configurator && status=0 || status=$? + continue + fi + cage_status=0 + env -u OMARCHY_CJK_RELAUNCH XDG_RUNTIME_DIR=$cjk_runtime OMARCHY_CJK_CONSOLE=1 OMARCHY_INSTALL_LANGUAGE=zh \ + LANG=C.UTF-8 WLR_RENDERER=pixman cage -- foot -c /usr/share/omarchy-iso/foot.ini "$0" || cage_status=$? + if [[ -f $cjk_runtime/return-to-vt ]]; then + OMARCHY_INSTALL_LANGUAGE=en ./configurator && status=0 || status=$? + elif [[ -f $cjk_runtime/cjk-console-started ]]; then + exit "$cage_status" + else + echo "The Chinese console could not start (cage exit $cage_status); continuing in English." + unset OMARCHY_CJK_RELAUNCH + OMARCHY_INSTALL_LANGUAGE=zh ./configurator && status=0 || status=$? + fi + done + if [[ -n ${OMARCHY_CJK_CONSOLE:-} ]] && ((status == vt_return)); then + touch "$XDG_RUNTIME_DIR/return-to-vt" + exit 0 + fi + ((status == 0)) || exit "$status" fi # Deferred-provisioning installs skip the celebration/reboot prompt and reboot on @@ -111,6 +161,11 @@ if [[ -f /root/defer-provisioning ]] || export OMARCHY_UI_DEFER_PROVISIONING=yes fi +# Only Chinese reaches the install from the CJK console; English returns to the VT. +if [[ -n ${OMARCHY_CJK_CONSOLE:-} ]]; then + export OMARCHY_INSTALL_LANG=zh +fi + # The foreground dashboard is now the sole visible install UI owner. It starts # the actual installer as a non-interactive child, logs child output, waits for # completion, then renders the final installed-time/reboot prompt itself. diff --git a/configs/airootfs/root/configurator b/configs/airootfs/root/configurator index 8e18b59..9f3fbc0 100644 --- a/configs/airootfs/root/configurator +++ b/configs/airootfs/root/configurator @@ -24,6 +24,72 @@ if [[ ! -r $SETUP_FORM ]]; then fi source "$SETUP_FORM" +# Chinese text for OMARCHY_INSTALL_LANG=zh; disk-step failures stay English for support logs. +OMARCHY_ZH+=( + ["Aborted installation"]="安装已中止" + ["You can retry later by running: ./.automated_script.sh"]="稍后可运行 ./.automated_script.sh 重试" + ["Let's setup your machine..."]="开始设置这台机器……" + ["Press Ctrl+C to prepare this machine for another owner."]="按 Ctrl+C 为另一位使用者准备这台机器。" + ["This prepares the machine for another owner."]="这会为另一位使用者准备这台机器。" + ["The system installs now, but setup is delayed until first boot."]="系统现在安装,设置留到首次开机。" + ["Yes, prepare for another owner"]="是,为他人准备" + ["No, keep setting up"]="否,继续设置" + ["Prepare this machine for another owner?"]="为另一位使用者准备这台机器?" + ["Let's setup your user account..."]="开始设置用户账户……" + ["Field"]="项目" + ["Value"]="值" + ["Username"]="用户名" + ["Password"]="密码" + ["Full name"]="姓名" + ["Email address"]="邮箱" + ["Hostname"]="主机名" + ["Keyboard"]="键盘" + ["Region"]="区域" + ["[Skipped]"]="[已跳过]" + ["Yes"]="是" + ["No, change it"]="否,去修改" + ["Does this look right?"]="以上信息正确吗?" + ["Not enough free space on %s"]="%s 的空闲空间不足" + ["%s has %s of usable free space; Omarchy needs at least 32GB."]="%s 有 %s 可用空闲空间;Omarchy 至少需要 32GB。" + ["Open the partition tool to free at least 32GB on %s, then try again."]="打开分区工具,在 %s 上腾出至少 32GB,然后重试。" + ["Back"]="返回" + ["Open partition tool"]="打开分区工具" + ["Return to installation mode?"]="返回安装方式选择?" + ["Checking for BitLocker on %s"]="检查 %s 上的 BitLocker" + ["BitLocker signature detected on %s."]="在 %s 上发现 BitLocker 签名。" + ["Turn BitLocker off in Windows and wait for the drive to finish decrypting, then try again."]="在 Windows 中关闭 BitLocker,等磁盘解密完成后重试。" + ["Suspending BitLocker is not enough — the drive stays encrypted."]="只暂停 BitLocker 不够,磁盘仍是加密的。" + ["Aborted: BitLocker is enabled on this disk."]="已中止:这块磁盘开启了 BitLocker。" + ["Checking existing EFI partitions on %s"]="检查 %s 上已有的 EFI 分区" + ["Found a Windows ESP at %s — leaving it untouched."]="在 %s 发现 Windows ESP,保持不动。" + ["Omarchy will create its own dedicated EFI partition in free space."]="Omarchy 会在空闲空间中创建自己的 EFI 分区。" + ["A new EFI partition will be created in free space."]="会在空闲空间中新建 EFI 分区。" + ["Analyzing free space on %s"]="分析 %s 的空闲空间" + ["Install Omarchy in the %s of free space."]="在 %s 空闲空间中安装 Omarchy。" + ["Press Ctrl+C for unencrypted install."]="按 Ctrl+C 改为不加密安装。" + ["Yes, install"]="是,开始安装" + ["Yes, install without encryption"]="是,不加密安装" + ["Confirm installing on %s"]="确认安装到 %s" + ["Creating partitions on %s"]="在 %s 上创建分区" + ["Setting up LUKS2 on %s"]="在 %s 上设置 LUKS2 加密" + ["Creating Btrfs filesystem and subvolumes"]="创建 Btrfs 文件系统和子卷" + ["Let's select where to install Omarchy..."]="选择 Omarchy 的安装位置……" + ["Select install disk"]="选择安装磁盘" + ["Partition tool for %s"]="%s 的分区工具" + ["Create unallocated free space for Omarchy, then write changes and quit."]="为 Omarchy 腾出未分配空间,然后写入更改并退出。" + ["Do not create an Omarchy partition here — leave the target area as Free space."]="不要在这里创建 Omarchy 分区,目标区域保持为 Free space。" + ["Tip: free-space install needs at least 32GB unallocated, plus 2GB more if no ESP already exists."]="提示:空闲空间安装至少需要 32GB 未分配空间;没有 ESP 时再加 2GB。" + ["Open cfdisk"]="打开 cfdisk" + ["Open partition tool for %s?"]="打开 %s 的分区工具?" + ["Full disk install"]="整盘安装" + ["Free space install (alongside existing data)"]="空闲空间安装(保留现有数据)" + ["Choose a different disk"]="换一块磁盘" + ["Let's select how to install Omarchy..."]="选择 Omarchy 的安装方式……" + ["Select installation mode on %s"]="选择 %s 上的安装方式" + ["Everything will be overwritten. There is no recovery possible."]="磁盘上的一切都会被覆盖,无法恢复。" + ["Confirm overwriting %s"]="确认覆盖 %s" +) + # Partition creation helpers, shared with test/partition-numbering-test.sh so # the numbering logic can be exercised without a real disk. DISK_PARTITIONING="${DISK_PARTITIONING:-/usr/share/omarchy-iso/disk-partitioning.sh}" @@ -176,9 +242,9 @@ greeter() { } abort() { - gum style "${1:-Aborted installation}" + gum style "${1:-$(omarchy_text "Aborted installation")}" echo - gum style "You can retry later by running: ./.automated_script.sh" + gum style "$(omarchy_text "You can retry later by running: ./.automated_script.sh")" exit 1 } @@ -203,6 +269,30 @@ notice() { # STEP 1: KEYBOARD LAYOUT +# The target's LANG follows the language picked on the first screen. +target_lang() { + if [[ $language == "zh" ]]; then + echo zh_CN.UTF-8 + else + echo en_US.UTF-8 + fi +} + +# .automated_script.sh moves Chinese into the CJK console and English back to the VT, which applies keymaps. +apply_language() { + if [[ $language == "zh" && -n ${OMARCHY_CJK_RELAUNCH:-} ]]; then + exit "$OMARCHY_CJK_RELAUNCH" + fi + if [[ $language == "en" && -n ${OMARCHY_VT_RETURN:-} ]]; then + exit "$OMARCHY_VT_RETURN" + fi + if [[ $language == "zh" && -n ${OMARCHY_CJK_CONSOLE:-} ]]; then + export OMARCHY_INSTALL_LANG=zh + else + unset OMARCHY_INSTALL_LANG + fi +} + keyboard_form() { # Ctrl+C is the hidden entry into deferred provisioning, offered on every # visit: the user step unwinds back here and nothing is on disk yet. Callers @@ -212,11 +302,24 @@ keyboard_form() { while true; do clear_logo echo - say "Let's setup your machine..." - say --foreground 8 "Press Ctrl+C to prepare this machine for another owner." + say "$(omarchy_text "Let's setup your machine...")" + say --foreground 8 "$(omarchy_text "Press Ctrl+C to prepare this machine for another owner.")" echo - omarchy_prompt_keyboard && status=0 || status=$? + status=0 + if ! $language_chosen; then + omarchy_prompt_language && status=0 || status=$? + ((status == 0)) && apply_language + fi + # Esc, Ctrl+C or a later "No, change it" asks the language again. + language_chosen=false + if ((status == 0)); then + local layouts=$OMARCHY_KEYBOARD_LAYOUTS selected="English (US)" + [[ $language == "zh" ]] && selected="Chinese (Simplified, Pinyin)" + # cage keeps its US layout, so the CJK console offers only US-keymap keyboards. + [[ -n ${OMARCHY_CJK_CONSOLE:-} ]] && layouts=$(awk -F'|' '$2 == "us"' <<<"$layouts") + OMARCHY_KEYBOARD_LAYOUTS=$layouts omarchy_prompt_keyboard "$selected" && status=0 || status=$? + fi ((status == 0)) && break # Esc means "back", and nothing precedes the first screen, so re-ask. @@ -245,11 +348,11 @@ keyboard_form() { confirm_prepare_for_another_owner() { clear_logo echo - say "This prepares the machine for another owner." - say --foreground 8 "The system installs now, but setup is delayed until first boot." + say "$(omarchy_text "This prepares the machine for another owner.")" + say --foreground 8 "$(omarchy_text "The system installs now, but setup is delayed until first boot.")" echo - gum confirm --affirmative "Yes, prepare for another owner" --negative "No, keep setting up" \ - "Prepare this machine for another owner?" + gum confirm --affirmative "$(omarchy_text "Yes, prepare for another owner")" --negative "$(omarchy_text "No, keep setting up")" \ + "$(omarchy_text "Prepare this machine for another owner?")" } # The user step. Deferred-provisioning installs skip it entirely — the machine's first owner @@ -257,7 +360,7 @@ confirm_prepare_for_another_owner() { # operator sets nothing user-specific. user_form() { - step "Let's setup your user account..." + step "$(omarchy_text "Let's setup your user account...")" # 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 @@ -290,19 +393,29 @@ user_step() { continue fi + # The timezone selects the region (mirrors and repositories), using the + # same lookup as the orchestrator. Global installs get no extra row. + region=$(cd /usr/share/omarchy-iso 2>/dev/null && python -m orchestrator.region "$timezone") || region="global" + region_row="" + if [[ $region != "global" ]]; then + region_row="\n$(omarchy_text Region),$region" + fi + # Add manual padding since gum table -p doesn't respect padding - echo -e "Field,Value -Username,$username -Password,$(printf "%${#password}s" | tr ' ' '*') -Full name,${full_name:-[Skipped]} -Email address,${email_address:-[Skipped]} -Hostname,$hostname -Timezone,$timezone -Keyboard,$keyboard" | + local skipped + skipped=$(omarchy_text "[Skipped]") + echo -e "$(omarchy_text Field),$(omarchy_text Value) +$(omarchy_text Username),$username +$(omarchy_text Password),$(printf "%${#password}s" | tr ' ' '*') +$(omarchy_text "Full name"),${full_name:-$skipped} +$(omarchy_text "Email address"),${email_address:-$skipped} +$(omarchy_text Hostname),$hostname +$(omarchy_text Timezone),$timezone$region_row +$(omarchy_text Keyboard),\"$keyboard_label\"" | gum table -s "," -p | sed "s/^/${PADDING_LEFT_SPACES}/" echo - if gum confirm --negative "No, change it" "Does this look right?"; then + if gum confirm --affirmative "$(omarchy_text "Yes")" --negative "$(omarchy_text "No, change it")" "$(omarchy_text "Does this look right?")"; then break else keyboard_form @@ -504,11 +617,11 @@ print_dry_run_files() { # action so users have a way to actually fix the situation. not_enough_space() { local available="${1:-0}" - step "Not enough free space on $disk" - say --foreground 1 "$disk has $(to_gb $available) of usable free space; Omarchy needs at least 32GB." - say "Open the partition tool to free at least 32GB on $disk, then try again." + step "$(omarchy_text "Not enough free space on %s" "$disk")" + say --foreground 1 "$(omarchy_text "%s has %s of usable free space; Omarchy needs at least 32GB." "$disk" "$(to_gb $available)")" + say "$(omarchy_text "Open the partition tool to free at least 32GB on %s, then try again." "$disk")" echo - if ! gum confirm --affirmative "Back" --negative "Open partition tool" "Return to installation mode?"; then + if ! gum confirm --affirmative "$(omarchy_text "Back")" --negative "$(omarchy_text "Open partition tool")" "$(omarchy_text "Return to installation mode?")"; then open_partition_tool fi return 1 @@ -518,14 +631,14 @@ not_enough_space() { # encryption confirm. Sets the globals run_partition_execute needs. Returns 1 # to fall back to the install-mode picker. run_partition_decide() { - step "Checking for BitLocker on $disk" + step "$(omarchy_text "Checking for BitLocker on %s" "$disk")" local bl_part bl_part=$(detect_bitlocker || true) if [[ -n "$bl_part" ]]; then - say --foreground 1 "BitLocker signature detected on $bl_part." - say "Turn BitLocker off in Windows and wait for the drive to finish decrypting, then try again." - say "Suspending BitLocker is not enough — the drive stays encrypted." - abort "Aborted: BitLocker is enabled on this disk." + say --foreground 1 "$(omarchy_text "BitLocker signature detected on %s." "$bl_part")" + say "$(omarchy_text "Turn BitLocker off in Windows and wait for the drive to finish decrypting, then try again.")" + say "$(omarchy_text "Suspending BitLocker is not enough — the drive stays encrypted.")" + abort "$(omarchy_text "Aborted: BitLocker is enabled on this disk.")" fi # Omarchy always creates its own dedicated ESP in free space and never @@ -534,17 +647,17 @@ run_partition_decide() { # Windows ESP is far too small for our Unified Kernel Images, and a shared # ESP forces the install unencrypted. A separate Linux ESP keeps Windows # and Omarchy fully isolated and keeps LUKS on the table. - step "Checking existing EFI partitions on $disk" + step "$(omarchy_text "Checking existing EFI partitions on %s" "$disk")" local detected_win_esp detected_win_esp=$(detect_windows_esp || true) if [[ -n "$detected_win_esp" ]]; then - say "Found a Windows ESP at $detected_win_esp — leaving it untouched." - say "Omarchy will create its own dedicated EFI partition in free space." + say "$(omarchy_text "Found a Windows ESP at %s — leaving it untouched." "$detected_win_esp")" + say "$(omarchy_text "Omarchy will create its own dedicated EFI partition in free space.")" else - say "A new EFI partition will be created in free space." + say "$(omarchy_text "A new EFI partition will be created in free space.")" fi - step "Analyzing free space on $disk" + step "$(omarchy_text "Analyzing free space on %s" "$disk")" partprobe "$disk" 2>/dev/null || true sleep 1 @@ -619,21 +732,21 @@ run_partition_decide() { while true; do clear_logo echo - say "Install Omarchy in the $(to_gb $INSTALL_MAX_B) of free space." + say "$(omarchy_text "Install Omarchy in the %s of free space." "$(to_gb $INSTALL_MAX_B)")" case $mode in encrypted) - say --foreground 8 "Press Ctrl+C for unencrypted install." - affirmative="Yes, install" + say --foreground 8 "$(omarchy_text "Press Ctrl+C for unencrypted install.")" + affirmative="$(omarchy_text "Yes, install")" ;; unencrypted) - affirmative="Yes, install without encryption" + affirmative="$(omarchy_text "Yes, install without encryption")" ;; esac echo - gum confirm --affirmative "$affirmative" --negative "No, change it" \ - "Confirm installing on $disk" + gum confirm --affirmative "$affirmative" --negative "$(omarchy_text "No, change it")" \ + "$(omarchy_text "Confirm installing on %s" "$disk")" confirm_status=$? case $confirm_status in @@ -691,7 +804,7 @@ run_partition_execute() { say "[dry] partition numbers are assigned by parted at creation, then read back" say "[dry] encrypted=$encrypt_installation kernel=$kernel_choice" else - step "Creating partitions on $disk" + step "$(omarchy_text "Creating partitions on %s" "$disk")" if $needs_mklabel; then disk_step "initializing GPT on $disk" parted --script "$disk" mklabel gpt partprobe "$disk" 2>/dev/null || true @@ -729,7 +842,7 @@ run_partition_execute() { disk_step "clearing stale signatures on $root_partition_device" wipefs -af "$root_partition_device" if [[ "$encrypt_installation" == "true" ]]; then - step "Setting up LUKS2 on $root_partition_device" + step "$(omarchy_text "Setting up LUKS2 on %s" "$root_partition_device")" # Kept as pipes rather than routed through disk_step: the passphrase must # not become an argv the process table can show. Folding stderr into # stdout still lands any error in the install log. @@ -746,7 +859,7 @@ run_partition_execute() { root_mapper="$root_partition_device" fi - step "Creating Btrfs filesystem and subvolumes" + step "$(omarchy_text "Creating Btrfs filesystem and subvolumes")" disk_step "creating the Btrfs filesystem on $root_mapper" \ mkfs.btrfs -f -L OMARCHY "$root_mapper" wait_for_device "$root_mapper" || disk_abort_hook "Root device $root_mapper never appeared" @@ -834,7 +947,7 @@ run_partition_execute() { "locale_config": { "kb_layout": "$keyboard", "sys_enc": "UTF-8", - "sys_lang": "en_US.UTF-8" + "sys_lang": "$(target_lang)" }, "mirror_config": { "custom_repositories": [], @@ -864,7 +977,7 @@ _EOF_ } disk_form() { - step "Let's select where to install Omarchy..." + step "$(omarchy_text "Let's select where to install Omarchy...")" # Don't offer the install media as an option (Arch ISO mounts it here) local boot_source @@ -890,19 +1003,19 @@ disk_form() { fi done <<<"$available_disks" - selected_display=$(echo "$disk_options" | gum choose --header "Select install disk") || abort + selected_display=$(echo "$disk_options" | gum choose --header "$(omarchy_text "Select install disk")") || abort disk=$(echo "$selected_display" | awk '{print $1}') } # STEP 4: INSTALL MODE open_partition_tool() { - step "Partition tool for $disk" - gum style "Create unallocated free space for Omarchy, then write changes and quit." - gum style --foreground 8 "Do not create an Omarchy partition here — leave the target area as Free space." - gum style --foreground 8 "Tip: free-space install needs at least 32GB unallocated, plus 2GB more if no ESP already exists." + step "$(omarchy_text "Partition tool for %s" "$disk")" + gum style "$(omarchy_text "Create unallocated free space for Omarchy, then write changes and quit.")" + gum style --foreground 8 "$(omarchy_text "Do not create an Omarchy partition here — leave the target area as Free space.")" + gum style --foreground 8 "$(omarchy_text "Tip: free-space install needs at least 32GB unallocated, plus 2GB more if no ESP already exists.")" echo - gum confirm --affirmative "Open cfdisk" --negative "Back" "Open partition tool for $disk?" || return 0 + gum confirm --affirmative "$(omarchy_text "Open cfdisk")" --negative "$(omarchy_text "Back")" "$(omarchy_text "Open partition tool for %s?" "$disk")" || return 0 clear cfdisk "$disk" || true @@ -935,17 +1048,17 @@ requires_full_disk_install() { } install_mode_form() { - local choices=("Full disk install") + local choices=("$(omarchy_text "Full disk install")") # The free-space/protected path registers an EFI boot entry and is UEFI-only. # Full-disk installs still support BIOS through the orchestrator's BIOS branch. if [[ -d /sys/firmware/efi ]] && ! $full_disk_only; then - choices+=("Free space install (alongside existing data)") + choices+=("$(omarchy_text "Free space install (alongside existing data)")") fi - choices+=("Choose a different disk") + choices+=("$(omarchy_text "Choose a different disk")") - step "Let's select how to install Omarchy..." - install_mode=$(gum choose --header "Select installation mode on $disk" "${choices[@]}") || abort + step "$(omarchy_text "Let's select how to install Omarchy...")" + install_mode=$(gum choose --header "$(omarchy_text "Select installation mode on %s" "$disk")" "${choices[@]}") || abort } confirm_disk_overwrite() { @@ -955,17 +1068,17 @@ confirm_disk_overwrite() { while true; do clear_logo echo - say "Everything will be overwritten. There is no recovery possible." + say "$(omarchy_text "Everything will be overwritten. There is no recovery possible.")" if [[ $mode == "encrypted" ]]; then - say --foreground 8 "Press Ctrl+C for unencrypted install." - affirmative="Yes, install" + say --foreground 8 "$(omarchy_text "Press Ctrl+C for unencrypted install.")" + affirmative="$(omarchy_text "Yes, install")" else - affirmative="Yes, install without encryption" + affirmative="$(omarchy_text "Yes, install without encryption")" fi echo - gum confirm --affirmative "$affirmative" --negative "No, change it" "Confirm overwriting ${disk}" + gum confirm --affirmative "$affirmative" --negative "$(omarchy_text "No, change it")" "$(omarchy_text "Confirm overwriting %s" "$disk")" confirm_status=$? case $confirm_status in @@ -1004,13 +1117,13 @@ select_installation() { # With no free-space option to offer, a full-disk install is the only # mode, so skip the picker and go straight to the overwrite confirm. if $full_disk_only; then - install_mode="Full disk install" + install_mode=$(omarchy_text "Full disk install") else install_mode_form fi case "$install_mode" in - "Full disk install") + "$(omarchy_text "Full disk install")") if confirm_disk_overwrite; then install_target="full_disk" return 0 @@ -1019,13 +1132,13 @@ select_installation() { # wants a different disk. $full_disk_only && disk_form ;; - "Free space install"*) + "$(omarchy_text "Free space install (alongside existing data)")") if run_partition_decide; then install_target="free_space" return 0 fi ;; - "Choose a different disk") + "$(omarchy_text "Choose a different disk")") disk_form ;; esac @@ -1036,12 +1149,20 @@ select_installation() { defer_provisioning=false install_target="full_disk" +# A rerun arrives with the language chosen; otherwise English. +language="${OMARCHY_INSTALL_LANGUAGE:-en}" +language_chosen=false # Let the live VT reach its settled width before the first screen so the logo # isn't measured against the transient 80-column boot console. wait_for_stable_terminal -greeter +if [[ -n ${OMARCHY_INSTALL_LANGUAGE:-} ]]; then + apply_language + language_chosen=true +else + greeter +fi keyboard_form @@ -1227,7 +1348,7 @@ cat <<-_EOF_ >user_configuration.json "locale_config": { "kb_layout": "$keyboard", "sys_enc": "UTF-8", - "sys_lang": "en_US.UTF-8" + "sys_lang": "$(target_lang)" }, "mirror_config": { "custom_repositories": [], diff --git a/configs/airootfs/usr/local/bin/omarchy-install-dashboard b/configs/airootfs/usr/local/bin/omarchy-install-dashboard index e20e61a..3bb8d22 100755 --- a/configs/airootfs/usr/local/bin/omarchy-install-dashboard +++ b/configs/airootfs/usr/local/bin/omarchy-install-dashboard @@ -88,6 +88,81 @@ tips=( "Keep the system fresh with Update in the Omarchy menu" ) +# omarchy_text and its Chinese table come from the setup form the configurator uses. +SETUP_FORM="${SETUP_FORM:-/usr/share/omarchy-iso/setup-form.sh}" +[[ -r $SETUP_FORM ]] || SETUP_FORM="${OMARCHY_PATH:-/usr/share/omarchy}/install/provisioning/setup-form.sh" +source "$SETUP_FORM" +OMARCHY_ZH+=( + ["Tip: %s"]="提示:%s" + ["Installed Omarchy"]="Omarchy 安装完成" + ["Installed Omarchy in %s"]="Omarchy 安装完成,用时 %s" + ["Reboot Now"]="立即重启" + ["Last log lines:"]="日志末尾:" + ["Last target log lines:"]="目标系统日志末尾:" + ["Press Enter to continue…"]="按回车继续……" + ["No log uploader is available in this environment."]="这个环境没有日志上传工具。" + ["Install log: %s"]="安装日志:%s" + ["Target log: %s"]="目标系统日志:%s" + ["Omarchy installation stopped"]="Omarchy 安装已停止" + ["Installer exited with status %s"]="安装程序退出,状态码 %s" + ["Get help at https://omarchy.org/discord"]="求助:https://omarchy.org/discord" + ["What would you like to do?"]="接下来做什么?" + ["Upload log for support"]="上传日志求助" + ["View full log"]="查看完整日志" + ["Drop to shell"]="进入 shell" + ["Reboot"]="重启" + ["Power off"]="关机" + ["The install medium was misread"]="安装介质读取出错" + ["This package is intact on this ISO, so the download and the write were fine:"]="这个软件包在 ISO 中完好,下载和写入都没有问题:" + ["Try another USB port or another stick, and test this machine's memory"]="换一个 USB 口或换一个 U 盘,并检测这台机器的内存" + ["The install medium is damaged"]="安装介质已损坏" + ["This package no longer matches the checksum it was built with:"]="这个软件包和构建时的校验和不再一致:" + ["Re-download the ISO, verify it with sha256sum, and write the USB again"]="重新下载 ISO,用 sha256sum 校验,再重新写入 U 盘" + ["If a verified ISO fails here too, the USB stick itself is the problem"]="如果校验过的 ISO 在这里也失败,问题出在 U 盘本身" + ["The install medium is damaged or misread"]="安装介质已损坏或读取出错" + ["pacman rejected this package, read straight off this ISO:"]="pacman 拒绝了这个直接从 ISO 读出的软件包:" + ["Verify your download with sha256sum, then write the USB again"]="用 sha256sum 校验下载的文件,再重新写入 U 盘" + ["If the download verifies, the stick or this machine's memory is at fault"]="如果下载校验无误,问题出在 U 盘或这台机器的内存" +) + +if [[ ${OMARCHY_INSTALL_LANG:-} == "zh" ]]; then + whimsy=( + "备好贴心的默认设置" + "省掉装机的折腾" + "挑选打磨过的软件包" + "调配协调的配色" + "准备悦目的像素" + "唤醒 Hyprland" + "接好顺手的界面" + "搭起漂亮的系统" + "写好趁手的命令" + "让机器焕然一新" + "擦亮每一个像素" + "调好终端的品味" + "织出流畅的工作流" + ) + tips=( + "Super + Space 打开 Omarchy 菜单:应用、设置等" + "Super + K 查看全部快捷键" + "Super 是键盘上的 Windows 或 Command 键" + "用 Xournal++ 给 PDF 签名" + "用 LocalSend 和手机、电脑互传文件" + "菜单 安装 > 网页应用 把任意网站变成应用" + "Super + Return 打开终端,Super + Shift + Return 打开浏览器" + "图片用 Pinta,视频用 Kdenlive,文档用 LibreOffice" + "Super + Ctrl + Print 用 OCR 抓取屏幕文字" + "Print 截屏,Alt + Print 录屏" + "Super + Ctrl + R 设置提醒" + "菜单 风格 > 主题 切换主题" + "双击菜单栏让它透明" + "菜单 安装 > Windows 运行完整的 Windows 虚拟机" + "Super + Ctrl + V 打开剪贴板管理器" + "Super + 1 到 0 切换工作区,加 Shift 带上窗口" + "Super + Print 拾取屏幕上任意位置的颜色" + "用菜单里的 更新 让系统保持最新" + ) +fi + cleanup() { printf '%s%s' "$RESET" "$SHOW_CURSOR" >"$TTY_PATH" 2>/dev/null || true } @@ -286,8 +361,8 @@ left_padding() { visible_len() { local text="$1" - text="$(printf '%b' "$text" | sed -E $'s/\x1b\\[[0-9;?]*[A-Za-z]//g')" - printf '%s' "${#text}" + # wc -L counts display columns, so double-width CJK centers too. + printf '%b' "$text" | sed -E $'s/\x1b\\[[0-9;?]*[A-Za-z]//g' | wc -L } blank_line() { printf '\r%s\n' "$CLEAR_LINE"; } @@ -678,7 +753,7 @@ render_dynamic() { render_icon "$DISPLAY_PM" message="$(truncate_for_width "$(current_whimsy)" "$PROGRESS_TEXT_WIDTH")" - tip="$(truncate_for_width "Tip: $(current_tip)" "$PROGRESS_TEXT_WIDTH")" + tip="$(truncate_for_width "$(omarchy_text "Tip: %s" "$(current_tip)")" "$PROGRESS_TEXT_WIDTH")" printf '%s%d;1H' "$CSI" "$((TOP_ROW + ICON_HEIGHT + 1))" center "${GREEN}$((DISPLAY_PM / 10))%${RESET}" "$PROGRESS_TEXT_WIDTH" center "${GREEN}${message}${RESET}" "$PROGRESS_TEXT_WIDTH" @@ -718,9 +793,13 @@ install_duration() { } render_finish() { - local duration effect_canvas_width title="Installed Omarchy" + local duration effect_canvas_width title duration="$(install_duration || true)" - [[ -n $duration ]] && title+=" in ${duration}" + if [[ -n $duration ]]; then + title=$(omarchy_text "Installed Omarchy in %s" "$duration") + else + title=$(omarchy_text "Installed Omarchy") + fi # ttfx centers its text two columns right of plain centering. A canvas two # columns narrower aligns the animation with the title and fallback logo. effect_canvas_width="$(term_cols)" @@ -765,7 +844,7 @@ render_finish() { } reboot_prompt() { - local prompt_pad + local prompt_pad label # Nobody to confirm; the OMARCHY_UI_AUTO_REBOOT check at the call site still # applies, so setting both stops on the finish screen without a keyboard. @@ -776,24 +855,31 @@ reboot_prompt() { # gum allows. FINISH_HEIGHT budgets for the same three rows. printf '%s%d;1H%s' "$CSI" "$((FINISH_TOP_ROW + LOGO_HEIGHT + 2))" "$CLEAR_LINE" >"$TTY_PATH" - prompt_pad=$(( ( $(term_cols) - 18 ) / 2 )) + # gum pads the button label by four columns on each side. + label=$(omarchy_text "Reboot Now") + prompt_pad=$(( ( $(term_cols) - $(visible_len "$label") - 8 ) / 2 )) (( prompt_pad < 0 )) && prompt_pad=0 gum confirm \ --padding "0 0 0 $prompt_pad" \ --show-help=false \ --default \ - --affirmative "Reboot Now" \ + --affirmative "$label" \ --negative "" \ "" <"$TTY_PATH" >"$TTY_PATH" 2>&1 } +# Columns, not characters: each CJK character takes two. truncate_for_width() { local text="$1" width="$2" - if (( ${#text} > width )); then - printf '%s…' "${text:0:$((width - 1))}" - else + if (( $(printf '%s' "$text" | wc -L) <= width )); then printf '%s' "$text" + return fi + text=${text:0:$((width - 1))} + while (( $(printf '%s' "$text" | wc -L) > width - 1 )); do + text=${text%?} + done + printf '%s…' "$text" } render_failure_log_tail() { @@ -819,8 +905,8 @@ render_failure_log_tail() { fi [[ -f $tail_log ]] || return 0 - label="Last log lines:" - [[ $tail_log == /mnt/* ]] && label="Last target log lines:" + label=$(omarchy_text "Last log lines:") + [[ $tail_log == /mnt/* ]] && label=$(omarchy_text "Last target log lines:") line_at "$CONTENT_WIDTH" 2 "${DIM}${label}${RESET}" printf '\n' tail -n "$lines" "$tail_log" 2>/dev/null | while IFS= read -r line; do @@ -843,7 +929,7 @@ find_log_uploader() { } prompt_enter() { - printf '\nPress Enter to continue…' >"$TTY_PATH" + printf '\n%s' "$(omarchy_text "Press Enter to continue…")" >"$TTY_PATH" IFS= read -r _ <"$TTY_PATH" || true } @@ -854,9 +940,9 @@ upload_failure_log() { "$uploader" install >"$TTY_PATH" 2>&1 || true else { - echo "No log uploader is available in this environment." - echo "Install log: $LOG_FILE" - [[ -f /mnt/var/log/omarchy-install.log ]] && echo "Target log: /mnt/var/log/omarchy-install.log" + printf '%s\n' "$(omarchy_text "No log uploader is available in this environment.")" + printf '%s\n' "$(omarchy_text "Install log: %s" "$LOG_FILE")" + [[ -f /mnt/var/log/omarchy-install.log ]] && printf '%s\n' "$(omarchy_text "Target log: %s" /mnt/var/log/omarchy-install.log)" } >"$TTY_PATH" fi prompt_enter @@ -907,6 +993,8 @@ render_media_diagnosis() { blank_line continue fi + # The support log keeps English; the screen shows the translation, and package names pass through. + [[ ${OMARCHY_INSTALL_LANG:-} == "zh" ]] && line=${OMARCHY_ZH[$line]:-$line} if (( first )); then center "${RED}$(truncate_for_width "$line" "$CONTENT_WIDTH")${RESET}" "$CONTENT_WIDTH" first=0 @@ -922,8 +1010,8 @@ render_failure() { blank_line render_logo blank_line - center "${RED}Omarchy installation stopped${RESET}" "$CONTENT_WIDTH" - center "Installer exited with status $status" "$CONTENT_WIDTH" + center "${RED}$(omarchy_text "Omarchy installation stopped")${RESET}" "$CONTENT_WIDTH" + center "$(omarchy_text "Installer exited with status %s" "$status")" "$CONTENT_WIDTH" render_media_diagnosis "$media_diagnosis" @@ -938,12 +1026,12 @@ render_failure() { blank_line render_failure_log_tail "$(media_diagnosis_rows "$media_diagnosis")" blank_line - center "${DIM}Get help at https://omarchy.org/discord${RESET}" "$CONTENT_WIDTH" + center "${DIM}$(omarchy_text "Get help at https://omarchy.org/discord")${RESET}" "$CONTENT_WIDTH" blank_line } failure_menu() { - local choice uploader + local choice uploader actions shown i [[ ${OMARCHY_UI_FAILURE_ACTION:-} == "exit" ]] && return 0 # The failure screen and log tail have already rendered; exit with the @@ -953,26 +1041,20 @@ failure_menu() { while true; do uploader="" uploader=$(find_log_uploader || true) - if [[ -n $uploader ]]; then - choice=$(gum choose \ - --height 6 \ - --header "What would you like to do?" \ - "Upload log for support" \ - "View full log" \ - "Drop to shell" \ - "Reboot" \ - "Power off" \ - <"$TTY_PATH" 2>"$TTY_PATH") || choice="Drop to shell" - else - choice=$(gum choose \ - --height 5 \ - --header "What would you like to do?" \ - "View full log" \ - "Drop to shell" \ - "Reboot" \ - "Power off" \ - <"$TTY_PATH" 2>"$TTY_PATH") || choice="Drop to shell" - fi + actions=("View full log" "Drop to shell" "Reboot" "Power off") + [[ -n $uploader ]] && actions=("Upload log for support" "${actions[@]}") + shown=() + for i in "${!actions[@]}"; do + shown+=("$(omarchy_text "${actions[i]}")") + done + choice=$(gum choose \ + --height "$((${#actions[@]} + 1))" \ + --header "$(omarchy_text "What would you like to do?")" \ + "${shown[@]}" \ + <"$TTY_PATH" 2>"$TTY_PATH") || choice="" + for i in "${!shown[@]}"; do + [[ ${shown[i]} == "$choice" ]] && choice=${actions[i]} + done case "$choice" in "Upload log for support") diff --git a/configs/airootfs/usr/share/omarchy-iso/foot.ini b/configs/airootfs/usr/share/omarchy-iso/foot.ini new file mode 100644 index 0000000..6685477 --- /dev/null +++ b/configs/airootfs/usr/share/omarchy-iso/foot.ini @@ -0,0 +1,29 @@ +# The Chinese installer's console: the VT's Tokyo Night palette, with Noto Sans Mono CJK from the offline mirror. +[main] +font=Noto Sans Mono CJK SC:size=13 +# Noto's tall default rows leave the summary too few lines for the logo. +line-height=22px + +[colors-dark] +background=1a1b26 +foreground=a9b1d6 +regular0=1a1b26 +regular1=f7768e +regular2=9ece6a +regular3=e0af68 +regular4=7aa2f7 +regular5=bb9af7 +regular6=7dcfff +regular7=a9b1d6 +bright0=414868 +bright1=f7768e +bright2=9ece6a +bright3=e0af68 +bright4=7aa2f7 +bright5=bb9af7 +bright6=7dcfff +bright7=c0caf5 + +# cage draws no title bar, and a reserved one leaves a black strip. +[csd] +preferred=none diff --git a/configs/airootfs/usr/share/omarchy-iso/orchestrator/context.py b/configs/airootfs/usr/share/omarchy-iso/orchestrator/context.py index 436bfa8..994697e 100644 --- a/configs/airootfs/usr/share/omarchy-iso/orchestrator/context.py +++ b/configs/airootfs/usr/share/omarchy-iso/orchestrator/context.py @@ -12,6 +12,7 @@ from pathlib import Path from typing import Any from .keyboard import validate_input_selection +from .region import GLOBAL, resolve_region @dataclass @@ -29,6 +30,7 @@ class InstallContext: arch_config_path: Path omarchy_install: dict[str, Any] defer_provisioning: bool = False + region: str = GLOBAL target: Path = Path("/mnt") omarchy_path: Path = Path("/usr/share/omarchy") @@ -61,6 +63,11 @@ class InstallContext: validate_input_selection(omarchy_install.get("input_method"), omarchy_install.get("input_xkb_layout", "")) + # Resolved before any disk is touched, so an autoinstall naming a + # region this ISO lacks fails without wiping anything. + region = resolve_region(omarchy_install, user_configuration.get("timezone")) + omarchy_install["region"] = region + # 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 @@ -125,6 +132,7 @@ class InstallContext: arch_config_path=arch_config_path, omarchy_install=omarchy_install, defer_provisioning=defer_provisioning, + region=region, state_dir=state_dir, ) disk_config = user_configuration.get("disk_config", {}) 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 e30e8c7..83b8487 100644 --- a/configs/airootfs/usr/share/omarchy-iso/orchestrator/phases_impl.py +++ b/configs/airootfs/usr/share/omarchy-iso/orchestrator/phases_impl.py @@ -36,6 +36,7 @@ from . import archinstall_adapter as arch from .command import capture, capture_identifier, require_text from .context import InstallContext from .keyboard import configure_keyboard +from .region import GLOBAL, region_keyrings, region_packages from .ui import error, info @@ -298,6 +299,11 @@ def arch_install_system(ctx: InstallContext) -> None: info("› installing Omarchy runtime + omarchy-base.packages") installer.add_additional_packages(_runtime_package_list(ctx)) + if region_pkgs := region_packages(ctx.region): + info(f"› installing {ctx.region} region packages: {', '.join(region_pkgs)}") + installer.add_additional_packages(region_pkgs) + _write_region_marker(ctx) + # Tailscale is bundled in the offline mirror but only installed # when an autoinstall drive staged an auth key; must happen here, # while the mirror is still bind-mounted, not in the phase that @@ -728,6 +734,17 @@ def _unmask_mkinitcpio_pacman_hooks( info(f"warning: failed to restore pacman hook mask for {name}: {exc}") +def _write_region_marker(ctx: InstallContext) -> None: + """Tell the runtime's pacman finalizer which profile to apply. Global + installs write nothing, so they stay identical to a region-less install.""" + if ctx.region == GLOBAL: + return + marker = ctx.target / "etc/omarchy/region" + marker.parent.mkdir(parents=True, exist_ok=True) + marker.write_text(ctx.region + "\n") + marker.chmod(0o644) + + def _runtime_package_list(ctx: InstallContext) -> list[str]: """Selected Omarchy runtime package + every package in the ISO-bundled base package list that isn't already installed early.""" @@ -1135,6 +1152,14 @@ def _run_target_setup_command(ctx: InstallContext, cmd: list[str], *, user: str def run_system_finalizer(ctx: InstallContext) -> None: + # The region's keyring packages are installed by now, but the build + # container's GPG state is not inherited. Initialize the target's trust + # database with only this region's keys before the finalizer enables its + # online repositories; global installs never trust them. + if keyrings := region_keyrings(ctx.region): + _run_target_setup_command(ctx, ["pacman-key", "--init"]) + _run_target_setup_command(ctx, ["pacman-key", "--populate", "archlinux", *keyrings]) + if ctx.defer_provisioning: cmd = ["/usr/bin/omarchy-apply-system", "--defer-provisioning", "--first-install"] else: diff --git a/configs/airootfs/usr/share/omarchy-iso/orchestrator/region.py b/configs/airootfs/usr/share/omarchy-iso/orchestrator/region.py new file mode 100644 index 0000000..9ff8159 --- /dev/null +++ b/configs/airootfs/usr/share/omarchy-iso/orchestrator/region.py @@ -0,0 +1,68 @@ +"""Region selection: which of the runtime's region profiles a target gets. + +The builder copies every profile from the bundled runtime's default/regions/ +into REGIONS_DIR, and their packages into the offline mirror. Nobody picks a +region by hand: the timezone the user already chose selects it, and an +autoinstall config may name one explicitly in omarchy_install.region. Run as +`python -m orchestrator.region ` to print the region the installer +would use, which is how the configurator fills its summary. +""" + +from __future__ import annotations + +import re +import sys +from pathlib import Path + +REGIONS_DIR = Path("/usr/share/omarchy-iso/regions") +GLOBAL = "global" + + +def _entries(path: Path) -> list[str]: + if not path.is_file(): + return [] + lines = (line.strip() for line in path.read_text().splitlines()) + return [line for line in lines if line and not line.startswith("#")] + + +def region_for_timezone(timezone: str | None, regions_dir: Path | None = None) -> str: + regions_dir = regions_dir or REGIONS_DIR + if timezone and regions_dir.is_dir(): + for profile in sorted(regions_dir.iterdir()): + if timezone in _entries(profile / "timezones"): + return profile.name + return GLOBAL + + +def resolve_region(omarchy_install: dict, timezone: str | None, regions_dir: Path | None = None) -> str: + regions_dir = regions_dir or REGIONS_DIR + region = omarchy_install.get("region") + if region is None or region == "": + return region_for_timezone(timezone, regions_dir) + # Profiles are named by ISO 3166-1 alpha-2 code; checking the shape first + # keeps a path like ".." from passing the directory test. + if not isinstance(region, str) or ( + region != GLOBAL and not (re.fullmatch(r"[a-z]{2}", region) and (regions_dir / region).is_dir()) + ): + raise RuntimeError(f"Unsupported region: {region!r}") + return region + + +def region_packages(region: str, regions_dir: Path | None = None) -> list[str]: + if region == GLOBAL: + return [] + return _entries((regions_dir or REGIONS_DIR) / region / "packages") + + +def region_keyrings(region: str, regions_dir: Path | None = None) -> list[str]: + """Pacman keyrings the profile ships, named the Arch way: -keyring + installs /usr/share/pacman/keyrings/.gpg.""" + return [ + package.removesuffix("-keyring") + for package in region_packages(region, regions_dir) + if package.endswith("-keyring") + ] + + +if __name__ == "__main__": + print(region_for_timezone(sys.argv[1] if len(sys.argv) > 1 else None)) diff --git a/test/unit/install-media-diagnosis-test.sh b/test/unit/install-media-diagnosis-test.sh index cbad41d..41ded2c 100755 --- a/test/unit/install-media-diagnosis-test.sh +++ b/test/unit/install-media-diagnosis-test.sh @@ -273,6 +273,12 @@ mkdir -p "$omarchy_share" printf 'logo bottom row%*s\n' 66 '' } >"$omarchy_share/logo.txt" +# The ISO vendors the runtime's setup form; the dashboard only needs its text lookup. +cat >"$work/setup-form.sh" <<'FORM' +declare -gA OMARCHY_ZH=() +omarchy_text() { local text=$1; shift; printf -- "$text" "$@"; } +FORM + run_dashboard() { local install_log="$1" @@ -284,7 +290,7 @@ run_dashboard() { "error": "Pacstrap failed. See /var/log/archinstall.log"}]} STATE : >"$screen" - script -qefc "stty rows 40 cols 120; PATH='$stubs:$PATH' OMARCHY_PATH='$omarchy_share' OMARCHY_UI_INTERACTIVE=no OMARCHY_UI_FAILURE_ACTION=exit OMARCHY_FAILURE_TAIL_LOG='$install_log' '$DASHBOARD' '$install_log' '$state_file' -- bash -c 'exit 1'" \ + script -qefc "stty rows 40 cols 120; PATH='$stubs:$PATH' OMARCHY_PATH='$omarchy_share' SETUP_FORM='$work/setup-form.sh' OMARCHY_UI_INTERACTIVE=no OMARCHY_UI_FAILURE_ACTION=exit OMARCHY_FAILURE_TAIL_LOG='$install_log' '$DASHBOARD' '$install_log' '$state_file' -- bash -c 'exit 1'" \ "$screen" >/dev/null 2>&1 } diff --git a/test/unit/install-text-zh-test.sh b/test/unit/install-text-zh-test.sh new file mode 100644 index 0000000..da38e40 --- /dev/null +++ b/test/unit/install-text-zh-test.sh @@ -0,0 +1,52 @@ +#!/bin/bash +# +# Every installer text needs an OMARCHY_ZH entry, or a Chinese install shows it in English. + +set -euo pipefail + +ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd) +SETUP_FORM="${OMARCHY_SOURCE:-$ROOT/../omarchy}/install/provisioning/setup-form.sh" + +fail() { + printf 'not ok - %s\n' "$1" + [[ -z ${2:-} ]] || printf '%s\n' "$2" | sed 's/^/# /' + exit 1 +} + +if [[ ! -r $SETUP_FORM ]]; then + printf 'ok - # skip the setup form is not checked out next to this repo (set OMARCHY_SOURCE)\n' + exit 0 +fi + +source "$SETUP_FORM" +for file in configs/airootfs/root/configurator configs/airootfs/usr/local/bin/omarchy-install-dashboard; do + eval "$(sed -n '/^OMARCHY_ZH+=(/,/^)/p' "$ROOT/$file")" +done + +mapfile -t texts < <( + grep -ho 'omarchy_text "[^"$]*"' "$ROOT/configs/airootfs/root/configurator" "$ROOT/configs/airootfs/usr/local/bin/omarchy-install-dashboard" | + sed 's/^omarchy_text "//; s/"$//' | sort -u +) +((${#texts[@]} > 50)) || fail "the installer routes its text through omarchy_text" "found ${#texts[@]}" +for text in "${texts[@]}"; do + [[ -n ${OMARCHY_ZH[$text]:-} ]] || fail "every installer text has a Chinese entry" "missing: $text" +done +printf 'ok - every installer and dashboard text has a Chinese entry\n' + +for action in "View full log" "Drop to shell" "Reboot" "Power off" "Upload log for support"; do + [[ -n ${OMARCHY_ZH[$action]:-} ]] || fail "labels passed through variables have Chinese entries" "missing: $action" +done +printf 'ok - labels passed through variables have Chinese entries\n' + +mapfile -t diagnosis < <(grep -x ' *echo "[^"$]*"' "$ROOT/configs/airootfs/usr/local/bin/omarchy-install-diagnose-media" | sed 's/^ *echo "//; s/"$//') +((${#diagnosis[@]} >= 10)) || fail "the media diagnosis sentences are found" "found ${#diagnosis[@]}" +for line in "${diagnosis[@]}"; do + [[ -n ${OMARCHY_ZH[$line]:-} ]] || fail "every media diagnosis sentence has a Chinese entry" "missing: $line" +done +printf 'ok - every media diagnosis sentence has a Chinese entry\n' + +[[ $(OMARCHY_INSTALL_LANG=zh omarchy_text "Confirm overwriting %s" /dev/vda) == "确认覆盖 /dev/vda" ]] || + fail "Chinese text keeps its printf arguments" +[[ $(omarchy_text "Confirm overwriting %s" /dev/vda) == "Confirm overwriting /dev/vda" ]] || + fail "English stays the default" +printf 'ok - omarchy_text formats Chinese and English alike\n' diff --git a/test/unit/test_kernel_selection.py b/test/unit/test_kernel_selection.py index a366a2e..8c952bf 100644 --- a/test/unit/test_kernel_selection.py +++ b/test/unit/test_kernel_selection.py @@ -108,6 +108,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, omarchy_install={}, + region="global", ) for name in ("_mount_offline_package_cache", "_mask_mkinitcpio_pacman_hooks", "_configure_limine_boot", "_write_pre_mounted_fstab"): diff --git a/test/unit/test_region.py b/test/unit/test_region.py new file mode 100644 index 0000000..6119bc9 --- /dev/null +++ b/test/unit/test_region.py @@ -0,0 +1,236 @@ +"""The target's region comes from its timezone, using the runtime's profiles.""" + +import json +import os +import subprocess +import sys +import tempfile +import types +import unittest +from contextlib import ExitStack +from pathlib import Path +from unittest import mock + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / "configs/airootfs/usr/share/omarchy-iso")) +sys.modules.setdefault( + "orchestrator.archinstall_adapter", + types.ModuleType("orchestrator.archinstall_adapter"), +) +from orchestrator import context, phases_impl, region # noqa: E402 + + +def write_profiles(root: Path) -> Path: + regions = root / "regions" + cn = regions / "cn" + (cn / "pacman").mkdir(parents=True) + (cn / "timezones").write_text("# China\nAsia/Shanghai\n\n Asia/Urumqi \n") + (cn / "packages").write_text("# keyring first\narchlinuxcn-keyring\nextra-tool\n") + (cn / "pacman/pacman.conf.append").write_text("[archlinuxcn]\nServer = https://example.invalid/$arch\n") + for channel in ("stable", "rc", "edge"): + (cn / f"pacman/mirrorlist-{channel}.prepend").write_text(f"Server = https://{channel}.example.invalid/$repo/os/$arch\n") + return regions + + +class RegionSelectionTest(unittest.TestCase): + def setUp(self): + tmp = tempfile.TemporaryDirectory() + self.addCleanup(tmp.cleanup) + self.root = Path(tmp.name) + self.regions = write_profiles(self.root) + patcher = mock.patch.object(region, "REGIONS_DIR", self.regions) + patcher.start() + self.addCleanup(patcher.stop) + + def test_timezone_selects_region(self): + for timezone, expected in [ + ("Asia/Shanghai", "cn"), + ("Asia/Urumqi", "cn"), + ("Asia/Hong_Kong", "global"), + ("America/New_York", "global"), + ("UTC", "global"), + ("# China", "global"), + ("", "global"), + (None, "global"), + ]: + with self.subTest(timezone=timezone): + self.assertEqual(region.region_for_timezone(timezone), expected) + + def test_iso_without_profiles_is_global(self): + self.assertEqual(region.region_for_timezone("Asia/Shanghai", self.root / "missing"), "global") + self.assertEqual(region.resolve_region({}, "Asia/Shanghai", self.root / "missing"), "global") + + def test_autoinstall_region_overrides_timezone(self): + self.assertEqual(region.resolve_region({"region": "cn"}, "UTC"), "cn") + self.assertEqual(region.resolve_region({"region": "global"}, "Asia/Shanghai"), "global") + self.assertEqual(region.resolve_region({}, "Asia/Shanghai"), "cn") + for bad in ("us", "CN", "..", "cn/../cn", True, 1, ["cn"]): + with self.subTest(region=bad), self.assertRaises(RuntimeError): + region.resolve_region({"region": bad}, "UTC") + + def test_profile_packages_and_keyrings(self): + self.assertEqual(region.region_packages("cn"), ["archlinuxcn-keyring", "extra-tool"]) + self.assertEqual(region.region_keyrings("cn"), ["archlinuxcn"]) + self.assertEqual(region.region_packages("global"), []) + self.assertEqual(region.region_keyrings("global"), []) + + def from_env(self, config: dict) -> context.InstallContext: + config_path = self.root / "config.json" + creds_path = self.root / "creds.json" + config_path.write_text(json.dumps(config)) + creds_path.write_text(json.dumps({"users": [{"username": "test"}]})) + with mock.patch.dict(os.environ, { + "OMARCHY_INSTALL_CONFIG": str(config_path), + "OMARCHY_INSTALL_CREDS": str(creds_path), + "OMARCHY_INSTALL_STATE_DIR": str(self.root / "state"), + }, clear=True), mock.patch.object(context, "_default_kernel", return_value="linux-omarchy"): + return context.InstallContext.from_env() + + def test_context_resolves_region_before_install(self): + self.assertEqual(self.from_env({"timezone": "Asia/Shanghai"}).region, "cn") + self.assertEqual(self.from_env({"timezone": "Europe/Berlin"}).region, "global") + # Deferred installs carry UTC until first boot, so they are global + # unless the autoinstall config names a region. + self.assertEqual(self.from_env({"timezone": "UTC"}).region, "global") + self.assertEqual(self.from_env({"timezone": "UTC", "omarchy_install": {"region": "cn"}}).region, "cn") + with self.assertRaises(RuntimeError): + self.from_env({"omarchy_install": {"region": "zz"}}) + + def test_marker_is_written_only_for_regional_targets(self): + target = self.root / "target" + phases_impl._write_region_marker(types.SimpleNamespace(target=target, region="global")) + self.assertFalse((target / "etc/omarchy/region").exists()) + + phases_impl._write_region_marker(types.SimpleNamespace(target=target, region="cn")) + marker = target / "etc/omarchy/region" + self.assertEqual(marker.read_text(), "cn\n") + self.assertEqual(marker.stat().st_mode & 0o777, 0o644) + + def test_region_packages_install_while_the_offline_mirror_is_mounted(self): + for selected, expected_packages in [("global", []), ("cn", [["archlinuxcn-keyring", "extra-tool"]])]: + with self.subTest(region=selected), ExitStack() as stack: + events = [] + installer = mock.MagicMock() + installer.add_additional_packages.side_effect = lambda packages: events.append(packages) + config = types.SimpleNamespace( + kernels=["linux-omarchy"], locale_config=None, mirror_config=None, + swap=None, auth_config=None, app_config=None, timezone=None, + ntp=False, hostname="test", pacman_config="/etc/pacman.conf", + ) + ctx = types.SimpleNamespace( + state={"arch_config_handler": types.SimpleNamespace(config=config), "mirror_handler": None}, + target=self.root / "target", tailscale_authkey_path=None, omarchy_install={}, + region=selected, + ) + for name in ("_mount_offline_package_cache", "_mask_mkinitcpio_pacman_hooks", + "_unmask_mkinitcpio_pacman_hooks", "_configure_limine_boot", + "_write_pre_mounted_fstab", "_install_early_packages"): + stack.enter_context(mock.patch.object(phases_impl, name)) + stack.enter_context(mock.patch.object( + phases_impl, "_unmount_offline_package_cache", side_effect=lambda _: events.append("unmount"))) + stack.enter_context(mock.patch.object(phases_impl, "configure_keyboard", return_value=True)) + stack.enter_context(mock.patch.object(phases_impl, "_runtime_package_list", return_value=["omarchy"])) + for name, value in (("is_pre_mount", True), ("root_user", None)): + stack.enter_context(mock.patch.object(phases_impl.arch, name, return_value=value, 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 + + phases_impl.arch_install_system(ctx) + + self.assertEqual(events, [["linux-omarchy-headers"], ["omarchy"], *expected_packages, "unmount"]) + self.assertEqual((ctx.target / "etc/omarchy/region").exists(), selected != "global") + + def test_finalizer_trusts_only_the_selected_region(self): + for selected, expected in [ + ("global", []), + ("cn", [["pacman-key", "--init"], ["pacman-key", "--populate", "archlinux", "archlinuxcn"]]), + ]: + with self.subTest(region=selected), \ + mock.patch.object(phases_impl, "_run_target_setup_command") as run, \ + mock.patch.object(phases_impl, "_mask_mkinitcpio_pacman_hooks"), \ + mock.patch.object(phases_impl, "_unmask_mkinitcpio_pacman_hooks"): + ctx = types.SimpleNamespace(region=selected, defer_provisioning=True, target=Path("/unused")) + phases_impl.run_system_finalizer(ctx) + commands = [call.args[1] for call in run.call_args_list] + self.assertEqual(commands[:-1], expected) + self.assertEqual(commands[-1][0], "/usr/bin/omarchy-apply-system") + + +class PrepareRegionsTest(unittest.TestCase): + def run_prepare(self, regions: Path, root: Path) -> tuple[Path, Path, str]: + payload = root / "payload" + payload.mkdir() + online = root / "online.conf" + online.write_text("[core]\nInclude = /etc/pacman.d/mirrorlist\n") + stubs = root / "bin" + stubs.mkdir() + log = root / "calls.log" + for name in ("pacman", "pacman-key"): + stub = stubs / name + stub.write_text(f'#!/bin/bash\necho "{name} $*" >> "{log}"\n') + stub.chmod(0o755) + subprocess.run( + ["bash", str(ROOT / "builder/prepare-regions.sh"), str(regions), str(payload), str(online)], + env={**os.environ, "PATH": f"{stubs}:{os.environ['PATH']}"}, + check=True, capture_output=True, text=True, + ) + return payload, online, log.read_text() if log.exists() else "" + + def test_ships_every_profile_and_bootstraps_its_keyring(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + regions = write_profiles(root) + payload, online, calls = self.run_prepare(regions, root) + self.assertEqual((payload / "regions/cn/timezones").read_text(), (regions / "cn/timezones").read_text()) + self.assertIn("[archlinuxcn]", online.read_text()) + self.assertTrue(online.read_text().startswith("[core]\n")) + self.assertEqual(calls.splitlines(), [ + f"pacman --config {online} --noconfirm -Sy --needed archlinuxcn-keyring", + "pacman-key --populate archlinuxcn", + ]) + + def settings_package(self, root: Path, with_regions: bool) -> Path: + tree = root / "pkgroot" + (tree / "usr/share/omarchy/default/pacman").mkdir(parents=True) + if with_regions: + write_profiles(root / "src") + (root / "src/regions").rename(tree / "usr/share/omarchy/default/regions") + package = root / "omarchy-settings-1-1-any.pkg.tar.zst" + subprocess.run(["bsdtar", "--zstd", "-cf", str(package), "-C", str(tree), "usr"], check=True) + return package + + def test_published_build_reads_profiles_from_the_settings_package(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + payload, online, calls = self.run_prepare(self.settings_package(root, with_regions=True), root) + self.assertEqual((payload / "regions/cn/packages").read_text(), "# keyring first\narchlinuxcn-keyring\nextra-tool\n") + self.assertIn("[archlinuxcn]", online.read_text()) + self.assertIn("pacman-key --populate archlinuxcn", calls) + + def test_settings_package_without_profiles_builds_a_global_iso(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + payload, online, calls = self.run_prepare(self.settings_package(root, with_regions=False), root) + self.assertFalse((payload / "regions").exists()) + self.assertEqual(calls, "") + + def test_unreadable_settings_package_fails_the_build(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + package = root / "omarchy-settings-1-1-any.pkg.tar.zst" + package.write_text("not an archive") + with self.assertRaises(subprocess.CalledProcessError): + self.run_prepare(package, root) + + def test_runtime_without_profiles_builds_a_global_iso(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + payload, online, calls = self.run_prepare(root / "missing", root) + self.assertFalse((payload / "regions").exists()) + self.assertNotIn("archlinuxcn", online.read_text()) + self.assertEqual(calls, "") + + +if __name__ == "__main__": + unittest.main()