From f7b9d74d5e7932ee35bfe6cfd694b0fbbf38fc86 Mon Sep 17 00:00:00 2001 From: Ivan Date: Sun, 3 May 2026 22:04:10 -0500 Subject: [PATCH] feat(build): add script to thin Mach-O binaries and improve macOS universal build process --- scripts/build-macos-universal.sh | 16 +++--- scripts/ci/github-install-deps.sh | 73 ++++++++++++++++++++----- scripts/thin-backend-mach-o.sh | 90 +++++++++++++++++++++++++++++++ 3 files changed, 160 insertions(+), 19 deletions(-) create mode 100755 scripts/thin-backend-mach-o.sh diff --git a/scripts/build-macos-universal.sh b/scripts/build-macos-universal.sh index c4123fe..7a141c8 100644 --- a/scripts/build-macos-universal.sh +++ b/scripts/build-macos-universal.sh @@ -2,6 +2,14 @@ # Build darwin-arm64 and darwin-x64 cx_Freeze backends, then electron-builder --mac --universal. # On Apple Silicon, the x64 backend must be built with an x86_64 Python (e.g. Homebrew in /usr/local). # Set PYTHON_CMD_X64 to that interpreter if Poetry's default env is arm64-only. +# +# Optional env vars: +# MESHCHATX_MAC_UNIVERSAL_STRIP_AUDIO=1 Drop _miniaudio.abi3.so from both per-arch +# backend trees before lipo. Use this when a +# universal2 miniaudio wheel cannot be coerced +# into a single-arch build on a given runner. +# Audio decode falls back to wave + LXST/pyogg. +# MESHCHATX_FRONTEND_PREBUILT=1 Reuse meshchatx/public/ instead of rebuilding. set -euo pipefail ROOT="$(cd "$(dirname "$0")/.." && pwd)" @@ -46,12 +54,8 @@ else cross-env ARCH=x64 pnpm run build-backend fi -# @electron/universal v2.x checks SHA equality for every non-Mach-O file -# and throws if any differ (no x64ArchFiles escape for PLAIN files). -# cx_Freeze's library.zip contains only architecture-independent Python -# bytecode; the per-arch native extensions (.dylib/.so) live outside the -# zip. The two zips differ solely in .pyc header timestamps and zip -# metadata, so copying one over the other is safe and makes the merge pass. +bash scripts/thin-backend-mach-o.sh + bash scripts/unify-backend-plain-files.sh exec pnpm exec electron-builder --mac --universal --publish=never diff --git a/scripts/ci/github-install-deps.sh b/scripts/ci/github-install-deps.sh index 6c3424a..a1fc9b2 100755 --- a/scripts/ci/github-install-deps.sh +++ b/scripts/ci/github-install-deps.sh @@ -32,11 +32,56 @@ python -m poetry check --lock python -m poetry install --no-interaction --no-ansi python -m poetry run python scripts/patch_lxst_pyogg_ogg_ctypes.py -# Python 3.14 may install miniaudio from sdist; a mis-linked x86_64-only -# _miniaudio.abi3.so in the arm64 cx_Freeze tree differs from the x64 slice and -# breaks @electron/universal (lipo cannot merge two x86_64-only Mach-O files). -if [[ "$(uname -s)" == "Darwin" && "$(uname -m)" == "arm64" ]]; then - if ! poetry run python -c " +if [[ "$(uname -s)" == "Darwin" ]]; then + if poetry run python -c "import platform, sys; sys.exit(0 if platform.machine() == 'arm64' else 1)"; then + _miniaudio_state="$(poetry run python -c " +import importlib.util +import pathlib +import subprocess +import sys + +spec = importlib.util.find_spec('miniaudio') +if not spec or not spec.origin: + print('missing') + sys.exit(0) +so = pathlib.Path(spec.origin).resolve().parent / '_miniaudio.abi3.so' +if not so.is_file(): + print('missing') + sys.exit(0) +out = subprocess.check_output(['file', str(so)], text=True) +has_arm = 'arm64' in out +has_x86 = 'x86_64' in out +if not has_arm and has_x86: + print('x86only') +elif has_arm and has_x86: + print('universal') +elif has_arm and not has_x86: + print('arm64only') +else: + print('unknown') +" 2>/dev/null || echo "missing")" + case "$_miniaudio_state" in + x86only) + echo "miniaudio _miniaudio.abi3.so is x86_64-only on arm64 venv; rebuilding from source." >&2 + _need_rebuild=1 + ;; + universal) + echo "miniaudio _miniaudio.abi3.so is universal2; rebuilding as arm64-only so @electron/universal can lipo with the x64 slice." >&2 + _need_rebuild=1 + ;; + *) + _need_rebuild=0 + ;; + esac + if [[ "${_need_rebuild:-0}" == "1" ]]; then + ( + export ARCHFLAGS="-arch arm64" + export CFLAGS="-arch arm64" + export CXXFLAGS="-arch arm64" + poetry run python -m pip install --force-reinstall --no-cache-dir --no-binary miniaudio "miniaudio>=1.70,<2" + ) + fi + if ! poetry run python -c " import importlib.util import pathlib import subprocess @@ -49,17 +94,19 @@ so = pathlib.Path(spec.origin).resolve().parent / '_miniaudio.abi3.so' if not so.is_file(): sys.exit(0) out = subprocess.check_output(['file', str(so)], text=True) -if 'x86_64' in out and 'arm64' not in out: +if 'arm64' not in out: + sys.stderr.write(out) sys.exit(1) sys.exit(0) "; then - echo "Rebuilding miniaudio for arm64 (was x86_64-only)." >&2 - ( - export ARCHFLAGS="-arch arm64" - export CFLAGS="-arch arm64 ${CFLAGS:-}" - export CXXFLAGS="-arch arm64 ${CXXFLAGS:-}" - poetry run python -m pip install --force-reinstall --no-cache-dir --no-binary miniaudio "miniaudio>=1.70,<2" - ) + if [[ "${MESHCHATX_MAC_UNIVERSAL_STRIP_AUDIO:-0}" == "1" ]]; then + echo "miniaudio native extension is not arm64-capable, but MESHCHATX_MAC_UNIVERSAL_STRIP_AUDIO=1 is set; continuing (the build will drop _miniaudio.abi3.so before lipo)." >&2 + else + echo "miniaudio native extension is not arm64-capable; universal macOS builds will fail at lipo." >&2 + echo "Re-run with MESHCHATX_MAC_UNIVERSAL_STRIP_AUDIO=1 to drop optional audio decoding for the DMG." >&2 + exit 1 + fi + fi fi fi diff --git a/scripts/thin-backend-mach-o.sh b/scripts/thin-backend-mach-o.sh new file mode 100755 index 0000000..f99c856 --- /dev/null +++ b/scripts/thin-backend-mach-o.sh @@ -0,0 +1,90 @@ +#!/usr/bin/env bash + +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" + +ARM64_DIR="$ROOT/build/exe/darwin-arm64" +X64_DIR="$ROOT/build/exe/darwin-x64" + +if [[ ! -d "$ARM64_DIR" || ! -d "$X64_DIR" ]]; then + echo "thin-backend: one or both backend dirs missing, skipping" + exit 0 +fi + +if [[ "$(uname -s)" != "Darwin" ]]; then + echo "thin-backend: not running on macOS (uname=$(uname -s)); skipping" + exit 0 +fi + +if ! command -v lipo >/dev/null 2>&1; then + echo "thin-backend: lipo not found on PATH; skipping" >&2 + exit 0 +fi + +if ! command -v file >/dev/null 2>&1; then + echo "thin-backend: file(1) not found on PATH; skipping" >&2 + exit 0 +fi + +STRIP_AUDIO="${MESHCHATX_MAC_UNIVERSAL_STRIP_AUDIO:-0}" + +drop_audio_natives() { + local tree="$1" + local removed=0 + while IFS= read -r -d '' f; do + echo "thin-backend: stripping audio native (MESHCHATX_MAC_UNIVERSAL_STRIP_AUDIO=1): ${f#"$tree"/}" >&2 + rm -f "$f" + removed=$((removed + 1)) + done < <(find "$tree" -type f \( -name "_miniaudio*.so" -o -name "_miniaudio*.dylib" \) -print0) + if [[ $removed -gt 0 ]]; then + echo "thin-backend: removed $removed _miniaudio file(s) from ${tree#"$ROOT"/}" + fi +} + +if [[ "$STRIP_AUDIO" == "1" || "$STRIP_AUDIO" == "true" ]]; then + drop_audio_natives "$ARM64_DIR" + drop_audio_natives "$X64_DIR" +fi + +thin_tree() { + local tree="$1" want_arch="$2" + local thinned=0 already=0 skipped=0 + while IFS= read -r -d '' f; do + local ft + ft=$(file --brief --no-pad "$f" 2>/dev/null || true) + if [[ "$ft" != Mach-O* ]]; then + continue + fi + if [[ "$ft" != *universal* ]]; then + already=$((already + 1)) + continue + fi + local archs + archs=$(lipo -archs "$f" 2>/dev/null || true) + if [[ -z "$archs" ]]; then + skipped=$((skipped + 1)) + continue + fi + if ! grep -qw "$want_arch" <<<"$archs"; then + echo "thin-backend: WARNING: $f is universal but lacks $want_arch (archs=$archs); leaving as-is" >&2 + skipped=$((skipped + 1)) + continue + fi + local tmp + tmp="$(mktemp -t thin-backend.XXXXXX)" + if lipo -thin "$want_arch" "$f" -output "$tmp" 2>/dev/null; then + cat "$tmp" >"$f" + rm -f "$tmp" + thinned=$((thinned + 1)) + else + rm -f "$tmp" + echo "thin-backend: WARNING: lipo -thin $want_arch failed on $f" >&2 + skipped=$((skipped + 1)) + fi + done < <(find "$tree" -type f \( -name "*.so" -o -name "*.dylib" -o -name "*.bundle" \) -print0) + echo "thin-backend: ${tree#"$ROOT"/} -> $want_arch (thinned=$thinned, already-single=$already, skipped=$skipped)" +} + +thin_tree "$ARM64_DIR" arm64 +thin_tree "$X64_DIR" x86_64