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: