android target + APK pipeline; LSP imports honor stdlib paths

Android (toolchain):
  --target android / --target android-arm64 → aarch64-linux-android21.
  target.zig discovers $ANDROID_NDK_HOME (or scans
  ~/Library/Android/sdk/ndk/* for the newest), invokes the NDK clang
  with -shared -fPIC and links libsxhello.so against -llog -landroid
  -lEGL -lGLESv3 -lm -ldl. native_app_glue.c from the NDK is compiled
  and linked alongside the sx .o so apps can use the conventional
  android_main(struct android_app*) shape; -u ANativeActivity_onCreate
  keeps glue's symbol live.

Android (APK):
  --apk <out> wraps the .so into a debug-signed installable APK.
  target.zig discovers the SDK at $ANDROID_HOME (or
  ~/Library/Android/sdk), picks the newest build-tools + platforms,
  generates a NativeActivity AndroidManifest.xml from --bundle-id,
  packages via aapt2 link, appends the lib/ tree, zipalign, then
  apksigner against ~/.android/debug.keystore (auto-generated via
  keytool on first use). One command end-to-end:
      sx build --target android --apk out.apk \\
          --bundle-id co.swipelab.foo main.sx
  Verified on Pixel 7 Pro: install + launch reaches android_main.

Compiler (entry-point linkage):
  Top-level fn defs default to LLVM internal linkage and are lazily
  lowered (only `main` was eagerly lowered before). Added
  isExportedEntryName() — a small allowlist for names the OS loader
  calls: `main`, `android_main`, `ANativeActivity_onCreate`,
  `JNI_OnLoad`. These get eagerly lowered AND keep external linkage,
  so they actually land in .dynsym.

LSP (imports):
  DocumentStore now takes the install-discovered stdlib_paths and
  forwards them into resolveImportPath, mirroring the compiler. Before
  this, every `#import "modules/..."` resolved through the stdlib path
  failed silently inside the LSP and identifiers from those modules
  showed as `undefined variable`. Repro on label.sx: 1 false positive
  before, 0 after.
This commit is contained in:
agra
2026-05-18 23:09:55 +03:00
parent f41a121a29
commit f66cda6d11
5 changed files with 384 additions and 15 deletions

View File

@@ -20,6 +20,16 @@ const Function = inst_mod.Function;
const Module = mod_mod.Module;
const Builder = mod_mod.Builder;
/// Names that must keep external LLVM linkage because the OS loader (not
/// sx code) is the caller. Without this they'd default to internal and
/// either DCE away or stay hidden from the dynamic symbol table.
fn isExportedEntryName(name: []const u8) bool {
return std.mem.eql(u8, name, "main") or
std.mem.eql(u8, name, "android_main") or
std.mem.eql(u8, name, "ANativeActivity_onCreate") or
std.mem.eql(u8, name, "JNI_OnLoad");
}
// ── Scope ───────────────────────────────────────────────────────────────
const Binding = struct {
@@ -569,7 +579,7 @@ pub const Lowering = struct {
switch (decl.data) {
.const_decl => |cd| {
if (cd.value.data == .fn_decl) {
if (std.mem.eql(u8, cd.name, "main")) {
if (isExportedEntryName(cd.name)) {
self.lazyLowerFunction(cd.name);
}
} else if (cd.value.data == .comptime_expr) {
@@ -577,7 +587,7 @@ pub const Lowering = struct {
}
},
.fn_decl => |fd| {
if (std.mem.eql(u8, fd.name, "main")) {
if (isExportedEntryName(fd.name)) {
self.lazyLowerFunction(fd.name);
}
},
@@ -728,7 +738,7 @@ pub const Lowering = struct {
return;
}
func.is_extern = false; // promote from extern stub to real function
func.linkage = if (std.mem.eql(u8, name, "main")) .external else .internal;
func.linkage = if (isExportedEntryName(name)) .external else .internal;
if (fd.call_conv == .c) func.call_conv = .c;
// Set inst_counter to param count (params occupy refs 0..N-1)
std.debug.assert(func.params.len == fd.params.len); // AST and IR param counts must match
@@ -836,8 +846,11 @@ pub const Lowering = struct {
);
_ = func_id;
// Set linkage for main
if (std.mem.eql(u8, name, "main")) {
// Set linkage. Default for fn defs is `internal` (LLVM DCE-friendly,
// matches C `static`). isExportedEntryName lists the names the OS
// loader calls — `main`, Android NativeActivity hooks — which must
// stay externally visible.
if (isExportedEntryName(name)) {
self.builder.currentFunc().linkage = .external;
}

View File

@@ -47,13 +47,19 @@ pub const DocumentStore = struct {
io: std.Io,
/// Workspace root path (from initialize). Used to absolutify CWD-relative import paths.
root_path: []const u8 = "",
/// Install-discovered stdlib search paths. Mirrors the compiler's
/// `--lib-path` resolution so `#import "modules/std.sx"` etc. find the
/// shipped library files even when the workspace is something other
/// than the sx repo (e.g. /Users/agra/projects/game).
stdlib_paths: []const []const u8 = &.{},
/// All loaded documents keyed by resolved file path.
by_path: std.StringHashMap(*Document),
pub fn init(allocator: std.mem.Allocator, io: std.Io) DocumentStore {
pub fn init(allocator: std.mem.Allocator, io: std.Io, stdlib_paths: []const []const u8) DocumentStore {
return .{
.allocator = allocator,
.io = io,
.stdlib_paths = stdlib_paths,
.by_path = std.StringHashMap(*Document).init(allocator),
};
}
@@ -184,7 +190,7 @@ pub const DocumentStore = struct {
for (root.data.root.decls) |decl| {
if (decl.data != .import_decl) continue;
const imp = decl.data.import_decl;
const resolved_path = try sx.imports.resolveImportPath(self.allocator, self.io, base_dir, imp.path, self.rootPathOpt(), &.{});
const resolved_path = try sx.imports.resolveImportPath(self.allocator, self.io, base_dir, imp.path, self.rootPathOpt(), self.stdlib_paths);
try import_list.append(self.allocator, .{
.ns = imp.name,
.path = resolved_path,

View File

@@ -24,13 +24,15 @@ pub const Server = struct {
io: std.Io,
shutdown_requested: bool = false,
root_path: []const u8 = "",
stdlib_paths: []const []const u8 = &.{},
pub fn init(allocator: std.mem.Allocator, transport: *Transport, io: std.Io) Server {
pub fn init(allocator: std.mem.Allocator, transport: *Transport, io: std.Io, stdlib_paths: []const []const u8) Server {
return .{
.allocator = allocator,
.documents = DocumentStore.init(allocator, io),
.documents = DocumentStore.init(allocator, io, stdlib_paths),
.transport = transport,
.io = io,
.stdlib_paths = stdlib_paths,
};
}
@@ -271,7 +273,7 @@ pub const Server = struct {
if (findImportPathAtOffset(doc.source, offset)) |import_path| {
const base_dir = sx.imports.dirName(file_path);
const rp: ?[]const u8 = if (self.root_path.len > 0) self.root_path else null;
const resolved = try sx.imports.resolveImportPath(self.allocator, self.io, base_dir, import_path, rp, &.{});
const resolved = try sx.imports.resolveImportPath(self.allocator, self.io, base_dir, import_path, rp, self.stdlib_paths);
// For directory imports, try to read as file first
if (std.Io.Dir.readFileAlloc(.cwd(), self.io, resolved, self.allocator, .limited(10 * 1024 * 1024))) |_| {

View File

@@ -19,7 +19,7 @@ pub fn main(init: std.process.Init) !void {
// LSP subcommand doesn't need a file argument
if (std.mem.eql(u8, command, "lsp")) {
runLsp(allocator, io);
runLsp(allocator, io, stdlib_paths);
return;
}
@@ -61,6 +61,10 @@ pub fn main(init: std.process.Init) !void {
"arm64-apple-ios14.0-simulator"
else if (std.mem.eql(u8, raw, "ios-sim-x86"))
"x86_64-apple-ios14.0-simulator"
else if (std.mem.eql(u8, raw, "android") or std.mem.eql(u8, raw, "android-arm64"))
"aarch64-linux-android21"
else if (std.mem.eql(u8, raw, "android-x86_64"))
"x86_64-linux-android21"
else
raw;
target_config.triple = (try allocator.dupeZ(u8, expanded)).ptr;
@@ -92,6 +96,18 @@ pub fn main(init: std.process.Init) !void {
i += 1;
if (i >= args.len) { std.debug.print("error: --bundle requires a path (e.g. MyApp.app)\n", .{}); return; }
target_config.bundle_path = args[i];
} else if (std.mem.eql(u8, arg, "--apk")) {
i += 1;
if (i >= args.len) { std.debug.print("error: --apk requires a path (e.g. out.apk)\n", .{}); return; }
target_config.apk_path = args[i];
} else if (std.mem.eql(u8, arg, "--manifest")) {
i += 1;
if (i >= args.len) { std.debug.print("error: --manifest requires a path\n", .{}); return; }
target_config.manifest_path = args[i];
} else if (std.mem.eql(u8, arg, "--keystore")) {
i += 1;
if (i >= args.len) { std.debug.print("error: --keystore requires a path\n", .{}); return; }
target_config.keystore_path = args[i];
} else if (std.mem.eql(u8, arg, "--bundle-id")) {
i += 1;
if (i >= args.len) { std.debug.print("error: --bundle-id requires a value (e.g. co.swipelab.myapp)\n", .{}); return; }
@@ -335,7 +351,7 @@ fn printUsage() void {
, .{});
}
fn runLsp(allocator: std.mem.Allocator, io: std.Io) void {
fn runLsp(allocator: std.mem.Allocator, io: std.Io, stdlib_paths: []const []const u8) void {
const Transport = sx.lsp.transport.Transport;
const Server = sx.lsp.server.Server;
@@ -346,7 +362,7 @@ fn runLsp(allocator: std.mem.Allocator, io: std.Io) void {
var stdin_reader = stdin_file.readerStreaming(io, &read_buf);
var transport = Transport.init(allocator, io, &stdin_reader.interface, stdout_file);
var server = Server.init(allocator, &transport, io);
var server = Server.init(allocator, &transport, io, stdlib_paths);
while (true) {
const msg = transport.readMessage() catch |err| {
@@ -583,6 +599,14 @@ fn compileWithTimer(allocator: std.mem.Allocator, io: std.Io, input_path: []cons
std.debug.print("bundled: {s}\n", .{bp});
}
// Wrap into an .apk if requested (Android).
if (merged_config.apk_path) |ap| {
timer.mark();
sx.target.createApk(allocator, io, final_output, merged_config) catch std.process.exit(1);
timer.record("apk");
std.debug.print("apk: {s}\n", .{ap});
}
// Post-process wasm HTML: inject content hash for cache busting
if (merged_config.isEmscripten() and std.mem.endsWith(u8, final_output, ".html")) {
sx.target.postProcessWasmHtml(allocator, io, final_output);

View File

@@ -30,8 +30,17 @@ pub const TargetConfig = struct {
/// and ad-hoc signed for simulator runs.
bundle_path: ?[]const u8 = null,
/// CFBundleIdentifier for the bundle (e.g. "co.swipelab.sxhello").
/// Required when `bundle_path` is set.
/// Required when `bundle_path` is set. On Android, doubles as the
/// AndroidManifest package="..." attribute.
bundle_id: ?[]const u8 = null,
/// Path to a `.apk` file to produce (Android). When set, the linked
/// `.so` is wrapped into a debug-signed APK ready for `adb install`.
apk_path: ?[]const u8 = null,
/// Custom AndroidManifest.xml path. When null, a minimal NativeActivity
/// manifest is generated from `bundle_id`.
manifest_path: ?[]const u8 = null,
/// Debug keystore for APK signing. Defaults to `~/.android/debug.keystore`.
keystore_path: ?[]const u8 = null,
/// Codesigning identity (e.g. `"Apple Development: Alex (TEAMID)"` or a
/// SHA-1 fingerprint from `security find-identity -p codesigning`).
/// When null, ad-hoc signs with `-` (sufficient for simulator, not device).
@@ -110,11 +119,18 @@ pub const TargetConfig = struct {
return self.isIOS() and !self.tripleContains("simulator");
}
/// Check if target triple indicates Linux.
/// Check if target triple indicates Linux (NOT Android — Android uses
/// "linux-android" too but isAndroid() must take precedence).
pub fn isLinux(self: TargetConfig) bool {
if (self.isAndroid()) return false;
return self.tripleContains("linux");
}
/// Check if target triple indicates Android (e.g. aarch64-linux-android21).
pub fn isAndroid(self: TargetConfig) bool {
return self.tripleContains("android");
}
/// Check if target triple indicates Emscripten (contains "emscripten").
pub fn isEmscripten(self: TargetConfig) bool {
return self.tripleContains("emscripten");
@@ -205,6 +221,259 @@ pub fn runJITFromObject(obj_buf: c.LLVMMemoryBufferRef) !u8 {
return if (result >= 0 and result <= 255) @intCast(result) else 1;
}
/// Discover the Android SDK root. Honors $ANDROID_HOME / $ANDROID_SDK_ROOT,
/// otherwise picks the default install location on macOS. Caller owns slice.
pub fn discoverAndroidSdk(allocator: std.mem.Allocator, io: std.Io) ![]const u8 {
if (std.c.getenv("ANDROID_HOME")) |env| {
return try allocator.dupe(u8, std.mem.span(env));
}
if (std.c.getenv("ANDROID_SDK_ROOT")) |env| {
return try allocator.dupe(u8, std.mem.span(env));
}
const home_env = std.c.getenv("HOME") orelse {
std.debug.print("error: cannot locate Android SDK — set $ANDROID_HOME\n", .{});
return error.SdkNotFound;
};
const home = std.mem.span(home_env);
const sdk = try std.fmt.allocPrint(allocator, "{s}/Library/Android/sdk", .{home});
var dir = std.Io.Dir.openDir(.cwd(), io, sdk, .{}) catch {
std.debug.print("error: no Android SDK at {s} — install via Android Studio or set $ANDROID_HOME\n", .{sdk});
return error.SdkNotFound;
};
dir.close(io);
return sdk;
}
/// Pick the lexicographically-highest subdir of `<sdk>/<subdir>` (matches the
/// "newest version" convention for `build-tools/<version>` and
/// `platforms/android-<api>`). Caller owns the joined slice.
fn findHighestSubdir(allocator: std.mem.Allocator, io: std.Io, root: []const u8, subdir: []const u8) ![]const u8 {
const parent = try std.fmt.allocPrint(allocator, "{s}/{s}", .{ root, subdir });
var dir = std.Io.Dir.openDir(.cwd(), io, parent, .{ .iterate = true }) catch {
std.debug.print("error: no {s} under {s}\n", .{ subdir, root });
return error.SdkNotFound;
};
defer dir.close(io);
var best: ?[]const u8 = null;
var it = dir.iterate();
while (it.next(io) catch null) |entry| {
if (entry.kind != .directory) continue;
if (best == null or std.mem.order(u8, entry.name, best.?) == .gt) {
best = try allocator.dupe(u8, entry.name);
}
}
const name = best orelse {
std.debug.print("error: no versions under {s}\n", .{parent});
return error.SdkNotFound;
};
return try std.fmt.allocPrint(allocator, "{s}/{s}", .{ parent, name });
}
/// Wrap a linked Android `.so` into a debug-signed APK. Steps:
/// 1. Place the .so under `lib/arm64-v8a/` in a staging directory.
/// 2. Generate (or copy) AndroidManifest.xml.
/// 3. aapt2 link → empty APK with resources/manifest.
/// 4. Append the lib/ tree via `zip`.
/// 5. zipalign → aligned APK.
/// 6. apksigner → final signed APK at `target_config.apk_path`.
pub fn createApk(allocator: std.mem.Allocator, io: std.Io, so_path: []const u8, target_config: TargetConfig) !void {
const apk_path = target_config.apk_path orelse return error.NoApkPath;
const bundle_id = target_config.bundle_id orelse {
std.debug.print("error: --apk requires --bundle-id (e.g. co.swipelab.myapp)\n", .{});
return error.MissingBundleId;
};
const sdk_root = try discoverAndroidSdk(allocator, io);
const build_tools = try findHighestSubdir(allocator, io, sdk_root, "build-tools");
const platform_dir = try findHighestSubdir(allocator, io, sdk_root, "platforms");
const android_jar = try std.fmt.allocPrint(allocator, "{s}/android.jar", .{platform_dir});
const aapt2 = try std.fmt.allocPrint(allocator, "{s}/aapt2", .{build_tools});
const zipalign = try std.fmt.allocPrint(allocator, "{s}/zipalign", .{build_tools});
const apksigner = try std.fmt.allocPrint(allocator, "{s}/apksigner", .{build_tools});
// Staging dir alongside the apk output.
const stage = try std.fmt.allocPrint(allocator, "{s}.stage", .{apk_path});
const lib_dir = try std.fmt.allocPrint(allocator, "{s}/lib/arm64-v8a", .{stage});
const cwd = std.Io.Dir.cwd();
cwd.deleteTree(io, stage) catch {};
try cwd.createDirPath(io, lib_dir);
// libsxhello.so must literally start with "lib" for Android's loader.
// The user's -o path already does (e.g. lib/.../libsxhello.so). We copy
// by basename into the staging lib dir.
const so_basename = std.fs.path.basename(so_path);
const so_dest = try std.fs.path.join(allocator, &.{ lib_dir, so_basename });
cwd.copyFile(so_path, cwd, so_dest, io, .{}) catch return error.ApkStageFailed;
// Manifest: either user-supplied or auto-generated.
const manifest_path = if (target_config.manifest_path) |mp|
try allocator.dupe(u8, mp)
else blk: {
const generated = try std.fmt.allocPrint(allocator, "{s}/AndroidManifest.xml", .{stage});
const lib_name = libNameFromSoBasename(so_basename);
const manifest_xml = try buildAndroidManifest(allocator, bundle_id, lib_name);
try cwd.writeFile(io, .{ .sub_path = generated, .data = manifest_xml });
break :blk generated;
};
// aapt2 link → unaligned apk with manifest + resources (none for now).
const unaligned = try std.fmt.allocPrint(allocator, "{s}.unaligned", .{apk_path});
try runProcess(allocator, io, &.{
aapt2, "link",
"-I", android_jar,
"--manifest", manifest_path,
"-o", unaligned,
});
// Append lib/ tree. Using the `zip` command rather than re-encoding the
// APK from scratch because aapt2 doesn't include arbitrary directories
// and zip is on every macOS/Linux host by default.
try runProcessIn(allocator, io, stage, &.{ "zip", "-q", "-r", unaligned, "lib/" });
// zipalign → aligned apk.
const aligned = try std.fmt.allocPrint(allocator, "{s}.aligned", .{apk_path});
try runProcess(allocator, io, &.{ zipalign, "-f", "4", unaligned, aligned });
// apksigner → final signed apk at apk_path.
const keystore = target_config.keystore_path orelse blk: {
const home_env = std.c.getenv("HOME") orelse return error.NoHomeDir;
break :blk try std.fmt.allocPrint(allocator, "{s}/.android/debug.keystore", .{std.mem.span(home_env)});
};
// Generate debug keystore on first use (keytool defaults match Android's).
try ensureDebugKeystore(allocator, io, keystore);
try runProcess(allocator, io, &.{
apksigner, "sign",
"--ks", keystore,
"--ks-pass", "pass:android",
"--key-pass", "pass:android",
"--ks-key-alias", "androiddebugkey",
"--out", apk_path,
aligned,
});
// Clean up intermediate files; keep stage/ in case users want to inspect.
cwd.deleteFile(io, unaligned) catch {};
cwd.deleteFile(io, aligned) catch {};
cwd.deleteTree(io, stage) catch {};
}
/// `libfoo.so` → `foo` (Android's `android.app.lib_name` meta-data wants the
/// trimmed name; the loader prepends `lib` and appends `.so`).
fn libNameFromSoBasename(basename: []const u8) []const u8 {
var name = basename;
if (std.mem.startsWith(u8, name, "lib")) name = name[3..];
if (std.mem.endsWith(u8, name, ".so")) name = name[0 .. name.len - 3];
return name;
}
fn buildAndroidManifest(allocator: std.mem.Allocator, package: []const u8, lib_name: []const u8) ![]const u8 {
return std.fmt.allocPrint(allocator,
\\<?xml version="1.0" encoding="utf-8"?>
\\<manifest xmlns:android="http://schemas.android.com/apk/res/android"
\\ package="{s}"
\\ android:versionCode="1"
\\ android:versionName="1.0">
\\ <uses-sdk android:minSdkVersion="21" android:targetSdkVersion="34" />
\\ <application android:label="{s}" android:hasCode="false">
\\ <activity
\\ android:name="android.app.NativeActivity"
\\ android:exported="true"
\\ android:label="{s}"
\\ android:configChanges="orientation|keyboardHidden|screenSize">
\\ <meta-data android:name="android.app.lib_name" android:value="{s}" />
\\ <intent-filter>
\\ <action android:name="android.intent.action.MAIN" />
\\ <category android:name="android.intent.category.LAUNCHER" />
\\ </intent-filter>
\\ </activity>
\\ </application>
\\</manifest>
\\
, .{ package, lib_name, lib_name, lib_name });
}
fn ensureDebugKeystore(allocator: std.mem.Allocator, io: std.Io, keystore_path: []const u8) !void {
const cwd = std.Io.Dir.cwd();
if (cwd.statFile(io, keystore_path, .{})) |_| {
return;
} else |_| {}
if (std.fs.path.dirname(keystore_path)) |dir| {
cwd.createDirPath(io, dir) catch {};
}
try runProcess(allocator, io, &.{
"keytool",
"-genkeypair",
"-keystore", keystore_path,
"-storepass", "android",
"-alias", "androiddebugkey",
"-keypass", "android",
"-keyalg", "RSA",
"-keysize", "2048",
"-validity", "10000",
"-dname", "CN=Android Debug,O=Android,C=US",
});
}
fn runProcess(allocator: std.mem.Allocator, io: std.Io, argv: []const []const u8) !void {
return runProcessIn(allocator, io, null, argv);
}
fn runProcessIn(allocator: std.mem.Allocator, io: std.Io, work_dir: ?[]const u8, argv: []const []const u8) !void {
if (std.c.getenv("SX_DEBUG_APK") != null) {
std.debug.print("[sx] apk:", .{});
for (argv) |a| std.debug.print(" {s}", .{a});
std.debug.print("\n", .{});
}
const cwd_opt: std.process.Child.Cwd = if (work_dir) |wd| .{ .path = wd } else .inherit;
const result = std.process.run(allocator, io, .{ .argv = argv, .cwd = cwd_opt }) catch |e| {
std.debug.print("error: failed to spawn {s}: {}\n", .{ argv[0], e });
return error.ApkStepFailed;
};
defer allocator.free(result.stdout);
defer allocator.free(result.stderr);
if (result.term != .exited or result.term.exited != 0) {
std.debug.print("error: {s} failed:\n{s}\n{s}\n", .{ argv[0], result.stdout, result.stderr });
return error.ApkStepFailed;
}
}
/// Discover the Android NDK root. Honors $ANDROID_NDK_HOME / $ANDROID_NDK_ROOT,
/// otherwise picks the highest-versioned NDK under $HOME/Library/Android/sdk/ndk
/// (the SDK Manager default install location on macOS). Caller owns the slice.
pub fn discoverAndroidNdk(allocator: std.mem.Allocator, io: std.Io) ![]const u8 {
if (std.c.getenv("ANDROID_NDK_HOME")) |env| {
return try allocator.dupe(u8, std.mem.span(env));
}
if (std.c.getenv("ANDROID_NDK_ROOT")) |env| {
return try allocator.dupe(u8, std.mem.span(env));
}
const home_env = std.c.getenv("HOME") orelse {
std.debug.print("error: cannot locate Android NDK \u{2014} set $ANDROID_NDK_HOME\n", .{});
return error.NdkNotFound;
};
const home = std.mem.span(home_env);
const ndk_root = try std.fmt.allocPrint(allocator, "{s}/Library/Android/sdk/ndk", .{home});
var dir = std.Io.Dir.openDir(.cwd(), io, ndk_root, .{ .iterate = true }) catch {
std.debug.print("error: no NDK at {s} \u{2014} install via Android Studio or set $ANDROID_NDK_HOME\n", .{ndk_root});
return error.NdkNotFound;
};
defer dir.close(io);
var best: ?[]const u8 = null;
var it = dir.iterate();
while (it.next(io) catch null) |entry| {
if (entry.kind != .directory) continue;
if (best == null or std.mem.order(u8, entry.name, best.?) == .gt) {
best = try allocator.dupe(u8, entry.name);
}
}
const version = best orelse {
std.debug.print("error: no NDK versions under {s}\n", .{ndk_root});
return error.NdkNotFound;
};
return try std.fmt.allocPrint(allocator, "{s}/{s}", .{ ndk_root, version });
}
/// Run `xcrun --sdk <sdk_name> --show-sdk-path` and return the trimmed path.
/// Caller owns the returned slice.
pub fn discoverAppleSdk(allocator: std.mem.Allocator, io: std.Io, sdk_name: []const u8) ![]const u8 {
@@ -273,6 +542,61 @@ pub fn link(allocator: std.mem.Allocator, io: std.Io, output_obj: []const u8, ex
var it = std.mem.tokenizeScalar(u8, flag, ' ');
while (it.next()) |part| try argv.append(allocator, part);
}
} else if (target_config.isAndroid()) {
// Android: NDK clang. Produces a shared library (.so) loaded by
// NativeActivity. native_app_glue.c (from the NDK) is compiled and
// linked alongside the sx code so apps can use the conventional
// `android_main(struct android_app*)` event-loop shape — the glue
// owns `ANativeActivity_onCreate` and forwards into android_main on
// a dedicated thread. `-u ANativeActivity_onCreate` keeps the glue's
// symbol from being stripped (nothing in our .o references it).
const ndk_root = if (target_config.sysroot) |sr|
try allocator.dupe(u8, sr)
else
try discoverAndroidNdk(allocator, io);
const host_tag: []const u8 = if (@import("builtin").os.tag == .macos) "darwin-x86_64" else "linux-x86_64";
const clang = try std.fmt.allocPrint(allocator, "{s}/toolchains/llvm/prebuilt/{s}/bin/clang", .{ ndk_root, host_tag });
const glue_src = try std.fmt.allocPrint(allocator, "{s}/sources/android/native_app_glue/android_native_app_glue.c", .{ndk_root});
const glue_obj = try std.fmt.allocPrint(allocator, "{s}.glue.o", .{output_obj});
var glue_argv = std.ArrayList([]const u8).empty;
try glue_argv.appendSlice(allocator, &.{ clang, "-c", "-fPIC" });
if (target_config.triple) |t| {
try glue_argv.append(allocator, "-target");
try glue_argv.append(allocator, std.mem.span(t));
}
try glue_argv.appendSlice(allocator, &.{ glue_src, "-o", glue_obj });
const glue_slice = try glue_argv.toOwnedSlice(allocator);
var glue_child = std.process.spawn(io, .{ .argv = glue_slice }) catch return error.LinkError;
const glue_term = glue_child.wait(io) catch return error.LinkError;
if (glue_term != .exited or glue_term.exited != 0) return error.LinkError;
try argv.append(allocator, clang);
if (target_config.triple) |t| {
try argv.append(allocator, "-target");
try argv.append(allocator, std.mem.span(t));
}
try argv.append(allocator, "-shared");
try argv.append(allocator, "-fPIC");
try argv.appendSlice(allocator, &.{ "-u", "ANativeActivity_onCreate" });
try argv.append(allocator, output_obj);
try argv.append(allocator, glue_obj);
for (extra_objects) |eo| try argv.append(allocator, eo);
try argv.append(allocator, "-o");
try argv.append(allocator, output_bin);
for (target_config.lib_paths) |lp| {
try argv.append(allocator, try std.fmt.allocPrint(allocator, "-L{s}", .{lp}));
}
for (libraries) |lib| {
try argv.append(allocator, try std.fmt.allocPrint(allocator, "-l{s}", .{lib}));
}
// Default libs available on every Android runtime; linker drops
// unreferenced ones automatically.
try argv.appendSlice(allocator, &.{ "-llog", "-landroid", "-lEGL", "-lGLESv3", "-lm", "-ldl" });
for (target_config.extra_link_flags) |flag| {
var it = std.mem.tokenizeScalar(u8, flag, ' ');
while (it.next()) |part| try argv.append(allocator, part);
}
} else if (target_config.isEmscripten()) {
// Emscripten: use emcc as the linker/driver
const linker = target_config.linker orelse "emcc";