Skip to content

Instantly share code, notes, and snippets.

@jserv
Created February 21, 2026 16:16
Show Gist options
  • Select an option

  • Save jserv/7163bc8a667c66b330f4e6b406753505 to your computer and use it in GitHub Desktop.

Select an option

Save jserv/7163bc8a667c66b330f4e6b406753505 to your computer and use it in GitHub Desktop.
Run Lotus 1-2-3 on macOS + Apple Silicon
#!/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