From 03c7575d98b320c28df51be249d9d502564fda3d Mon Sep 17 00:00:00 2001 From: Amir Ar <275349426+theZebco@users.noreply.github.com> Date: Fri, 23 Oct 2026 04:06:37 +0330 Subject: [PATCH] =?UTF-8?q?build:=20Apple=20cores=20again=20=E2=80=94=20th?= =?UTF-8?q?e=20iOS/macOS=20xcframework=20and=20a=20macOS=20dylib?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The release carries libxray-apple-cgo.zip again (upstream's static LibXray.xcframework: iOS 15+, macOS 12+, tvOS 17+) and libxray-macos-dylib.zip: a universal c-shared libXray.dylib for apps that load libXray over FFI, as with the Windows DLL and the Linux .so, built like the Apple build's macOS slices (macOS 12, same SDK and flags) and with an @rpath install name. Both run on the stable macos-26 image. Each checks what it built: the dylib per architecture (Go 1.27.3, the v26.10.21 core), both architectures present, minos 12.0, CGoInvoke/CGoFree exported; the xcframework's iOS, simulator and macOS slices exist and export CGoInvoke. --- .github/workflows/build.yml | 53 +++++++++++++++--- build/app/macos.py | 105 ++++++++++++++++++++++++++++++++++++ build/main.py | 8 +++ build/test_build.py | 4 +- build/test_verify_core.py | 6 +++ build/verify_core.py | 20 +++++++ 6 files changed, 189 insertions(+), 7 deletions(-) create mode 100644 build/app/macos.py diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 650296d7..74b2d993 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -19,9 +19,10 @@ jobs: fail-fast: false matrix: include: - # Only what Cloakify ships: a failed leg blocks the release. The - # per-target steps below stay as upstream wrote them, so the other - # targets come back by adding their entries. + # Only what Cloakify ships or will: a failed leg blocks the release. + # The per-target steps below stay as upstream wrote them, so the + # other targets come back by adding their entries. Apple runs on a + # stable macOS image, not upstream's preview one. - runner: ubuntu-24.04 target: linux arch: x64 @@ -34,6 +35,12 @@ jobs: arch: x64 goarch: amd64 artifact: windows-x64 + - runner: macos-26 + target: apple + artifact: apple-cgo + - runner: macos-26 + target: macos + artifact: macos-dylib runs-on: ${{ matrix.runner }} timeout-minutes: 60 @@ -137,7 +144,7 @@ jobs: # Apple toolchain # ========================= - name: Select Xcode - if: matrix.target == 'apple' + if: matrix.target == 'apple' || matrix.target == 'macos' uses: maxim-lobanov/setup-xcode@v1 with: xcode-version: latest-stable @@ -227,11 +234,19 @@ jobs: run: | python3 build/main.py apple go + # A c-shared dylib for apps that load libXray over FFI, as on Windows + # and Linux (build/app/macos.py). + - name: Build macOS dylib (FFI) + if: matrix.target == 'macos' + run: | + python3 build/main.py macos + # The certificate fix rides on both the core and the toolchain: check # what was built, not what the runner says it has. A clean Windows 10 - # has no C++ runtime, so the DLL must not import one. + # has no C++ runtime, so the DLL must not import one. The xcframework's + # static libraries carry no build information for `go version -m`; + # their toolchain is go.mod's floor, which the go command enforces. - name: Verify the built core - if: matrix.target == 'linux' || matrix.target == 'windows' || matrix.target == 'android' shell: bash run: | set -e @@ -243,6 +258,27 @@ jobs: python build/verify_core.py --no-cxx-runtime windows_dll/libXray.dll ;; android) python build/verify_core.py libXray.aar ;; + macos) + lib=macos_dylib/libXray.dylib + python build/verify_core.py "$lib" + for arch in arm64 x86_64; do + lipo "$lib" -verify_arch "$arch" || { echo "$lib: no $arch"; exit 1; } + # macOS 12, as build/app/macos.py and the Apple build's macOS slices. + minos="$(otool -arch "$arch" -l "$lib" | awk '/LC_BUILD_VERSION/ {f=1} f && $1 == "minos" {print $2; exit}')" + [ "$minos" = "12.0" ] || { echo "$lib ($arch): minos $minos, expected 12.0"; exit 1; } + done + for sym in _CGoInvoke _CGoFree; do + nm -gU "$lib" | grep -q " T $sym\$" || { echo "$lib: $sym not exported"; exit 1; } + done + otool -D "$lib" + ;; + apple) + for slice in ios-arm64 ios-arm64_x86_64-simulator macos-arm64_x86_64; do + lib="LibXray.xcframework/$slice/libXray.a" + [ -f "$lib" ] || { echo "missing $lib"; ls LibXray.xcframework; exit 1; } + nm -gU "$lib" 2>/dev/null | grep -q " T _CGoInvoke\$" || { echo "$lib: _CGoInvoke not exported"; exit 1; } + done + ;; esac # ========================= @@ -275,6 +311,11 @@ jobs: mkdir -p "dist/${{ matrix.artifact }}" cp -R LibXray.xcframework "dist/${{ matrix.artifact }}/" ;; + macos) + mkdir -p "dist/${{ matrix.artifact }}" + cp macos_dylib/libXray.dylib "dist/${{ matrix.artifact }}/" + cp macos_dylib/libXray.h "dist/${{ matrix.artifact }}/" + ;; esac # ========================= # Upload artifacts diff --git a/build/app/macos.py b/build/app/macos.py new file mode 100644 index 00000000..aa9e1609 --- /dev/null +++ b/build/app/macos.py @@ -0,0 +1,105 @@ +import os.path +import subprocess + +from app.build import Builder +from app.cmd import create_dir_if_not_exists, delete_dir_if_exists + + +class MacOSBuilder(Builder): + """A c-shared libXray.dylib, universal (arm64 + x86_64), for desktop apps + that drive libXray over plain FFI as they do the Windows DLL and the Linux + .so — the Apple build makes static xcframeworks only. Same SDK, minimum + version and flags as the Apple build's macOS slices.""" + + # As AppleGoBuilder's macOS targets. + min_version = "12.0" + arches = (("arm64", "arm64"), ("amd64", "x86_64")) + + def __init__( + self, + build_dir: str, + use_local_xray_core: bool = False, + keep_go_env_snapshot: bool = True, + ): + super().__init__(build_dir, use_local_xray_core, keep_go_env_snapshot) + self.framework_dir = os.path.join(self.lib_dir, "macos_dylib") + delete_dir_if_exists(self.framework_dir) + create_dir_if_not_exists(self.framework_dir) + self.lib_file = "libXray.dylib" + self.lib_header_file = "libXray.h" + + def before_build(self): + super().before_build() + self.prepare_static_lib() + + def build(self): + self.snapshot_go_env() + try: + self.before_build() + self.build_macos() + finally: + self.restore_go_env() + + def build_macos(self): + sdk_path = self.get_sdk_dir_path() + arch_files = [self.build_arch(go_arch, apple_arch, sdk_path) for go_arch, apple_arch in self.arches] + + universal_file = os.path.join(self.framework_dir, self.lib_file) + self.run(["lipo", "-create", *arch_files, "-output", universal_file], "lipo -create") + # Go names the library after its build path; apps load it from their + # own bundle. + self.run(["install_name_tool", "-id", f"@rpath/{self.lib_file}", universal_file], "install_name_tool") + + # One header (the same for every arch); the per-arch files go. + os.replace( + arch_files[0].replace(".dylib", ".h"), + os.path.join(self.framework_dir, self.lib_header_file), + ) + for arch_file in arch_files: + os.remove(arch_file) + header = arch_file.replace(".dylib", ".h") + if os.path.exists(header): + os.remove(header) + + def build_arch(self, go_arch: str, apple_arch: str, sdk_path: str) -> str: + output_file = os.path.join(self.framework_dir, f"libXray-{go_arch}.dylib") + flags = f"-isysroot {sdk_path} -mmacosx-version-min={self.min_version} -arch {apple_arch}" + run_env = os.environ.copy() + run_env["GOOS"] = "darwin" + run_env["GOARCH"] = go_arch + run_env["GOFLAGS"] = "-tags=darwin" + run_env["CC"] = "xcrun --sdk macosx --toolchain macosx clang" + run_env["CXX"] = "xcrun --sdk macosx --toolchain macosx clang++" + run_env["CGO_CFLAGS"] = flags + run_env["CGO_CXXFLAGS"] = flags + run_env["CGO_LDFLAGS"] = flags + run_env["CGO_ENABLED"] = "1" + + cmd = [ + "go", + "build", + "-trimpath", + "-ldflags", + "-s -w -buildid=", + f"-o={output_file}", + "-buildmode=c-shared", + self.main_package(), + ] + os.chdir(self.lib_dir) + print(cmd, f"GOARCH={go_arch}") + ret = subprocess.run(cmd, env=run_env) + if ret.returncode != 0: + raise Exception(f"build_macos {go_arch} failed") + return output_file + + def get_sdk_dir_path(self) -> str: + ret = subprocess.run(["xcrun", "--sdk", "macosx", "--show-sdk-path"], capture_output=True) + if ret.returncode != 0: + raise Exception("get_sdk_dir_path for macosx failed") + return ret.stdout.decode().strip() + + @staticmethod + def run(cmd: list[str], what: str): + print(cmd) + if subprocess.run(cmd).returncode != 0: + raise Exception(f"{what} failed") diff --git a/build/main.py b/build/main.py index d3d056b8..4e6d0854 100644 --- a/build/main.py +++ b/build/main.py @@ -6,6 +6,7 @@ from app.apple_go import AppleGoBuilder from app.apple_gomobile import AppleGoMobileBuilder from app.linux import LinuxBuilder +from app.macos import MacOSBuilder from app.windows import WindowsBuilder LOCAL_ARG = "local" @@ -60,6 +61,13 @@ def parse_build_args(args: list[str]) -> tuple[bool, bool]: ) builder.build() + elif platform == "macos": + use_local_xray_core, keep_go_env_snapshot = parse_build_args(sys.argv[2:]) + builder = MacOSBuilder( + build_dir_path(), use_local_xray_core, keep_go_env_snapshot + ) + builder.build() + elif platform == "windows": use_local_xray_core, keep_go_env_snapshot = parse_build_args(sys.argv[2:]) builder = WindowsBuilder( diff --git a/build/test_build.py b/build/test_build.py index 63956632..2f771cab 100644 --- a/build/test_build.py +++ b/build/test_build.py @@ -12,6 +12,7 @@ from app.apple_gomobile import AppleGoMobileBuilder from app.build import LIBXRAY_MOD_NAME, XRAY_CORE_MOD_NAME, DEFAULT_XRAY_CORE_VERSION from app.linux import LinuxBuilder +from app.macos import MacOSBuilder from app.windows import WindowsBuilder from main import parse_build_args @@ -157,7 +158,7 @@ def test_default_initialization_keeps_existing_modules(self): def test_all_builders_accept_snapshot_option(self): for builder_type in ( AndroidBuilder, AppleGoBuilder, AppleGoMobileBuilder, - LinuxBuilder, WindowsBuilder, + LinuxBuilder, MacOSBuilder, WindowsBuilder, ): for keep_snapshot in (False, True): with self.subTest(builder=builder_type.__name__, keep_snapshot=keep_snapshot): @@ -174,6 +175,7 @@ def test_cli_passes_options_to_every_builder(self): (["apple", "go"], "app.apple_go.AppleGoBuilder"), (["apple", "gomobile"], "app.apple_gomobile.AppleGoMobileBuilder"), (["linux"], "app.linux.LinuxBuilder"), + (["macos"], "app.macos.MacOSBuilder"), (["windows"], "app.windows.WindowsBuilder"), ): for options in ([], ["local"], ["--no-snapshot"], ["local", "--no-snapshot"], ["--no-snapshot", "local"]): diff --git a/build/test_verify_core.py b/build/test_verify_core.py index 36e45506..83c9b676 100644 --- a/build/test_verify_core.py +++ b/build/test_verify_core.py @@ -75,6 +75,12 @@ def test_mingw_runtime_libraries_are_found_too(self): def test_a_dll_with_only_system_imports_passes(self): self.assertEqual(verify_core.dll_problems("libXray.dll", ["KERNEL32.dll", "msvcrt.dll", "WS2_32.dll"]), []) + def test_a_universal_macho_is_told_apart(self): + self.assertTrue(verify_core.is_universal_macho(b"\xca\xfe\xba\xbe\x00\x00\x00\x02")) + self.assertTrue(verify_core.is_universal_macho(b"\xca\xfe\xba\xbf")) + for single in (b"\xcf\xfa\xed\xfe", b"\x7fELF", b"MZ\x90\x00", b"PK\x03\x04", b""): + self.assertFalse(verify_core.is_universal_macho(single), single) + def test_no_imports_read_is_a_failure_not_a_pass(self): self.assertEqual(verify_core.dll_problems("libXray.dll", []), ["libXray.dll: no imports could be read"]) diff --git a/build/verify_core.py b/build/verify_core.py index b61155d9..ab20f4a3 100644 --- a/build/verify_core.py +++ b/build/verify_core.py @@ -5,8 +5,10 @@ Xray-core v26.10.21's certificate fix depends on both. An Android AAR is checked through each of its jni/*/libgojni.so. With --no-cxx-runtime, a Windows DLL must not import a C++ runtime, which a clean Windows 10 lacks. +A universal macOS dylib is checked one architecture at a time. python build/verify_core.py linux_so/libXray.so + python build/verify_core.py macos_dylib/libXray.dylib python build/verify_core.py --no-cxx-runtime windows_dll/libXray.dll python build/verify_core.py libXray.aar """ @@ -76,7 +78,25 @@ def problems(binary: str, report: str, floor: tuple[int, ...], xray_core: str) - return found +def is_universal_macho(head: bytes) -> bool: + """A universal (fat) Mach-O: one file holding a library per architecture.""" + return head[:4] in (b"\xca\xfe\xba\xbe", b"\xca\xfe\xba\xbf") + + def go_binaries(artifact: Path, workdir: Path) -> list[Path]: + with open(artifact, "rb") as f: + head = f.read(4) + if is_universal_macho(head): + # Every architecture checked on its own, not just the first. + archs = subprocess.run(["lipo", "-archs", str(artifact)], capture_output=True, text=True, check=True) + thin = [] + for arch in archs.stdout.split(): + out = workdir / f"{artifact.stem}-{arch}{artifact.suffix}" + subprocess.run(["lipo", str(artifact), "-thin", arch, "-output", str(out)], check=True) + thin.append(out) + if not thin: + raise SystemExit(f"{artifact}: no architectures") + return thin if artifact.suffix != ".aar": return [artifact] with zipfile.ZipFile(artifact) as aar: