Files
2026-08-27 21:09:14 +00:00

214 lines
9.3 KiB
TypeScript

/**
* macOS SDK resolution for cross-compiling darwin targets from a non-darwin
* host (Linux CI boxes, contributor Linux machines).
*
* Native darwin builds never touch this file — they get the SDK from
* `xcode-select`/`xcrun` (see `detectMacosSdk()` in config.ts). When the host
* is not darwin there is no Xcode, so the SDK comes from (in order):
*
* 1. `--macos-sdk=<path>` / `$MACOS_SDK_PATH` — explicit, always wins.
* 2. A well-known install location (`/opt/MacOSX*.sdk`, an osxcross tree).
* 3. `<cacheDir>/MacOSX<version>.sdk` — extracted on demand at configure
* time by the vendored `xmac` tool (scripts/build/xmac.mjs), which
* downloads the Command Line Tools SDK package (~60 MB) directly from
* Apple's public software-update CDN — the same approach `xwin` uses for
* the Windows SDK. Nothing is fetched from a third-party mirror and
* nothing is redistributed; every machine downloads from Apple itself.
*
* Only the SDK is needed — no osxcross, no cctools. clang is inherently a
* cross-compiler and lld's Mach-O port (`ld64.lld`) does the link, so the
* sysroot (headers + .tbd stubs + frameworks) is the only Apple bit required.
*
* The deployment target (`-mmacosx-version-min`, `cfg.osxDeploymentTarget`)
* — not the SDK version — controls the oldest macOS the binary runs on, so
* the pin tracks the newest SDK Apple serves: newer SDKs list more symbols in
* their `.tbd` stubs (required when the WebKit prebuilt was built against a
* newer SDK than ours) and that is the only direction that can't break the
* link.
*/
import { spawnSync } from "node:child_process";
import { existsSync, readdirSync } from "node:fs";
import { mkdir, rename, rm } from "node:fs/promises";
import { join, resolve } from "node:path";
import { BuildError } from "./error.ts";
/**
* Pinned SDK + Command Line Tools release for the auto-extract path. Apple's
* software-update catalog is a rolling window — old CLT releases eventually
* stop being served — so when the build fails saying the release is gone, run
* `bun scripts/build/xmac.mjs list` and bump both pins to the newest entry.
*/
export const MACOS_SDK_VERSION = "26.5";
/** The Command Line Tools release whose package contains MACOS_SDK_VERSION. */
export const MACOS_SDK_CLT_RELEASE = "26.5";
/** The vendored xmac bundle (see the header of that file for provenance). */
export const XMAC_PATH = join(import.meta.dirname, "xmac.mjs");
/** `<cacheDir>/MacOSX<version>.sdk` — where the auto-extract lands. */
export function macosSdkCachePath(cacheDir: string, version: string = MACOS_SDK_VERSION): string {
return resolve(cacheDir, `MacOSX${version}.sdk`);
}
/** A directory "looks like" a macOS SDK if it has the C headers we need. */
function isMacosSdk(path: string): boolean {
return existsSync(join(path, "usr", "include", "sys", "syscall.h"));
}
/**
* Pick the highest-versioned `MacOSX*.sdk` under `dir`, or undefined.
* `MacOSX.sdk` (unversioned symlink, Xcode-style layout) wins if present.
*/
function newestSdkIn(dir: string): string | undefined {
if (!existsSync(dir)) return undefined;
const unversioned = join(dir, "MacOSX.sdk");
if (isMacosSdk(unversioned)) return unversioned;
let best: { version: number; path: string } | undefined;
for (const entry of readdirSync(dir)) {
const m = entry.match(/^MacOSX(\d+)(?:\.(\d+))?\.sdk$/);
if (!m) continue;
const path = join(dir, entry);
if (!isMacosSdk(path)) continue;
const version = Number(m[1]) * 100 + Number(m[2] ?? 0);
if (best === undefined || version > best.version) best = { version, path };
}
return best?.path;
}
/**
* Resolve the macOS SDK path for a cross build. Does NOT download — that
* happens later in `ensureMacosSdk()` (configure is sync, downloads aren't).
* The returned path may not exist yet when it's the cache-dir default.
*/
export function resolveMacosSdkPath(explicit: string | undefined, cacheDir: string, cwd: string): string {
// 1. Explicit: --macos-sdk= or $MACOS_SDK_PATH. Must exist — a typo'd
// explicit path silently falling back to a download would be confusing.
const requested = explicit ?? process.env.MACOS_SDK_PATH;
if (requested !== undefined && requested !== "") {
const abs = resolve(cwd, requested);
if (!isMacosSdk(abs)) {
throw new BuildError(`macOS SDK not found at ${abs}`, {
hint: "Expected a MacOSX*.sdk directory (containing usr/include). Check --macos-sdk / MACOS_SDK_PATH.",
});
}
return abs;
}
// 2. Well-known install locations: /opt/MacOSX*.sdk (what CI images /
// bootstrap install) or an osxcross tree.
for (const candidate of [newestSdkIn("/opt"), newestSdkIn("/opt/macos-sdk"), newestSdkIn("/opt/osxcross/SDK")]) {
if (candidate !== undefined) return candidate;
}
// 3. The cache dir — a previous auto-extract of the *pinned* version is
// reused as-is; any other MacOSX*.sdk in there is deliberately ignored so
// that bumping MACOS_SDK_VERSION fetches the new pin instead of silently
// reusing a stale SDK. ensureMacosSdk() extracts into this path when it
// doesn't exist yet.
return macosSdkCachePath(cacheDir);
}
/**
* Make sure `cfg.osxSysroot` exists on disk, downloading + extracting the
* pinned SDK into the cache dir when that's where resolveMacosSdkPath()
* pointed. Called from configure() before ninja runs so compile edges never
* race the extraction.
*/
export async function ensureMacosSdk(cfg: {
osxSysroot: string | undefined;
cacheDir: string;
darwin: boolean;
bun: string;
host: { os: string };
}): Promise<void> {
if (!cfg.darwin || cfg.host.os === "darwin" || cfg.osxSysroot === undefined) return;
if (isMacosSdk(cfg.osxSysroot)) return;
const expected = macosSdkCachePath(cfg.cacheDir);
if (resolve(cfg.osxSysroot) !== expected) {
// A non-default path that doesn't exist: never overwrite/auto-fill a
// user-specified location.
throw new BuildError(`macOS SDK not found at ${cfg.osxSysroot}`, {
hint:
`Install a macOS SDK there, or unset MACOS_SDK_PATH/--macos-sdk to let the build ` +
`fetch MacOSX${MACOS_SDK_VERSION}.sdk from Apple into ${expected}.`,
});
}
// The SDK is Apple's, under the "macOS SDK and Xcode Agreement". xmac
// downloads it from Apple's own CDN to this machine (nothing is
// redistributed), but extracting and using it means accepting Apple's
// terms — say so before passing --accept-license on the user's behalf.
console.log(`[macos-sdk] targeting macOS from a ${cfg.host.os} host; fetching MacOSX${MACOS_SDK_VERSION}.sdk`);
console.log(`[macos-sdk] the SDK is downloaded directly from Apple's software-update CDN and is subject to`);
console.log(
`[macos-sdk] Apple's SDK license terms (https://www.apple.com/legal/sla/ — \`bun ${relativeXmac()} license\`).`,
);
// Extract into a staging dir, then rename the .sdk into place — same
// discipline as fetchPrebuilt() so an interrupted configure never leaves a
// half-extracted tree claiming to be an SDK. The downloaded .pkg itself is
// cached separately under <cacheDir>/xmac so a failed/interrupted
// extraction doesn't re-download ~60 MB.
const suffix = `.${process.pid}.${Date.now().toString(36)}`;
const staging = `${expected}${suffix}.staging`;
await mkdir(cfg.cacheDir, { recursive: true });
try {
const result = spawnSync(
cfg.bun,
[
XMAC_PATH,
"splat",
"--accept-license",
"--sdk-only",
"--release",
MACOS_SDK_CLT_RELEASE,
"--sdk",
MACOS_SDK_VERSION,
"--output",
staging,
"--cache-dir",
join(cfg.cacheDir, "xmac"),
],
// Progress goes to the terminal (stderr); the `key: value` result lines
// on stdout are not needed — the output layout is deterministic.
{ stdio: ["ignore", "inherit", "inherit"], encoding: "utf8" },
);
if (result.error || result.status !== 0) {
throw new BuildError(
`Failed to fetch MacOSX${MACOS_SDK_VERSION}.sdk from Apple's CDN` +
(result.error ? `: ${result.error.message}` : ` (exit ${result.status})`),
{
hint:
`Apple's software-update catalog is a rolling window; if Command Line Tools ` +
`${MACOS_SDK_CLT_RELEASE} is no longer served, run \`bun ${relativeXmac()} list\` and bump ` +
`MACOS_SDK_VERSION / MACOS_SDK_CLT_RELEASE in scripts/build/macos-sdk.ts. ` +
`Extraction also needs \`xz\` on PATH (apt install xz-utils).`,
},
);
}
// xmac's splat layout is `<output>/SDKs/<name>.sdk` (plus version-alias
// symlinks); move the one real SDK directory to the flat cache path the
// rest of the build expects.
const extracted = join(staging, "SDKs", `MacOSX${MACOS_SDK_VERSION}.sdk`);
if (!isMacosSdk(extracted)) {
throw new BuildError(`xmac did not produce ${extracted}`, {
hint: `Expected MacOSX${MACOS_SDK_VERSION}.sdk inside the Command Line Tools ${MACOS_SDK_CLT_RELEASE} package.`,
});
}
await rm(expected, { recursive: true, force: true });
await rename(extracted, expected);
console.log(`[macos-sdk] extracted to ${expected}`);
} finally {
await rm(staging, { recursive: true, force: true }).catch(() => {});
}
}
/** `scripts/build/xmac.mjs` relative to the repo root, for log messages. */
function relativeXmac(): string {
return "scripts/build/xmac.mjs";
}