700 lines
28 KiB
Bash
Executable File
700 lines
28 KiB
Bash
Executable File
#!/usr/bin/env bash
|
||
# build.sh — produce a runnable oMLX.app for local manual testing.
|
||
#
|
||
# Side-by-side Swift bundle path: this builds `oMLX.app` alongside the
|
||
# legacy Python/PyObjC `oMLX.app` until the Swift app becomes the primary
|
||
# release channel.
|
||
# Pipeline:
|
||
# 1. xcodebuild with `-resolvePackageDependencies` so SPM deps pick up
|
||
# any new minor/patch within the pin range each build
|
||
# 2. (auto) rebuild the venvstacks export if sources have drifted
|
||
# 3. stage the Swift `.app` and copy the venvstacks-produced Python
|
||
# layers into Contents/Resources/Python/ verbatim
|
||
# 4. embed the omlx package from the worktree and ad-hoc sign
|
||
#
|
||
# venvstacks is the single source of truth for the bundle's Python
|
||
# environment — we no longer post-process its output with a uv-sync
|
||
# overlay. If a dep needs to move, edit packaging/venvstacks.toml (or
|
||
# bump pyproject.toml + uv.lock for the project itself) and let the
|
||
# fingerprint check trigger a fresh venvstacks rebuild.
|
||
#
|
||
# Donor source resolution (in order):
|
||
# 1. $OMLX_DONOR_APP — explicit override (e.g. /Applications/oMLX.app).
|
||
# Bypasses venvstacks rebuild; uses the override as-is.
|
||
# 2. packaging/_export/ — the venvstacks export tree. Default for dev
|
||
# builds. Rebuilt automatically when stale
|
||
# (fingerprint of pyproject.toml + venvstacks.toml
|
||
# + uv.lock differs from packaging/_export/.fingerprint).
|
||
# 3. /Applications/oMLX.app — last-resort fallback when --no-rebuild-donor
|
||
# is set and no local export exists.
|
||
#
|
||
# Usage:
|
||
# apps/omlx-mac/Scripts/build.sh # Release, auto-rebuild donor when stale
|
||
# apps/omlx-mac/Scripts/build.sh debug # Debug build instead
|
||
# apps/omlx-mac/Scripts/build.sh swift # rebuild Swift app only; reuse existing _export/
|
||
# apps/omlx-mac/Scripts/build.sh swift debug # Debug Swift app rebuild; reuse existing _export/
|
||
# apps/omlx-mac/Scripts/build.sh swift-fast # like swift, but skip embedded native signing
|
||
# apps/omlx-mac/Scripts/build.sh release --bare # skip Python embed
|
||
# (no server, just the
|
||
# AppView shell)
|
||
# apps/omlx-mac/Scripts/build.sh release --with-custom-kernel
|
||
# # build and bundle optional
|
||
# # native custom kernels
|
||
# apps/omlx-mac/Scripts/build.sh --rebuild-donor # force venvstacks rebuild
|
||
# apps/omlx-mac/Scripts/build.sh --no-rebuild-donor # never rebuild; use
|
||
# existing donor even if stale
|
||
#
|
||
# Env overrides:
|
||
# OMLX_DONOR_APP=/path/to/oMLX.app # explicit donor (bypasses venvstacks)
|
||
# OMLX_EXPORT_DIR=/path/to/_export # override the venvstacks export tree
|
||
# (release builds set this per macOS target)
|
||
# OMLX_NEXT_OUT=/path/to/output_dir # final stage location
|
||
# PYTHON_BIN=/path/to/python3 # python used for venvstacks driver
|
||
# (default: PATH lookup of python3)
|
||
# OMLX_WITH_CUSTOM_KERNEL=1 # same as --with-custom-kernel
|
||
# OMLX_CUSTOM_KERNEL_DEPLOYMENT_TARGET=15.0
|
||
# # macOS min version for custom kernels
|
||
|
||
set -euo pipefail
|
||
|
||
SWIFT_REBUILD=0
|
||
SKIP_EMBEDDED_SIGN=0
|
||
case "$(echo "${1:-}" | tr '[:upper:]' '[:lower:]')" in
|
||
swift)
|
||
SWIFT_REBUILD=1
|
||
shift
|
||
;;
|
||
swift-fast)
|
||
SWIFT_REBUILD=1
|
||
SKIP_EMBEDDED_SIGN=1
|
||
shift
|
||
;;
|
||
esac
|
||
|
||
CONFIG=Release
|
||
if [ $# -gt 0 ]; then
|
||
case "$(echo "$1" | tr '[:upper:]' '[:lower:]')" in
|
||
debug) CONFIG=Debug; shift ;;
|
||
release) CONFIG=Release; shift ;;
|
||
--*) ;;
|
||
*)
|
||
echo "error: unknown configuration '$1' (expected swift|swift-fast|debug|release)" >&2
|
||
exit 2
|
||
;;
|
||
esac
|
||
fi
|
||
|
||
BARE=0
|
||
WITH_CUSTOM_KERNEL="${OMLX_WITH_CUSTOM_KERNEL:-0}"
|
||
REBUILD_DONOR=auto # auto | force | never
|
||
for arg in "$@"; do
|
||
case "$arg" in
|
||
--bare) BARE=1 ;;
|
||
--with-custom-kernel) WITH_CUSTOM_KERNEL=1 ;;
|
||
--rebuild-donor) REBUILD_DONOR=force ;;
|
||
--no-rebuild-donor) REBUILD_DONOR=never ;;
|
||
*) echo "error: unknown flag '$arg'" >&2; exit 2 ;;
|
||
esac
|
||
done
|
||
case "$(echo "$WITH_CUSTOM_KERNEL" | tr '[:upper:]' '[:lower:]')" in
|
||
1|true|yes|on) WITH_CUSTOM_KERNEL=1 ;;
|
||
*) WITH_CUSTOM_KERNEL=0 ;;
|
||
esac
|
||
if [ "$SWIFT_REBUILD" -eq 1 ] && [ "$REBUILD_DONOR" = "force" ]; then
|
||
echo "error: swift cannot be combined with --rebuild-donor; use release --rebuild-donor." >&2
|
||
exit 2
|
||
fi
|
||
|
||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||
PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||
REPO_ROOT="$(cd "$PROJECT_DIR/../.." && pwd)"
|
||
PACKAGING_DIR="$REPO_ROOT/packaging"
|
||
CUSTOM_KERNEL_DIRS=(
|
||
"$REPO_ROOT/omlx/custom_kernels/glm_moe_dsa"
|
||
"$REPO_ROOT/omlx/custom_kernels/minimax_m3"
|
||
"$REPO_ROOT/omlx/custom_kernels/qwen35_prefill"
|
||
)
|
||
# OMLX_EXPORT_DIR overrides the venvstacks export tree we copy Python
|
||
# layers from. Release builds use this to point at a per-target export
|
||
# copy with platform-specific mlx-metal wheels swapped in.
|
||
LOCAL_EXPORT="${OMLX_EXPORT_DIR:-$PACKAGING_DIR/_export}"
|
||
|
||
# OMLX_DONOR_APP is "explicit" only when the user set it; the default
|
||
# (/Applications/oMLX.app) is treated as a fallback, not an override.
|
||
OMLX_DONOR_APP_SET="${OMLX_DONOR_APP+1}"
|
||
OMLX_DONOR_APP="${OMLX_DONOR_APP:-/Applications/oMLX.app}"
|
||
OUTPUT_DIR="${OMLX_NEXT_OUT:-$PROJECT_DIR/build/Stage}"
|
||
BUILD_DIR="$PROJECT_DIR/build"
|
||
|
||
LIGHT_BLUE="\033[1;34m"
|
||
GREEN="\033[1;32m"
|
||
YELLOW="\033[1;33m"
|
||
RED="\033[1;31m"
|
||
RESET="\033[0m"
|
||
|
||
log() { printf "${LIGHT_BLUE}[build.sh]${RESET} %s\n" "$*"; }
|
||
ok() { printf "${GREEN}[build.sh]${RESET} %s\n" "$*"; }
|
||
warn() { printf "${YELLOW}[build.sh]${RESET} %s\n" "$*"; }
|
||
die() { printf "${RED}[build.sh ERROR]${RESET} %s\n" "$*" >&2; exit 1; }
|
||
|
||
_is_mach_o_file() {
|
||
local path="$1"
|
||
local type
|
||
type="$(file -b "$path" 2>/dev/null || true)"
|
||
[[ "$type" == *"Mach-O"* ]]
|
||
}
|
||
|
||
_sign_embedded_mach_o_files() {
|
||
local root="$1"
|
||
local count=0
|
||
|
||
while IFS= read -r -d '' path; do
|
||
_is_mach_o_file "$path" || continue
|
||
codesign --force --sign - "$path" >/dev/null 2>&1
|
||
count=$((count + 1))
|
||
done < <(
|
||
find "$root" \
|
||
\( -path "*/.dSYM/*" -o -path "*/__pycache__/*" \) -prune -o \
|
||
-type f \( \
|
||
-name "*.so" -o \
|
||
-name "*.dylib" -o \
|
||
-name "*.bundle" -o \
|
||
-perm -100 -o \
|
||
-perm -010 -o \
|
||
-perm -001 \
|
||
\) -print0
|
||
)
|
||
|
||
ok " + signed $count embedded Mach-O files"
|
||
}
|
||
|
||
_app_python_layers_path() {
|
||
local app="$1"
|
||
for candidate in \
|
||
"$app/Contents/Resources/Python" \
|
||
"$app/Contents/Python" \
|
||
"$app/Contents/Frameworks"
|
||
do
|
||
if [ -d "$candidate/cpython-3.11" ] && [ -d "$candidate/framework-mlx-base" ]; then
|
||
printf "%s\n" "$candidate"
|
||
return 0
|
||
fi
|
||
done
|
||
return 1
|
||
}
|
||
|
||
if [ "$SWIFT_REBUILD" -eq 1 ] && [ "$BARE" -eq 0 ] && [ ! -d "$LOCAL_EXPORT" ]; then
|
||
die "Swift rebuild requires existing venvstacks export at $LOCAL_EXPORT. Run build.sh release first."
|
||
fi
|
||
|
||
# --- Resolve donor: pick a layer source and (re)build via venvstacks if stale
|
||
|
||
PYTHON_BIN="${PYTHON_BIN:-$(command -v python3 || true)}"
|
||
|
||
# Returns 0 if the local _export/ exists and its stored fingerprint matches
|
||
# the current pyproject/venvstacks/lockfile state.
|
||
_local_export_fresh() {
|
||
[ -d "$LOCAL_EXPORT" ] || return 1
|
||
[ -f "$LOCAL_EXPORT/.fingerprint" ] || return 1
|
||
[ -n "$PYTHON_BIN" ] || return 1
|
||
local current
|
||
current="$("$PYTHON_BIN" "$PACKAGING_DIR/build.py" --print-fingerprint 2>/dev/null || true)"
|
||
[ -n "$current" ] || return 1
|
||
[ "$(cat "$LOCAL_EXPORT/.fingerprint")" = "$current" ]
|
||
}
|
||
|
||
_rebuild_venvstacks_export() {
|
||
[ -n "$PYTHON_BIN" ] || die "python3 not found — install Python 3.11+ on PATH or set PYTHON_BIN."
|
||
log "Rebuilding venvstacks export (this may take 5–10 minutes)…"
|
||
"$PYTHON_BIN" "$PACKAGING_DIR/build.py" --venvstacks-only \
|
||
|| die "venvstacks rebuild failed; see output above."
|
||
[ -d "$LOCAL_EXPORT" ] || die "venvstacks rebuild reported success but $LOCAL_EXPORT is missing."
|
||
ok "Venvstacks export ready at $LOCAL_EXPORT"
|
||
}
|
||
|
||
_custom_kernel_deployment_target() {
|
||
printf "%s\n" "${OMLX_CUSTOM_KERNEL_DEPLOYMENT_TARGET:-${MACOSX_DEPLOYMENT_TARGET:-15.0}}"
|
||
}
|
||
|
||
_custom_kernel_pythonpath() {
|
||
[ -n "${DONOR_LAYERS:-}" ] || die "custom kernel build requires resolved donor layers."
|
||
local mlx_site="$DONOR_LAYERS/framework-mlx-base/lib/python3.11/site-packages"
|
||
[ -d "$mlx_site" ] || die "donor missing framework MLX site-packages: $mlx_site"
|
||
if [ -n "${PYTHONPATH:-}" ]; then
|
||
printf "%s:%s\n" "$mlx_site" "$PYTHONPATH"
|
||
else
|
||
printf "%s\n" "$mlx_site"
|
||
fi
|
||
}
|
||
|
||
_clean_custom_kernel_build_artifacts() {
|
||
local dir
|
||
for dir in "${CUSTOM_KERNEL_DIRS[@]}"; do
|
||
[ -d "$dir" ] || continue
|
||
find "$dir" -maxdepth 1 -type f \( \
|
||
-name "*.so" -o \
|
||
-name "*.dylib" -o \
|
||
-name "*.metallib" \
|
||
\) -delete
|
||
done
|
||
|
||
if [ -d "$REPO_ROOT/build" ]; then
|
||
for ext_name in \
|
||
"omlx.custom_kernels.glm_moe_dsa._ext" \
|
||
"omlx.custom_kernels.minimax_m3._ext" \
|
||
"omlx.custom_kernels.qwen35_prefill._ext"; do
|
||
find "$REPO_ROOT/build" \
|
||
-type d \
|
||
-name "$ext_name" \
|
||
-prune \
|
||
-exec rm -rf {} +
|
||
done
|
||
fi
|
||
}
|
||
|
||
_sdk_supports_nax() {
|
||
local sdk_version
|
||
sdk_version="$(xcrun -sdk macosx --show-sdk-version 2>/dev/null)" || return 1
|
||
[ "$(printf '%s\n26.2\n' "$sdk_version" | sort -V | head -1)" = "26.2" ]
|
||
}
|
||
|
||
_custom_kernel_minos() {
|
||
otool -l "$1" 2>/dev/null | awk '
|
||
/LC_BUILD_VERSION/ { in_build = 1 }
|
||
in_build && /minos/ { print $2; exit }
|
||
'
|
||
}
|
||
|
||
_validate_custom_kernel_deployment_target() {
|
||
local expected="$1"
|
||
local path
|
||
local minos
|
||
|
||
command -v otool >/dev/null 2>&1 || return 0
|
||
|
||
local dir
|
||
for dir in "${CUSTOM_KERNEL_DIRS[@]}"; do
|
||
for path in "$dir"/_ext*.so "$dir"/lib*.dylib; do
|
||
[ -e "$path" ] || continue
|
||
minos="$(_custom_kernel_minos "$path")"
|
||
[ -n "$minos" ] || die "could not read deployment target from $path."
|
||
[ "$minos" = "$expected" ] \
|
||
|| die "custom kernel $path has macOS min $minos, expected $expected."
|
||
done
|
||
done
|
||
}
|
||
|
||
_check_custom_kernel_nanobind() {
|
||
# setup.py build_ext runs without pip build isolation, so the pyproject
|
||
# [build-system] nanobind pin is NOT enforced here — the kernels are
|
||
# built with whatever nanobind $PYTHON_BIN resolves. A version that does
|
||
# not match the one the bundled mlx wheel was built with isolates the
|
||
# nanobind NB_DOMAIN: the extensions import and list symbols normally
|
||
# but reject every mlx array at call time (issue #2139).
|
||
local custom_kernel_pythonpath="$1"
|
||
local expected actual
|
||
expected="$(sed -n 's/^[[:space:]]*"nanobind==\([0-9][0-9.]*\)".*/\1/p' \
|
||
"$REPO_ROOT/pyproject.toml" | head -1)"
|
||
[ -n "$expected" ] || die "could not read the nanobind pin from pyproject.toml [build-system]."
|
||
actual="$(PYTHONPATH="$custom_kernel_pythonpath" "$PYTHON_BIN" -c \
|
||
'import nanobind; print(nanobind.__version__)' 2>/dev/null)" \
|
||
|| die "nanobind is not importable by $PYTHON_BIN — run: $PYTHON_BIN -m pip install nanobind==$expected"
|
||
[ "$actual" = "$expected" ] \
|
||
|| die "nanobind $actual does not match the pyproject build pin $expected; the kernels would reject every mlx array at runtime (issue #2139). Run: $PYTHON_BIN -m pip install nanobind==$expected"
|
||
}
|
||
|
||
_check_custom_kernel_abi() {
|
||
# Import each freshly built extension against the donor (bundled) mlx and
|
||
# exercise the array type caster once. A metallib existence check cannot
|
||
# catch a nanobind ABI mismatch — only an actual call does.
|
||
local custom_kernel_pythonpath="$1"
|
||
log "Verifying custom kernel ABI against the bundled MLX…"
|
||
(
|
||
cd "$REPO_ROOT"
|
||
PYTHONPATH="$custom_kernel_pythonpath" "$PYTHON_BIN" - <<'PYEOF'
|
||
import importlib.util
|
||
import pathlib
|
||
import sys
|
||
|
||
import mlx.core as mx
|
||
|
||
failures = []
|
||
for name in ("glm_moe_dsa", "minimax_m3", "qwen35_prefill"):
|
||
ext_dir = pathlib.Path("omlx/custom_kernels") / name
|
||
so = next(ext_dir.glob("_ext.*.so"), None)
|
||
if so is None:
|
||
failures.append(f"{name}: _ext extension missing")
|
||
continue
|
||
# Extension modules must load under their compiled name (PyInit__ext);
|
||
# CPython keys the extension cache by (name, path), so loading three
|
||
# different .so files as "_ext" is fine.
|
||
spec = importlib.util.spec_from_file_location("_ext", so)
|
||
ext = importlib.util.module_from_spec(spec)
|
||
try:
|
||
spec.loader.exec_module(ext)
|
||
except Exception as exc:
|
||
failures.append(f"{name}: import failed: {exc}")
|
||
continue
|
||
probe = getattr(ext, "abi_probe", None)
|
||
if probe is None:
|
||
failures.append(f"{name}: abi_probe symbol missing (stale build?)")
|
||
continue
|
||
try:
|
||
probe(mx.zeros((1,)))
|
||
except TypeError as exc:
|
||
failures.append(f"{name}: mlx array rejected (nanobind ABI mismatch): {exc}")
|
||
for failure in failures:
|
||
print(failure, file=sys.stderr)
|
||
sys.exit(1 if failures else 0)
|
||
PYEOF
|
||
) || die "custom kernel ABI check failed — the built extensions reject mlx arrays (issue #2139); see output above."
|
||
}
|
||
|
||
_build_custom_kernels() {
|
||
[ -n "$PYTHON_BIN" ] || die "python3 not found — install Python 3.11+ on PATH or set PYTHON_BIN."
|
||
local deployment_target
|
||
local cmake_args
|
||
local custom_kernel_pythonpath
|
||
deployment_target="$(_custom_kernel_deployment_target)"
|
||
cmake_args="${CMAKE_ARGS:-}"
|
||
custom_kernel_pythonpath="$(_custom_kernel_pythonpath)"
|
||
|
||
_check_custom_kernel_nanobind "$custom_kernel_pythonpath"
|
||
log "Building optional native custom kernels (macOS deployment target $deployment_target)…"
|
||
_clean_custom_kernel_build_artifacts
|
||
(
|
||
cd "$REPO_ROOT"
|
||
export PYTHONPATH="$custom_kernel_pythonpath"
|
||
export OMLX_CUSTOM_KERNEL_DEPLOYMENT_TARGET="$deployment_target"
|
||
export MACOSX_DEPLOYMENT_TARGET="${MACOSX_DEPLOYMENT_TARGET:-$deployment_target}"
|
||
if [[ "$cmake_args" != *"CMAKE_OSX_DEPLOYMENT_TARGET"* ]]; then
|
||
export CMAKE_ARGS="${cmake_args:+$cmake_args }-DCMAKE_OSX_DEPLOYMENT_TARGET=$deployment_target"
|
||
fi
|
||
"$PYTHON_BIN" setup.py build_ext --inplace --force --with-custom-kernel
|
||
) || die "custom kernel build failed; see output above."
|
||
[ -f "$REPO_ROOT/omlx/custom_kernels/glm_moe_dsa/omlx_glm_kernels.metallib" ] \
|
||
|| die "custom kernel build finished but GLM metallib is missing."
|
||
[ -f "$REPO_ROOT/omlx/custom_kernels/minimax_m3/omlx_minimax_m3_kernels.metallib" ] \
|
||
|| die "custom kernel build finished but MiniMax M3 metallib is missing."
|
||
[ -f "$REPO_ROOT/omlx/custom_kernels/qwen35_prefill/omlx_qwen35_prefill_kernels.metallib" ] \
|
||
|| die "custom kernel build finished but Qwen3.5 prefill metallib is missing."
|
||
# The NAX (M5 tensor unit) metallib is SDK-gated in cmake, not
|
||
# deployment-gated: any build on SDK 26.2+ must produce it. A silent
|
||
# omission would ship DMGs whose M5 qmm quietly falls back to the
|
||
# classic kernels (same failure shape as issue #2137).
|
||
if _sdk_supports_nax; then
|
||
[ -f "$REPO_ROOT/omlx/custom_kernels/qwen35_prefill/omlx_qwen35_prefill_kernels_nax.metallib" ] \
|
||
|| die "custom kernel build finished but the Qwen3.5 NAX metallib is missing despite SDK $(xcrun -sdk macosx --show-sdk-version) supporting it; see the cmake output."
|
||
else
|
||
warn "macOS SDK < 26.2: building without the Qwen3.5 NAX metallib; M5 GPUs will use the classic kernels."
|
||
fi
|
||
_validate_custom_kernel_deployment_target "$deployment_target"
|
||
_check_custom_kernel_abi "$custom_kernel_pythonpath"
|
||
ok " + custom kernels ($deployment_target)"
|
||
}
|
||
|
||
resolve_donor_layers() {
|
||
# Fast Swift rebuild mode reuses an already-built venvstacks export.
|
||
# It intentionally refuses to rebuild or fall back to /Applications.
|
||
if [ "$SWIFT_REBUILD" -eq 1 ]; then
|
||
if [ ! -d "$LOCAL_EXPORT" ]; then
|
||
die "Swift rebuild requires existing venvstacks export at $LOCAL_EXPORT. Run build.sh release first."
|
||
fi
|
||
DONOR_LAYERS="$LOCAL_EXPORT"
|
||
DONOR_SOURCE="$LOCAL_EXPORT (swift rebuild, no venvstacks rebuild)"
|
||
_local_export_fresh \
|
||
|| warn "Local export fingerprint mismatch; using stale layers (swift rebuild)."
|
||
return
|
||
fi
|
||
|
||
# Explicit OMLX_DONOR_APP override → use it, skip rebuild logic entirely.
|
||
if [ -n "$OMLX_DONOR_APP_SET" ]; then
|
||
[ -d "$OMLX_DONOR_APP" ] || die "OMLX_DONOR_APP set but not found: $OMLX_DONOR_APP"
|
||
DONOR_LAYERS="$(_app_python_layers_path "$OMLX_DONOR_APP")" \
|
||
|| die "OMLX_DONOR_APP missing bundled Python layers: $OMLX_DONOR_APP"
|
||
DONOR_SOURCE="OMLX_DONOR_APP=$OMLX_DONOR_APP"
|
||
return
|
||
fi
|
||
|
||
case "$REBUILD_DONOR" in
|
||
force)
|
||
_rebuild_venvstacks_export
|
||
DONOR_LAYERS="$LOCAL_EXPORT"
|
||
DONOR_SOURCE="$LOCAL_EXPORT (forced rebuild)"
|
||
;;
|
||
never)
|
||
if [ -d "$LOCAL_EXPORT" ]; then
|
||
DONOR_LAYERS="$LOCAL_EXPORT"
|
||
DONOR_SOURCE="$LOCAL_EXPORT (no rebuild)"
|
||
_local_export_fresh \
|
||
|| warn "Local export fingerprint mismatch; using stale layers (--no-rebuild-donor)."
|
||
elif [ -d "$OMLX_DONOR_APP" ]; then
|
||
DONOR_LAYERS="$(_app_python_layers_path "$OMLX_DONOR_APP")" \
|
||
|| die "Fallback donor missing bundled Python layers: $OMLX_DONOR_APP"
|
||
DONOR_SOURCE="$OMLX_DONOR_APP (fallback, --no-rebuild-donor)"
|
||
else
|
||
die "No donor available: $LOCAL_EXPORT and $OMLX_DONOR_APP both missing, --no-rebuild-donor prevents rebuild."
|
||
fi
|
||
;;
|
||
auto)
|
||
if _local_export_fresh; then
|
||
DONOR_LAYERS="$LOCAL_EXPORT"
|
||
DONOR_SOURCE="$LOCAL_EXPORT (cached, fingerprint match)"
|
||
else
|
||
if [ -d "$LOCAL_EXPORT" ]; then
|
||
log "Local export is stale (fingerprint mismatch) — rebuilding."
|
||
else
|
||
log "Local export missing — building."
|
||
fi
|
||
_rebuild_venvstacks_export
|
||
DONOR_LAYERS="$LOCAL_EXPORT"
|
||
DONOR_SOURCE="$LOCAL_EXPORT (rebuilt)"
|
||
fi
|
||
;;
|
||
esac
|
||
}
|
||
|
||
# --- Derive bundle version from omlx/_version.py --------------------------
|
||
#
|
||
# omlx/_version.py is the canonical source — pyproject.toml reads it via
|
||
# `[tool.setuptools.dynamic] version = {attr = ...}`. Mirror that into the
|
||
# Swift bundle so CFBundleShortVersionString (MARKETING_VERSION) tracks
|
||
# the Python package without a second hand-maintained string.
|
||
#
|
||
# CURRENT_PROJECT_VERSION uses `git rev-list --count HEAD` so each commit
|
||
# gives a monotonically-increasing CFBundleVersion — enough for the
|
||
# GitHub Releases updater's version comparisons.
|
||
#
|
||
# If either lookup fails the script bails rather than silently shipping
|
||
# stale numbers; falling back to the pbxproj placeholders would mask a
|
||
# real regression.
|
||
|
||
VERSION_FILE="$REPO_ROOT/omlx/_version.py"
|
||
[ -f "$VERSION_FILE" ] || die "missing $VERSION_FILE — cannot derive bundle version"
|
||
APP_VERSION=$(grep -oE '__version__[[:space:]]*=[[:space:]]*"[^"]+"' "$VERSION_FILE" | \
|
||
sed -E 's/.*"([^"]+)".*/\1/')
|
||
[ -n "$APP_VERSION" ] || die "could not parse __version__ from $VERSION_FILE"
|
||
BUILD_NUMBER=$(git -C "$REPO_ROOT" rev-list --count HEAD 2>/dev/null || echo 1)
|
||
log "Bundle version: $APP_VERSION (build $BUILD_NUMBER) — from omlx/_version.py"
|
||
|
||
# --- xcodebuild -----------------------------------------------------------
|
||
|
||
log "Building oMLX ($CONFIG)…"
|
||
mkdir -p "$BUILD_DIR"
|
||
|
||
log "Resolving Swift package dependencies…"
|
||
xcodebuild -resolvePackageDependencies \
|
||
-project "$PROJECT_DIR/oMLX.xcodeproj" \
|
||
-scheme oMLX \
|
||
>"$BUILD_DIR/spm-resolve.log" 2>&1 \
|
||
|| warn "SPM resolve emitted warnings; continuing with existing Package.resolved (see $BUILD_DIR/spm-resolve.log)."
|
||
|
||
xcodebuild \
|
||
-project "$PROJECT_DIR/oMLX.xcodeproj" \
|
||
-scheme oMLX \
|
||
-configuration "$CONFIG" \
|
||
-destination 'platform=macOS' \
|
||
-derivedDataPath "$BUILD_DIR" \
|
||
CODE_SIGN_IDENTITY="-" \
|
||
CODE_SIGNING_REQUIRED=NO \
|
||
CODE_SIGNING_ALLOWED=NO \
|
||
MARKETING_VERSION="$APP_VERSION" \
|
||
CURRENT_PROJECT_VERSION="$BUILD_NUMBER" \
|
||
build >"$BUILD_DIR/xcodebuild.log" 2>&1 \
|
||
|| { tail -40 "$BUILD_DIR/xcodebuild.log" >&2; die "xcodebuild failed; full log: $BUILD_DIR/xcodebuild.log"; }
|
||
|
||
XCODE_APP="$BUILD_DIR/Build/Products/$CONFIG/oMLX.app"
|
||
[ -d "$XCODE_APP" ] || die "Expected $XCODE_APP — check build log."
|
||
ok "Built $XCODE_APP"
|
||
|
||
# --- Stage --------------------------------------------------------------
|
||
|
||
mkdir -p "$OUTPUT_DIR"
|
||
STAGED_APP="$OUTPUT_DIR/oMLX.app"
|
||
|
||
log "Staging bundle at $STAGED_APP"
|
||
rm -rf "$STAGED_APP"
|
||
ditto "$XCODE_APP" "$STAGED_APP"
|
||
|
||
if [ "$BARE" -eq 1 ]; then
|
||
warn "--bare set: skipping Python embed. The server will fail to spawn."
|
||
ok "Bundle ready: $STAGED_APP"
|
||
exit 0
|
||
fi
|
||
|
||
RESOURCES_DIR="$STAGED_APP/Contents/Resources"
|
||
PYTHON_DIR="$RESOURCES_DIR/Python"
|
||
mkdir -p "$PYTHON_DIR" "$RESOURCES_DIR"
|
||
|
||
# --- Embed Python layers --------------------------------------------------
|
||
|
||
resolve_donor_layers
|
||
log "Using donor: $DONOR_SOURCE"
|
||
[ -d "$DONOR_LAYERS/cpython-3.11" ] || die "Donor missing cpython-3.11 at $DONOR_LAYERS"
|
||
[ -d "$DONOR_LAYERS/framework-mlx-base" ] || die "Donor missing framework-mlx-base at $DONOR_LAYERS"
|
||
|
||
log "Copying cpython-3.11 from donor…"
|
||
ditto "$DONOR_LAYERS/cpython-3.11" "$PYTHON_DIR/cpython-3.11"
|
||
ok " + cpython-3.11"
|
||
|
||
log "Copying framework-mlx-base from donor (~1 GB)…"
|
||
ditto "$DONOR_LAYERS/framework-mlx-base" "$PYTHON_DIR/framework-mlx-base"
|
||
ok " + framework-mlx-base"
|
||
|
||
if [ -d "$DONOR_LAYERS/__venvstacks__" ]; then
|
||
ditto "$DONOR_LAYERS/__venvstacks__" "$PYTHON_DIR/__venvstacks__"
|
||
ok " + __venvstacks__ metadata"
|
||
fi
|
||
|
||
# --- Embed omlx package ---------------------------------------------------
|
||
|
||
if [ "$WITH_CUSTOM_KERNEL" = "1" ]; then
|
||
_build_custom_kernels
|
||
fi
|
||
|
||
log "Copying omlx package from source tree…"
|
||
rm -rf "$RESOURCES_DIR/omlx"
|
||
mkdir -p "$RESOURCES_DIR/omlx"
|
||
# rsync gives us per-tree exclude semantics that ditto lacks.
|
||
RSYNC_EXCLUDES=(
|
||
--exclude='__pycache__'
|
||
--exclude='*.pyc'
|
||
--exclude='tests'
|
||
--exclude='.git'
|
||
--exclude='custom_kernels/*/csrc'
|
||
)
|
||
if [ "$WITH_CUSTOM_KERNEL" != "1" ]; then
|
||
RSYNC_EXCLUDES+=(
|
||
--exclude='custom_kernels/*/*.so'
|
||
--exclude='custom_kernels/*/*.dylib'
|
||
--exclude='custom_kernels/*/*.metallib'
|
||
)
|
||
fi
|
||
rsync -a \
|
||
"${RSYNC_EXCLUDES[@]}" \
|
||
"$REPO_ROOT/omlx/" "$RESOURCES_DIR/omlx/"
|
||
ok " + omlx package"
|
||
|
||
log "Writing engine commit metadata..."
|
||
"$PYTHON_BIN" "$PACKAGING_DIR/build.py" --write-engine-commits "$RESOURCES_DIR/omlx" \
|
||
|| die "failed to write engine commit metadata."
|
||
ok " + _engine_commits.json"
|
||
|
||
# --- Embed CLI wrapper ----------------------------------------------------
|
||
|
||
log "Writing app-bundle CLI wrapper..."
|
||
CLI_WRAPPER="$STAGED_APP/Contents/MacOS/omlx-cli"
|
||
cat > "$CLI_WRAPPER" <<'EOF'
|
||
#!/bin/sh
|
||
set -eu
|
||
|
||
REAL_PATH="$(realpath "$0")"
|
||
APP_ROOT="$(CDPATH= cd -- "$(dirname -- "$REAL_PATH")/.." && pwd)"
|
||
RESOURCES="$APP_ROOT/Resources"
|
||
PYROOT="$RESOURCES/Python"
|
||
CPYTHON="$PYROOT/cpython-3.11"
|
||
PYTHON="$CPYTHON/bin/python3"
|
||
MLX_SITE="$PYROOT/framework-mlx-base/lib/python3.11/site-packages"
|
||
|
||
export PYTHONHOME="$CPYTHON"
|
||
export PYTHONDONTWRITEBYTECODE=1
|
||
if [ -n "${PYTHONPATH:-}" ]; then
|
||
export PYTHONPATH="$RESOURCES:$MLX_SITE:$PYTHONPATH"
|
||
else
|
||
export PYTHONPATH="$RESOURCES:$MLX_SITE"
|
||
fi
|
||
|
||
exec "$PYTHON" -m omlx.cli "$@"
|
||
EOF
|
||
chmod 755 "$CLI_WRAPPER"
|
||
ok " + omlx-cli"
|
||
|
||
# --- Compile AppIcon.icon (Tahoe Liquid Glass) ----------------------------
|
||
#
|
||
# Xcode 26.5's project build system does NOT route a standalone
|
||
# `Resources/AppIcon.icon` bundle into the actool invocation for macOS
|
||
# targets (despite folder.iconcomposer.icon being a declared input file
|
||
# type in the AssetCatalogCompiler.xcspec). The icon ends up missing from
|
||
# the bundle entirely.
|
||
#
|
||
# Workaround: invoke actool ourselves with both the asset catalog AND the
|
||
# .icon bundle as positional inputs. The result is a real AppIcon.icns +
|
||
# enriched Assets.car that macOS 26's Liquid Glass icon system picks up
|
||
# natively — no system tile wrap around our brand mark.
|
||
#
|
||
# We also patch the staged Info.plist with CFBundleIconName +
|
||
# CFBundleIconFile since the actool partial-info-plist isn't merged into
|
||
# the final plist by the time we're staging.
|
||
|
||
ICON_BUNDLE="$REPO_ROOT/apps/omlx-mac/Resources/AppIcon.icon"
|
||
XCASSETS_DIR="$REPO_ROOT/apps/omlx-mac/Resources/Assets.xcassets"
|
||
INFO_PLIST="$STAGED_APP/Contents/Info.plist"
|
||
|
||
if [ -d "$ICON_BUNDLE" ] && [ -d "$XCASSETS_DIR" ]; then
|
||
log "Compiling AppIcon.icon via actool…"
|
||
ACTOOL=/Applications/Xcode.app/Contents/Developer/usr/bin/actool
|
||
ICON_TMP=$(mktemp -d)
|
||
if "$ACTOOL" \
|
||
"$XCASSETS_DIR" \
|
||
"$ICON_BUNDLE" \
|
||
--compile "$ICON_TMP" \
|
||
--app-icon AppIcon \
|
||
--target-device mac \
|
||
--minimum-deployment-target 26.0 \
|
||
--platform macosx \
|
||
--bundle-identifier app.omlx \
|
||
--output-format human-readable-text \
|
||
--output-partial-info-plist "$ICON_TMP/icon.plist" \
|
||
>/dev/null 2>&1; then
|
||
cp "$ICON_TMP/AppIcon.icns" "$RESOURCES_DIR/AppIcon.icns"
|
||
cp "$ICON_TMP/Assets.car" "$RESOURCES_DIR/Assets.car"
|
||
/usr/libexec/PlistBuddy \
|
||
-c "Delete :CFBundleIconName" "$INFO_PLIST" 2>/dev/null || true
|
||
/usr/libexec/PlistBuddy \
|
||
-c "Add :CFBundleIconName string AppIcon" "$INFO_PLIST"
|
||
/usr/libexec/PlistBuddy \
|
||
-c "Delete :CFBundleIconFile" "$INFO_PLIST" 2>/dev/null || true
|
||
/usr/libexec/PlistBuddy \
|
||
-c "Add :CFBundleIconFile string AppIcon" "$INFO_PLIST"
|
||
ok " + AppIcon.icns + Assets.car (Tahoe icon-composer)"
|
||
else
|
||
warn "actool merge of AppIcon.icon failed; bundle ships legacy icon"
|
||
fi
|
||
rm -rf "$ICON_TMP"
|
||
fi
|
||
|
||
# --- Re-sign ad-hoc -------------------------------------------------------
|
||
#
|
||
# Even with CODE_SIGNING_ALLOWED=NO during xcodebuild, the staged bundle must
|
||
# have a coherent app-bundle signature. Sign embedded native Python/MLX
|
||
# libraries first, then seal the outer app bundle so TCC can match Full Disk
|
||
# Access grants against the app identity instead of a bare linker signature.
|
||
|
||
if [ "$SKIP_EMBEDDED_SIGN" -eq 1 ]; then
|
||
warn "swift-fast set: skipping embedded native code signing."
|
||
else
|
||
log "Ad-hoc signing embedded native code…"
|
||
_sign_embedded_mach_o_files "$PYTHON_DIR"
|
||
codesign --force --sign - "$CLI_WRAPPER" >/dev/null 2>&1
|
||
ok " + signed omlx-cli wrapper"
|
||
fi
|
||
|
||
log "Ad-hoc resigning app bundle…"
|
||
codesign --force --sign - "$STAGED_APP"
|
||
|
||
# Drop quarantine attributes so the bundle launches from anywhere.
|
||
xattr -dr com.apple.quarantine "$STAGED_APP" 2>/dev/null || true
|
||
|
||
# --- Done ----------------------------------------------------------------
|
||
|
||
ok "Done."
|
||
echo
|
||
echo "Bundle ready:"
|
||
echo " $STAGED_APP"
|
||
echo
|
||
echo "To launch:"
|
||
echo " open '$STAGED_APP'"
|
||
echo
|
||
echo "Server log will appear at:"
|
||
echo " ~/Library/Application Support/oMLX/logs/server.log"
|