Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 47 additions & 6 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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

# =========================
Expand Down Expand Up @@ -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
Expand Down
105 changes: 105 additions & 0 deletions build/app/macos.py
Original file line number Diff line number Diff line change
@@ -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")
8 changes: 8 additions & 0 deletions build/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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(
Expand Down
4 changes: 3 additions & 1 deletion build/test_build.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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):
Expand All @@ -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"]):
Expand Down
6 changes: 6 additions & 0 deletions build/test_verify_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"])

Expand Down
20 changes: 20 additions & 0 deletions build/verify_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
"""
Expand Down Expand Up @@ -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:
Expand Down
Loading