diff --git a/README.md b/README.md index dd893d0..645697f 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,20 @@ The configurator asks for the region on its first screen. China reruns the insta Use `--dev` or `--rc` to build against those package channels. Both `--dev` and `--edge` select the dev packages from the edge mirror. +### Regional builds + +Every ISO bundles the China profile from its runtime, so either region can be picked on the first screen. `--region cn` only preselects China there and adds a `-cn` suffix to the artifact and offline cache; the default, `--region global`, keeps the existing names. Supported country profiles use lowercase ISO 3166-1 alpha-2 codes; currently only `cn` is implemented. + +For an unpublished regional runtime, use matching local source and package-recipe checkouts: + +```bash +./bin/omarchy-iso-make --region cn --no-boot-offer --local-source /path/to/omarchy /path/to/omarchy-pkgs +``` + +A China install puts each channel server's `.omarchy.cn` twin first, keeps `.omarchy.org` behind it, appends the USTC Arch mirror, and adds ArchLinuxCN with its verified keyring. USTC is rolling, not a matching stable/RC snapshot, so that last fallback's version compatibility is not guaranteed. The region architecture also stages optional new-user defaults before user creation, including deferred setup, without touching existing homes. China preselects `Asia/Shanghai` as the timezone. See `docs/regions.md` in the companion runtime checkout. + +Non-global cache and ISO names gain a `-cn` suffix. A runtime without the selected profile fails the build. These builds require an x86_64 Arch container environment; packaging checks on another architecture do not substitute for a fresh build, offline installation, and graphical acceptance run. + ## Autoinstall The shipped ISO installs itself with no keyboard when it finds its configuration on a second drive. Attach a drive labeled `cidata` alongside the ISO and the installer copies the config off it and skips the configurator; with no such drive, nothing changes and the wizard runs as usual. No rebuild, no extra boot entry. @@ -52,7 +66,7 @@ These are the configurator's own output files, so the way to get a starting set | `authorized_keys` | No | SSH public keys in sshd's own format, one per line | | `tailscale_authkey` | No | Tailscale auth key; the machine joins your tailnet on first boot | -Both required files must be present or the installer falls back to the configurator. `user_configuration.json` may set `omarchy_install.region` to `global` (the default) or `china`, the region the configurator asks for on its first screen. Generate the password hash for `user_credentials.json` with `openssl passwd -6 "yourpassword"`. +Both required files must be present or the installer falls back to the configurator. `user_configuration.json` may set `omarchy_install.region` to `global` (the default) or `cn`, the region the configurator asks for on its first screen. Generate the password hash for `user_credentials.json` with `openssl passwd -6 "yourpassword"`. Encryption itself is configured by the `disk_encryption` block inside `user_configuration.json` — which carries the passphrase in plaintext, so treat a drive built from an encrypted install accordingly. The flag file must match it: it drives the encrypted install's SDDM autologin and the final boot validation, not the encryption. diff --git a/bin/omarchy-iso-make b/bin/omarchy-iso-make index 5dbbeff..b38001c 100755 --- a/bin/omarchy-iso-make +++ b/bin/omarchy-iso-make @@ -6,8 +6,17 @@ NO_CACHE="" KEEP_PKG_CACHE="" LOCAL_OMARCHY_PATH="" LOCAL_PKGS_PATH="" +OMARCHY_REGION="${OMARCHY_REGION:-global}" while [[ $# -gt 0 ]]; do case $1 in + --region) + OMARCHY_REGION="${2:-}" + if [[ $OMARCHY_REGION != "global" && $OMARCHY_REGION != "cn" ]]; then + echo "Error: --region requires global or cn (ISO 3166-1 alpha-2)." >&2 + exit 1 + fi + shift 2 + ;; --no-cache) NO_CACHE=1 shift @@ -57,12 +66,17 @@ while [[ $# -gt 0 ]]; do ;; *) echo "Unknown option: $1" - echo "Usage: $0 [--no-cache] [--keep-pkg-cache] [--no-boot-offer] [--debug] [--edge] [--dev|--rc] [--local-source ]" + echo "Usage: $0 [--no-cache] [--keep-pkg-cache] [--no-boot-offer] [--debug] [--edge] [--dev|--rc] [--region global|cn] [--local-source ]" exit 1 ;; esac done +if [[ $OMARCHY_REGION != "global" && $OMARCHY_REGION != "cn" ]]; then + echo "Error: unsupported region '$OMARCHY_REGION'. Expected global or cn." >&2 + exit 1 +fi + if [[ -n $LOCAL_OMARCHY_PATH ]]; then [[ -d $LOCAL_OMARCHY_PATH ]] || { echo "Error: Omarchy checkout not found at $LOCAL_OMARCHY_PATH" >&2; exit 1; } [[ -d $LOCAL_PKGS_PATH ]] || { echo "Error: pkgs checkout not found at $LOCAL_PKGS_PATH" >&2; exit 1; } @@ -116,6 +130,10 @@ BUILD_SCRIPT="builder/build-iso.sh" BUILD_ROOT=$(realpath "${BASH_SOURCE[0]%/*}/..") BUILD_RELEASE_PATH="$BUILD_ROOT/release" mkdir -p "$BUILD_RELEASE_PATH" +# Each build writes into its own directory, so concurrent builds (such as a +# global and a regional one) never pick up and rename each other's ISO. +BUILD_OUT=$(mktemp -d "$BUILD_RELEASE_PATH/.build-XXXXXX") +trap 'rm -rf "$BUILD_OUT"' EXIT OMARCHY_ISO_REF="${OMARCHY_ISO_REF:-quattro}" OMARCHY_MIRROR="${OMARCHY_MIRROR:-stable}" @@ -125,10 +143,11 @@ DOCKER_ARGS=( --privileged -e "OMARCHY_ISO_REF=$OMARCHY_ISO_REF" -e "OMARCHY_MIRROR=$OMARCHY_MIRROR" + -e "OMARCHY_REGION=$OMARCHY_REGION" -e "OMARCHY_INSTALL_DEBUG=${OMARCHY_INSTALL_DEBUG:-}" -e "HOST_UID=$(id -u)" -e "HOST_GID=$(id -g)" - -v "$BUILD_RELEASE_PATH/:/out/" + -v "$BUILD_OUT/:/out/" -v "$BUILD_ROOT/$BUILD_SCRIPT:/$BUILD_SCRIPT:ro" -v "$BUILD_ROOT/archiso:/archiso:ro" -v "$BUILD_ROOT/builder:/builder:ro" @@ -146,10 +165,14 @@ if [[ -d /var/cache/pacman/pkg ]]; then DOCKER_ARGS+=(-v "/var/cache/pacman/pkg:/var/cache/pacman/pkg") fi -# Mount the build cache directory where packages are downloaded. Channels use -# different package snapshots and must never share an offline mirror cache. +# Channels and regions must not share an offline mirror cache. Keep the +# existing global cache path and artifact suffix unchanged. +region_suffix="" +if [[ $OMARCHY_REGION != "global" ]]; then + region_suffix="-$OMARCHY_REGION" +fi if [[ -z "$NO_CACHE" ]]; then - OFFLINE_REPO_BUILD_CACHE_DIR="$HOME/.cache/omarchy/iso_${OMARCHY_MIRROR}/airootfs/var/cache/omarchy" + OFFLINE_REPO_BUILD_CACHE_DIR="$HOME/.cache/omarchy/iso_${OMARCHY_MIRROR}${region_suffix}/airootfs/var/cache/omarchy" mkdir -p "$OFFLINE_REPO_BUILD_CACHE_DIR" DOCKER_ARGS+=(-v "$OFFLINE_REPO_BUILD_CACHE_DIR:/var/cache/airootfs/var/cache/omarchy") else @@ -168,9 +191,14 @@ fi "${DOCKER[@]}" run "${DOCKER_ARGS[@]}" archlinux/archlinux:latest /$BUILD_SCRIPT -latest_iso=$(\ls -t "$BUILD_RELEASE_PATH"/*.iso | head -n1) -iso_ref="${latest_iso%.*}-$OMARCHY_ISO_REF.iso" -mv -f "$latest_iso" "$iso_ref" +built_isos=("$BUILD_OUT"/*.iso) +if (( ${#built_isos[@]} != 1 )) || [[ ! -f ${built_isos[0]} ]]; then + echo "Error: expected exactly one ISO in $BUILD_OUT" >&2 + exit 1 +fi +built_iso_name=$(basename "${built_isos[0]}" .iso) +iso_ref="$BUILD_RELEASE_PATH/$built_iso_name-${OMARCHY_ISO_REF}${region_suffix}.iso" +mv -f "${built_isos[0]}" "$iso_ref" if [[ -z "${NO_BOOT_OFFER:-}" ]]; then echo diff --git a/bin/omarchy-iso-test b/bin/omarchy-iso-test index 7de2f08..e70d569 100755 --- a/bin/omarchy-iso-test +++ b/bin/omarchy-iso-test @@ -14,7 +14,7 @@ # Usage: omarchy-iso-test [release/omarchy.iso] [options] # # --encrypt Drive the encrypted install flow (default: unencrypted) -# --region REGION Pick global (default) or china on the first screen +# --region REGION Pick global (default) or cn on the first screen # --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 @@ -87,8 +87,8 @@ if [[ ! -f ${ISO:-} ]]; then exit 1 fi -if [[ $REGION != "global" && $REGION != "china" ]]; then - echo "--region must be global or china, not $REGION" >&2 +if [[ $REGION != "global" && $REGION != "cn" ]]; then + echo "--region must be global or cn, not $REGION" >&2 exit 1 fi @@ -96,10 +96,10 @@ omarchy-pkg-add qemu-full edk2-ovmf socat imagemagick tesseract tesseract-data-e # After the region screen a China install reads in Chinese. OCR_LANGS=eng -[[ $REGION == "china" ]] && OCR_LANGS=eng+chi_sim +[[ $REGION == "cn" ]] && OCR_LANGS=eng+chi_sim # region_text picks the text a screen shows in this run. region_text() { - if [[ $REGION == "china" ]]; then + if [[ $REGION == "cn" ]]; then printf '%s' "$2" else printf '%s' "$1" @@ -111,7 +111,7 @@ OVMF_VARS_TEMPLATE="/usr/share/edk2/x64/OVMF_VARS.4m.fd" BASE_NAME="$(basename "$ISO" .iso)" $ENCRYPT && BASE_NAME+="-encrypted" -[[ $REGION == "china" ]] && BASE_NAME+="-china" +[[ $REGION == "cn" ]] && BASE_NAME+="-cn" $PROVISION && BASE_NAME+="-provision" BASE_DIR="$REPO_DIR/test-runs/$BASE_NAME" RUN_DIR="$BASE_DIR/runs/$(date +%Y%m%d-%H%M%S)" @@ -262,7 +262,7 @@ ocr_screen() { [[ -s $shot ]] || return 0 magick "$shot" -colorspace gray -negate -resize 150% "$prepped" 2>/dev/null || return 0 # The logo and the black strip under foot throw tesseract off CJK, so China runs also read between them, without spaces. - if [[ $REGION == "china" ]]; then + if [[ $REGION == "cn" ]]; then magick "$shot" -gravity north -chop 0x35% -gravity south -chop 0x4% -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 @@ -648,7 +648,7 @@ drive_configurator() { # Region comes first, Global preselected and China one row below. wait_for_screen "Select region" 300 capture_console "success-installer-01-region" - [[ $REGION == "china" ]] && press down + [[ $REGION == "cn" ]] && press down press ret # Global asks for the keyboard; China reruns in cage + foot on a bilingual region screen with a US keyboard. Ctrl+C on either defers provisioning. diff --git a/builder/build-iso.sh b/builder/build-iso.sh index 80bd55a..cf30e8c 100755 --- a/builder/build-iso.sh +++ b/builder/build-iso.sh @@ -4,6 +4,11 @@ set -e OMARCHY_ISO_REF="${OMARCHY_ISO_REF:-quattro}" OMARCHY_MIRROR="${OMARCHY_MIRROR:-stable}" +OMARCHY_REGION="${OMARCHY_REGION:-global}" +case "$OMARCHY_REGION" in + global|cn) ;; + *) echo "Unsupported region: $OMARCHY_REGION" >&2; exit 1 ;; +esac # Edge, dev, and local-source ISOs install the dev packages explicitly. Those # package recipes track the quattro branch. This avoids relying on pacman's @@ -67,6 +72,7 @@ cp -r /configs/* "$build_cache_dir/" mkdir -p "$build_cache_dir/airootfs/usr/share/omarchy-iso" echo "$OMARCHY_MIRROR" > "$build_cache_dir/airootfs/root/omarchy_mirror" echo "$OMARCHY_ISO_REF" > "$build_cache_dir/airootfs/root/omarchy_iso_ref" +echo "$OMARCHY_REGION" > "$build_cache_dir/airootfs/root/omarchy_region" cat > "$build_cache_dir/airootfs/usr/share/omarchy-iso/package-targets" <&2 + exit 1 + fi base_pkg_lists=(/tmp/omarchy-pkglists/usr/share/omarchy/install/omarchy-base.packages /tmp/omarchy-pkglists/usr/share/omarchy/install/omarchy-other.packages) # Extracted on its own, tolerating a miss: bsdtar exits non-zero for a member # it can't find, so asking for this alongside the package lists would abort the @@ -186,6 +200,16 @@ mkdir -p "$build_cache_dir/airootfs/usr/share/omarchy-iso" cp "${base_pkg_lists[0]}" "$build_cache_dir/airootfs/usr/share/omarchy-iso/omarchy-base.packages" cp "${base_pkg_lists[1]}" "$build_cache_dir/airootfs/usr/share/omarchy-iso/omarchy-other.packages" +# Work on writable copies, leaving the mounted runtime and ISO configs intact. +online_config=/tmp/omarchy-pacman-online.conf +cp "/configs/pacman-online-${OMARCHY_MIRROR}.conf" "$online_config" +# Every ISO offers China on its first screen, whatever region it preselects. +bash /builder/prepare-region.sh cn "$runtime_root" "$build_cache_dir/airootfs" "$online_config" +base_pkg_lists=( + "$build_cache_dir/airootfs/usr/share/omarchy-iso/omarchy-base.packages" + "$build_cache_dir/airootfs/usr/share/omarchy-iso/omarchy-other.packages" +) + # The configurator's setup form comes from the runtime this ISO bundles, so the # installer and the first-boot setup that finishes a deferred install can never # disagree. A runtime predating the split ships no such file, which would leave @@ -251,7 +275,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 } @@ -269,7 +293,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-region.sh b/builder/prepare-region.sh new file mode 100644 index 0000000..0e530e1 --- /dev/null +++ b/builder/prepare-region.sh @@ -0,0 +1,41 @@ +#!/bin/bash + +# Add the selected runtime's regional payload to the ISO. Repository defaults +# stay in the runtime package, not in a second set of ISO-specific templates. +set -euo pipefail + +region="$1" +runtime="$2" +iso_root="$3" +online_config="$4" + +case "$region" in + global) exit 0 ;; + cn) ;; + *) echo "Unsupported region: $region" >&2; exit 1 ;; +esac + +profile="$runtime/default/regions/$region" +if [[ ! -f $runtime/bin/omarchy-apply-pacman || ! -f $profile/packages || + ! -f $profile/pacman/pacman.conf.append || ! -f $profile/pacman/mirrorlist.append ]]; then + echo "Runtime does not support region '$region'; publish a matching runtime or use --local-source." >&2 + exit 1 +fi + +# The offline mirror carries the profile packages; the installer adds them to +# the target only when the person picks this region. +payload="$iso_root/usr/share/omarchy-iso" +cp -a "$profile" "$payload/region" +printf '\n' >> "$payload/omarchy-other.packages" +cat "$profile/packages" >> "$payload/omarchy-other.packages" + +# Only the community repository is needed for regional package downloads. +# Keep the channel's build mirrors intact; target mirrors are restored later. +printf '\n' >> "$online_config" +cat "$profile/pacman/pacman.conf.append" >> "$online_config" + +# The keyring package must be signed by a key already trusted by Arch. Do not +# bootstrap trust from an unchecked keyserver response or disable signatures. +# https://github.com/archlinuxcn/repo#usage +pacman --config "$online_config" --noconfirm -Sy --needed archlinuxcn-keyring +pacman-key --populate archlinuxcn diff --git a/configs/airootfs/root/.automated_script.sh b/configs/airootfs/root/.automated_script.sh index 606c037..6a9e704 100644 --- a/configs/airootfs/root/.automated_script.sh +++ b/configs/airootfs/root/.automated_script.sh @@ -116,7 +116,7 @@ else mkdir -p -m 700 "$cjk_runtime" rm -f "$cjk_runtime/cjk-console-started" "$cjk_runtime/return-to-vt" cage_status=0 - env -u OMARCHY_CJK_RELAUNCH XDG_RUNTIME_DIR=$cjk_runtime OMARCHY_CJK_CONSOLE=1 OMARCHY_INSTALL_REGION=china \ + env -u OMARCHY_CJK_RELAUNCH XDG_RUNTIME_DIR=$cjk_runtime OMARCHY_CJK_CONSOLE=1 OMARCHY_INSTALL_REGION=cn \ 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_REGION=global ./configurator && status=0 || status=$? @@ -125,7 +125,7 @@ else else echo "The Chinese console could not start (cage exit $cage_status); continuing in English." unset OMARCHY_CJK_RELAUNCH - OMARCHY_INSTALL_REGION=china ./configurator && status=0 || status=$? + OMARCHY_INSTALL_REGION=cn ./configurator && status=0 || status=$? fi done if [[ -n ${OMARCHY_CJK_CONSOLE:-} ]] && ((status == vt_return)); then @@ -145,7 +145,7 @@ if [[ -f /root/defer-provisioning ]] || export OMARCHY_UI_DEFER_PROVISIONING=yes fi -if [[ -n ${OMARCHY_CJK_CONSOLE:-} && "$(jq -r '.omarchy_install.region // "global"' /root/user_configuration.json)" == "china" ]]; then +if [[ -n ${OMARCHY_CJK_CONSOLE:-} && "$(jq -r '.omarchy_install.region // "global"' /root/user_configuration.json)" == "cn" ]]; then export OMARCHY_INSTALL_LANG=zh fi diff --git a/configs/airootfs/root/configurator b/configs/airootfs/root/configurator index 5dbd624..26c1613 100644 --- a/configs/airootfs/root/configurator +++ b/configs/airootfs/root/configurator @@ -91,6 +91,15 @@ OMARCHY_ZH+=( ["Everything will be overwritten. There is no recovery possible."]="磁盘上的一切都会被覆盖,无法恢复。" ["Confirm overwriting %s"]="确认覆盖 %s" ) +# Every ISO carries the cn profile here (builder/prepare-region.sh copies it); +# China preselects its timezone when there is no geo guess, which is every +# offline install. +REGION_TIMEZONE="${REGION_TIMEZONE:-/usr/share/omarchy-iso/region/timezone}" +region_timezone() { + if [[ $region == "cn" ]]; then + head -n 1 "$REGION_TIMEZONE" + fi +} # Partition creation helpers, shared with test/partition-numbering-test.sh so # the numbering logic can be exercised without a real disk. @@ -273,13 +282,13 @@ notice() { # .automated_script.sh moves China into the CJK console and Global back to the VT, which applies keymaps. apply_region_language() { - if [[ $region == "china" && -n ${OMARCHY_CJK_RELAUNCH:-} ]]; then + if [[ $region == "cn" && -n ${OMARCHY_CJK_RELAUNCH:-} ]]; then exit "$OMARCHY_CJK_RELAUNCH" fi if [[ $region == "global" && -n ${OMARCHY_VT_RETURN:-} ]]; then exit "$OMARCHY_VT_RETURN" fi - if [[ $region == "china" && -n ${OMARCHY_CJK_CONSOLE:-} ]]; then + if [[ $region == "cn" && -n ${OMARCHY_CJK_CONSOLE:-} ]]; then export OMARCHY_INSTALL_LANG=zh else unset OMARCHY_INSTALL_LANG @@ -363,7 +372,7 @@ user_form() { omarchy_prompt_identity || return $? omarchy_prompt_hostname || return $? - omarchy_prompt_timezone || return $? + omarchy_prompt_timezone "$(region_timezone)" || return $? } # Returns 0 on confirmed account details, or as soon as the keyboard screen @@ -384,8 +393,9 @@ user_step() { fi # Add manual padding since gum table -p doesn't respect padding - local skipped + local skipped region_label=Global skipped=$(omarchy_text "[Skipped]") + [[ $region == "cn" ]] && region_label=China echo -e "$(omarchy_text Field),$(omarchy_text Value) $(omarchy_text Username),$username $(omarchy_text Password),$(printf "%${#password}s" | tr ' ' '*') @@ -394,7 +404,7 @@ $(omarchy_text "Email address"),${email_address:-$skipped} $(omarchy_text Hostname),$hostname $(omarchy_text Timezone),$timezone $(omarchy_text Keyboard),$keyboard -$(omarchy_text Region),$(omarchy_text "${region^}")" | +$(omarchy_text Region),$(omarchy_text "$region_label")" | gum table -s "," -p | sed "s/^/${PADDING_LEFT_SPACES}/" echo @@ -1131,8 +1141,9 @@ select_installation() { defer_provisioning=false install_target="full_disk" -# A rerun in the other console arrives with the region already chosen. -region="${OMARCHY_INSTALL_REGION:-global}" +# A rerun in the other console arrives with the region already chosen; a +# regional build preselects its own. +region="${OMARCHY_INSTALL_REGION:-$(<"${ISO_REGION_FILE:-/root/omarchy_region}")}" # 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. diff --git a/configs/airootfs/usr/share/omarchy-iso/orchestrator/context.py b/configs/airootfs/usr/share/omarchy-iso/orchestrator/context.py index be2d4c6..0ac8a2d 100644 --- a/configs/airootfs/usr/share/omarchy-iso/orchestrator/context.py +++ b/configs/airootfs/usr/share/omarchy-iso/orchestrator/context.py @@ -67,8 +67,8 @@ class InstallContext: # Autoinstall configs written before regions existed install Global. region = omarchy_install.get("region", "global") - if region not in ("global", "china"): - raise RuntimeError(f"omarchy_install.region must be global or china, not {region!r}") + if region not in ("global", "cn"): + raise RuntimeError(f"omarchy_install.region must be global or cn, not {region!r}") omarchy_install["region"] = region if creds_path.exists(): 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 63900d2..3620b56 100644 --- a/configs/airootfs/usr/share/omarchy-iso/orchestrator/phases_impl.py +++ b/configs/airootfs/usr/share/omarchy-iso/orchestrator/phases_impl.py @@ -39,6 +39,32 @@ from .keyboard import configure_keyboard from .ui import error, info +ISO_REGION_PAYLOAD = Path("/usr/share/omarchy-iso/region") + + +def _region_packages(ctx: InstallContext) -> list[str]: + if ctx.omarchy_install["region"] != "cn": + return [] + lines = (ISO_REGION_PAYLOAD / "packages").read_text().splitlines() + return [s for s in (raw.strip() for raw in lines) if s and not s.startswith("#")] + + +def _stage_region_defaults(ctx: InstallContext) -> None: + """Seed the target before useradd, including deferred first-boot users. + + Region is a bootstrap default, not a locale or a user-level policy. + Existing home directories are never rewritten by this step. + """ + region = ctx.omarchy_install["region"] + skel = ISO_REGION_PAYLOAD / "skel" + if region == "cn" and skel.is_dir(): + shutil.copytree(skel, ctx.target / "etc/skel", dirs_exist_ok=True) + marker = ctx.target / "etc/omarchy/region" + marker.parent.mkdir(parents=True, exist_ok=True) + marker.write_text(region + "\n") + marker.chmod(0o644) + + # Package targets are written by builder/build-iso.sh. Stable ISOs use the # stable package names, while dev/local-source ISOs install the dev package # names explicitly instead of relying on provides=omarchy resolution. @@ -286,6 +312,7 @@ def arch_install_system(ctx: InstallContext) -> None: _install_early_packages(installer) _configure_limine_boot(ctx, installer, config) + _stage_region_defaults(ctx) info("› creating user (with /etc/skel populated)") if config.auth_config and config.auth_config.users: installer.create_users(config.auth_config.users) @@ -746,6 +773,7 @@ def _runtime_package_list(ctx: InstallContext) -> list[str]: continue if s not in already_installed and s not in pkgs: pkgs.append(s) + pkgs += [p for p in _region_packages(ctx) if p not in pkgs] # Local builds live only in this ISO's mirror; a later region switch could not fetch them. if PINNED_PACKAGES.exists(): pkgs += [p for p in PINNED_PACKAGES.read_text().split() if p not in already_installed and p not in pkgs] @@ -1138,6 +1166,13 @@ def _run_target_setup_command(ctx: InstallContext, cmd: list[str], *, user: str def run_system_finalizer(ctx: InstallContext) -> None: + # The runtime package transaction has installed the regional keyring from + # the ISO. Initialize the target's trust database before enabling the online + # community repository; the build container's GPG state is not inherited. + if ctx.omarchy_install["region"] == "cn": + _run_target_setup_command(ctx, ["pacman-key", "--init"]) + _run_target_setup_command(ctx, ["pacman-key", "--populate", "archlinux", "archlinuxcn"]) + if ctx.defer_provisioning: cmd = ["/usr/bin/omarchy-apply-system", "--defer-provisioning", "--first-install"] else: diff --git a/test/unit/test_kernel_selection.py b/test/unit/test_kernel_selection.py index 5e01f73..ceabe50 100644 --- a/test/unit/test_kernel_selection.py +++ b/test/unit/test_kernel_selection.py @@ -93,6 +93,7 @@ class KernelSelectionTest(unittest.TestCase): events = [] installer = mock.MagicMock() installer.minimal_installation.side_effect = lambda **kwargs: events.append("base") + installer.create_users.side_effect = lambda users: events.append("user") def install_packages(packages): events.append(packages) @@ -102,7 +103,7 @@ class KernelSelectionTest(unittest.TestCase): installer.add_additional_packages.side_effect = install_packages config = types.SimpleNamespace( kernels=kernels, locale_config=None, mirror_config=None, - swap=None, auth_config=None, app_config=None, timezone=None, + swap=None, auth_config=types.SimpleNamespace(users=["alice"]), app_config=None, timezone=None, ntp=False, hostname="test", pacman_config="/etc/pacman.conf", ) ctx = types.SimpleNamespace( @@ -116,6 +117,7 @@ class KernelSelectionTest(unittest.TestCase): unmount = stack.enter_context(mock.patch.object(phases_impl, "_unmount_offline_package_cache")) stack.enter_context(mock.patch.object(phases_impl, "configure_keyboard", return_value=True)) stack.enter_context(mock.patch.object(phases_impl, "_install_early_packages", side_effect=lambda inst: events.append("early"))) + stack.enter_context(mock.patch.object(phases_impl, "_stage_region_defaults", side_effect=lambda target: events.append("region"))) 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)) @@ -128,7 +130,7 @@ class KernelSelectionTest(unittest.TestCase): phases_impl.arch_install_system(ctx) expected = ["base", [f"{kernel}-headers" for kernel in kernels]] if not fail_headers: - expected += ["early", ["omarchy"]] + expected += ["early", "region", "user", ["omarchy"]] self.assertEqual(events, expected) unmask.assert_called_once_with(ctx) unmount.assert_called_once_with(ctx) diff --git a/test/unit/test_pin_local_packages.py b/test/unit/test_pin_local_packages.py index 62b10eb..5ce4f32 100644 --- a/test/unit/test_pin_local_packages.py +++ b/test/unit/test_pin_local_packages.py @@ -104,7 +104,7 @@ class RuntimePackagesTest(unittest.TestCase): mock.patch.object(phases_impl, "_early_packages", return_value=["git"]), \ mock.patch.object(phases_impl, "_package_targets", return_value={ "runtime": "omarchy-dev", "settings": "omarchy-settings-dev", "nvim": "omarchy-nvim"}): - return phases_impl._runtime_package_list(types.SimpleNamespace()) + return phases_impl._runtime_package_list(types.SimpleNamespace(omarchy_install={"region": "global"})) def test_local_builds_install_on_every_region(self): packages = self.runtime_packages("omarchy-dev\nomarchy-settings-dev\nrime-ice-installer\n") diff --git a/test/unit/test_region.py b/test/unit/test_region.py index 16f5695..308aad5 100644 --- a/test/unit/test_region.py +++ b/test/unit/test_region.py @@ -1,8 +1,9 @@ -"""The configurator's region reaches the target setup as OMARCHY_REGION.""" +"""Region: the install-time choice, the bundled cn profile, and regional builds.""" import json import os import re +import shutil import subprocess import sys import tempfile @@ -42,7 +43,7 @@ class RegionTest(unittest.TestCase): (None, "global"), ({"mode": "full_disk"}, "global"), ({"mode": "full_disk", "region": "global"}, "global"), - ({"mode": "full_disk", "region": "china"}, "china"), + ({"mode": "full_disk", "region": "cn"}, "cn"), ]: with self.subTest(omarchy_install=omarchy_install), tempfile.TemporaryDirectory() as tmp: ctx = load_context(Path(tmp), omarchy_install) @@ -51,11 +52,11 @@ class RegionTest(unittest.TestCase): def test_context_rejects_unknown_region(self): for region in ("China", "mars", ""): with self.subTest(region=region), tempfile.TemporaryDirectory() as tmp: - with self.assertRaisesRegex(RuntimeError, "omarchy_install.region must be global or china"): + with self.assertRaisesRegex(RuntimeError, "omarchy_install.region must be global or cn"): load_context(Path(tmp), {"mode": "full_disk", "region": region}) def test_target_setup_receives_region(self): - for region in ("global", "china"): + for region in ("global", "cn"): with self.subTest(region=region), tempfile.TemporaryDirectory() as tmp: root = Path(tmp) ctx = types.SimpleNamespace( @@ -74,11 +75,11 @@ class RegionTest(unittest.TestCase): chroot = next(call.args[0] for call in run.call_args_list if call.args[0][0] == "arch-chroot") self.assertIn(f"OMARCHY_REGION={region}", chroot) - def run_keyboard_form(self, script: str, **env: str) -> subprocess.CompletedProcess: + def run_keyboard_form(self, script: str, preselect: str = "global", **env: str) -> subprocess.CompletedProcess: configurator = (ROOT / "configs/airootfs/root/configurator").read_text() language = re.search(r"^apply_region_language\(\) \{\n.*?^\}", configurator, re.M | re.S) form = re.search(r"^keyboard_form\(\) \{\n.*?^\}", configurator, re.M | re.S) - init = re.search(r'^region="\$\{OMARCHY_INSTALL_REGION:-global\}"$', configurator, re.M) + init = re.search(r'^region="\$\{OMARCHY_INSTALL_REGION:-.*\}"$', configurator, re.M) self.assertIsNotNone(language) self.assertIsNotNone(form) self.assertIsNotNone(init) @@ -98,8 +99,11 @@ class RegionTest(unittest.TestCase): "keyboard_form", 'echo "region=$region defer=$defer_provisioning keyboard=${keyboard:-} lang=${OMARCHY_INSTALL_LANG:-}"', ]) - return subprocess.run(["bash", "-c", harness], capture_output=True, text=True, - env={"PATH": os.environ["PATH"], **env}) + with tempfile.TemporaryDirectory() as tmp: + iso_region = Path(tmp) / "omarchy_region" + iso_region.write_text(preselect + "\n") + return subprocess.run(["bash", "-c", harness], capture_output=True, text=True, + env={"PATH": os.environ["PATH"], "ISO_REGION_FILE": str(iso_region), **env}) def lines(self, result: subprocess.CompletedProcess) -> list[str]: return [line for line in result.stdout.splitlines() if line] @@ -109,34 +113,317 @@ class RegionTest(unittest.TestCase): self.assertIn("region=global defer=true", out.stdout) def test_back_from_keyboard_keeps_the_chosen_region(self): - out = self.run_keyboard_form("ok:region=china rc:1 ok:true ok:keyboard=us") + out = self.run_keyboard_form("ok:region=cn rc:1 ok:true ok:keyboard=us") self.assertEqual(self.lines(out), [ "region-preselect=global", "keyboard-prompt", - "region-preselect=china", + "region-preselect=cn", "keyboard-prompt", - "region=china defer=false keyboard=us lang=", + "region=cn defer=false keyboard=us lang=", ]) + def test_a_cn_iso_preselects_china(self): + out = self.run_keyboard_form("ok:true", preselect="cn", OMARCHY_CJK_RELAUNCH="10") + self.assertEqual(out.returncode, 10) + self.assertEqual(self.lines(out), ["region-preselect=cn"]) + def test_china_on_the_vt_hands_over_to_the_cjk_console(self): - out = self.run_keyboard_form("ok:region=china", OMARCHY_CJK_RELAUNCH="10") + out = self.run_keyboard_form("ok:region=cn", OMARCHY_CJK_RELAUNCH="10") self.assertEqual(out.returncode, 10) self.assertEqual(self.lines(out), ["region-preselect=global"]) def test_cjk_console_reads_chinese_with_a_us_keyboard(self): out = self.run_keyboard_form("ok:true", OMARCHY_CJK_CONSOLE="1", OMARCHY_VT_RETURN="11", - OMARCHY_INSTALL_REGION="china") + OMARCHY_INSTALL_REGION="cn") self.assertEqual(out.returncode, 0) self.assertEqual(self.lines(out), [ - "region-preselect=china", - "region=china defer=false keyboard=us lang=zh", + "region-preselect=cn", + "region=cn defer=false keyboard=us lang=zh", ]) def test_global_in_the_cjk_console_returns_to_the_vt(self): out = self.run_keyboard_form("ok:region=global", OMARCHY_CJK_CONSOLE="1", OMARCHY_VT_RETURN="11", - OMARCHY_INSTALL_REGION="china") + OMARCHY_INSTALL_REGION="cn") self.assertEqual(out.returncode, 11) self.assertNotIn("keyboard-prompt", out.stdout) + +class RegionTargetTest(unittest.TestCase): + def setUp(self): + tmp = tempfile.TemporaryDirectory() + self.addCleanup(tmp.cleanup) + self.root = Path(tmp.name) + self.target = self.root / "target" + self.payload = self.root / "payload" + patch = mock.patch.object(phases_impl, "ISO_REGION_PAYLOAD", self.payload) + patch.start() + self.addCleanup(patch.stop) + + def ctx(self, region, deferred=False): + return types.SimpleNamespace(target=self.target, username="alice", defer_provisioning=deferred, + omarchy_install={"region": region}) + + def test_global_does_not_change_user_defaults(self): + (self.payload / "skel").mkdir(parents=True) + (self.payload / "skel/.regional").write_text("cn only\n") + phases_impl._stage_region_defaults(self.ctx("global")) + self.assertEqual((self.target / "etc/omarchy/region").read_text(), "global\n") + self.assertFalse((self.target / "etc/skel").exists()) + + def test_cn_seeds_skel_without_touching_existing_users_or_locale(self): + profile = self.payload / "skel/.config/example/defaults" + profile.parent.mkdir(parents=True) + profile.write_text("regional defaults\n") + existing = self.target / "home/alice/.config/example/defaults" + existing.parent.mkdir(parents=True) + existing.write_text("personal settings\n") + skel = self.target / "etc/skel" + skel.mkdir(parents=True) + (skel / ".bashrc").write_text("original\n") + (self.target / "etc/locale.conf").write_text("LANG=en_US.UTF-8\n") + + phases_impl._stage_region_defaults(self.ctx("cn")) + self.assertEqual((skel / ".config/example/defaults").read_text(), "regional defaults\n") + self.assertEqual((skel / ".bashrc").read_text(), "original\n") + self.assertEqual(existing.read_text(), "personal settings\n") + self.assertEqual((self.target / "etc/locale.conf").read_text(), "LANG=en_US.UTF-8\n") + self.assertEqual((self.target / "etc/omarchy/region").read_text(), "cn\n") + + def test_cn_without_user_defaults_only_records_the_region(self): + phases_impl._stage_region_defaults(self.ctx("cn")) + self.assertEqual((self.target / "etc/omarchy/region").read_text(), "cn\n") + self.assertFalse((self.target / "etc/skel").exists()) + + def test_cn_keyring_is_initialized_before_finalization_for_both_install_modes(self): + for deferred in (False, True): + ctx = self.ctx("cn", deferred) + with 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"): + phases_impl.run_system_finalizer(ctx) + commands = [call.args[1] for call in run.call_args_list] + self.assertEqual(commands[:2], [["pacman-key", "--init"], ["pacman-key", "--populate", "archlinux", "archlinuxcn"]]) + expected = ["--defer-provisioning"] if deferred else ["--install-user", "alice"] + self.assertEqual(commands[2], ["/usr/bin/omarchy-apply-system", *expected, "--first-install"]) + + def test_keyring_failure_prevents_enabling_online_repositories(self): + ctx = self.ctx("cn") + with mock.patch.object(phases_impl, "_run_target_setup_command", side_effect=RuntimeError("keyring failed")) as run: + with self.assertRaisesRegex(RuntimeError, "keyring failed"): + phases_impl.run_system_finalizer(ctx) + self.assertEqual(run.call_count, 1) + + def test_global_trusts_no_regional_keyring(self): + with 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"): + phases_impl.run_system_finalizer(self.ctx("global")) + self.assertNotIn("pacman-key", [call.args[1][0] for call in run.call_args_list]) + + +class RegionBuildTest(unittest.TestCase): + def setUp(self): + tmp = tempfile.TemporaryDirectory() + self.addCleanup(tmp.cleanup) + self.root = Path(tmp.name) + self.runtime = self.root / "runtime" + self.profile = self.runtime / "default/regions/cn" + (self.profile / "pacman").mkdir(parents=True) + (self.runtime / "bin").mkdir() + (self.runtime / "bin/omarchy-apply-pacman").touch() + (self.profile / "packages").write_text("archlinuxcn-keyring\nexample-regional-package\n") + (self.profile / "pacman/pacman.conf.append").write_text("[archlinuxcn]\nServer = https://mirrors.ustc.edu.cn/archlinuxcn/$arch\n") + (self.profile / "pacman/mirrorlist.append").write_text("Server = https://mirrors.ustc.edu.cn/archlinux/$repo/os/$arch\n") + (self.profile / "timezone").write_text("Asia/Shanghai\n") + self.iso = self.root / "iso" + self.payload = self.iso / "usr/share/omarchy-iso" + self.payload.mkdir(parents=True) + self.packages = self.payload / "omarchy-base.packages" + self.packages.write_text("base\n") + self.other = self.payload / "omarchy-other.packages" + self.other.write_text("other\n") + self.config = self.root / "pacman.conf" + shutil.copyfile(ROOT / "configs/pacman-online-stable.conf", self.config) + self.original_config = self.config.read_bytes() + self.bin = self.root / "bin" + self.bin.mkdir() + self.log = self.root / "commands" + for command in ("pacman", "pacman-key"): + stub = self.bin / command + stub.write_text('#!/bin/bash\nprintf "%s %s\\n" "${0##*/}" "$*" >> "$TEST_LOG"\nif [[ ${0##*/} == pacman ]]; then exit "${TEST_PACMAN_STATUS:-0}"; fi\n') + stub.chmod(0o755) + + def prepare(self, region, **env): + return subprocess.run( + ["bash", str(ROOT / "builder/prepare-region.sh"), region, str(self.runtime), str(self.iso), str(self.config)], + env={**os.environ, "PATH": f"{self.bin}:{os.environ['PATH']}", "TEST_LOG": str(self.log), **env}, + capture_output=True, text=True, + ) + + def test_cn_bundles_its_packages_installs_them_only_for_china_and_verifies_keyring_first(self): + result = self.prepare("cn") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(self.packages.read_text(), "base\n") + self.assertEqual(self.other.read_text(), "other\n\narchlinuxcn-keyring\nexample-regional-package\n") + self.assertEqual(self.config.read_bytes(), self.original_config + b"\n[archlinuxcn]\nServer = https://mirrors.ustc.edu.cn/archlinuxcn/$arch\n") + self.assertEqual((self.payload / "region/packages").read_text(), "archlinuxcn-keyring\nexample-regional-package\n") + self.assertEqual((self.payload / "region/timezone").read_text(), "Asia/Shanghai\n") + self.assertEqual(self.log.read_text().splitlines(), [f"pacman --config {self.config} --noconfirm -Sy --needed archlinuxcn-keyring", "pacman-key --populate archlinuxcn"]) + with mock.patch.object(phases_impl, "Path", return_value=self.packages), \ + mock.patch.object(phases_impl, "ISO_REGION_PAYLOAD", self.payload / "region"), \ + mock.patch.object(phases_impl, "_package_targets", return_value={"runtime": "omarchy-dev", "settings": "omarchy-settings-dev", "nvim": "omarchy-nvim"}): + for region, extra in (("global", []), ("cn", ["archlinuxcn-keyring", "example-regional-package"])): + ctx = types.SimpleNamespace(omarchy_install={"region": region}) + self.assertEqual(phases_impl._runtime_package_list(ctx), ["omarchy-dev", "base", *extra]) + + def test_global_is_unchanged_without_region_support_in_runtime(self): + shutil.rmtree(self.runtime) + result = self.prepare("global") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(self.other.read_text(), "other\n") + self.assertEqual(self.config.read_bytes(), self.original_config) + self.assertFalse(self.log.exists()) + self.assertFalse((self.payload / "region").exists()) + + def test_missing_runtime_support_fails_before_package_operations(self): + (self.runtime / "bin/omarchy-apply-pacman").unlink() + result = self.prepare("cn") + self.assertNotEqual(result.returncode, 0) + self.assertIn("--local-source", result.stderr) + self.assertFalse(self.log.exists()) + self.assertEqual(self.config.read_bytes(), self.original_config) + + def test_profile_without_user_defaults_is_supported(self): + result = self.prepare("cn") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertFalse((self.payload / "region/skel").exists()) + + def test_untrusted_keyring_aborts_without_importing_keys(self): + result = self.prepare("cn", TEST_PACMAN_STATUS="1") + self.assertNotEqual(result.returncode, 0) + self.assertNotIn("pacman-key", self.log.read_text()) + + +class RegionMakeTest(unittest.TestCase): + def setUp(self): + tmp = tempfile.TemporaryDirectory() + self.addCleanup(tmp.cleanup) + self.root = Path(tmp.name) + (self.root / "bin").mkdir() + shutil.copyfile(ROOT / "bin/omarchy-iso-make", self.root / "bin/omarchy-iso-make") + for directory in ("configs/airootfs/root", "configs/airootfs/usr/local/bin", "stubs"): + (self.root / directory).mkdir(parents=True) + (self.root / "configs/profiledef.sh").touch() + self.log = self.root / "docker-args" + stubs = { + "git": '#!/bin/bash\nexit 0\n', + "sudo": '#!/bin/bash\necho "unexpected sudo" >&2\nexit 99\n', + "docker": '''#!/bin/bash +if [[ $1 == version ]]; then exit 0; fi +printf '%s\\n' "$@" > "$TEST_LOG" +for arg in "$@"; do + if [[ $arg == *:/out/ ]]; then touch "${arg%:/out/}/omarchy-test-x86_64.iso"; fi +done +''', + } + for name, content in stubs.items(): + stub = self.root / "stubs" / name + stub.write_text(content) + stub.chmod(0o755) + + def make(self, *args): + env = {key: value for key, value in os.environ.items() if not key.startswith("OMARCHY_")} + return subprocess.run( + ["bash", "bin/omarchy-iso-make", "--keep-pkg-cache", "--no-boot-offer", *args], + cwd=self.root, env={**env, "HOME": str(self.root), "PATH": f"{self.root / 'stubs'}:{env['PATH']}", "TEST_LOG": str(self.log), "TEST_ROOT": str(self.root)}, + capture_output=True, text=True, + ) + + def test_region_is_independent_of_channel_and_names_cache_and_artifact(self): + for flags, channel, ref in (([], "stable", "quattro"), (["--rc"], "rc", "rc"), (["--edge"], "edge", "edge"), (["--dev"], "edge", "dev")): + with self.subTest(channel=ref): + release = self.root / "release" + if release.exists(): + shutil.rmtree(release) + result = self.make(*flags, "--region", "cn") + self.assertEqual(result.returncode, 0, result.stderr) + args = self.log.read_text().splitlines() + self.assertIn("OMARCHY_REGION=cn", args) + self.assertIn(f"OMARCHY_MIRROR={channel}", args) + self.assertIn(f"OMARCHY_ISO_REF={ref}", args) + self.assertIn(f"{self.root}/.cache/omarchy/iso_{channel}-cn/airootfs/var/cache/omarchy:/var/cache/airootfs/var/cache/omarchy", args) + self.assertTrue((release / f"omarchy-test-x86_64-{ref}-cn.iso").exists()) + + def test_default_preserves_existing_global_cache_and_name(self): + result = self.make() + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("OMARCHY_REGION=global", self.log.read_text().splitlines()) + self.assertIn("/iso_stable/", self.log.read_text()) + self.assertTrue((self.root / "release/omarchy-test-x86_64-quattro.iso").exists()) + + def test_build_renames_only_its_own_iso(self): + release = self.root / "release" + release.mkdir() + other = release / "omarchy-other-x86_64.iso" + other.touch() + result = self.make("--region", "cn") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertTrue(other.exists()) + self.assertTrue((release / "omarchy-test-x86_64-quattro-cn.iso").exists()) + self.assertEqual(list(release.glob(".build-*")), []) + + def test_local_source_keeps_region_and_mounts_both_checkouts(self): + for name in ("runtime", "packages"): + (self.root / name).mkdir() + result = self.make("--region", "cn", "--local-source", str(self.root / "runtime"), str(self.root / "packages")) + self.assertEqual(result.returncode, 0, result.stderr) + args = self.log.read_text().splitlines() + self.assertIn("OMARCHY_REGION=cn", args) + self.assertIn("OMARCHY_ISO_REF=local", args) + self.assertIn(f"{self.root}/runtime:/omarchy-source:ro", args) + self.assertIn(f"{self.root}/packages:/omarchy-pkgs:ro", args) + self.assertTrue((self.root / "release/omarchy-test-x86_64-local-cn.iso").exists()) + + def test_invalid_regions_fail_without_starting_build(self): + for flags in (("--region",), ("--region", "chn"), ("--region", "CN"), ("--region", "zz"), ("--region", "../cn")): + with self.subTest(flags=flags): + result = self.make(*flags) + self.assertNotEqual(result.returncode, 0) + self.assertFalse(self.log.exists()) + self.assertFalse((self.root / "release").exists()) + + + +class ConfiguratorRegionTimezoneTest(unittest.TestCase): + """The configurator hands the cn timezone to the setup form when China is chosen.""" + + def setUp(self): + configurator = (ROOT / "configs/airootfs/root/configurator").read_text() + helper = re.search(r"^region_timezone\(\) \{\n.*?^\}", configurator, re.M | re.S) + self.assertIsNotNone(helper) + self.helper = helper.group(0) + self.configurator = configurator + tmp = tempfile.TemporaryDirectory() + self.addCleanup(tmp.cleanup) + self.file = Path(tmp.name) / "timezone" + + def region_timezone(self, region): + self.file.write_text("Asia/Shanghai\n") + return subprocess.run( + ["bash", "-c", f"set -euo pipefail\nregion={region}\n{self.helper}\nregion_timezone"], + env={**os.environ, "REGION_TIMEZONE": str(self.file)}, + capture_output=True, text=True, check=True, + ).stdout + + def test_global_offers_no_timezone(self): + self.assertEqual(self.region_timezone("global"), "") + + def test_china_offers_the_profile_timezone(self): + self.assertEqual(self.region_timezone("cn"), "Asia/Shanghai\n") + + def test_user_form_passes_it_to_the_timezone_prompt(self): + self.assertIn('omarchy_prompt_timezone "$(region_timezone)"', self.configurator) + + if __name__ == "__main__": unittest.main()