diff --git a/README.md b/README.md index a8094dd..2b2ce95 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Omarchy ISO -The Omarchy ISO streamlines [the installation of Omarchy](https://learn.omacom.io/2/the-omarchy-manual/50/getting-started). It includes the Omarchy Configurator as a front-end to archinstall and automatically launches the [Omarchy Installer](https://github.com/basecamp/omarchy) after base arch has been setup. +The Omarchy ISO is the only supported way to install Omarchy. It ships the Omarchy Configurator, installs Arch Linux, installs the Omarchy packages from the bundled mirror, runs target system setup in the chroot, creates the user, and runs `omarchy-setup-user` for that user. ## Downloading the latest ISO @@ -8,20 +8,18 @@ See the ISO link on [omarchy.org](https://omarchy.org). ## Creating the ISO -Run `./bin/omarchy-iso-make` and the output goes into `./release`. You can build from your local $OMARCHY_PATH for testing by using `--local-source` or from a checkout of the dev branch (instead of master) by using `--dev`. +Run `./bin/omarchy-iso-make`; output goes into `./release`. -### Environment Variables +For local development, build the ISO from sibling checkouts: -You can customize the repositories used during the build process by passing in variables: - -- `OMARCHY_INSTALLER_REPO` - GitHub repository for the installer (default: `basecamp/omarchy`) -- `OMARCHY_INSTALLER_REF` - Git ref (branch/tag) for the installer (default: `master`) - -Example usage: ```bash -OMARCHY_INSTALLER_REPO="myuser/omarchy-fork" OMARCHY_INSTALLER_REF="some-feature" ./bin/omarchy-iso-make +./bin/omarchy-iso-make --local-source ../omarchy-installer ../omarchy-pkgs ``` +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. + +Use `--dev` or `--rc` to build against those package channels. + ## Testing the ISO Run `./bin/omarchy-iso-boot [release/omarchy.iso]`. @@ -32,7 +30,7 @@ Run `./bin/omarchy-iso-sign [gpg-user] [release/omarchy.iso]`. ## Uploading the ISO -Run `./bin/omarchy-iso-upload [release/omarchy.iso]`. This requires you've configured rclone (use `rclone config`). +Run `./bin/omarchy-iso-upload [release/omarchy.iso]`. This requires rclone configuration (`rclone config`). ## Full release of the ISO diff --git a/bin/omarchy-install-ui-preview b/bin/omarchy-install-ui-preview index cf2c70e..42ab7aa 100755 --- a/bin/omarchy-install-ui-preview +++ b/bin/omarchy-install-ui-preview @@ -65,7 +65,7 @@ logs=( "pacman-key: populating archlinux keyring" "mount: /mnt ready" "pacstrap: installing linux btrfs-progs limine" - "pacman: installing omarchy-installer" + "pacman: installing omarchy" "useradd: creating ryan with /etc/skel defaults" "limine-update: generating omarchy_linux.efi" "snapper: creating root config" diff --git a/bin/omarchy-iso-configurator b/bin/omarchy-iso-configurator index d17e873..d3ff01c 100755 --- a/bin/omarchy-iso-configurator +++ b/bin/omarchy-iso-configurator @@ -3,25 +3,13 @@ set -euo pipefail repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -cleanup() { - [[ -n "${mock_omarchy_install:-}" ]] && rm -rf "$(dirname "$mock_omarchy_install")" -} -trap cleanup EXIT - -if [[ -z "${OMARCHY_INSTALL:-}" ]]; then - if [[ -r /root/omarchy/install/helpers/all.sh ]]; then - export OMARCHY_INSTALL=/root/omarchy/install - else - mock_root="$(mktemp -d)" - mock_omarchy_install="$mock_root/install" - mkdir -p "$mock_omarchy_install/helpers" - cat >"$mock_omarchy_install/helpers/all.sh" <<'EOF' -PADDING_LEFT=0 -PADDING_LEFT_SPACES="" -clear_logo() { clear; } -EOF - export OMARCHY_INSTALL="$mock_omarchy_install" - fi +if [[ -z "${OMARCHY_PATH:-}" ]]; then + for candidate in "$repo_root/../omarchy-installer" "$repo_root/../omarchy" /usr/share/omarchy; do + if [[ -f "$candidate/logo.txt" ]]; then + export OMARCHY_PATH="$candidate" + break + fi + done fi exec bash "$repo_root/configs/airootfs/root/configurator" dry "$@" diff --git a/bin/omarchy-iso-make b/bin/omarchy-iso-make index 2977f50..7b785d0 100755 --- a/bin/omarchy-iso-make +++ b/bin/omarchy-iso-make @@ -1,11 +1,9 @@ #!/bin/bash -# Abort if anything fails set -e -# Parse command line arguments NO_CACHE="" -LOCAL_INSTALLER_PATH="" +LOCAL_OMARCHY_PATH="" LOCAL_PKGS_PATH="" while [[ $# -gt 0 ]]; do case $1 in @@ -22,42 +20,38 @@ while [[ $# -gt 0 ]]; do shift ;; --local-source) - # --local-source - # Builds omarchy* from the two checkouts and bundles them in the ISO's - # offline mirror instead of pulling published versions from the omarchy - # network mirror. - LOCAL_INSTALLER_PATH="${2:-}" + LOCAL_OMARCHY_PATH="${2:-}" LOCAL_PKGS_PATH="${3:-}" - if [[ -z $LOCAL_INSTALLER_PATH || -z $LOCAL_PKGS_PATH ]]; then - echo "Error: --local-source requires two args: " >&2 + if [[ -z $LOCAL_OMARCHY_PATH || -z $LOCAL_PKGS_PATH ]]; then + echo "Error: --local-source requires two args: " >&2 exit 1 fi OMARCHY_MIRROR=edge + OMARCHY_ISO_REF=local shift 3 ;; --dev) - OMARCHY_INSTALLER_REF=dev + OMARCHY_ISO_REF=dev OMARCHY_MIRROR=edge shift ;; --rc) + OMARCHY_ISO_REF=rc OMARCHY_MIRROR=rc - OMARCHY_INSTALLER_REF=rc shift ;; *) echo "Unknown option: $1" - echo "Usage: $0 [--no-cache] [--no-boot-offer] [--debug] [--dev|--rc] [--local-source ]" + echo "Usage: $0 [--no-cache] [--no-boot-offer] [--debug] [--dev|--rc] [--local-source ]" exit 1 ;; esac done -# Validate local-source paths up front (before any sudo / network work). -if [[ -n $LOCAL_INSTALLER_PATH ]]; then - [[ -d $LOCAL_INSTALLER_PATH ]] || { echo "Error: installer checkout not found at $LOCAL_INSTALLER_PATH" >&2; exit 1; } - [[ -d $LOCAL_PKGS_PATH ]] || { echo "Error: pkgs checkout not found at $LOCAL_PKGS_PATH" >&2; exit 1; } - LOCAL_INSTALLER_PATH=$(realpath "$LOCAL_INSTALLER_PATH") +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; } + LOCAL_OMARCHY_PATH=$(realpath "$LOCAL_OMARCHY_PATH") LOCAL_PKGS_PATH=$(realpath "$LOCAL_PKGS_PATH") fi @@ -96,27 +90,21 @@ lint_file_permissions() { lint_file_permissions -# Clear out old packages sudo rm -rf /var/cache/pacman/pkg/* - -# Ensure we have the submodule checked out git submodule update --init --recursive --jobs=8 -# Prepare build repo BUILD_SCRIPT="builder/build-iso.sh" BUILD_ROOT=$(realpath "${BASH_SOURCE[0]%/*}/..") BUILD_RELEASE_PATH="$BUILD_ROOT/release" mkdir -p "$BUILD_RELEASE_PATH" -OMARCHY_INSTALLER_REPO="${OMARCHY_INSTALLER_REPO:-basecamp/omarchy}" -OMARCHY_INSTALLER_REF="${OMARCHY_INSTALLER_REF:-master}" +OMARCHY_ISO_REF="${OMARCHY_ISO_REF:-stable}" OMARCHY_MIRROR="${OMARCHY_MIRROR:-stable}" DOCKER_ARGS=( --rm --privileged - -e "OMARCHY_INSTALLER_REPO=$OMARCHY_INSTALLER_REPO" - -e "OMARCHY_INSTALLER_REF=$OMARCHY_INSTALLER_REF" + -e "OMARCHY_ISO_REF=$OMARCHY_ISO_REF" -e "OMARCHY_MIRROR=$OMARCHY_MIRROR" -e "OMARCHY_INSTALL_DEBUG=${OMARCHY_INSTALL_DEBUG:-}" -e "HOST_UID=$(id -u)" @@ -128,43 +116,30 @@ DOCKER_ARGS=( -v "$BUILD_ROOT/configs:/configs:ro" ) -# Mount the local checkouts when --local-source was passed (paths already -# validated/resolved above). -if [[ -n $LOCAL_INSTALLER_PATH ]]; then +if [[ -n $LOCAL_OMARCHY_PATH ]]; then DOCKER_ARGS+=( - -v "$LOCAL_INSTALLER_PATH:/omarchy-installer:ro" + -v "$LOCAL_OMARCHY_PATH:/omarchy-source:ro" -v "$LOCAL_PKGS_PATH:/omarchy-pkgs:ro" ) fi -# Use local pacman cache if you already have one on host to speed up repeat runs -if [ -d "/var/cache/pacman/pkg" ]; then +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. Keyed by -# channel so stable/edge/rc don't pollute each other but the cache persists -# across days (different channels have different package sets; days don't). -# omarchy* packages always rebuild fresh inside the container regardless of -# what's in the cache (build-omarchy-packages.sh nukes its work dir and the -# final mv overwrites the cached .pkg.tar.zst). --no-cache bypasses the mount -# entirely if you want a truly clean download. -if [[ -z "$NO_CACHE" ]]; then +if [[ -z $NO_CACHE ]]; then OFFLINE_REPO_BUILD_CACHE_DIR="$HOME/.cache/omarchy/iso_${OMARCHY_MIRROR}/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") fi -# Run the builder with assembled args docker run "${DOCKER_ARGS[@]}" archlinux/archlinux:latest /$BUILD_SCRIPT -# Move as install ref latest_iso=$(\ls -t "$BUILD_RELEASE_PATH"/*.iso | head -n1) -iso_ref="${latest_iso%.*}-$OMARCHY_INSTALLER_REF.iso" +iso_ref="${latest_iso%.*}-$OMARCHY_ISO_REF.iso" mv -f "$latest_iso" "$iso_ref" -# Offer to boot the new build -if [[ -z "$NO_BOOT_OFFER" ]]; then +if [[ -z "${NO_BOOT_OFFER:-}" ]]; then echo gum confirm "Boot $iso_ref?" && omarchy-iso-boot "$iso_ref" fi diff --git a/bin/omarchy-iso-release b/bin/omarchy-iso-release index 97e679a..ce4482f 100755 --- a/bin/omarchy-iso-release +++ b/bin/omarchy-iso-release @@ -1,7 +1,7 @@ #!/bin/bash MAKE_ARGS=() -INSTALLER_REF=master +ISO_REF=stable RELEASE_SUFFIX="" while [[ $# -gt 0 ]]; do @@ -12,7 +12,7 @@ while [[ $# -gt 0 ]]; do ;; --rc) MAKE_ARGS+=("$1") - INSTALLER_REF=rc + ISO_REF=rc RELEASE_SUFFIX="-rc" shift ;; @@ -42,21 +42,21 @@ BUILD_RELEASE_PATH="$BUILD_ROOT/release" # Make ISOs if [[ ! " ${MAKE_ARGS[*]} " =~ " --no-make " ]]; then - echo -e "\e[32mMaking $INSTALLER_REF ISO\e[0m" + echo -e "\e[32mMaking $ISO_REF ISO\e[0m" omarchy-iso-make --no-cache --no-boot-offer "${MAKE_ARGS[@]}" else echo -e "\e[31mSkipping remaking ISOs\e[0m" fi # Sign and upload ISO -latest_iso=$(\ls -t "$BUILD_RELEASE_PATH"/*x86_64-"$INSTALLER_REF".iso | head -n1) +latest_iso=$(\ls -t "$BUILD_RELEASE_PATH"/*x86_64-"$ISO_REF".iso | head -n1) release_iso="$BUILD_RELEASE_PATH/omarchy-$BUILD_VERSION$RELEASE_SUFFIX.iso" cp "$latest_iso" "$release_iso" -echo -e "\e[32mSigning $INSTALLER_REF ISO\e[0m" +echo -e "\e[32mSigning $ISO_REF ISO\e[0m" omarchy-iso-sign "$release_iso" iso_sha=$(sha256sum "$release_iso") -echo -e "\e[32mSHA256 for $INSTALLER_REF ISO: $iso_sha\e[0m" +echo -e "\e[32mSHA256 for $ISO_REF ISO: $iso_sha\e[0m" omarchy-iso-upload "$release_iso" diff --git a/builder/build-iso.sh b/builder/build-iso.sh index f99abb8..687c09a 100755 --- a/builder/build-iso.sh +++ b/builder/build-iso.sh @@ -5,7 +5,7 @@ set -e # Packages installed into the Arch container used to build the ISO. pacman-key --init pacman --noconfirm -Sy archlinux-keyring -pacman --noconfirm -Sy archiso git sudo base-devel jq grub +pacman --noconfirm -Sy archiso git sudo base-devel jq grub imagemagick neovim nodejs npm tree-sitter-cli # Pre-import the omarchy signing key (so pacman trusts our [omarchy] repo # during the build without keyserver lookups). @@ -48,10 +48,10 @@ if [[ ${OMARCHY_INSTALL_DEBUG:-} == "1" ]]; then echo "debug=1" echo "built_at=$(date -Is)" echo "mirror=$OMARCHY_MIRROR" - if [[ -d /omarchy-installer ]]; then - echo "omarchy_installer_source=/omarchy-installer" - git -c safe.directory=/omarchy-installer -C /omarchy-installer rev-parse HEAD 2>/dev/null | sed 's/^/omarchy_installer_commit=/' || true - git -c safe.directory=/omarchy-installer -C /omarchy-installer status --short 2>/dev/null | sed 's/^/omarchy_installer_status=/' || true + if [[ -d /omarchy-source ]]; then + echo "omarchy_source=/omarchy-source" + git -c safe.directory=/omarchy-source -C /omarchy-source rev-parse HEAD 2>/dev/null | sed 's/^/omarchy_commit=/' || true + git -c safe.directory=/omarchy-source -C /omarchy-source status --short 2>/dev/null | sed 's/^/omarchy_status=/' || true fi if [[ -d /omarchy-pkgs ]]; then echo "omarchy_pkgs_source=/omarchy-pkgs" @@ -64,7 +64,7 @@ fi # When --local-source is in effect, build omarchy* from the mounted source # trees and drop them in the offline mirror. Otherwise pacman -Syw below # downloads the published versions from the omarchy network mirror. -if [[ -d /omarchy-installer && -d /omarchy-pkgs ]]; then +if [[ -d /omarchy-source && -d /omarchy-pkgs ]]; then bash /builder/build-omarchy-packages.sh "$offline_mirror_dir" LOCAL_OMARCHY_BUILD=1 fi @@ -80,22 +80,17 @@ mkdir -p "$build_cache_dir/airootfs/opt/packages/" cp "/tmp/$NODE_FILENAME" "$build_cache_dir/airootfs/opt/packages/" # Packages installed into the live ISO environment itself (NOT the target system). -# omarchy-installer + omarchy-settings need to be HERE so: -# 1. /usr/share/omarchy/{install,default,bin} is available to the configurator -# and .automated_script.sh natively (no .pkg.tar.zst extraction hack) -# 2. omarchy-settings's post_install hook drops omarchy's plymouthd.conf into -# /etc/plymouth so mkinitcpio's plymouth hook picks up the Omarchy theme -# when mkarchiso builds the live initramfs (otherwise plymouth shows -# the default theme on boot). -arch_packages=(linux-t2 git gum jq openssl plymouth python-terminaltexteffects tzupdate omarchy-keyring omarchy-settings omarchy-installer lvm2 cryptsetup parted) +# omarchy-settings 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 python-terminaltexteffects tzupdate omarchy-keyring omarchy-settings lvm2 cryptsetup parted) printf '%s\n' "${arch_packages[@]}" >> "$build_cache_dir/packages.x86_64" # Build the offline mirror: everything pacstrap might want during the target # install. With --local-source, the omarchy* packages we just built are # 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-installer ]]; then - base_pkg_lists=(/omarchy-installer/install/omarchy-base.packages /omarchy-installer/install/omarchy-other.packages) +if [[ -d /omarchy-source ]]; then + base_pkg_lists=(/omarchy-source/install/omarchy-base.packages /omarchy-source/install/omarchy-other.packages) else # Pull the same package lists out of the freshly-downloaded omarchy package # so we don't need a local checkout in the non-local-source path. @@ -106,6 +101,10 @@ else base_pkg_lists=(/tmp/omarchy-pkglists/usr/share/omarchy/install/omarchy-base.packages /tmp/omarchy-pkglists/usr/share/omarchy/install/omarchy-other.packages) fi +mkdir -p "$build_cache_dir/airootfs/usr/share/omarchy-iso" +cp "${base_pkg_lists[0]}" "$build_cache_dir/airootfs/usr/share/omarchy-iso/omarchy-base.packages" +cp "${base_pkg_lists[1]}" "$build_cache_dir/airootfs/usr/share/omarchy-iso/omarchy-other.packages" + # Collect every package we want available in the offline mirror. declare -a all_packages mapfile -t all_packages < <( @@ -113,9 +112,9 @@ mapfile -t all_packages < <( cat "$build_cache_dir/packages.x86_64" grep -hv '^#\|^$' "${base_pkg_lists[@]}" grep -hv '^#\|^$' /builder/archinstall.packages - # Always include the omarchy packages so the target install can find - # omarchy-installer and companion packages in the offline mirror. - printf 'omarchy\nomarchy-settings\nomarchy-installer\nomarchy-limine\nomarchy-nvim\n' + # Always include the Omarchy packages so the target install can find + # the runtime and companion packages in the offline mirror. + printf 'omarchy\nomarchy-settings\nomarchy-limine\nomarchy-nvim\n' } | sort -u ) @@ -123,7 +122,7 @@ mapfile -t all_packages < <( # the mirror; strip them from the pacman -Syw list so it doesn't try to fetch # the published versions on top. if [[ -n ${LOCAL_OMARCHY_BUILD:-} ]]; then - all_packages=($(printf '%s\n' "${all_packages[@]}" | grep -vE '^(omarchy|omarchy-settings|omarchy-installer|omarchy-limine|omarchy-nvim)$')) + all_packages=($(printf '%s\n' "${all_packages[@]}" | grep -vE '^(omarchy|omarchy-settings|omarchy-limine|omarchy-nvim)$')) fi mkdir -p /tmp/offlinedb diff --git a/builder/build-omarchy-packages.sh b/builder/build-omarchy-packages.sh index 69858cf..d823e69 100755 --- a/builder/build-omarchy-packages.sh +++ b/builder/build-omarchy-packages.sh @@ -1,9 +1,6 @@ #!/bin/bash -# Build omarchy + companion omarchy packages from mounted source -# (/omarchy-installer + /omarchy-pkgs) and drop the .pkg.tar.zst -# files into the offline-mirror directory passed as $1. -# -# Run inside the ISO build container (build-iso.sh sources this). +# Build Omarchy packages from mounted source (/omarchy-source + /omarchy-pkgs) +# and place the resulting .pkg.tar.zst files in the offline mirror. set -e @@ -13,8 +10,8 @@ if [[ -z $offline_mirror_dir ]]; then exit 1 fi -if [[ ! -d /omarchy-installer ]]; then - echo "ERROR: /omarchy-installer not mounted (pass --local-source or set OMARCHY_INSTALLER_PATH)" >&2 +if [[ ! -d /omarchy-source ]]; then + echo "ERROR: /omarchy-source not mounted (pass --local-source or set OMARCHY_SOURCE_PATH)" >&2 exit 1 fi if [[ ! -d /omarchy-pkgs ]]; then @@ -26,9 +23,6 @@ work_dir=/tmp/omarchy-pkg-build rm -rf "$work_dir" mkdir -p "$work_dir" -# makepkg refuses to run as root; create a builder user with passwordless -# sudo for pacman dep installation during the build, and hand it ownership -# of the work dir so PKGDEST writes succeed. if ! id builder &>/dev/null; then useradd -m -s /bin/bash builder fi @@ -38,16 +32,10 @@ chown builder:builder "$work_dir" pacman -Sy --noconfirm -# Order matters: omarchy depends on omarchy-settings/-installer which depend on -# the base packages; omarchy-installer depends on the others. Build leaves -# first so dependents can pacman -S them from /tmp once needed. We don't -# actually install during the build, so the order is mostly cosmetic — but -# keeping it stable helps logs. packages=( omarchy-limine omarchy-settings omarchy - omarchy-installer omarchy-nvim ) @@ -59,13 +47,10 @@ for pkg in "${packages[@]}"; do cp -a "/omarchy-pkgs/pkgbuilds/$pkg" "$pkg_work" chown -R builder:builder "$pkg_work" - # --nodeps: don't install deps into the build container. We just need to - # produce the .pkg.tar.zst; pacman resolves runtime deps at install time on - # the target system from the offline mirror. su builder -c " cd '$pkg_work' && PKGDEST='$work_dir' \ - OMARCHY_SRC=/omarchy-installer \ + OMARCHY_SRC=/omarchy-source \ makepkg --noconfirm --skippgpcheck --skipchecksums --nodeps -f " done @@ -74,5 +59,5 @@ mkdir -p "$offline_mirror_dir" mv "$work_dir"/*.pkg.tar.zst "$offline_mirror_dir/" echo -echo "Built omarchy packages, placed in $offline_mirror_dir:" +echo "Built Omarchy packages, placed in $offline_mirror_dir:" ls "$offline_mirror_dir"/omarchy*.pkg.tar.zst | sed 's|^| |' diff --git a/configs/airootfs/etc/pacman.d/hooks/omarchy-root-shell.hook b/configs/airootfs/etc/pacman.d/hooks/omarchy-root-shell.hook new file mode 100644 index 0000000..f4faa64 --- /dev/null +++ b/configs/airootfs/etc/pacman.d/hooks/omarchy-root-shell.hook @@ -0,0 +1,21 @@ +# remove from airootfs! +# +# Mirror omarchy-settings' /etc/skel shell defaults into /root so the live ISO +# console has the same bashrc, starship prompt, and aliases as an installed +# Omarchy system. Fires during airootfs pacstrap (when omarchy-settings is +# installed); the "remove from airootfs" magic string causes +# zzzz99-remove-custom-hooks-from-airootfs.hook to delete this file before +# the squashfs is built, so no runtime hook remains. + +[Trigger] +Operation = Install +Operation = Upgrade +Type = Package +Target = omarchy-settings + +[Action] +Description = Mirroring /etc/skel shell defaults into /root for the live ISO... +When = PostTransaction +Depends = coreutils +Depends = omarchy-settings +Exec = /bin/sh -c 'install -d -m 0700 /root/.config && for f in .bashrc .bash_profile; do [ -f "/etc/skel/$f" ] && install -m 0644 "/etc/skel/$f" "/root/$f"; done; [ -f /etc/skel/.config/starship.toml ] && install -m 0644 /etc/skel/.config/starship.toml /root/.config/starship.toml; true' diff --git a/configs/airootfs/root/.automated_script.sh b/configs/airootfs/root/.automated_script.sh index e30ca04..804c194 100644 --- a/configs/airootfs/root/.automated_script.sh +++ b/configs/airootfs/root/.automated_script.sh @@ -19,7 +19,6 @@ export OMARCHY_INSTALL_LOG_FILE=/var/log/omarchy-install.log if [[ -f /usr/share/omarchy-iso/install-debug ]]; then export OMARCHY_INSTALL_DEBUG=1 fi -source "$OMARCHY_INSTALL/helpers/all.sh" # Tokyo Night palette so the live VT matches the installed look. set_tokyo_night_colors() { @@ -46,7 +45,7 @@ export FORCE_COLOR=1 if [[ ${OMARCHY_INSTALL_DEBUG:-} == "1" ]]; then echo "=== Omarchy ISO debug build ===" [[ -f /usr/share/omarchy-iso/build-info ]] && cat /usr/share/omarchy-iso/build-info - pacman -Q omarchy-installer omarchy-settings omarchy-keyring 2>/dev/null || true + pacman -Q omarchy-settings omarchy-keyring 2>/dev/null || true echo "================================" fi diff --git a/configs/airootfs/root/configurator b/configs/airootfs/root/configurator index 7c6d87e..84082c4 100644 --- a/configs/airootfs/root/configurator +++ b/configs/airootfs/root/configurator @@ -1,7 +1,54 @@ #!/bin/bash -# Rely on Omarchy for styling and error handling -source "$OMARCHY_INSTALL/helpers/all.sh" +# Minimal live-ISO presentation helpers. Keep these local to the ISO: the +# installed Omarchy package owns target setup helpers, while the ISO owns the +# installer UI that runs before any target packages exist. +OMARCHY_PATH="${OMARCHY_PATH:-/usr/share/omarchy}" +LOGO_PATH="${LOGO_PATH:-$OMARCHY_PATH/logo.txt}" + +if [[ -e /dev/tty ]]; then + TERM_SIZE=$(stty size 2>/dev/null max) max = length } END { print max+0 }' "$LOGO_PATH" 2>/dev/null || echo 0) +else + LOGO_WIDTH=7 +fi +(( LOGO_WIDTH > 0 )) || LOGO_WIDTH=7 +PADDING_LEFT=$(((TERM_WIDTH - LOGO_WIDTH) / 2)) +(( PADDING_LEFT < 0 )) && PADDING_LEFT=0 +PADDING_LEFT_SPACES=$(printf "%*s" "$PADDING_LEFT" "") +PADDING="0 0 0 $PADDING_LEFT" + +export TERM_HEIGHT TERM_WIDTH LOGO_PATH PADDING_LEFT PADDING_LEFT_SPACES PADDING +export GUM_CONFIRM_PROMPT_FOREGROUND="6" +export GUM_CONFIRM_SELECTED_FOREGROUND="0" +export GUM_CONFIRM_SELECTED_BACKGROUND="2" +export GUM_CONFIRM_UNSELECTED_FOREGROUND="7" +export GUM_CONFIRM_UNSELECTED_BACKGROUND="0" +export GUM_CHOOSE_PADDING="$PADDING" +export GUM_FILTER_PADDING="$PADDING" +export GUM_INPUT_PADDING="$PADDING" +export GUM_SPIN_PADDING="$PADDING" +export GUM_TABLE_PADDING="$PADDING" +export GUM_CONFIRM_PADDING="$PADDING" + +clear_logo() { + printf "\033[H\033[2J" + if [[ -f $LOGO_PATH ]]; then + gum style --foreground 2 --padding "1 0 0 $PADDING_LEFT" "$(<"$LOGO_PATH")" + else + gum style --foreground 2 --padding "1 0 0 $PADDING_LEFT" "Omarchy" + fi +} # Ensure tzupdate is available for guessing timezone command -v tzupdate >/dev/null || sudo pacman -Sy --noconfirm tzupdate @@ -16,10 +63,15 @@ abort() { step() { clear_logo echo - gum style "$1" + gum style --padding "0 0 0 $PADDING_LEFT" "$1" echo } +# Padded body text to line up with the logo and gum widgets. +say() { + gum style --padding "0 0 0 $PADDING_LEFT" "$@" +} + notice() { clear_logo echo @@ -27,6 +79,76 @@ notice() { echo } +# Tools menu — recovery-style actions kept off the main install path. +# Inlines its own disk picker so the launcher can run before disk_form is +# defined further down in the script. +tools_menu() { + while true; do + step "Tools" + local choice + choice=$(gum choose --header "Pick a tool" \ + "Open partition tool" \ + "Drop to shell" \ + "Reboot" \ + "Power off" \ + "Back to installer") || return 0 + + case "$choice" in + "Open partition tool") + local d disks + disks=$(lsblk -dpno NAME,TYPE 2>/dev/null | awk '$2=="disk"{print $1}' | grep -E '/dev/(sd|hd|vd|nvme|mmcblk|xv)' || true) + if [[ -z "$disks" ]]; then + notice "No suitable disks found" 2 + continue + fi + d=$(echo "$disks" | gum choose --header "Select disk") || continue + clear + cfdisk "$d" || true + partprobe "$d" 2>/dev/null || true + sync + sleep 1 + ;; + "Drop to shell") + clear + echo "Type 'exit' to return to the tools menu." + bash --norc || true + ;; + "Reboot") + reboot + ;; + "Power off") + poweroff + ;; + "Back to installer") + return 0 + ;; + esac + done +} + +# Landing screen: Enter installs, Ctrl-T opens the tools menu. +launcher_form() { + while true; do + step "Install Omarchy" + say "Press Enter to install Omarchy on this machine." + echo + say --foreground 8 "Ctrl-T Tools" + + local key + IFS= read -rsn1 key || true + case "$key" in + ""|$'\n'|$'\r') + return 0 + ;; + $'\x14') + tools_menu + ;; + esac + done +} + +launcher_form + # STEP 1: KEYBOARD LAYOUT keyboard_form() { @@ -280,6 +402,21 @@ to_gb() { awk -v bytes="$1" 'BEGIN {printf "%.1fGB", bytes/1024/1024/1024}' } +# Shared error screen for every "can't fit Omarchy here" outcome of the +# partition install. Offers the partition tool as the meaningful second +# 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." + echo + if ! gum confirm --affirmative "Back" --negative "Open partition tool" "Return to installation mode?"; then + open_partition_tool + fi + return 1 +} + run_partition_install() { local dry=false [[ "${1:-}" == "dry" ]] && dry=true @@ -288,8 +425,8 @@ run_partition_install() { local bl_part bl_part=$(detect_bitlocker || true) if [[ -n "$bl_part" ]]; then - gum style --foreground 1 "BitLocker signature detected on $bl_part." - gum style "Suspend or disable BitLocker in Windows before installing alongside it." + say --foreground 1 "BitLocker signature detected on $bl_part." + say "Suspend or disable BitLocker in Windows before installing alongside it." abort "Aborted: BitLocker is active on this disk." fi @@ -298,7 +435,7 @@ run_partition_install() { shared_esp=$(detect_windows_esp || true) if [[ -n "$shared_esp" ]]; then - gum style "Found Windows ESP at $shared_esp — will install alongside." + say "Found Windows ESP at $shared_esp — will install alongside." local tmp_mp esp_free_mib tmp_mp=$(mktemp -d) mount -o ro "$shared_esp" "$tmp_mp" @@ -307,39 +444,50 @@ run_partition_install() { if (( esp_free_mib < 100 )); then abort "Windows ESP $shared_esp has only ${esp_free_mib}MiB free — need at least 100MiB for Omarchy." fi - gum style "Windows ESP has ${esp_free_mib}MiB free." + say "Windows ESP has ${esp_free_mib}MiB free." else - gum style "No Windows ESP detected on $disk — a new EFI partition will be created in free space." + say "No Windows ESP detected on $disk — a new EFI partition will be created in free space." fi step "Analyzing free space on $disk" partprobe "$disk" 2>/dev/null || true sleep 1 - parted --script "$disk" unit B print free 2>/dev/null || lsblk -b "$disk" - echo - local FREE_SPACE_INFO - FREE_SPACE_INFO=$(parted -m "$disk" unit B print free 2>/dev/null | \ - awk -F: ' - $NF ~ /free/ { - start=$2; end=$3; size=$4; - gsub(/B/, "", start); - gsub(/B/, "", end); - gsub(/B/, "", size); - if (size+0 > max_size+0) { - max_size=size; max_start=start; max_end=end; + # An unlabeled disk has no partition table for parted to scan, so we + # synthesize a whole-disk free region and mklabel gpt before mkpart. + local FREE_SPACE_INFO needs_mklabel=false pt_type disk_size_b + pt_type=$(lsblk -dno PTTYPE "$disk" 2>/dev/null) + if [[ -z "$pt_type" ]]; then + disk_size_b=$(lsblk -bdno SIZE "$disk" 2>/dev/null) + if [[ -n "$disk_size_b" && "$disk_size_b" -gt 0 ]]; then + local _mib=$((1024 * 1024)) + local _start=$_mib + local _end=$(( (disk_size_b - _mib) / _mib * _mib )) + FREE_SPACE_INFO="$_start $_end $((_end - _start))" + needs_mklabel=true + fi + else + # -s (script mode) keeps parted from prompting on stderr for warnings + # like a stale GPT backup; without it the configurator hangs invisibly. + FREE_SPACE_INFO=$(parted -ms "$disk" unit B print free /dev/null | \ + awk -F: ' + $NF ~ /free/ { + start=$2; end=$3; size=$4; + gsub(/B/, "", start); + gsub(/B/, "", end); + gsub(/B/, "", size); + if (size+0 > max_size+0) { + max_size=size; max_start=start; max_end=end; + } } - } - END { - if (max_size+0 > 0) printf "%s %s %s\n", max_start, max_end, max_size; - }') + END { + if (max_size+0 > 0) printf "%s %s %s\n", max_start, max_end, max_size; + }') + fi if [[ -z "$FREE_SPACE_INFO" ]]; then - gum style --foreground 1 "No usable free space detected on $disk." - gum style "Partition install uses unallocated free space, not a pre-created partition." - gum style "Open the partition tool, delete/shrink partitions until the target area says Free space, then leave it unallocated." - gum confirm --affirmative "Back" --negative "Back" "Return to installation mode?" >/dev/null 2>&1 || true - return 1 + not_enough_space 0 + return $? fi local FREE_START_B FREE_END_B FREE_SIZE_B @@ -363,9 +511,8 @@ run_partition_install() { ROOT_END_B=$((FREE_END_B / ALIGN * ALIGN)) if (( ROOT_END_B <= ROOT_START_B )); then - gum style --foreground 1 "Not enough aligned free space for partitions after alignment." - gum confirm --affirmative "Back" --negative "Back" "Return to installation mode?" >/dev/null 2>&1 || true - return 1 + not_enough_space "$FREE_SIZE_B" + return $? fi local INSTALL_START_B INSTALL_MAX_B INSTALL_MAX_GIB INSTALL_SIZE_GIB @@ -377,22 +524,20 @@ run_partition_install() { INSTALL_MAX_B=$((ROOT_END_B - INSTALL_START_B)) if (( INSTALL_MAX_B < MIN_INSTALL_B )); then - gum style --foreground 1 "Usable free space ($(to_gb $INSTALL_MAX_B)) is below the 32GB minimum for Omarchy." - gum style "Open the partition tool, delete/shrink partitions until the target area says Free space, then leave it unallocated." - gum confirm --affirmative "Back" --negative "Back" "Return to installation mode?" >/dev/null 2>&1 || true - return 1 + not_enough_space "$INSTALL_MAX_B" + return $? fi INSTALL_MAX_GIB=$((INSTALL_MAX_B / gib)) while true; do step "Choose Omarchy size" - gum style "Free region: $(to_gb $FREE_START_B) — $(to_gb $FREE_END_B) ($(to_gb $FREE_SIZE_B))" + say "Free region: $(to_gb $FREE_START_B) — $(to_gb $FREE_END_B) ($(to_gb $FREE_SIZE_B))" if [[ -z "$shared_esp" ]]; then - gum style "The size includes a new 2GiB EFI partition; the rest becomes root." + say "The size includes a new 2GiB EFI partition; the rest becomes root." else - gum style "Using existing ESP: $shared_esp" + say "Using existing ESP: $shared_esp" fi - gum style "Omarchy can use 32–${INSTALL_MAX_GIB}GiB total. Leave the default to use all available space." + say "Omarchy can use 32–${INSTALL_MAX_GIB}GiB total. Leave the default to use all available space." echo INSTALL_SIZE_GIB=$(gum input \ @@ -414,32 +559,61 @@ run_partition_install() { ROOT_END_B=$((ROOT_START_B + INSTALL_SIZE_GIB * gib)) fi - step "Proposed layout on $disk" - gum style "Free region: $(to_gb $FREE_START_B) — $(to_gb $FREE_END_B) ($(to_gb $FREE_SIZE_B))" - if [[ -z "$shared_esp" ]]; then - gum style " New EFI partition: $(to_gb $EFI_START_B) — $(to_gb $EFI_END_B) (2GB FAT32)" - else - gum style " Shared ESP: $shared_esp (Windows, mounted at /efi)" - fi - gum style " New root (Btrfs): $(to_gb $ROOT_START_B) — $(to_gb $ROOT_END_B) ($(to_gb $((ROOT_END_B - ROOT_START_B))))" - echo + # Shared Windows ESP can't host our LUKS layout, so encryption isn't + # offered in that case. Otherwise default to encrypted and hide the + # unencrypted path behind Ctrl+C, matching the full-disk flow. + local mode="encrypted" affirmative confirm_status + [[ -n "$shared_esp" ]] && mode="forced_unencrypted" - gum confirm --affirmative "Yes, partition" --negative "Cancel" \ - "Create Omarchy partitions in the free space on $disk?" || return 1 + while true; do + step "Proposed layout on $disk" + say "Free region: $(to_gb $FREE_START_B) — $(to_gb $FREE_END_B) ($(to_gb $FREE_SIZE_B))" + if [[ -z "$shared_esp" ]]; then + say " New EFI partition: $(to_gb $EFI_START_B) — $(to_gb $EFI_END_B) (2GB FAT32)" + else + say " Shared ESP: $shared_esp (Windows, mounted at /efi)" + fi + say " New root (Btrfs): $(to_gb $ROOT_START_B) — $(to_gb $ROOT_END_B) ($(to_gb $((ROOT_END_B - ROOT_START_B))))" + echo - echo - if gum confirm \ - --affirmative "Yes, encrypt" \ - --negative "No, leave unencrypted" \ - "Encrypt the Omarchy root partition with LUKS2?"; then - encrypt_installation=true - else - encrypt_installation=false - fi + case $mode in + encrypted) + say --foreground 8 "Press Ctrl+C for unencrypted install." + affirmative="Yes, install" + ;; + unencrypted) + affirmative="Yes, install without encryption" + ;; + forced_unencrypted) + say --foreground 8 "Shared Windows ESP cannot host encrypted Omarchy — installing unencrypted." + affirmative="Yes, install" + ;; + esac + echo - if [[ "$encrypt_installation" == "true" && -n "$shared_esp" ]]; then - abort "Encrypted install on a shared Windows ESP is not yet supported. Please choose unencrypted, or use Full Install." - fi + gum confirm --affirmative "$affirmative" --negative "Cancel" \ + "Create Omarchy partitions in the free space on $disk?" + confirm_status=$? + + case $confirm_status in + 0) + [[ $mode == "encrypted" ]] && encrypt_installation=true || encrypt_installation=false + break + ;; + 1) + return 1 + ;; + 130) + case $mode in + encrypted) mode="unencrypted" ;; + unencrypted) mode="encrypted" ;; + esac + ;; + *) + abort + ;; + esac + done local esp_mount_in_target if [[ "$encrypt_installation" == "true" ]]; then @@ -465,20 +639,31 @@ run_partition_install() { root_part_num=$(( ${last_part:-0} + 1 )) fi - local efi_dev root_partition_device + local efi_dev root_partition_device root_mapper if [[ -n "$shared_esp" ]]; then efi_dev="$shared_esp" else efi_dev=$(partition_path "$disk" "$efi_part_num") fi root_partition_device=$(partition_path "$disk" "$root_part_num") + if [[ "$encrypt_installation" == "true" ]]; then + root_mapper="/dev/mapper/omarchy_root" + else + root_mapper="$root_partition_device" + fi if $dry; then - gum style "[dry] would create root partition at $root_partition_device" - [[ -z "$shared_esp" ]] && gum style "[dry] would create EFI partition at $efi_dev" - gum style "[dry] encrypted=$encrypt_installation kernel=$kernel_choice" + $needs_mklabel && say "[dry] would initialize GPT on $disk" + say "[dry] would create root partition at $root_partition_device" + [[ -z "$shared_esp" ]] && say "[dry] would create EFI partition at $efi_dev" + say "[dry] encrypted=$encrypt_installation kernel=$kernel_choice" else step "Creating partitions on $disk" + if $needs_mklabel; then + parted --script "$disk" mklabel gpt + partprobe "$disk" 2>/dev/null || true + sleep 1 + fi if [[ -z "$shared_esp" ]]; then parted --script "$disk" mkpart primary fat32 "${EFI_START_B}B" "${EFI_END_B}B" parted --script "$disk" set "$efi_part_num" esp on @@ -498,7 +683,6 @@ run_partition_install() { sleep 1 done - local root_mapper if [[ "$encrypt_installation" == "true" ]]; then step "Setting up LUKS2 on $root_partition_device" printf "%s" "$password" | cryptsetup luksFormat --type luks2 --batch-mode "$root_partition_device" - @@ -568,6 +752,16 @@ $credentials_encryption_line } _EOF_ + local luks_uuid_json="null" luks_uuid + if [[ "$encrypt_installation" == "true" ]]; then + if $dry; then + luks_uuid="00000000-0000-0000-0000-000000000000" + else + luks_uuid=$(blkid -s UUID -o value "$root_partition_device") + fi + luks_uuid_json="\"$luks_uuid\"" + fi + cat <<-_EOF_ >user_configuration.json { "app_config": null, @@ -576,6 +770,23 @@ _EOF_ "audio_config": { "audio": "pipewire" }, "bootloader_config": { "bootloader": "Limine", "uki": false, "removable": false }, "custom_commands": [], + "omarchy_install": { + "mode": "protected", + "target_mount": "/mnt", + "boot": { + "esp_mount": "$esp_mount_in_target", + "esp_path": "/EFI/limine", + "efi_binary": "limine_x64.efi", + "enable_fallback": false + }, + "storage": { + "esp_device": "$efi_dev", + "root_device": "$root_partition_device", + "root_mapper": "$root_mapper", + "luks_uuid": $luks_uuid_json, + "kernel": "$kernel_choice" + } + }, "disk_config": { "config_type": "pre_mounted_config", "mountpoint": "/mnt" @@ -608,8 +819,7 @@ _EOF_ "base-devel", "git", "omarchy-keyring", - "omarchy", - "omarchy-installer" + "omarchy" ], "profile_config": { "gfx_driver": null, @@ -620,27 +830,6 @@ _EOF_ } _EOF_ - local luks_uuid_json="null" luks_uuid - if [[ "$encrypt_installation" == "true" ]]; then - if $dry; then - luks_uuid="00000000-0000-0000-0000-000000000000" - else - luks_uuid=$(blkid -s UUID -o value "$root_partition_device") - fi - luks_uuid_json="\"$luks_uuid\"" - fi - - cat >/root/protected_install.json <user_configuration.json "audio_config": { "audio": "pipewire" }, "bootloader_config": { "bootloader": "Limine", "uki": false, "removable": false }, "custom_commands": [], + "omarchy_install": { + "mode": "full_disk", + "target_mount": "/mnt", + "boot": { + "esp_mount": "/boot", + "esp_path": "/EFI/limine", + "efi_binary": "limine_x64.efi", + "enable_fallback": true + }, + "storage": { + "kernel": "$kernel_choice" + } + }, "disk_config": { "btrfs_options": { "snapshot_config": { @@ -946,8 +1152,7 @@ cat <<-_EOF_ >user_configuration.json "base-devel", "git", "omarchy-keyring", - "omarchy", - "omarchy-installer" + "omarchy" ], "profile_config": { "gfx_driver": null, diff --git a/configs/airootfs/usr/local/bin/omarchy-install-dashboard b/configs/airootfs/usr/local/bin/omarchy-install-dashboard index 1c02c6f..a61e2ee 100755 --- a/configs/airootfs/usr/local/bin/omarchy-install-dashboard +++ b/configs/airootfs/usr/local/bin/omarchy-install-dashboard @@ -201,6 +201,9 @@ weights = [ ('Installing base system', 8, 84, 85), ('Configuring protected boot', 84, 86, 8), ('Configuring hibernation', 86, 88, 5), + ('Configuring system', 88, 93, 10), + ('Finalizing Limine boot', 93, 96, 8), + ('Finalizing user', 96, 98, 10), ('Finalizing in chroot', 88, 98, 10), ('Configuring login', 98, 99, 3), ('Validating protected boot setup', 99, 100, 5), diff --git a/configs/airootfs/usr/share/omarchy-iso/orchestrator/archinstall_adapter.py b/configs/airootfs/usr/share/omarchy-iso/orchestrator/archinstall_adapter.py index fe04d24..6e2afe7 100644 --- a/configs/airootfs/usr/share/omarchy-iso/orchestrator/archinstall_adapter.py +++ b/configs/airootfs/usr/share/omarchy-iso/orchestrator/archinstall_adapter.py @@ -66,7 +66,7 @@ def load_arch_config(config_path: Path, creds_path: Path) -> ArchConfigHandler: import sys saved_argv = sys.argv sys.argv = [ - saved_argv[0] if saved_argv else "omarchy-install", + saved_argv[0] if saved_argv else "omarchy-iso-install", "--config", str(config_path), "--creds", str(creds_path), ] diff --git a/configs/airootfs/usr/share/omarchy-iso/orchestrator/context.py b/configs/airootfs/usr/share/omarchy-iso/orchestrator/context.py index 78cb99e..1d2e704 100644 --- a/configs/airootfs/usr/share/omarchy-iso/orchestrator/context.py +++ b/configs/airootfs/usr/share/omarchy-iso/orchestrator/context.py @@ -21,6 +21,8 @@ class InstallContext: user_configuration: dict user_credentials: dict + arch_config_path: Path + omarchy_install: dict[str, Any] target: Path = Path("/mnt") omarchy_path: Path = Path("/usr/share/omarchy") @@ -42,6 +44,15 @@ class InstallContext: config_path = Path(config_str) creds_path = Path(creds_str) user_configuration = json.loads(config_path.read_text()) + omarchy_install = user_configuration.get("omarchy_install") or _default_omarchy_install(user_configuration) + + arch_configuration = dict(user_configuration) + arch_configuration.pop("omarchy_install", None) + state_dir = Path(os.environ.get("OMARCHY_INSTALL_STATE_DIR", "/run/omarchy-install")) + state_dir.mkdir(parents=True, exist_ok=True) + arch_config_path = state_dir / "archinstall-user_configuration.json" + arch_config_path.write_text(json.dumps(arch_configuration, indent=2) + "\n") + ctx = cls( config_path=config_path, creds_path=creds_path, @@ -50,13 +61,14 @@ class InstallContext: encrypt=_read_text(os.environ.get("OMARCHY_INSTALL_ENCRYPT_FILE")).lower() in ("true", "yes", "1"), user_configuration=user_configuration, user_credentials=json.loads(creds_path.read_text()), + arch_config_path=arch_config_path, + omarchy_install=omarchy_install, + state_dir=state_dir, ) disk_config = user_configuration.get("disk_config", {}) - if ( - disk_config.get("config_type") == "pre_mounted_config" - and disk_config.get("mountpoint") - ): - ctx.target = Path(disk_config["mountpoint"]) + target_mount = omarchy_install.get("target_mount") or disk_config.get("mountpoint") + if target_mount: + ctx.target = Path(target_mount) return ctx @property @@ -65,6 +77,8 @@ class InstallContext: @property def mode(self) -> str: + if mode := self.omarchy_install.get("mode"): + return mode cfg_type = self.user_configuration.get("disk_config", {}).get("config_type") return "protected" if cfg_type == "pre_mounted_config" else "full_disk" @@ -73,6 +87,22 @@ class InstallContext: return self.mode == "protected" +def _default_omarchy_install(user_configuration: dict) -> dict[str, Any]: + disk_config = user_configuration.get("disk_config", {}) + mode = "protected" if disk_config.get("config_type") == "pre_mounted_config" else "full_disk" + return { + "mode": mode, + "target_mount": disk_config.get("mountpoint") or "/mnt", + "boot": { + "esp_mount": "/boot", + "esp_path": "/EFI/limine", + "efi_binary": "limine_x64.efi", + "enable_fallback": mode == "full_disk", + }, + "storage": {}, + } + + def _read_text(path: str | None) -> str: if not path: return "" diff --git a/configs/airootfs/usr/share/omarchy-iso/orchestrator/main.py b/configs/airootfs/usr/share/omarchy-iso/orchestrator/main.py index 277a29f..020ce02 100644 --- a/configs/airootfs/usr/share/omarchy-iso/orchestrator/main.py +++ b/configs/airootfs/usr/share/omarchy-iso/orchestrator/main.py @@ -23,43 +23,33 @@ def build_phases(ctx: InstallContext): hooks (limine-mkinitcpio-hook, in particular) and useradd happen at points where their prerequisites are guaranteed to be in place. - Two modes: - - full_disk: archinstall owns disk layout + bootloader. - - protected: Omarchy owns disk layout + bootloader; archinstall is - used for pacstrap + users + packages only. + Full-disk and protected installs use the same phase sequence. The + configurator only changes the JSON input: full-disk asks archinstall to + create/mount the layout, while protected provides an already-mounted target + and the partition details Omarchy needs for boot/fstab generation. """ from .phases_impl import ( prepare_live, - arch_install_full, - arch_install_base, - verify_protected_mounts, - configure_protected_boot, + prepare_install_target, + arch_install_system, configure_hibernation, + run_system_finalizer, + finalize_limine_boot, run_chroot_finalizer, configure_login, - validate_boot_full, - validate_boot_protected, + validate_boot, ) - if ctx.is_protected: - return [ - ("Preparing live environment", prepare_live), - ("Verifying protected mounts", verify_protected_mounts), - ("Installing base system", arch_install_base), - ("Configuring protected boot", configure_protected_boot), - ("Configuring hibernation", configure_hibernation), - ("Finalizing in chroot", run_chroot_finalizer), - ("Configuring login", configure_login), - ("Validating protected boot setup", validate_boot_protected), - ] - return [ ("Preparing live environment", prepare_live), - ("Installing Arch + Omarchy", arch_install_full), + ("Preparing install target", prepare_install_target), + ("Installing Arch + Omarchy", arch_install_system), ("Configuring hibernation", configure_hibernation), - ("Finalizing in chroot", run_chroot_finalizer), + ("Configuring system", run_system_finalizer), + ("Finalizing Limine boot", finalize_limine_boot), + ("Finalizing user", run_chroot_finalizer), ("Configuring login", configure_login), - ("Validating boot setup", validate_boot_full), + ("Validating boot setup", validate_boot), ] 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 5057995..207dca2 100644 --- a/configs/airootfs/usr/share/omarchy-iso/orchestrator/phases_impl.py +++ b/configs/airootfs/usr/share/omarchy-iso/orchestrator/phases_impl.py @@ -1,36 +1,26 @@ """Concrete phase implementations. -Phase ordering (full-disk): +Phase ordering (full-disk and protected/pre-mounted): - prepare_live → pacman-key init, disk cleanup, load configurator - handlers (archinstall patch happens in the - wrapper before Python imports archinstall) - arch_install_full → archinstall-driven install (partition, base, - early omarchy pkgs, Omarchy Limine setup, - useradd, runtime omarchy pkgs) - run_chroot_finalizer → bind mounts + sudoers shim + arch-chroot finalize.sh + prepare_live → pacman-key init, disk cleanup when wiping, + load configurator handlers (archinstall patch + happens in the wrapper before Python imports it) + prepare_install_target → verify pre-mounted target/ESP when the JSON uses + pre_mounted_config; no-op for full-disk installs + arch_install_system → one archinstall flow for partition/mount-or-use, + base install, early Omarchy packages, Limine setup, + useradd, runtime Omarchy packages, Snapper, fstab + configure_hibernation → root-owned swap/resume drop-ins + run_system_finalizer → arch-chroot root omarchy-setup-system + finalize_limine_boot → final Limine config/UKI build after hardware drop-ins + run_chroot_finalizer → arch-chroot -u user omarchy-setup-user configure_login → sddm autologin for unencrypted installs - validate_boot_full → assert UKI / limine.conf / kernel cmdline are sane - -Phase ordering (protected / pre-mounted): - - prepare_live → same, minus disk cleanup - verify_protected_mounts → confirm target + ESP are mounted; load - /root/protected_install.json into ctx.state - arch_install_base → archinstall used as pacstrap + users + - packages driver only; no bootloader, - no fstab, no mkinitcpio - configure_protected_boot → protected fstab/crypttab + Limine EFI handoff; - final UKI build still happens in finalizer - run_chroot_finalizer → same - configure_login → same - validate_boot_protected → implemented in Step 8 + validate_boot → assert UKI / limine.conf / kernel cmdline are sane """ from __future__ import annotations import hashlib -import json import os import re import shutil @@ -44,17 +34,16 @@ from .context import InstallContext from .ui import info -# Packages installed BEFORE useradd. omarchy-settings and omarchy-nvim -# populate /etc/skel so the user's home gets seeded correctly. omarchy-installer -# is INTENTIONALLY absent — it's live-ISO-only install tooling, never installed -# on the target. finalize.sh + install/ scripts get copied to /opt in -# run_chroot_finalizer. +# Packages installed BEFORE useradd. omarchy-settings and omarchy-nvim populate +# /etc/skel so the user's home gets seeded correctly. Target-side setup commands +# are installed later by the omarchy runtime package and executed in chroot. EARLY_BOOTSTRAP_PACKAGES = [ "base-devel", "git", "limine", "efibootmgr", "omarchy-keyring", + "omarchy-limine", "omarchy-settings", ] @@ -105,7 +94,7 @@ def prepare_live(ctx: InstallContext) -> None: info("› loading configurator output") ctx.state["arch_config_handler"] = arch.load_arch_config( - ctx.config_path, ctx.creds_path + ctx.arch_config_path, ctx.creds_path ) ctx.state["mirror_handler"] = arch.make_mirror_handler(offline=True) @@ -121,24 +110,37 @@ def _install_disk(ctx: InstallContext) -> str | None: # ───────────────────────────────────────────────────────────────────────────── -# arch_install_full: everything inside a single Installer context manager. +# arch_install_system: everything inside a single Installer context manager. # Reorders guided.py's perform_installation() so early Omarchy packages install # before user creation and before our Omarchy-owned Limine setup copies files # from the target's limine package. # ───────────────────────────────────────────────────────────────────────────── -def arch_install_full(ctx: InstallContext) -> None: - """Full-disk install: archinstall owns disk layout + bootloader.""" +def prepare_install_target(ctx: InstallContext) -> None: + if ctx.is_protected: + verify_protected_mounts(ctx) + + +def arch_install_system(ctx: InstallContext) -> None: + """Install the target system from the archinstall JSON. + + The phase sequence is the same for full-disk and protected installs. The + JSON decides whether archinstall should create/mount a disk layout or use + a pre-mounted target, and Omarchy derives boot/fstab details from that same + input. + """ handler = ctx.state["arch_config_handler"] mirror_handler = ctx.state["mirror_handler"] config = handler.config + pre_mounted = arch.is_pre_mount(config) - info("› partitioning + formatting + encrypting") - arch.perform_filesystem_operations(config) + if not pre_mounted: + info("› partitioning + formatting + encrypting") + arch.perform_filesystem_operations(config) info("› opening installer context") with arch.open_installer(config, ctx.target, silent=True) as installer: - if not arch.is_pre_mount(config): + if not pre_mounted: installer.mount_ordered_layout() installer.sanity_check( @@ -147,7 +149,7 @@ def arch_install_full(ctx: InstallContext) -> None: skip_wkd=True, ) - if not arch.is_pre_mount(config) and arch.is_encrypted(config): + if not pre_mounted and arch.is_encrypted(config): installer.generate_key_files() if config.mirror_config: @@ -174,17 +176,7 @@ def arch_install_full(ctx: InstallContext) -> None: installer.setup_swap(algo=config.swap.algorithm) _install_early_packages(installer) - - if arch.bootloader_enabled(config): - if not arch.is_limine(config): - raise RuntimeError( - "Omarchy full-disk installs only support Limine bootloader setup" - ) - info("› installing bootloader (Limine)") - _install_limine_omarchy(ctx, installer, config) - - info("› writing Limine config (so limine-mkinitcpio-hook fires correctly)") - _write_limine_defaults_from_config(ctx, installer, config) + _configure_limine_boot(ctx, installer, config) info("› creating user (with /etc/skel populated)") if config.auth_config and config.auth_config.users: @@ -206,7 +198,29 @@ def arch_install_full(ctx: InstallContext) -> None: if root := arch.root_user(config): installer.set_user_password(root) - installer.genfstab() + if pre_mounted: + _write_pre_mounted_fstab(ctx) + else: + installer.genfstab() + + +def _configure_limine_boot(ctx: InstallContext, installer, config) -> None: + if not arch.bootloader_enabled(config): + return + if not arch.is_limine(config): + raise RuntimeError("Omarchy installs only support Limine bootloader setup") + + info("› installing bootloader (Limine)") + if arch.is_pre_mount(config): + _install_pre_mounted_limine(ctx) + else: + _install_limine_omarchy(ctx, installer, config) + + info("› writing Limine config") + if arch.is_pre_mount(config): + _write_pre_mounted_limine_defaults(ctx) + else: + _write_limine_defaults_from_config(ctx, installer, config) def _install_limine_omarchy(ctx: InstallContext, installer, config) -> None: @@ -223,7 +237,6 @@ def _install_limine_omarchy(ctx: InstallContext, installer, config) -> None: bootloader_removable = bool( getattr(bootloader_config, "removable", False) if bootloader_config else False ) - limine_path = ctx.target / "usr" / "share" / "limine" if arch.has_uefi(): if efi_partition is None: @@ -231,62 +244,136 @@ def _install_limine_omarchy(ctx: InstallContext, installer, config) -> None: if not efi_partition.mountpoint: raise RuntimeError("EFI partition is not mounted") - parent_dev_path = arch.parent_device_path(efi_partition.safe_dev_path) - efi_dir_path = ctx.target / efi_partition.mountpoint.relative_to("/") / "EFI" - efi_dir_path_target = efi_partition.mountpoint / "EFI" - if bootloader_removable: - efi_dir_path = efi_dir_path / "BOOT" - efi_dir_path_target = efi_dir_path_target / "BOOT" - else: - # Non-removable UEFI installs place the x64 binary at EFI/limine/BOOTX64.EFI. - efi_dir_path = efi_dir_path / "limine" - efi_dir_path_target = efi_dir_path_target / "limine" - - efi_dir_path.mkdir(parents=True, exist_ok=True) - for filename in ("BOOTIA32.EFI", "BOOTX64.EFI"): - _copy_required(limine_path / filename, efi_dir_path / filename) - - hook_command = ( - f"/usr/bin/cp /usr/share/limine/BOOTIA32.EFI {efi_dir_path_target}/ && " - f"/usr/bin/cp /usr/share/limine/BOOTX64.EFI {efi_dir_path_target}/" - ) - - loader_path = _limine_efi_loader_path(bootloader_removable) - subprocess.run( - [ - "efibootmgr", - "--create", - "--disk", str(parent_dev_path), - "--part", str(efi_partition.partn), - "--label", "Limine", - "--loader", loader_path, - "--unicode", - "--verbose", - ], - check=True, + _install_limine_efi( + ctx, + esp_mount=str(efi_partition.mountpoint), + disk=arch.parent_device_path(efi_partition.safe_dev_path), + part=int(efi_partition.partn), + removable=bootloader_removable, ) else: - boot_limine_path = ctx.target / "boot" / "limine" - boot_limine_path.mkdir(parents=True, exist_ok=True) + _install_limine_bios(ctx, boot_partition) - parent_dev_path = arch.parent_device_path(boot_partition.safe_dev_path) - if unique_path := arch.unique_device_path(parent_dev_path): - parent_dev_path = unique_path - - _copy_required(limine_path / "limine-bios.sys", boot_limine_path / "limine-bios.sys") - subprocess.run( - ["arch-chroot", str(ctx.target), "limine", "bios-install", str(parent_dev_path)], - check=True, - ) - hook_command = ( - f"/usr/bin/limine bios-install {parent_dev_path} && " - "/usr/bin/cp /usr/share/limine/limine-bios.sys /boot/limine/" - ) - - _write_limine_pacman_hook(ctx.target, hook_command) installer._helper_flags["bootloader"] = "limine" +def _install_pre_mounted_limine(ctx: InstallContext) -> None: + boot = _boot_intent(ctx) + storage = _storage_intent(ctx) + esp_device = storage.get("esp_device") + if not esp_device: + raise RuntimeError("omarchy_install.storage.esp_device missing") + + pre_state = _read_efibootmgr() + windows_before = _find_label_entries(pre_state["entries"], "Windows") + disk, part = _split_partition_device(esp_device) + _install_limine_efi( + ctx, + esp_mount=boot["esp_mount"], + disk=Path(disk), + part=part, + esp_path=boot.get("esp_path", "/EFI/limine"), + efi_binary=boot.get("efi_binary", "limine_x64.efi"), + pre_state=pre_state, + ) + + post_state = _read_efibootmgr() + windows_after = _find_label_entries(post_state["entries"], "Windows") + if windows_before and not windows_after: + raise RuntimeError("Windows boot entry disappeared during Limine install — aborting") + + +def _install_limine_efi( + ctx: InstallContext, + *, + esp_mount: str, + disk: Path, + part: int, + removable: bool = False, + esp_path: str = "/EFI/limine", + efi_binary: str = "limine_x64.efi", + pre_state: dict | None = None, +) -> None: + if removable: + esp_path = "/EFI/BOOT" + efi_binary = "BOOTX64.EFI" + + limine_path = ctx.target / "usr" / "share" / "limine" + source_name = "BOOTX64.EFI" + target_dir = Path(esp_mount) / esp_path.lstrip("/") + target_path = target_dir / efi_binary + _copy_required(limine_path / source_name, ctx.target / target_path.relative_to("/")) + + hook_command = f"/usr/bin/cp /usr/share/limine/{source_name} {target_path}" + _write_limine_pacman_hook(ctx.target, hook_command) + + loader = "\\" + str(Path(esp_path) / efi_binary).strip("/").replace("/", "\\") + _register_limine_efi_entry(disk, part, loader, pre_state=pre_state) + + +def _register_limine_efi_entry( + disk: Path, + part: int, + loader: str, + *, + pre_state: dict | None = None, +) -> None: + pre_state = pre_state or _read_efibootmgr() + stale_limine = _find_label_entries(pre_state["entries"], "Limine") + for num in stale_limine: + subprocess.run( + ["efibootmgr", "--bootnum", num, "--delete-bootnum"], + check=False, capture_output=True, + ) + + subprocess.run( + [ + "efibootmgr", + "--create", + "--disk", str(disk), + "--part", str(part), + "--label", "Limine", + "--loader", loader, + "--unicode", + "--verbose", + ], + check=True, + ) + + post_state = _read_efibootmgr() + new_limine = _find_label_entries(post_state["entries"], "Limine") + if not new_limine: + raise RuntimeError("efibootmgr --create reported success but no Limine entry found") + limine_num = new_limine[0] + + keep = [num for num in pre_state["order"] if num not in stale_limine and num != limine_num] + subprocess.run( + ["efibootmgr", "--bootorder", ",".join([limine_num, *keep])], + check=True, capture_output=True, + ) + + +def _install_limine_bios(ctx: InstallContext, boot_partition) -> None: + boot_limine_path = ctx.target / "boot" / "limine" + boot_limine_path.mkdir(parents=True, exist_ok=True) + + parent_dev_path = arch.parent_device_path(boot_partition.safe_dev_path) + if unique_path := arch.unique_device_path(parent_dev_path): + parent_dev_path = unique_path + + limine_path = ctx.target / "usr" / "share" / "limine" + _copy_required(limine_path / "limine-bios.sys", boot_limine_path / "limine-bios.sys") + subprocess.run( + ["arch-chroot", str(ctx.target), "limine", "bios-install", str(parent_dev_path)], + check=True, + ) + hook_command = ( + f"/usr/bin/limine bios-install {parent_dev_path} && " + "/usr/bin/cp /usr/share/limine/limine-bios.sys /boot/limine/" + ) + _write_limine_pacman_hook(ctx.target, hook_command) + + def _copy_required(src: Path, dst: Path) -> None: if not src.exists(): raise RuntimeError(f"Required Limine file missing: {src}") @@ -294,21 +381,6 @@ def _copy_required(src: Path, dst: Path) -> None: shutil.copy2(src, dst) -def _limine_efi_loader_path(bootloader_removable: bool) -> str: - try: - efi_bitness = Path("/sys/firmware/efi/fw_platform_size").read_text().strip() - except Exception as err: - raise RuntimeError( - "Could not read /sys/firmware/efi/fw_platform_size to determine EFI bitness" - ) from err - - if efi_bitness == "64": - return "\\EFI\\BOOT\\BOOTX64.EFI" if bootloader_removable else "\\EFI\\limine\\BOOTX64.EFI" - if efi_bitness == "32": - return "\\EFI\\BOOT\\BOOTIA32.EFI" if bootloader_removable else "\\EFI\\limine\\BOOTIA32.EFI" - raise RuntimeError(f'EFI bitness is neither 32 nor 64 bits. Found "{efi_bitness}".') - - def _write_limine_pacman_hook(target: Path, hook_command: str) -> None: hook_contents = textwrap.dedent( f"""\ @@ -337,25 +409,49 @@ def _write_limine_defaults_from_config(ctx: InstallContext, installer, config) - raise RuntimeError(f"Could not detect root at mountpoint {ctx.target}") cmdline = " ".join(installer._get_kernel_params(root)) + _write_limine_defaults(ctx, cmdline, esp_mount=_installer_esp_mount(installer)) + + +def _write_limine_defaults( + ctx: InstallContext, + cmdline: str, + *, + esp_mount: str, + enable_fallback: bool | None = None, +) -> None: if not cmdline.strip(): raise RuntimeError("Could not compute kernel cmdline from install config") if "root=" not in cmdline: raise RuntimeError(f"Computed cmdline has no root=: {cmdline!r}") - default_template = _limine_template(ctx, "default.conf") + default_text = _limine_template(ctx, "default.conf").read_text() + default_text = default_text.replace("@@CMDLINE@@", cmdline) + default_text = re.sub(r'^ESP_PATH=.*$', f'ESP_PATH="{esp_mount}"', default_text, flags=re.MULTILINE) + if enable_fallback is not None: + default_text = default_text.rstrip() + f"\nENABLE_LIMINE_FALLBACK={'yes' if enable_fallback else 'no'}\n" + if not arch.has_uefi(): + default_text = default_text.rstrip() + "\nENABLE_UKI=no\nENABLE_LIMINE_FALLBACK=no\n" + default_limine = ctx.target / "etc" / "default" / "limine" default_limine.parent.mkdir(parents=True, exist_ok=True) - default_limine.write_text(default_template.read_text().replace("@@CMDLINE@@", cmdline)) + default_limine.write_text(default_text) kernel_cmdline = ctx.target / "etc" / "kernel" / "cmdline" kernel_cmdline.parent.mkdir(parents=True, exist_ok=True) kernel_cmdline.write_text(cmdline + "\n") - limine_conf = ctx.target / "boot" / "limine.conf" + limine_conf = ctx.target / esp_mount.lstrip("/") / "limine.conf" limine_conf.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(_limine_template(ctx, "limine.conf"), limine_conf) +def _installer_esp_mount(installer) -> str: + if efi_partition := installer._get_efi_partition(): + if efi_partition.mountpoint: + return str(efi_partition.mountpoint) + return "/boot" + + def _configure_snapper_root(ctx: InstallContext) -> None: """Configure Omarchy's root-only Snapper setup during the archinstall phase, not inside the finalizer chroot. @@ -410,6 +506,8 @@ def _configure_snapper_root(ctx: InstallContext) -> None: def _limine_template(ctx: InstallContext, filename: str) -> Path: candidates = [ + ctx.target / "usr" / "share" / "omarchy" / "install" / "assets" / "limine" / filename, + ctx.target / "usr" / "share" / "omarchy" / "default" / "limine" / filename, ctx.omarchy_path / "install" / "assets" / "limine" / filename, ctx.omarchy_path / "default" / "limine" / filename, ] @@ -484,9 +582,9 @@ def _unmask_mkinitcpio_pacman_hooks(ctx: InstallContext) -> None: def _runtime_package_list(ctx: InstallContext) -> list[str]: - """omarchy + every package in install/omarchy-base.packages that isn't - already in EARLY_PACKAGES.""" - base_pkgs_file = ctx.omarchy_path / "install" / "omarchy-base.packages" + """omarchy + every package in the ISO-bundled base package list that + isn't already in EARLY_PACKAGES.""" + base_pkgs_file = Path("/usr/share/omarchy-iso/omarchy-base.packages") pkgs = ["omarchy"] early = set(EARLY_PACKAGES) for raw in base_pkgs_file.read_text().splitlines(): @@ -499,12 +597,22 @@ def _runtime_package_list(ctx: InstallContext) -> list[str]: # ───────────────────────────────────────────────────────────────────────────── -# verify_protected_mounts: confirm the configurator pre-mounted everything -# we need under ctx.target and load /root/protected_install.json so -# configure_protected_boot has the partition intent to act on. +# Install intent helpers: normalize the Omarchy-specific part of the +# configurator JSON so full-disk and pre-mounted installs feed the same boot +# and target setup code. # ───────────────────────────────────────────────────────────────────────────── -PROTECTED_INTENT_PATH = Path("/root/protected_install.json") +def _boot_intent(ctx: InstallContext) -> dict: + boot = dict(ctx.omarchy_install.get("boot") or {}) + boot.setdefault("esp_mount", "/boot") + boot.setdefault("esp_path", "/EFI/limine") + boot.setdefault("efi_binary", "limine_x64.efi") + boot.setdefault("enable_fallback", not ctx.is_protected) + return boot + + +def _storage_intent(ctx: InstallContext) -> dict: + return dict(ctx.omarchy_install.get("storage") or {}) def verify_protected_mounts(ctx: InstallContext) -> None: @@ -512,32 +620,22 @@ def verify_protected_mounts(ctx: InstallContext) -> None: if not _is_mountpoint(target): raise RuntimeError(f"protected mode: {target} is not a mountpoint") - if not PROTECTED_INTENT_PATH.exists(): - raise RuntimeError( - f"protected mode: expected partition intent at {PROTECTED_INTENT_PATH} " - "(configurator should have written it)" - ) + boot = _boot_intent(ctx) + storage = _storage_intent(ctx) + for key in ("esp_device", "root_device"): + if not storage.get(key): + raise RuntimeError(f"protected mode: omarchy_install.storage.{key} missing") - intent = json.loads(PROTECTED_INTENT_PATH.read_text()) - for key in ("esp_device", "esp_mount", "esp_path", "luks_uuid", "root_device", "kernel"): - if key not in intent: - raise RuntimeError( - f"protected mode: {PROTECTED_INTENT_PATH} missing key '{key}'" - ) - - esp_mp = target / intent["esp_mount"].lstrip("/") + esp_mp = target / boot["esp_mount"].lstrip("/") if not _is_mountpoint(esp_mp): - esp_dev = intent["esp_device"] + esp_dev = storage["esp_device"] if not Path(esp_dev).exists(): - raise RuntimeError( - f"protected mode: ESP device {esp_dev} from {PROTECTED_INTENT_PATH} does not exist" - ) + raise RuntimeError(f"protected mode: ESP device {esp_dev} does not exist") info(f"› remounting protected ESP {esp_dev} at {esp_mp}") esp_mp.mkdir(parents=True, exist_ok=True) subprocess.run(["mount", esp_dev, str(esp_mp)], check=True) - ctx.state["protected"] = intent - info(f"› protected intent loaded: kernel={intent['kernel']} esp={intent['esp_mount']}") + info(f"› protected target verified: kernel={storage.get('kernel', 'linux')} esp={boot['esp_mount']}") def _is_mountpoint(path: Path) -> bool: @@ -549,129 +647,13 @@ def _is_mountpoint(path: Path) -> bool: return res.returncode == 0 and bool(res.stdout.strip()) -# ───────────────────────────────────────────────────────────────────────────── -# arch_install_base: archinstall used as a pacstrap + base-config driver only. -# No disk layout, no bootloader, no fstab, no mkinitcpio (step 8 owns those). -# ───────────────────────────────────────────────────────────────────────────── +# ── pre-mounted fstab / crypttab / cmdline ─────────────────────────────────── -def arch_install_base(ctx: InstallContext) -> None: - """Protected install base: archinstall handles pacstrap + users + packages - only. Bootloader, fstab, mkinitcpio, crypttab are owned by - configure_protected_boot.""" - handler = ctx.state["arch_config_handler"] - mirror_handler = ctx.state["mirror_handler"] - config = handler.config - - info("› opening installer context (pre-mounted target)") - with arch.open_installer(config, ctx.target, silent=True) as installer: - installer.sanity_check( - offline=True, - skip_ntp=True, - skip_wkd=True, - ) - - if config.mirror_config: - installer.set_mirrors(mirror_handler, config.mirror_config, on_target=False) - - _mask_mkinitcpio_pacman_hooks(ctx) - try: - info("› installing base system (mkinitcpio deferred to configure_protected_boot)") - installer.minimal_installation( - optional_repositories=( - config.mirror_config.optional_repositories - if config.mirror_config else [] - ), - mkinitcpio=False, - hostname=config.hostname, - locale_config=config.locale_config, - pacman_config=config.pacman_config, - ) - - if config.mirror_config: - installer.set_mirrors(mirror_handler, config.mirror_config, on_target=True) - - if config.swap and config.swap.enabled: - installer.setup_swap(algo=config.swap.algorithm) - - _install_early_packages(installer) - - info("› creating user (with /etc/skel populated)") - if config.auth_config and config.auth_config.users: - installer.create_users(config.auth_config.users) - - info("› installing Omarchy runtime + omarchy-base.packages") - installer.add_additional_packages(_runtime_package_list(ctx)) - - info("› configuring root Snapper snapshots") - _configure_snapper_root(ctx) - - # Protected mode owns boot setup separately, so pacstrap limine + - # efibootmgr here while the live ISO's offline mirror is still the - # active pacman source. Doing it later via arch-chroot would hit - # the target's network-mirror pacman.conf and fail offline. - info("› installing limine + efibootmgr (protected boot)") - installer.add_additional_packages(["limine", "efibootmgr"]) - finally: - _unmask_mkinitcpio_pacman_hooks(ctx) - - if config.timezone: - installer.set_timezone(config.timezone) - if config.ntp: - installer.activate_time_synchronization() - if root := arch.root_user(config): - installer.set_user_password(root) - - -# ───────────────────────────────────────────────────────────────────────────── -# configure_protected_boot: protected-mode fstab + Limine handoff. -# -# Free-space installs should converge with full-disk installs as soon as the -# target is mounted. This phase only writes the mount/boot intent that the -# common finalizer needs, and registers a Limine EFI entry on the chosen ESP. -# The final UKI/initramfs build is deliberately left to login/limine-snapper.sh -# (`limine-update`), same as full-disk installs. -# ───────────────────────────────────────────────────────────────────────────── - -def configure_protected_boot(ctx: InstallContext) -> None: - protected = ctx.state["protected"] - - info("› writing /etc/fstab") - _write_fstab(ctx, protected) - - if protected.get("luks_uuid"): - info("› writing /etc/crypttab.initramfs") - _write_crypttab(ctx, protected) - - info("› writing Limine defaults") - _write_protected_limine_defaults(ctx, protected) - - info("› capturing efibootmgr state pre-install") - pre_state = _read_efibootmgr() - windows_before = _find_label_entries(pre_state["entries"], "Windows") - - info("› installing Limine into protected ESP") - _install_protected_limine_efi(ctx, protected) - - info("› registering efibootmgr entry") - _register_efibootmgr_entry(ctx, protected, pre_state) - - info("› verifying Windows boot entry survived") - post_state = _read_efibootmgr() - windows_after = _find_label_entries(post_state["entries"], "Windows") - if windows_before and not windows_after: - raise RuntimeError( - "Windows boot entry disappeared during Limine install — aborting" - ) - - -# ── fstab ──────────────────────────────────────────────────────────────────── - -def _btrfs_root_device(protected: dict) -> str: - """Device that holds the btrfs filesystem (mapper for encrypted, raw - partition for plain).""" - if protected.get("luks_uuid"): - return "/dev/mapper/omarchy_root" - return protected["root_device"] +def _btrfs_root_device(ctx: InstallContext) -> str: + storage = _storage_intent(ctx) + if storage.get("luks_uuid"): + return storage.get("root_mapper") or "/dev/mapper/omarchy_root" + return storage["root_device"] def _blkid_uuid(device: str) -> str: @@ -685,8 +667,13 @@ def _blkid_uuid(device: str) -> str: return uuid -def _esp_device(ctx: InstallContext, protected: dict) -> str: - esp_mp = ctx.target / protected["esp_mount"].lstrip("/") +def _esp_device(ctx: InstallContext) -> str: + storage = _storage_intent(ctx) + if esp_device := storage.get("esp_device"): + return esp_device + + boot = _boot_intent(ctx) + esp_mp = ctx.target / boot["esp_mount"].lstrip("/") res = subprocess.run( ["findmnt", "-n", "-o", "SOURCE", str(esp_mp)], capture_output=True, text=True, check=True, @@ -697,16 +684,16 @@ def _esp_device(ctx: InstallContext, protected: dict) -> str: return dev -def _write_fstab(ctx: InstallContext, protected: dict) -> None: - btrfs_dev = _btrfs_root_device(protected) +def _write_pre_mounted_fstab(ctx: InstallContext) -> None: + boot = _boot_intent(ctx) + btrfs_dev = _btrfs_root_device(ctx) btrfs_uuid = _blkid_uuid(btrfs_dev) - esp_dev = _esp_device(ctx, protected) - esp_uuid = _blkid_uuid(esp_dev) - esp_mount = protected["esp_mount"] + esp_uuid = _blkid_uuid(_esp_device(ctx)) + esp_mount = boot["esp_mount"] btrfs_opts = "noatime,compress=zstd,subvol=" lines = [ - "# /etc/fstab — generated by omarchy installer (protected mode)", + "# /etc/fstab — generated by Omarchy ISO", "# ", f"UUID={btrfs_uuid} / btrfs {btrfs_opts}@ 0 0", f"UUID={btrfs_uuid} /home btrfs {btrfs_opts}@home 0 0", @@ -715,33 +702,25 @@ def _write_fstab(ctx: InstallContext, protected: dict) -> None: f"UUID={esp_uuid} {esp_mount} vfat umask=0077 0 2", "", ] - fstab = ctx.target / "etc" / "fstab" - fstab.write_text("\n".join(lines)) + (ctx.target / "etc" / "fstab").write_text("\n".join(lines)) -# ── crypttab ───────────────────────────────────────────────────────────────── - -def _write_crypttab(ctx: InstallContext, protected: dict) -> None: - luks_uuid = protected["luks_uuid"] +def _write_pre_mounted_crypttab(ctx: InstallContext) -> None: + storage = _storage_intent(ctx) + luks_uuid = storage.get("luks_uuid") + if not luks_uuid: + return crypttab = ctx.target / "etc" / "crypttab.initramfs" crypttab.write_text(f"omarchy_root UUID={luks_uuid} none luks,discard\n") -# ── Limine ─────────────────────────────────────────────────────────────────── - -def _protected_esp_mount(ctx: InstallContext, protected: dict) -> Path: - return ctx.target / protected["esp_mount"].lstrip("/") - - -def _omarchy_esp_path(ctx: InstallContext, protected: dict) -> Path: - return _protected_esp_mount(ctx, protected) / protected["esp_path"].lstrip("/") - - -def _build_cmdline(protected: dict, btrfs_uuid: str) -> str: - if protected.get("luks_uuid"): +def _build_pre_mounted_cmdline(ctx: InstallContext, btrfs_uuid: str) -> str: + storage = _storage_intent(ctx) + if storage.get("luks_uuid"): + root_mapper = storage.get("root_mapper") or "/dev/mapper/omarchy_root" return ( - f"cryptdevice=UUID={protected['luks_uuid']}:omarchy_root " - "root=/dev/mapper/omarchy_root zswap.enabled=0 " + f"cryptdevice=UUID={storage['luks_uuid']}:omarchy_root " + f"root={root_mapper} zswap.enabled=0 " "rootflags=subvol=@ rw rootfstype=btrfs" ) return ( @@ -750,43 +729,18 @@ def _build_cmdline(protected: dict, btrfs_uuid: str) -> str: ) -def _write_protected_limine_defaults(ctx: InstallContext, protected: dict) -> None: - btrfs_uuid = _blkid_uuid(_btrfs_root_device(protected)) - cmdline = _build_cmdline(protected, btrfs_uuid) +def _write_pre_mounted_limine_defaults(ctx: InstallContext) -> None: + boot = _boot_intent(ctx) + btrfs_uuid = _blkid_uuid(_btrfs_root_device(ctx)) + cmdline = _build_pre_mounted_cmdline(ctx, btrfs_uuid) - default_text = _limine_template(ctx, "default.conf").read_text() - default_text = default_text.replace("@@CMDLINE@@", cmdline) - default_text = re.sub(r'^ESP_PATH=.*$', f'ESP_PATH="{protected["esp_mount"]}"', default_text, flags=re.MULTILINE) - # Free-space installs may share a Windows ESP. Never claim EFI/BOOT as a - # fallback loader in that mode; use the explicit NVRAM entry instead. - default_text = re.sub(r'^ENABLE_LIMINE_FALLBACK=.*$', 'ENABLE_LIMINE_FALLBACK=no', default_text, flags=re.MULTILINE) - - default_limine = ctx.target / "etc" / "default" / "limine" - default_limine.parent.mkdir(parents=True, exist_ok=True) - default_limine.write_text(default_text) - - kernel_cmdline = ctx.target / "etc" / "kernel" / "cmdline" - kernel_cmdline.parent.mkdir(parents=True, exist_ok=True) - kernel_cmdline.write_text(cmdline + "\n") - - -def _install_protected_limine_efi(ctx: InstallContext, protected: dict) -> None: - src = ctx.target / "usr" / "share" / "limine" / "BOOTX64.EFI" - if not src.exists(): - raise RuntimeError( - f"Limine EFI binary not found at {src} — limine package missing in target" - ) - - limine_dir = _omarchy_esp_path(ctx, protected) - limine_dir.mkdir(parents=True, exist_ok=True) - shutil.copy2(src, limine_dir / "limine_x64.efi") - - esp_mount_target = Path(protected["esp_mount"]) - hook_command = ( - f"/usr/bin/cp /usr/share/limine/BOOTX64.EFI " - f"{esp_mount_target / protected['esp_path'].lstrip('/')}/limine_x64.efi" + _write_pre_mounted_crypttab(ctx) + _write_limine_defaults( + ctx, + cmdline, + esp_mount=boot["esp_mount"], + enable_fallback=bool(boot.get("enable_fallback")), ) - _write_limine_pacman_hook(ctx.target, hook_command) # ── efibootmgr ─────────────────────────────────────────────────────────────── @@ -833,57 +787,12 @@ def _split_partition_device(part_dev: str) -> tuple[str, int]: return f"/dev/{parent}", int(part_num) -def _register_efibootmgr_entry( - ctx: InstallContext, protected: dict, pre_state: dict -) -> None: - esp_dev = _esp_device(ctx, protected) - disk, part_num = _split_partition_device(esp_dev) - - # Clean up any stale Limine entries pointing at our protected install path - # so we don't accumulate dupes across re-installs. - for num in _find_label_entries(pre_state["entries"], "Limine"): - subprocess.run( - ["efibootmgr", "--bootnum", num, "--delete-bootnum"], - check=False, capture_output=True, - ) - - subprocess.run( - [ - "efibootmgr", - "--create", - "--disk", disk, - "--part", str(part_num), - "--label", "Limine", - "--loader", "\\EFI\\limine\\limine_x64.efi", - "--unicode", - ], - check=True, capture_output=True, text=True, - ) - - post = _read_efibootmgr() - new_omarchy = _find_label_entries(post["entries"], "Limine") - if not new_omarchy: - raise RuntimeError( - "efibootmgr --create reported success but no Limine entry found" - ) - omarchy_num = new_omarchy[0] - - # Preserve the original BootOrder, but put Omarchy first. - keep = [n for n in pre_state["order"] if n not in new_omarchy] - new_order = ",".join([omarchy_num, *keep]) - subprocess.run( - ["efibootmgr", "--bootorder", new_order], - check=True, capture_output=True, - ) - - def configure_hibernation(ctx: InstallContext) -> None: - """Configure swap/resume in the target as root before user finalization. + """Configure swap/resume in the target as root before user setup. - Hibernation is system boot configuration, not per-user setup. Keep it out - of finalize.sh so the finalizer stays focused on user/session defaults. - The final Limine UKI build still happens later in limine-snapper.sh, after - this writes the resume hook and kernel cmdline drop-in. + Hibernation is system boot configuration, not per-user setup. The final + Limine UKI build still happens later in finalize_limine_boot after this + writes the resume hook and kernel cmdline drop-in. """ setup = ctx.target / "usr" / "bin" / "omarchy-hibernation-setup" if not setup.exists(): @@ -893,8 +802,6 @@ def configure_hibernation(ctx: InstallContext) -> None: subprocess.run([ "arch-chroot", str(ctx.target), "env", - "OMARCHY_INSTALL_MODE=offline", - "OMARCHY_CHROOT_INSTALL=1", "OMARCHY_PATH=/usr/share/omarchy", "OMARCHY_INSTALL_LOG_FILE=/var/log/omarchy-install.log", "/usr/bin/omarchy-hibernation-setup", "--force", "--no-rebuild", @@ -946,155 +853,95 @@ def _debug_run(ctx: InstallContext, cmd: list[str]) -> None: # ───────────────────────────────────────────────────────────────────────────── -# run_chroot_finalizer: -# 1. point the target at the offline pacman.conf so chroot pacman uses the -# bundled mirror -# 2. bind-mount the offline mirror + /opt/packages into /mnt so chroot sees -# the same paths -# 3. write a passwordless-sudo shim for the install user (finalize.sh's -# scripts run as the user and shell out to sudo repeatedly) -# 4. copy the omarchy install tooling into /mnt/opt/omarchy-install (the -# target never gets the omarchy-installer package installed). Do NOT use -# /tmp: arch-chroot mounts a fresh tmpfs over the target's /tmp. -# 5. arch-chroot -u $user → /opt/omarchy-install/finalize.sh +# Target setup phases: +# 1. point the target at the offline pacman.conf +# 2. bind-mount the offline mirror + /opt/packages into /mnt for target pacman +# and bundled language runtimes +# 3. arch-chroot as root → omarchy-setup-system --first-install +# 4. arch-chroot as user → omarchy-setup-user --first-install # ───────────────────────────────────────────────────────────────────────────── -def run_chroot_finalizer(ctx: InstallContext) -> None: - debug_enabled = _install_debug_enabled() +def _prepare_target_setup(ctx: InstallContext) -> None: + if ctx.state.get("target_setup_prepared"): + return - # 1: offline pacman.conf shutil.copy("/etc/pacman.conf", str(ctx.target / "etc" / "pacman.conf")) - # 2: bind mounts. Tracked so the finish phase can tear them down cleanly. bind_mounts = [ ("/var/cache/omarchy/mirror/offline", "/var/cache/omarchy/mirror/offline"), ("/opt/packages", "/opt/packages"), ] ctx.state.setdefault("bind_mounts", []) + mounted = set(ctx.state["bind_mounts"]) for src, dst in bind_mounts: target_dst = ctx.target / dst.lstrip("/") target_dst.mkdir(parents=True, exist_ok=True) - subprocess.run(["mount", "--bind", src, str(target_dst)], check=True) - ctx.state["bind_mounts"].append(str(target_dst)) + if str(target_dst) not in mounted: + subprocess.run(["mount", "--bind", src, str(target_dst)], check=True) + ctx.state["bind_mounts"].append(str(target_dst)) + mounted.add(str(target_dst)) - # 3: sudoers shim. Removed after finalize.sh returns. - sudoers = ctx.target / "etc" / "sudoers.d" / "99-omarchy-installer" - sudoers.parent.mkdir(parents=True, exist_ok=True) - sudoers.write_text( - "root ALL=(ALL:ALL) NOPASSWD: ALL\n" - "%wheel ALL=(ALL:ALL) NOPASSWD: ALL\n" - f"{ctx.username} ALL=(ALL:ALL) NOPASSWD: ALL\n" - ) - sudoers.chmod(0o440) + ctx.state["target_setup_prepared"] = True + + +def _ensure_finalizer_log_started(ctx: InstallContext) -> tuple[str, int]: + if "omarchy_start_time" not in ctx.state: + ctx.state["omarchy_start_epoch"] = int(time.time()) + ctx.state["omarchy_start_time"] = time.strftime("%Y-%m-%d %H:%M:%S") - # run_logged appends as the install user. Bind the target's - # /var/log/omarchy-install.log to the live ISO log so the error UI, upload, - # and "view full log" all show the finalizer details instead of a separate - # hidden target-only log. - target_log = ctx.target / "var" / "log" / "omarchy-install.log" - omarchy_start_epoch = int(time.time()) - omarchy_start_time = time.strftime("%Y-%m-%d %H:%M:%S") ctx.log_path.parent.mkdir(parents=True, exist_ok=True) ctx.log_path.touch(exist_ok=True) ctx.log_path.chmod(0o666) + + if not ctx.state.get("omarchy_finalizer_header_written"): + with ctx.log_path.open("a", encoding="utf-8") as log: + log.write(f"=== Omarchy Target Setup Started: {ctx.state['omarchy_start_time']} ===\n") + ctx.state["omarchy_finalizer_header_written"] = True + + return ctx.state["omarchy_start_time"], ctx.state["omarchy_start_epoch"] + + +def _run_target_setup_command(ctx: InstallContext, cmd: list[str], *, user: str | None = None) -> None: + _prepare_target_setup(ctx) + omarchy_start_time, omarchy_start_epoch = _ensure_finalizer_log_started(ctx) + + target_log = ctx.target / "var" / "log" / "omarchy-install.log" target_log.parent.mkdir(parents=True, exist_ok=True) target_log.touch(exist_ok=True) target_log.chmod(0o666) - with ctx.log_path.open("a", encoding="utf-8") as log: - log.write(f"=== Omarchy Finalizer Started: {omarchy_start_time} ===\n") + log_bind_mounted = False try: subprocess.run(["mount", "--bind", str(ctx.log_path), str(target_log)], check=True) log_bind_mounted = True except subprocess.CalledProcessError as exc: with ctx.log_path.open("a", encoding="utf-8") as log: - log.write(f"[orchestrator] WARNING: failed to bind unified finalizer log: {exc}\n") + log.write(f"[orchestrator] WARNING: failed to bind unified setup log: {exc}\n") + + mirror_channel = _read_omarchy_mirror() + env_extras = [ + "OMARCHY_PATH=/usr/share/omarchy", + "OMARCHY_INSTALL=/usr/share/omarchy/install", + f"OMARCHY_INSTALL_USER={ctx.username}", + f"OMARCHY_START_TIME={omarchy_start_time}", + f"OMARCHY_START_EPOCH={omarchy_start_epoch}", + f"OMARCHY_USER_NAME={ctx.full_name}", + f"OMARCHY_USER_EMAIL={ctx.email}", + f"OMARCHY_MIRROR={mirror_channel}", + "OMARCHY_INSTALL_LOG_FILE=/var/log/omarchy-install.log", + ] + if _install_debug_enabled(): + env_extras.append("OMARCHY_INSTALL_DEBUG=1") + _debug_log(ctx, "running target setup command: " + " ".join(cmd)) + + chroot_cmd = ["arch-chroot"] + if user: + chroot_cmd += ["-u", user] + chroot_cmd += [str(ctx.target), "env", "--unset=XDG_RUNTIME_DIR", *env_extras, *cmd] try: - # 4: copy install tooling somewhere arch-chroot will not mask. /tmp is not - # safe here: arch-chroot mounts a fresh tmpfs over the target's /tmp before - # running commands, so files copied to /mnt/tmp are invisible in the chroot. - tooling_path = Path("/opt/omarchy-install") - target_tooling = ctx.target / tooling_path.relative_to("/") - target_tooling.parent.mkdir(parents=True, exist_ok=True) - if target_tooling.exists(): - shutil.rmtree(target_tooling) - subprocess.run( - ["cp", "-a", f"{ctx.omarchy_path}/.", str(target_tooling)], - check=True, - ) - if not (target_tooling / "finalize.sh").exists(): - raise RuntimeError( - f"Copied installer tooling but {target_tooling / 'finalize.sh'} is missing" - ) - - # Keep the payload root-owned. It only needs to be traversable/readable by - # the install user; sudoers below handles privileged work inside scripts. - subprocess.run(["chmod", "-R", "a+rX", str(target_tooling)], check=True) - subprocess.run( - [ - "arch-chroot", "-u", ctx.username, str(ctx.target), - "test", "-r", str(tooling_path / "finalize.sh"), - ], - check=True, - ) - - # 5: arch-chroot -u $user → finalize.sh - mirror_channel = _read_omarchy_mirror() - env_extras = [ - "OMARCHY_INSTALL_MODE=offline", - "OMARCHY_CHROOT_FINALIZER=1", - "OMARCHY_PATH=/usr/share/omarchy", - f"OMARCHY_INSTALL={tooling_path / 'install'}", - f"OMARCHY_START_TIME={omarchy_start_time}", - f"OMARCHY_START_EPOCH={omarchy_start_epoch}", - f"OMARCHY_USER_NAME={ctx.full_name}", - f"OMARCHY_USER_EMAIL={ctx.email}", - f"OMARCHY_MIRROR={mirror_channel}", - "OMARCHY_INSTALL_LOG_FILE=/var/log/omarchy-install.log", - f"USER={ctx.username}", - f"HOME=/home/{ctx.username}", - ] - if debug_enabled: - env_extras.append("OMARCHY_INSTALL_DEBUG=1") - _debug_log(ctx, "finalizer debug enabled") - _debug_log(ctx, f"copied installer tooling from {ctx.omarchy_path} to {target_tooling}") - _debug_dump_file(ctx, target_tooling / "finalize.sh") - _debug_run(ctx, [ - "arch-chroot", "-u", ctx.username, str(ctx.target), - "env", "--unset=XDG_RUNTIME_DIR", *env_extras, - "/bin/bash", "-n", str(tooling_path / "finalize.sh"), - ]) - _debug_run(ctx, [ - "arch-chroot", "-u", ctx.username, str(ctx.target), - "env", "--unset=XDG_RUNTIME_DIR", *env_extras, - "/bin/bash", "-lc", - "set -o pipefail; " - "echo debug-user=$(id); " - "echo debug-pwd=$(pwd); " - "echo debug-home=$HOME; " - "echo debug-omarchy-install=$OMARCHY_INSTALL; " - "echo debug-omarchy-path=$OMARCHY_PATH; " - "ls -ld /opt/omarchy-install /opt/omarchy-install/install /opt/omarchy-install/install/helpers /usr/share/omarchy /usr/share/omarchy/migrations /home/$USER /home/$USER/.local 2>&1 || true; " - "grep -nE '^[[:space:]]*(source|\\.)[[:space:]].*helpers/all[.]sh' /opt/omarchy-install/finalize.sh || true; " - "bash --version | head -1", - ]) - cmd = [ - "arch-chroot", - "-u", ctx.username, - str(ctx.target), - "env", "--unset=XDG_RUNTIME_DIR", - *env_extras, - "/bin/bash", str(tooling_path / "finalize.sh"), - ] - try: - subprocess.run(cmd, check=True) - except subprocess.CalledProcessError as exc: - if debug_enabled: - _debug_log(ctx, f"finalizer command exited with status {exc.returncode}") - raise + subprocess.run(chroot_cmd, check=True) finally: - sudoers.unlink(missing_ok=True) if log_bind_mounted: subprocess.run(["umount", str(target_log)], check=False, capture_output=True) try: @@ -1105,12 +952,123 @@ def run_chroot_finalizer(ctx: InstallContext) -> None: else: try: with ctx.log_path.open("a", encoding="utf-8") as live_log: - live_log.write("\n=== Target finalizer log ===\n") + live_log.write("\n=== Target setup log ===\n") live_log.write(target_log.read_text(errors="ignore")) except OSError: pass +def run_system_finalizer(ctx: InstallContext) -> None: + _run_target_setup_command( + ctx, + ["/usr/bin/omarchy-setup-system", "--install-user", ctx.username, "--first-install"], + ) + + +def finalize_limine_boot(ctx: InstallContext) -> None: + """Finalize Limine after target system setup has written all dynamic + boot drop-ins (hibernation, hardware quirks, protected-mode ESP settings). + """ + if not (ctx.target / "usr" / "bin" / "limine-update").exists(): + raise RuntimeError("/usr/bin/limine-update missing in target") + + default_limine = ctx.target / "etc" / "default" / "limine" + if not default_limine.exists(): + raise RuntimeError(f"{default_limine} missing") + + default_text = default_limine.read_text() + if "@@CMDLINE@@" in default_text: + raise RuntimeError(f"{default_limine} still contains @@CMDLINE@@") + + config_text = _limine_combined_config_text(ctx, default_text) + cmdline = _limine_kernel_cmdline(config_text) + if not cmdline.strip(): + raise RuntimeError(f"{default_limine} has no KERNEL_CMDLINE[default]+= line") + if "root=" not in cmdline: + raise RuntimeError(f"cmdline parsed from {default_limine} has no root=: {cmdline}") + + esp_path = _limine_setting(config_text, "ESP_PATH", "/boot") or "/boot" + esp_root = ctx.target / esp_path.lstrip("/") + if not esp_root.is_dir(): + raise RuntimeError(f"Limine ESP_PATH does not exist in target: {esp_root}") + + snapper_root = ctx.target / "etc" / "snapper" / "configs" / "root" + if not snapper_root.exists(): + raise RuntimeError(f"{snapper_root} missing") + + limine_conf = esp_root / "limine.conf" + if not limine_conf.exists(): + raise RuntimeError(f"{limine_conf} missing") + + subprocess.run(["arch-chroot", str(ctx.target), "limine-update"], check=True) + + subprocess.run( + ["arch-chroot", str(ctx.target), "btrfs", "quota", "disable", "/"], + check=False, + capture_output=True, + ) + subprocess.run( + ["arch-chroot", str(ctx.target), "systemctl", "enable", "limine-snapper-sync.service"], + check=True, + ) + + if "Omarchy" not in limine_conf.read_text(): + raise RuntimeError(f"{limine_conf} has no Omarchy entry") + if "cryptdevice=" in cmdline and "cryptdevice=" not in limine_conf.read_text(): + raise RuntimeError(f"encrypted install but {limine_conf} has no cryptdevice=") + + +def _strip_shell_quotes(value: str) -> str: + value = value.strip() + if len(value) >= 2 and value[0] == value[-1] and value[0] in ('"', "'"): + return value[1:-1] + return value + + +def _limine_combined_config_text(ctx: InstallContext, default_text: str) -> str: + chunks: list[str] = [] + for path in sorted((ctx.target / "usr" / "share" / "limine-entry-tool.d").glob("*.conf")): + chunks.append(path.read_text()) + + legacy_conf = ctx.target / "etc" / "limine-entry-tool.conf" + if legacy_conf.exists(): + chunks.append(legacy_conf.read_text()) + + for path in sorted((ctx.target / "etc" / "limine-entry-tool.d").glob("*.conf")): + chunks.append(path.read_text()) + + # /etc/default/limine has highest priority in limine-entry-tool. + chunks.append(default_text) + return "\n".join(chunks) + + +def _limine_setting(config_text: str, name: str, fallback: str | None = None) -> str | None: + pattern = re.compile(rf"^\s*{re.escape(name)}\s*=\s*(.*?)\s*$") + value = fallback + for line in config_text.splitlines(): + match = pattern.match(line) + if match: + value = _strip_shell_quotes(match.group(1)) + return value + + +def _limine_kernel_cmdline(config_text: str) -> str: + parts: list[str] = [] + pattern = re.compile(r'^\s*KERNEL_CMDLINE\[default\]\+=\s*(.*?)\s*$') + for line in config_text.splitlines(): + match = pattern.match(line) + if match: + parts.append(_strip_shell_quotes(match.group(1)).strip()) + return " ".join(part for part in parts if part).strip() + + +def run_chroot_finalizer(ctx: InstallContext) -> None: + _run_target_setup_command( + ctx, + ["/usr/bin/omarchy-setup-user", "--force", "--first-install"], + user=ctx.username, + ) + def _read_omarchy_mirror() -> str: p = Path("/root/omarchy_mirror") return p.read_text().strip() if p.exists() else "stable" @@ -1127,11 +1085,15 @@ def configure_login(ctx: InstallContext) -> None: sddm_dir = ctx.target / "etc" / "sddm.conf.d" sddm_dir.mkdir(parents=True, exist_ok=True) - (sddm_dir / "autologin.conf").unlink(missing_ok=True) (sddm_dir / "99-omarchy-login.conf").write_text( "[Theme]\nCurrent=omarchy\n\n" "[Users]\nRememberLastUser=true\nRememberLastSession=true\n" ) + (sddm_dir / "autologin.conf").write_text( + "[Autologin]\n" + f"User={ctx.username}\n" + "Session=omarchy.desktop\n" + ) state_dir = ctx.target / "var" / "lib" / "sddm" state_dir.mkdir(parents=True, exist_ok=True) @@ -1154,82 +1116,71 @@ def configure_login(ctx: InstallContext) -> None: # ───────────────────────────────────────────────────────────────────────────── -# validate_boot_full: hard checks before reboot. If the install ran but -# produced a UKI that can't actually boot, halt here rather than surprise +# validate_boot: hard checks before reboot. If the install ran but produced a +# boot config or UKI that can't actually boot, halt here rather than surprise # the user. # ───────────────────────────────────────────────────────────────────────────── -def validate_boot_full(ctx: InstallContext) -> None: - limine_conf = ctx.target / "boot" / "limine.conf" +def validate_boot(ctx: InstallContext) -> None: + boot = _boot_intent(ctx) + storage = _storage_intent(ctx) + esp_mount = ctx.target / boot["esp_mount"].lstrip("/") + + limine_conf = esp_mount / "limine.conf" if not limine_conf.exists(): raise RuntimeError(f"{limine_conf} missing") + limine_conf_text = limine_conf.read_text() + if "Omarchy" not in limine_conf_text: + raise RuntimeError(f"{limine_conf} has no Omarchy entry") - content = limine_conf.read_text() - if "Omarchy" not in content: - raise RuntimeError("/boot/limine.conf has no Omarchy entry") - - if ctx.encrypt and "cryptdevice=" not in content: - raise RuntimeError("Encrypted install but /boot/limine.conf has no cryptdevice=") + if ctx.encrypt and "cryptdevice=" not in limine_conf_text: + raise RuntimeError(f"Encrypted install but {limine_conf} has no cryptdevice=") kernel_cmdline = ctx.target / "etc" / "kernel" / "cmdline" if not kernel_cmdline.exists(): raise RuntimeError(f"{kernel_cmdline} missing — UKI would have no cmdline") - uki_dir = ctx.target / "boot" / "EFI" / "Linux" - if uki_dir.exists(): - ukis = list(uki_dir.glob("*_linux*.efi")) - if not ukis: - raise RuntimeError(f"No UKI found in {uki_dir}") + default_limine = ctx.target / "etc" / "default" / "limine" + config_text = _limine_combined_config_text(ctx, default_limine.read_text()) + uki_prefix = _limine_setting(config_text, "CUSTOM_UKI_NAME", "omarchy") or "omarchy" + kernel = storage.get("kernel") or (ctx.user_configuration.get("kernels") or ["linux"])[0] + + if arch.has_uefi(): + limine_binary = esp_mount / boot.get("esp_path", "/EFI/limine").lstrip("/") / boot.get("efi_binary", "limine_x64.efi") + if not limine_binary.exists() or limine_binary.stat().st_size == 0: + raise RuntimeError(f"{limine_binary} missing or empty") + + uki = esp_mount / "EFI" / "Linux" / f"{uki_prefix}_{kernel}.efi" + if not uki.exists() or uki.stat().st_size == 0: + raise RuntimeError(f"{uki} missing or empty") + + post = _read_efibootmgr() + if not _find_label_entries(post["entries"], "Limine"): + raise RuntimeError("no 'Limine' entry registered in efibootmgr") + + if ctx.is_protected: + _validate_pre_mounted_filesystems(ctx) -# ───────────────────────────────────────────────────────────────────────────── -# validate_boot_protected: hard checks for the dualboot/protected path. -# Mirrors validate_boot_full but checks the protected ESP, fstab/crypttab, -# final Limine UKI, and efibootmgr entry registration. -# ───────────────────────────────────────────────────────────────────────────── - -def validate_boot_protected(ctx: InstallContext) -> None: - protected = ctx.state["protected"] - kernel = protected["kernel"] - - limine_dir = _omarchy_esp_path(ctx, protected) - esp_mount = _protected_esp_mount(ctx, protected) - - bootx64 = limine_dir / "limine_x64.efi" - if not bootx64.exists() or bootx64.stat().st_size == 0: - raise RuntimeError(f"{bootx64} missing or empty") - - limine_conf = esp_mount / "limine.conf" - if not limine_conf.exists(): - raise RuntimeError(f"{limine_conf} missing") - if "Omarchy" not in limine_conf.read_text(): - raise RuntimeError(f"{limine_conf} has no Omarchy entry") - +def _validate_pre_mounted_filesystems(ctx: InstallContext) -> None: + storage = _storage_intent(ctx) fstab = ctx.target / "etc" / "fstab" if not fstab.exists(): raise RuntimeError(f"{fstab} missing") fstab_text = fstab.read_text() - btrfs_uuid = _blkid_uuid(_btrfs_root_device(protected)) - esp_uuid = _blkid_uuid(_esp_device(ctx, protected)) + btrfs_uuid = _blkid_uuid(_btrfs_root_device(ctx)) + esp_uuid = _blkid_uuid(_esp_device(ctx)) if btrfs_uuid not in fstab_text: raise RuntimeError(f"{fstab} missing btrfs UUID {btrfs_uuid}") if esp_uuid not in fstab_text: raise RuntimeError(f"{fstab} missing ESP UUID {esp_uuid}") - if protected.get("luks_uuid"): + if storage.get("luks_uuid"): crypttab = ctx.target / "etc" / "crypttab.initramfs" if not crypttab.exists(): raise RuntimeError(f"{crypttab} missing") - if protected["luks_uuid"] not in crypttab.read_text(): - raise RuntimeError(f"{crypttab} missing LUKS UUID {protected['luks_uuid']}") - - uki = esp_mount / "EFI" / "Linux" / f"omarchy_{kernel}.efi" - if not uki.exists() or uki.stat().st_size == 0: - raise RuntimeError(f"{uki} missing or empty") - - post = _read_efibootmgr() - if not _find_label_entries(post["entries"], "Limine"): - raise RuntimeError("no 'Limine' entry registered in efibootmgr") + if storage["luks_uuid"] not in crypttab.read_text(): + raise RuntimeError(f"{crypttab} missing LUKS UUID {storage['luks_uuid']}") # ───────────────────────────────────────────────────────────────────────────── diff --git a/plans/aarch64-support.md b/plans/aarch64-support.md index 8d3d404..cfa3455 100644 --- a/plans/aarch64-support.md +++ b/plans/aarch64-support.md @@ -16,7 +16,7 @@ These exist outside omarchy-iso. Flagged for visibility — without them, nothin 1. **aarch64 base packages must exist somewhere we can pacman from.** Vanilla Arch (`geo.mirror.pkgbuild.com`) is x86_64-only — there is no `core/os/aarch64`. The realistic source is **Arch Linux ARM** (`mirror.archlinuxarm.org`) for `core`/`extra`/`alarm`/`aur`. Decision required: target Arch Linux ARM as the aarch64 base distribution. 2. **`pkgs.omarchy.org/{stable,edge}/aarch64/`** must serve a real repo. Probed today, both return 404. omarchy-pkgs already has multi-arch build support per its README, so this is a publish step, not a port. -3. **Omarchy installer guard** at `~/.local/share/omarchy/install/preflight/guard.sh:25` hard-aborts on `uname -m != x86_64`. Must be relaxed to allow aarch64 (or scoped behind a flag). +3. **Omarchy ISO architecture guard** must allow supported architectures or scope any x86_64-only checks behind an explicit guard. 4. **archinstall + Limine** must work end-to-end on aarch64. archinstall supports it; Limine supports aarch64 UEFI. Worth a manual verification before committing to this bootloader path on ARM. --- @@ -109,9 +109,9 @@ Both scripts hardcode the OVMF path; small, mechanical fix. ### 10. Release script — `bin/omarchy-iso-release:52` ```bash -latest_iso=$(\ls -t "$BUILD_RELEASE_PATH"/*x86_64-"$INSTALLER_REF".iso | head -n1) +latest_iso=$(\ls -t "$BUILD_RELEASE_PATH"/*${OMARCHY_ARCH}-"$ISO_REF".iso | head -n1) ``` -Hardcoded `x86_64` glob. Take an arch arg, or do `*${OMARCHY_ARCH}-${INSTALLER_REF}.iso`. The ISO filename already contains arch (set by archiso from `profiledef.sh`), so this just needs the glob parameterized. +Hardcoded `x86_64` glob. Take an arch arg, or do `*${OMARCHY_ARCH}-${ISO_REF}.iso`. The ISO filename already contains arch (set by archiso from `profiledef.sh`), so this just needs the glob parameterized. ### 11. CI — `.github/workflows/nightly-build.yml` diff --git a/plans/oem-install.md b/plans/oem-install.md index abef043..9815026 100644 --- a/plans/oem-install.md +++ b/plans/oem-install.md @@ -60,7 +60,7 @@ Create a second ISO flavor for OEM preloading that is installed to internal stor - `grub/loopback.cfg`: remove `archlinux-accessibility` menuentry. - `syslinux/archiso_sys-linux.cfg`: remove `arch64speech` label block. - When `OMARCHY_MODE=oem`, override `iso_application` in `profiledef.sh` to `"Omarchy OEM Installer"`. -- Rename output ISO with `-oem` suffix (in addition to existing `-$OMARCHY_INSTALLER_REF` suffix). +- Rename output ISO with `-oem` suffix (in addition to existing `-$OMARCHY_ISO_REF` suffix). ### Hardening - Add post-injection build assertion that fails build if any expected boot config does not contain `copytoram=y`.