Created
February 21, 2026 16:16
-
-
Save jserv/7163bc8a667c66b330f4e6b406753505 to your computer and use it in GitHub Desktop.
Run Lotus 1-2-3 on macOS + Apple Silicon
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env bash | |
| # setup.sh - Bootstrap Lotus 1-2-3 on macOS arm64 via Blink | |
| # Authored by Jim Huang <jserv@ccns.ncku.edu.tw> | |
| # | |
| # Downloads the prebuilt i386 binary from GitHub, builds Blink, | |
| # populates the sysroot from Ubuntu archive i386 .debs, validates the layout, | |
| # and generates run-123.sh. No SSH/scp needed -- everything from public URLs. | |
| # | |
| set -euo pipefail | |
| SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" | |
| cd "$SCRIPT_DIR" | |
| # Global cleanup: track all temp directories, remove on exit. | |
| declare -a CLEANUP_DIRS=() | |
| cleanup() { for d in "${CLEANUP_DIRS[@]:-}"; do [ -n "$d" ] && rm -rf "$d"; done; } | |
| trap cleanup EXIT | |
| # Configuration | |
| RELEASE_TAG="v1.0.0rc4" | |
| # No noble (24.04) build exists upstream; jammy (22.04) is the newest and works. | |
| DEB_NAME="lotus123r3_1.0-4jammy_i386.deb" | |
| DEB_SHA256="505b641719c53931a35bbc9ff2e6aefb0f23acbbeaea924988c2890769f9c603" | |
| DEB_URL="https://github.com/taviso/123elf/releases/download/${RELEASE_TAG}/${DEB_NAME}" | |
| BLINK_REPO="https://github.com/jserv/blink" | |
| BLINK_BRANCH="i386-elf" | |
| BLINK_COMMIT="d2532eb151cdcbf69c472a1030049379cdf2e7f2" | |
| BLINK="./blink/o/blink/blink" | |
| # Ubuntu noble (24.04) i386 library packages from the public archive. | |
| # The i386 ld-linux.so.2 has /usr/lib/i386-linux-gnu/ as a built-in search path, | |
| # so extracting these .debs straight into sysroot/ gives the right layout. | |
| UBUNTU_MIRROR="https://security.ubuntu.com/ubuntu" | |
| LIBC6_DEB="libc6_2.39-0ubuntu8.7_i386.deb" | |
| LIBC6_SHA256="2ed5aab314a92a248e0b0191b6201497f5c44b3a73e6edb9d0f3aceefb68076f" | |
| LIBC6_URL="${UBUNTU_MIRROR}/pool/main/g/glibc/${LIBC6_DEB}" | |
| NCURSES_MIRROR="https://archive.ubuntu.com/ubuntu" | |
| LIBTINFO6_DEB="libtinfo6_6.4+20240113-1ubuntu2_i386.deb" | |
| LIBTINFO6_SHA256="66af73bb26395ccc8a8b9f7b4f7854ba38ae6d00602ab8a70dda30af77910057" | |
| LIBTINFO6_URL="${NCURSES_MIRROR}/pool/main/n/ncurses/${LIBTINFO6_DEB}" | |
| LIBNCURSES6_DEB="libncurses6_6.4+20240113-1ubuntu2_i386.deb" | |
| LIBNCURSES6_SHA256="56b1b4c0254a5eb02e8ccc03b323f4092cbd75ac3d80d11540f89ef40c91c91f" | |
| LIBNCURSES6_URL="${NCURSES_MIRROR}/pool/main/n/ncurses/${LIBNCURSES6_DEB}" | |
| NCURSES_BASE_DEB="ncurses-base_6.4+20240113-1ubuntu2_all.deb" | |
| NCURSES_BASE_SHA256="56dbac135d58e580c2c9e33d5fd7c215b48091a54e1d9fa3d41f538f4acbac5f" | |
| NCURSES_BASE_URL="${NCURSES_MIRROR}/pool/main/n/ncurses/${NCURSES_BASE_DEB}" | |
| # Helpers | |
| info() { printf '\033[1;34m==>\033[0m %s\n' "$*"; } | |
| err() { printf '\033[1;31mERR\033[0m %s\n' "$*" >&2; } | |
| ok() { printf '\033[1;32m OK\033[0m %s\n' "$*"; } | |
| die() { err "$@"; exit 1; } | |
| # Download a URL to a local file with HTTPS enforcement, retries, and timeouts. | |
| fetch() { | |
| curl --fail --show-error --location \ | |
| --proto '=https' --tlsv1.2 \ | |
| --retry 3 --retry-delay 2 \ | |
| --connect-timeout 30 --max-time 600 \ | |
| -o "$1" "$2" | |
| } | |
| need_cmd() { | |
| command -v "$1" >/dev/null 2>&1 || die "required command not found: $1" | |
| } | |
| verify_sha256() { | |
| local file="$1" expected="$2" | |
| local actual | |
| if command -v sha256sum >/dev/null 2>&1; then | |
| actual="$(sha256sum "$file" | cut -d' ' -f1)" | |
| elif command -v shasum >/dev/null 2>&1; then | |
| actual="$(shasum -a 256 "$file" | cut -d' ' -f1)" | |
| else | |
| die "no sha256sum or shasum found" | |
| fi | |
| if [ "$actual" != "$expected" ]; then | |
| die "checksum mismatch for ${file##*/}: expected ${expected}, got ${actual}" | |
| fi | |
| } | |
| # Find the first glob match using nullglob (no ls parsing). | |
| first_match() { | |
| local -a files=() | |
| shopt -s nullglob | |
| files=($1) | |
| shopt -u nullglob | |
| [ ${#files[@]} -gt 0 ] && printf '%s' "${files[0]}" | |
| } | |
| # Detect GNU Make (gmake on macOS, make on some Linux). | |
| detect_gmake() { | |
| local cmd | |
| for cmd in gmake make; do | |
| if command -v "$cmd" >/dev/null 2>&1; then | |
| if "$cmd" --version 2>/dev/null | grep -q "GNU Make"; then | |
| printf '%s' "$cmd" | |
| return | |
| fi | |
| fi | |
| done | |
| die "GNU Make not found (tried gmake, make)" | |
| } | |
| # Portable CPU count. | |
| ncpus() { | |
| getconf _NPROCESSORS_ONLN 2>/dev/null \ | |
| || sysctl -n hw.ncpu 2>/dev/null \ | |
| || echo 1 | |
| } | |
| # Validate tar members: reject absolute paths and .. path traversal. | |
| # Streams entries so a corrupt archive causes tar to fail under set -e. | |
| validate_tar_paths() { | |
| local archive="$1" p | |
| tar tf "$archive" | while IFS= read -r p; do | |
| case "$p" in | |
| /*|*/../*|../*|*/..) die "tar ${archive##*/}: unsafe path: $p" ;; | |
| esac | |
| done | |
| } | |
| # Download a .deb, verify its checksum, extract data into sysroot/ | |
| extract_deb_to_sysroot() { | |
| local url="$1" name="$2" sha256="$3" | |
| local tmpdir | |
| tmpdir="$(mktemp -d "${SCRIPT_DIR}/tmp.deb.XXXXXX")" | |
| CLEANUP_DIRS+=("$tmpdir") | |
| info " Fetching ${name} ..." | |
| fetch "${tmpdir}/${name}" "$url" | |
| verify_sha256 "${tmpdir}/${name}" "$sha256" | |
| ( | |
| cd "$tmpdir" | |
| ar x "$name" | |
| local data | |
| data="$(first_match 'data.tar.*')" | |
| [ -n "$data" ] || die "No data.tar.* in ${name}" | |
| validate_tar_paths "$data" | |
| tar xf "$data" --no-same-owner --no-same-permissions -C "${SCRIPT_DIR}/sysroot" | |
| ) | |
| } | |
| # Preflight | |
| info "Checking prerequisites" | |
| need_cmd curl | |
| need_cmd ar | |
| need_cmd tar | |
| need_cmd git | |
| need_cmd file | |
| need_cmd cc | |
| GMAKE="$(detect_gmake)" | |
| # Step 1: Download and extract the i386 .deb | |
| info "Downloading ${DEB_NAME}" | |
| mkdir -p bin | |
| NEED_LOTUS_DEB=false | |
| if [ ! -f "bin/123" ] || [ ! -d "sysroot/usr/share/lotus/123.v10" ] \ | |
| || [ ! -f "sysroot/usr/bin/123" ] \ | |
| || [ ! -f "sysroot/usr/share/lotus/etc/l123set.cf" ]; then | |
| NEED_LOTUS_DEB=true | |
| fi | |
| if [ "$NEED_LOTUS_DEB" = true ]; then | |
| TMPDIR_LOTUS="$(mktemp -d "${SCRIPT_DIR}/tmp.lotus.XXXXXX")" | |
| CLEANUP_DIRS+=("$TMPDIR_LOTUS") | |
| fetch "${TMPDIR_LOTUS}/${DEB_NAME}" "$DEB_URL" | |
| verify_sha256 "${TMPDIR_LOTUS}/${DEB_NAME}" "$DEB_SHA256" | |
| ok "Downloaded and verified ${DEB_NAME}" | |
| # .deb is an ar archive containing data.tar.* with the payload. | |
| info "Extracting from Lotus .deb" | |
| ( | |
| cd "$TMPDIR_LOTUS" | |
| ar x "$DEB_NAME" | |
| DATA_TAR="$(first_match 'data.tar.*')" | |
| [ -n "$DATA_TAR" ] || die "No data.tar.* found inside .deb" | |
| validate_tar_paths "$DATA_TAR" | |
| tar xf "$DATA_TAR" --no-same-owner --no-same-permissions | |
| # Place the i386 binary at both bin/123 (convenience) and | |
| # sysroot/usr/bin/123 (required: the binary resolves {LOTUSROOT} as | |
| # ../share/lotus relative to its own path, so it must live at | |
| # /usr/bin/123 inside the overlay for /usr/share/lotus/ to be found). | |
| BIN_PATH="" | |
| for candidate in usr/bin/123 usr/local/bin/123; do | |
| if [ -f "$candidate" ]; then | |
| BIN_PATH="$candidate" | |
| break | |
| fi | |
| done | |
| [ -n "$BIN_PATH" ] || die "Could not find 123 binary in extracted .deb" | |
| if [ ! -f "${SCRIPT_DIR}/bin/123" ]; then | |
| cp "$BIN_PATH" "${SCRIPT_DIR}/bin/123" | |
| chmod +x "${SCRIPT_DIR}/bin/123" | |
| fi | |
| mkdir -p "${SCRIPT_DIR}/sysroot/usr/bin" | |
| cp "$BIN_PATH" "${SCRIPT_DIR}/sysroot/usr/bin/123" | |
| chmod +x "${SCRIPT_DIR}/sysroot/usr/bin/123" | |
| # Extract Lotus support files (help, fonts, keymaps, config). | |
| if [ -d "usr/share/lotus" ]; then | |
| mkdir -p "${SCRIPT_DIR}/sysroot/usr/share/lotus" | |
| cp -R usr/share/lotus/. "${SCRIPT_DIR}/sysroot/usr/share/lotus/" | |
| fi | |
| ) | |
| ok "Placed bin/123 and Lotus support files" | |
| else | |
| info "bin/123 and Lotus support files already exist, skipping download" | |
| fi | |
| # Validate it is an i386 ELF | |
| FILE_INFO="$(file bin/123)" | |
| case "$FILE_INFO" in | |
| *ELF*32-bit*Intel*|*ELF*32-bit*80386*) | |
| ok "bin/123 is i386 ELF: ${FILE_INFO##*: }" ;; | |
| *) | |
| die "bin/123 is not i386 ELF: ${FILE_INFO}" ;; | |
| esac | |
| # Step 2: Clone and build blink | |
| info "Building blink" | |
| if [ -d "blink" ] && [ ! -d "blink/.git" ]; then | |
| die "blink/ exists but is not a git repository; remove it and re-run" | |
| fi | |
| if [ ! -d "blink" ]; then | |
| info "Cloning blink from ${BLINK_REPO} (branch ${BLINK_BRANCH})" | |
| git clone --quiet --depth=1 -b "$BLINK_BRANCH" "$BLINK_REPO" blink | |
| fi | |
| # Enforce pinned commit for reproducibility; force rebuild if commit changed. | |
| CURRENT_COMMIT="$(git -C blink rev-parse HEAD 2>/dev/null || echo "")" | |
| if [ "$CURRENT_COMMIT" != "$BLINK_COMMIT" ]; then | |
| info "Updating blink to commit ${BLINK_COMMIT}" | |
| git -C blink fetch --quiet --depth=1 origin "$BLINK_COMMIT" | |
| git -C blink checkout --quiet "$BLINK_COMMIT" | |
| rm -f "$BLINK" | |
| fi | |
| if [ -x "$BLINK" ]; then | |
| info "blink already built at ${BLINK}, skipping (delete to force rebuild)" | |
| else | |
| info "Compiling blink (this may take a minute) ..." | |
| ( | |
| cd blink | |
| [ -f config.mk ] || ./configure >/dev/null 2>&1 | |
| "$GMAKE" -j"$(ncpus)" >/dev/null || die "blink compilation failed" | |
| ) | |
| fi | |
| [ -x "$BLINK" ] || die "blink binary not found at ${BLINK}" | |
| ok "blink ready: ${BLINK}" | |
| # Step 3: Populate sysroot with i386 libs from Ubuntu archive | |
| # Download i386 .debs from the public Ubuntu archive and extract into sysroot/. | |
| # The .debs install to usr/lib/i386-linux-gnu/ which is a built-in search path | |
| # of the i386 ld-linux.so.2, so the layout works directly. | |
| info "Preparing sysroot from Ubuntu archive" | |
| mkdir -p sysroot | |
| NEED_FETCH=false | |
| [ -f "sysroot/usr/lib/i386-linux-gnu/libc.so.6" ] || NEED_FETCH=true | |
| [ -f "sysroot/usr/lib/i386-linux-gnu/libm.so.6" ] || NEED_FETCH=true | |
| [ -f "sysroot/usr/lib/i386-linux-gnu/libtinfo.so.6" ] || NEED_FETCH=true | |
| [ -f "sysroot/usr/lib/i386-linux-gnu/libncurses.so.6" ] || NEED_FETCH=true | |
| [ -d "sysroot/usr/share/terminfo" ] || NEED_FETCH=true | |
| if [ "$NEED_FETCH" = true ]; then | |
| # libc6 provides both libc.so.6 and libm.so.6; re-extract if either is missing. | |
| if [ ! -f "sysroot/usr/lib/i386-linux-gnu/libc.so.6" ] \ | |
| || [ ! -f "sysroot/usr/lib/i386-linux-gnu/libm.so.6" ]; then | |
| extract_deb_to_sysroot "$LIBC6_URL" "$LIBC6_DEB" "$LIBC6_SHA256" | |
| fi | |
| [ -f "sysroot/usr/lib/i386-linux-gnu/libtinfo.so.6" ] \ | |
| || extract_deb_to_sysroot "$LIBTINFO6_URL" "$LIBTINFO6_DEB" "$LIBTINFO6_SHA256" | |
| [ -f "sysroot/usr/lib/i386-linux-gnu/libncurses.so.6" ] \ | |
| || extract_deb_to_sysroot "$LIBNCURSES6_URL" "$LIBNCURSES6_DEB" "$LIBNCURSES6_SHA256" | |
| [ -d "sysroot/usr/share/terminfo" ] \ | |
| || extract_deb_to_sysroot "$NCURSES_BASE_URL" "$NCURSES_BASE_DEB" "$NCURSES_BASE_SHA256" | |
| ok "Fetched i386 libraries from Ubuntu archive" | |
| else | |
| info "sysroot i386 libraries exist, skipping fetch" | |
| fi | |
| # The 123 ELF interpreter is /lib/ld-linux.so.2. The .deb puts the real binary | |
| # at usr/lib/i386-linux-gnu/ld-linux.so.2 and a symlink at usr/lib/ld-linux.so.2. | |
| # Create the /lib/ symlink so blink's overlay resolves the interpreter path. | |
| if [ ! -e "sysroot/lib/ld-linux.so.2" ]; then | |
| mkdir -p sysroot/lib | |
| ln -sf ../usr/lib/i386-linux-gnu/ld-linux.so.2 sysroot/lib/ld-linux.so.2 | |
| fi | |
| # The 123 binary reads $HOME/.l123set at startup. Place it inside the sysroot | |
| # under /root/ and override HOME=/root so the overlay serves it. The file uses | |
| # {LOTUSROOT} as a placeholder the binary expands at runtime relative to its | |
| # own location (../share/lotus from the binary), so keep it verbatim. | |
| if [ ! -f "sysroot/root/.l123set" ]; then | |
| mkdir -p sysroot/root | |
| cp sysroot/usr/share/lotus/etc/l123set.cf sysroot/root/.l123set | |
| ok "Created sysroot/root/.l123set" | |
| fi | |
| # Step 4: Validate sysroot | |
| info "Validating sysroot" | |
| MISSING=0 | |
| for lib in usr/bin/123 \ | |
| lib/ld-linux.so.2 \ | |
| usr/lib/i386-linux-gnu/libc.so.6 \ | |
| usr/lib/i386-linux-gnu/libm.so.6 \ | |
| usr/lib/i386-linux-gnu/libncurses.so.6 \ | |
| usr/lib/i386-linux-gnu/libtinfo.so.6; do | |
| if [ -e "sysroot/${lib}" ] || [ -L "sysroot/${lib}" ]; then | |
| ok " ${lib}" | |
| else | |
| err " MISSING: ${lib}" | |
| MISSING=$((MISSING + 1)) | |
| fi | |
| done | |
| for dir in usr/share/lotus/123.v10 \ | |
| usr/share/lotus/etc; do | |
| if [ -d "sysroot/${dir}" ]; then | |
| ok " ${dir}/" | |
| else | |
| err " MISSING: ${dir}/" | |
| MISSING=$((MISSING + 1)) | |
| fi | |
| done | |
| [ "$MISSING" -eq 0 ] || die "${MISSING} required sysroot entries missing" | |
| ok "sysroot validated" | |
| # Step 5: Create run-123.sh | |
| cat > run-123.sh <<'LAUNCHER' | |
| #!/usr/bin/env bash | |
| # run-123.sh - Launch Lotus 1-2-3 via Blink | |
| # Exit from within 123: /Quit Yes | |
| SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" | |
| exec env BLINK_OVERLAYS="${SCRIPT_DIR}/sysroot:" \ | |
| TERM=xterm-256color \ | |
| HOME=/root \ | |
| "${SCRIPT_DIR}/blink/o/blink/blink" /usr/bin/123 "$@" | |
| LAUNCHER | |
| chmod +x run-123.sh | |
| ok "Created run-123.sh" | |
| # Launch | |
| info "Launching Lotus 1-2-3 (./run-123.sh)" | |
| info " (Exit with /Quit Yes from within 123)" | |
| echo "" |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment