const std = @import("std"); const llvm = @import("llvm_api.zig"); const c = llvm.c; /// One `#jni_main #jni_class("...")` declaration's Java-source emission. /// Populated by lowering and consumed by `createApk` to write a `.java` /// file under `/java/`, compile it via `javac`, and bundle the /// resulting `classes.dex` into the APK. pub const JniMainEmission = struct { /// foreign_path of the source decl (e.g. "co/swipelab/sxmain/SxApp"). /// Splits into package + class name for `/java//.java`. foreign_path: []const u8, /// Pre-rendered Java source bytes (from `jni_java_emit.emitJavaSource`). java_source: []const u8, }; pub const TargetConfig = struct { /// Target triple (e.g. "aarch64-apple-darwin"). Null = host default. triple: ?[*:0]const u8 = null, /// CPU name (e.g. "generic", "apple-m1"). Null = "generic". cpu: ?[*:0]const u8 = null, /// CPU features string (e.g. "+avx2"). Null = "". features: ?[*:0]const u8 = null, /// Optimization level. opt_level: OptLevel = .default, /// Library search paths (-L flags). lib_paths: []const []const u8 = &.{}, /// Framework search paths (-F flags). Apple-only. framework_paths: []const []const u8 = &.{}, /// Output path override. output_path: ?[]const u8 = null, /// Linker command (null = "cc" on Unix, "link.exe" on Windows). linker: ?[]const u8 = null, /// Sysroot for cross-compilation (passed as --sysroot to linker). sysroot: ?[]const u8 = null, /// Extra flags passed through to the linker (e.g. Emscripten -s flags). extra_link_flags: []const []const u8 = &.{}, /// Custom WASM shell template path (overrides the built-in template). wasm_shell_path: ?[]const u8 = null, /// Path to a `.app` bundle directory to produce (iOS/macOS). When set, the /// linker output is moved into the bundle alongside a generated Info.plist /// 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. 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). codesign_identity: ?[]const u8 = null, /// Path to a `.mobileprovision` to embed as `embedded.mobileprovision`. /// Required for real-device builds. provisioning_profile: ?[]const u8 = null, /// Path to an entitlements plist. When null and `provisioning_profile` /// is set, the entitlements are auto-extracted from the profile. entitlements_path: ?[]const u8 = null, pub const OptLevel = enum { none, less, default, aggressive, pub fn toLLVM(self: OptLevel) c.LLVMCodeGenOptLevel { return switch (self) { .none => c.LLVMCodeGenLevelNone, .less => c.LLVMCodeGenLevelLess, .default => c.LLVMCodeGenLevelDefault, .aggressive => c.LLVMCodeGenLevelAggressive, }; } }; /// Check if target triple indicates aarch64/arm64 (runtime check, not comptime). pub fn isAarch64(self: TargetConfig) bool { return self.tripleHasPrefix("aarch64", "arm64"); } /// Check if target triple indicates x86_64/x86-64. pub fn isX86_64(self: TargetConfig) bool { return self.tripleHasPrefix("x86_64", "x86-64"); } /// Check if target triple indicates Windows (contains "windows" or "win32"). pub fn isWindows(self: TargetConfig) bool { return self.tripleContains("windows") or self.tripleContains("win32"); } /// Check if target triple indicates WebAssembly (wasm32 or wasm64). pub fn isWasm(self: TargetConfig) bool { return self.tripleHasPrefix("wasm32", "wasm64"); } /// Check if target triple indicates wasm32 specifically (4-byte pointers, i32 size_t). pub fn isWasm32(self: TargetConfig) bool { return self.tripleHasPrefix("wasm32", "wasm32"); } /// Check if target triple indicates wasm64 specifically (8-byte pointers, i64 size_t). pub fn isWasm64(self: TargetConfig) bool { return self.tripleHasPrefix("wasm64", "wasm64"); } /// Check if target triple indicates macOS/Darwin (does not match iOS). pub fn isMacOS(self: TargetConfig) bool { if (self.isIOS()) return false; return self.tripleContains("darwin") or self.tripleContains("macos"); } /// Check if target triple indicates iOS (device or simulator). pub fn isIOS(self: TargetConfig) bool { return self.tripleContains("-apple-ios"); } /// Check if target triple indicates the iOS Simulator. pub fn isIOSSimulator(self: TargetConfig) bool { return self.isIOS() and self.tripleContains("simulator"); } /// Check if target triple indicates a real iOS device (not Simulator). pub fn isIOSDevice(self: TargetConfig) bool { return self.isIOS() and !self.tripleContains("simulator"); } /// 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"); } fn tripleHasPrefix(self: TargetConfig, prefix1: []const u8, prefix2: []const u8) bool { if (self.triple) |t| { const span = std.mem.span(t); return std.mem.startsWith(u8, span, prefix1) or std.mem.startsWith(u8, span, prefix2); } const dt = c.LLVMGetDefaultTargetTriple(); defer c.LLVMDisposeMessage(dt); const span = std.mem.span(dt); return std.mem.startsWith(u8, span, prefix1) or std.mem.startsWith(u8, span, prefix2); } fn tripleContains(self: TargetConfig, needle: []const u8) bool { if (self.triple) |t| { return std.mem.indexOf(u8, std.mem.span(t), needle) != null; } const dt = c.LLVMGetDefaultTargetTriple(); defer c.LLVMDisposeMessage(dt); return std.mem.indexOf(u8, std.mem.span(dt), needle) != null; } pub fn getCpu(self: TargetConfig) [*:0]const u8 { return self.cpu orelse "generic"; } pub fn getFeatures(self: TargetConfig) [*:0]const u8 { return self.features orelse ""; } pub fn getLinker(self: TargetConfig) []const u8 { return self.linker orelse "cc"; } }; /// Execute a precompiled object file in-process using LLVM's ORC JIT. /// Takes ownership of obj_buf. Returns the exit code from main(). pub fn runJITFromObject(obj_buf: c.LLVMMemoryBufferRef) !u8 { // Create LLJIT with default builder (no custom TM needed — .o is precompiled) var jit: c.LLVMOrcLLJITRef = null; var err = c.LLVMOrcCreateLLJIT(&jit, null); if (err != null) { const msg = c.LLVMGetErrorMessage(err); defer c.LLVMDisposeErrorMessage(msg); std.debug.print("JIT error: {s}\n", .{std.mem.span(msg)}); return error.CompileError; } defer _ = c.LLVMOrcDisposeLLJIT(jit); // Add process symbols so JIT can find libc (printf, etc.) const jd = c.LLVMOrcLLJITGetMainJITDylib(jit); const prefix = c.LLVMOrcLLJITGetGlobalPrefix(jit); var gen: c.LLVMOrcDefinitionGeneratorRef = null; err = c.LLVMOrcCreateDynamicLibrarySearchGeneratorForProcess(&gen, prefix, null, null); if (err != null) { const msg = c.LLVMGetErrorMessage(err); defer c.LLVMDisposeErrorMessage(msg); std.debug.print("JIT symbol gen error: {s}\n", .{std.mem.span(msg)}); return error.CompileError; } c.LLVMOrcJITDylibAddGenerator(jd, gen); // Add precompiled object file (transfers ownership of obj_buf) err = c.LLVMOrcLLJITAddObjectFile(jit, jd, obj_buf); if (err != null) { const msg = c.LLVMGetErrorMessage(err); defer c.LLVMDisposeErrorMessage(msg); std.debug.print("JIT add object error: {s}\n", .{std.mem.span(msg)}); return error.CompileError; } // Look up the "main" function var main_addr: c.LLVMOrcExecutorAddress = 0; err = c.LLVMOrcLLJITLookup(jit, &main_addr, "main"); if (err != null) { const msg = c.LLVMGetErrorMessage(err); defer c.LLVMDisposeErrorMessage(msg); std.debug.print("JIT lookup error: {s}\n", .{std.mem.span(msg)}); return error.CompileError; } // Cast to function pointer and call const main_fn: *const fn () callconv(.c) i32 = @ptrFromInt(main_addr); const result = main_fn(); 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 `/` (matches the /// "newest version" convention for `build-tools/` and /// `platforms/android-`). 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 }); } /// Write each `JniMainEmission`'s `.java` source under `/java//`, /// invoke `javac` to compile to `/classes/`, then `d8` to produce /// `/classes.dex`. The caller bundles `classes.dex` into the APK. /// /// `javac` is discovered via `$JAVA_HOME/bin/javac` first, then via PATH; if /// neither resolves, an error is reported pointing at the missing tool. The /// `--release 11` target keeps the emitted class file version low enough for /// every shipping d8 to consume without surprise. fn compileJniMainSources( allocator: std.mem.Allocator, io: std.Io, stage: []const u8, emissions: []const JniMainEmission, android_jar: []const u8, d8: []const u8, ) !void { const cwd = std.Io.Dir.cwd(); const java_root = try std.fmt.allocPrint(allocator, "{s}/java", .{stage}); const classes_root = try std.fmt.allocPrint(allocator, "{s}/classes", .{stage}); try cwd.createDirPath(io, java_root); try cwd.createDirPath(io, classes_root); var java_paths = std.ArrayList([]const u8).empty; var class_paths = std.ArrayList([]const u8).empty; for (emissions) |em| { const split = splitForeignPath(em.foreign_path); const pkg_dir = if (split.pkg.len > 0) try std.fmt.allocPrint(allocator, "{s}/{s}", .{ java_root, split.pkg }) else try allocator.dupe(u8, java_root); try cwd.createDirPath(io, pkg_dir); const java_path = try std.fmt.allocPrint(allocator, "{s}/{s}.java", .{ pkg_dir, split.cls }); try cwd.writeFile(io, .{ .sub_path = java_path, .data = em.java_source }); try java_paths.append(allocator, java_path); const class_path = if (split.pkg.len > 0) try std.fmt.allocPrint(allocator, "{s}/{s}/{s}.class", .{ classes_root, split.pkg, split.cls }) else try std.fmt.allocPrint(allocator, "{s}/{s}.class", .{ classes_root, split.cls }); try class_paths.append(allocator, class_path); } const javac = try discoverJavac(allocator, io); var javac_argv = std.ArrayList([]const u8).empty; try javac_argv.appendSlice(allocator, &.{ javac, "-d", classes_root, "-classpath", android_jar, "--release", "11", }); for (java_paths.items) |p| try javac_argv.append(allocator, p); try runProcess(allocator, io, try javac_argv.toOwnedSlice(allocator)); var d8_argv = std.ArrayList([]const u8).empty; try d8_argv.appendSlice(allocator, &.{ d8, "--release", "--lib", android_jar, "--output", stage, }); for (class_paths.items) |p| try d8_argv.append(allocator, p); try runProcess(allocator, io, try d8_argv.toOwnedSlice(allocator)); } /// Split a JNI foreign path like `co/swipelab/sxmain/SxApp` into /// `{ pkg = "co/swipelab/sxmain", cls = "SxApp" }`. A path with no `/` is /// the default Java package (`{ pkg = "", cls = path }`). const PathParts = struct { pkg: []const u8, cls: []const u8 }; fn splitForeignPath(foreign_path: []const u8) PathParts { const last_slash = std.mem.lastIndexOfScalar(u8, foreign_path, '/') orelse { return .{ .pkg = "", .cls = foreign_path }; }; return .{ .pkg = foreign_path[0..last_slash], .cls = foreign_path[last_slash + 1 ..], }; } /// Locate `javac`. Honors `$JAVA_HOME/bin/javac` first (the Android Studio /// JDK install on macOS sets this), then falls back to PATH lookup via /// `which`. Returns an absolute path so subsequent `runProcess` calls work /// regardless of the CWD passed via `runProcessIn`. fn discoverJavac(allocator: std.mem.Allocator, io: std.Io) ![]const u8 { if (std.c.getenv("JAVA_HOME")) |env| { const home = std.mem.span(env); const candidate = try std.fmt.allocPrint(allocator, "{s}/bin/javac", .{home}); if (std.Io.Dir.cwd().statFile(io, candidate, .{})) |_| { return candidate; } else |_| { allocator.free(candidate); } } const which = std.process.run(allocator, io, .{ .argv = &.{ "/usr/bin/which", "javac" } }) catch |e| { std.debug.print("error: failed to locate javac via PATH: {}\n", .{e}); return error.JavacNotFound; }; defer allocator.free(which.stderr); errdefer allocator.free(which.stdout); if (which.term != .exited or which.term.exited != 0) { std.debug.print("error: javac not on PATH and $JAVA_HOME unset \u{2014} install a JDK (Android Studio bundles one at $ANDROID_STUDIO/Contents/jre)\n", .{}); allocator.free(which.stdout); return error.JavacNotFound; } const trimmed = std.mem.trimEnd(u8, which.stdout, " \t\r\n"); const out = try allocator.dupe(u8, trimmed); allocator.free(which.stdout); return out; } /// 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. (Optional) Compile `#jni_main` Java sources to classes.dex. /// 4. aapt2 link → empty APK with resources/manifest. /// 5. Append the lib/ tree via `zip`. /// 6. (Optional) Append classes.dex if step 3 produced one. /// 7. zipalign → aligned APK. /// 8. 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, jni_main_decls: []const JniMainEmission) !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}); const d8 = try std.fmt.allocPrint(allocator, "{s}/d8", .{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; }; // `#jni_main #jni_class("...")` decls: write .java files, compile with // javac, produce classes.dex via d8. Slice 2 of the #jni_main pipeline: // the .dex is bundled but the manifest still points at NativeActivity, // so the .dex is not yet referenced at runtime (slice 3 wires it). const has_dex = jni_main_decls.len > 0; if (has_dex) { try compileJniMainSources(allocator, io, stage, jni_main_decls, android_jar, d8); } // 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/" }); if (has_dex) { try runProcessIn(allocator, io, stage, &.{ "zip", "-q", unaligned, "classes.dex" }); } // Bundle the project's `./assets/` directory (if present) at the APK's // top level so AAssetManager_open(path) at runtime can read them. // Resolves relative to the user's CWD at invocation time — matches the // convention chess uses (assets/ next to main.sx). if (std.Io.Dir.openDir(.cwd(), io, "assets", .{})) |dir_handle| { var dh = dir_handle; dh.close(io); try runProcess(allocator, io, &.{ "zip", "-q", "-r", unaligned, "assets/" }); } else |_| {} // 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, \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ \\ , .{ 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 --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 { const r = std.process.run(allocator, io, .{ .argv = &.{ "xcrun", "--sdk", sdk_name, "--show-sdk-path" }, }) catch |e| { std.debug.print("error: failed to run xcrun: {} \u{2014} install Xcode Command Line Tools (xcode-select --install)\n", .{e}); return error.SdkNotFound; }; defer allocator.free(r.stderr); errdefer allocator.free(r.stdout); if (r.term != .exited or r.term.exited != 0) { std.debug.print("error: xcrun --sdk {s} --show-sdk-path failed\n", .{sdk_name}); allocator.free(r.stdout); return error.SdkNotFound; } const trimmed = std.mem.trimEnd(u8, r.stdout, " \t\r\n"); const out = try allocator.dupe(u8, trimmed); allocator.free(r.stdout); return out; } pub fn link(allocator: std.mem.Allocator, io: std.Io, output_obj: []const u8, extra_objects: []const []const u8, output_bin: []const u8, libraries: []const []const u8, frameworks: []const []const u8, target_config: TargetConfig) !void { var argv = std.ArrayList([]const u8).empty; if (target_config.isIOS()) { // iOS: clang driver with -isysroot pointing at the iOS SDK. // -l libraries are generally wrong for iOS (Apple ships system code // as frameworks); user-declared #library still pass through. const linker = target_config.linker orelse "clang"; try argv.append(allocator, linker); if (target_config.triple) |t| { try argv.append(allocator, "-target"); try argv.append(allocator, std.mem.span(t)); } const sdk_path = if (target_config.sysroot) |sr| try allocator.dupe(u8, sr) else blk: { const sdk_name: []const u8 = if (target_config.isIOSSimulator()) "iphonesimulator" else "iphoneos"; break :blk try discoverAppleSdk(allocator, io, sdk_name); }; try argv.append(allocator, "-isysroot"); try argv.append(allocator, sdk_path); const min_flag: []const u8 = if (target_config.isIOSSimulator()) "-mios-simulator-version-min=14.0" else "-mios-version-min=14.0"; try argv.append(allocator, min_flag); // Embedded framework load path: bundle/Frameworks at runtime. try argv.append(allocator, "-Wl,-rpath,@executable_path/Frameworks"); try argv.append(allocator, output_obj); try argv.append(allocator, "-o"); try argv.append(allocator, output_bin); for (extra_objects) |eo| try argv.append(allocator, eo); for (target_config.lib_paths) |lp| { try argv.append(allocator, try std.fmt.allocPrint(allocator, "-L{s}", .{lp})); } for (target_config.framework_paths) |fp| { try argv.append(allocator, try std.fmt.allocPrint(allocator, "-F{s}", .{fp})); } for (libraries) |lib| { try argv.append(allocator, try std.fmt.allocPrint(allocator, "-l{s}", .{lib})); } for (frameworks) |fw| { try argv.append(allocator, "-framework"); try argv.append(allocator, fw); } 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.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). // // The `libraries` parameter (collected from `#library` directives) // and `frameworks` parameter (Apple-only by definition) are // intentionally ignored here. On Android, users opt into specific // libs via `opts.add_link_flag("-l")` in their build.sx — // the platform-specific link surface should be expressed in build // options rather than auto-inherited from every imported module // (most of which assume Apple targets). 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})); } // Default libs available on every Android runtime; linker drops // unreferenced ones automatically. `#library` directives are // intentionally NOT auto-emitted here (most assume Apple targets); // users opt in per-target via `opts.add_link_flag("-l...")` in // their build.sx. 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"; try argv.appendSlice(allocator, &.{ linker, output_obj, "-o", output_bin }); for (extra_objects) |eo| try argv.append(allocator, eo); if (target_config.sysroot) |sr| { try argv.append(allocator, try std.fmt.allocPrint(allocator, "--sysroot={s}", .{sr})); } for (target_config.lib_paths) |lp| { try argv.append(allocator, try std.fmt.allocPrint(allocator, "-L{s}", .{lp})); } // Skip -l flags for Emscripten: libraries like SDL3 are provided via // -sUSE_SDL=3, not -lSDL3. User provides everything via --lflags. // wasm64: automatically add -sMEMORY64 for the linker if (target_config.isWasm64()) { try argv.append(allocator, "-sMEMORY64"); } // HTML shell template: use custom path if set, otherwise write built-in template to temp file if (std.mem.endsWith(u8, output_bin, ".html")) { if (target_config.wasm_shell_path) |custom_shell| { try argv.appendSlice(allocator, &.{ "--shell-file", custom_shell }); } else { const shell_html = @embedFile("wasm_shell.html"); const shell_path = try std.fmt.allocPrint(allocator, "{s}.shell.html", .{output_obj}); std.Io.Dir.writeFile(.cwd(), io, .{ .sub_path = shell_path, .data = shell_html }) catch {}; try argv.appendSlice(allocator, &.{ "--shell-file", shell_path }); } } // Extra linker flags (e.g. -sUSE_SDL=3, -sUSE_WEBGL2=1, --preload-file assets) // Split space-separated flags into individual argv entries. 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.isWindows()) { // Windows: MSVC-style linker flags const linker = target_config.linker orelse "link.exe"; try argv.appendSlice(allocator, &.{ linker, output_obj }); for (extra_objects) |eo| try argv.append(allocator, eo); try argv.append(allocator, try std.fmt.allocPrint(allocator, "/OUT:{s}", .{output_bin})); for (target_config.lib_paths) |lp| { try argv.append(allocator, try std.fmt.allocPrint(allocator, "/LIBPATH:{s}", .{lp})); } for (libraries) |lib| { try argv.append(allocator, try std.fmt.allocPrint(allocator, "{s}.lib", .{lib})); } } else { // Unix: cc-style linker flags try argv.appendSlice(allocator, &.{ target_config.getLinker(), output_obj, "-o", output_bin }); for (extra_objects) |eo| try argv.append(allocator, eo); if (target_config.sysroot) |sr| { try argv.append(allocator, try std.fmt.allocPrint(allocator, "--sysroot={s}", .{sr})); } // User-supplied library paths first for (target_config.lib_paths) |lp| { try argv.append(allocator, try std.fmt.allocPrint(allocator, "-L{s}", .{lp})); } // Auto-detect host OS library paths when linking foreign libraries if (libraries.len > 0 and target_config.triple == null) { for (host_lib_paths) |path| { try argv.append(allocator, try std.fmt.allocPrint(allocator, "-L{s}", .{path})); } } for (libraries) |lib| { try argv.append(allocator, try std.fmt.allocPrint(allocator, "-l{s}", .{lib})); } // Frameworks: only meaningful on Apple targets; silently ignored elsewhere. if (target_config.isMacOS()) { for (frameworks) |fw| { try argv.append(allocator, "-framework"); try argv.append(allocator, fw); } } // Extra linker flags — split space-separated flags into individual argv entries. for (target_config.extra_link_flags) |flag| { var it = std.mem.tokenizeScalar(u8, flag, ' '); while (it.next()) |part| { try argv.append(allocator, part); } } } const argv_slice = try argv.toOwnedSlice(allocator); if (std.c.getenv("SX_DEBUG_LINK") != null) { std.debug.print("[sx] link argv:", .{}); for (argv_slice) |a| std.debug.print(" {s}", .{a}); std.debug.print("\n", .{}); } var child = std.process.spawn(io, .{ .argv = argv_slice, }) catch return error.LinkError; const result = child.wait(io) catch return error.LinkError; if (result != .exited) return error.LinkError; if (result.exited != 0) return error.LinkError; } /// Move `binary_path` into a freshly-created `` directory, /// write a minimal Info.plist, and ad-hoc codesign for simulator runs. /// The executable inside the bundle is named after the basename of /// `binary_path` (also used as CFBundleExecutable). pub fn createBundle(allocator: std.mem.Allocator, io: std.Io, binary_path: []const u8, target_config: TargetConfig, frameworks: []const []const u8) !void { const bundle_path = target_config.bundle_path orelse return error.NoBundlePath; const bundle_id = target_config.bundle_id orelse { std.debug.print("error: --bundle requires --bundle-id (e.g. co.swipelab.app)\n", .{}); return error.MissingBundleId; }; // Device builds without a real identity will be rejected by the device, // so fail fast with a clear hint. if (target_config.isIOSDevice() and target_config.codesign_identity == null) { std.debug.print("error: --target ios requires --codesign-identity (e.g. \"Apple Development: ...\") and --provisioning-profile \n", .{}); return error.MissingCodesignIdentity; } const cwd = std.Io.Dir.cwd(); cwd.deleteTree(io, bundle_path) catch {}; try cwd.createDirPath(io, bundle_path); const exe_name = std.fs.path.basename(binary_path); const exe_dest = try std.fs.path.join(allocator, &.{ bundle_path, exe_name }); cwd.rename(binary_path, cwd, exe_dest, io) catch return error.BundleMoveFailed; // Info.plist const plist = try buildInfoPlist(allocator, exe_name, bundle_id, target_config); const plist_path = try std.fs.path.join(allocator, &.{ bundle_path, "Info.plist" }); try cwd.writeFile(io, .{ .sub_path = plist_path, .data = plist }); // Embed provisioning profile if supplied. Required for device installs. if (target_config.provisioning_profile) |pp| { const profile_data = std.Io.Dir.readFileAlloc(.cwd(), io, pp, allocator, .limited(1 * 1024 * 1024)) catch { std.debug.print("error: cannot read provisioning profile: {s}\n", .{pp}); return error.ProvisioningProfileNotFound; }; const embedded_path = try std.fs.path.join(allocator, &.{ bundle_path, "embedded.mobileprovision" }); try cwd.writeFile(io, .{ .sub_path = embedded_path, .data = profile_data }); } // Embed any dynamic frameworks the binary links against. iOS apps load // frameworks from `.app/Frameworks/.framework/` via // the `@executable_path/Frameworks` rpath we set at link time. For each // framework, look it up in `framework_paths` and copy the bundle in. if (target_config.isIOS() and frameworks.len > 0) { const fw_dir = try std.fs.path.join(allocator, &.{ bundle_path, "Frameworks" }); try cwd.createDirPath(io, fw_dir); for (frameworks) |fw| { try embedFramework(allocator, io, fw, target_config.framework_paths, fw_dir); } } // Codesign: real identity for device, ad-hoc otherwise. const identity: []const u8 = target_config.codesign_identity orelse "-"; const ent_path: ?[]const u8 = if (target_config.entitlements_path) |e| e else blk: { if (target_config.provisioning_profile) |pp| { break :blk try extractEntitlements(allocator, io, pp, bundle_id); } break :blk null; }; try codesign(allocator, io, bundle_path, identity, ent_path); } /// Find `.framework` in one of `framework_paths` and copy it into /// `/.framework`. Shells out to `cp -R` because Zig's std /// doesn't expose a recursive-copy primitive on `Io.Dir` yet. fn embedFramework(allocator: std.mem.Allocator, io: std.Io, name: []const u8, framework_paths: []const []const u8, dest_dir: []const u8) !void { const cwd = std.Io.Dir.cwd(); const subdir = try std.fmt.allocPrint(allocator, "{s}.framework", .{name}); for (framework_paths) |fp| { const candidate = try std.fs.path.join(allocator, &.{ fp, subdir }); if (cwd.openDir(io, candidate, .{})) |d| { d.close(io); const dest = try std.fs.path.join(allocator, &.{ dest_dir, subdir }); const r = std.process.run(allocator, io, .{ .argv = &.{ "cp", "-R", candidate, dest }, }) catch return error.FrameworkCopyFailed; defer allocator.free(r.stdout); defer allocator.free(r.stderr); if (r.term != .exited or r.term.exited != 0) { std.debug.print("error: cp -R {s} -> {s} failed: {s}\n", .{ candidate, dest, r.stderr }); return error.FrameworkCopyFailed; } return; } else |_| {} } std.debug.print("warning: framework '{s}' not found in any -F path; runtime load will fail\n", .{name}); } /// Extract entitlements XML from a `.mobileprovision` and resolve the /// `application-identifier` wildcard (`.*`) to the concrete bundle ID /// (`.`). Without this substitution the device installer /// rejects the app with `MIInstallerErrorDomain error 13` / /// `0xe8008015 (A valid provisioning profile ... was not found)`. /// Writes the resolved entitlements to `.sx-tmp/entitlements.plist`. fn extractEntitlements(allocator: std.mem.Allocator, io: std.Io, profile_path: []const u8, bundle_id: []const u8) ![]const u8 { const cwd = std.Io.Dir.cwd(); cwd.createDirPath(io, ".sx-tmp") catch {}; const profile_plist_path = ".sx-tmp/profile.plist"; const ent_path = ".sx-tmp/entitlements.plist"; // 1. security cms -D -i -o profile.plist (decode CMS to plist) const r1 = std.process.run(allocator, io, .{ .argv = &.{ "security", "cms", "-D", "-i", profile_path, "-o", profile_plist_path }, }) catch return error.SecurityCommandFailed; defer allocator.free(r1.stdout); defer allocator.free(r1.stderr); if (r1.term != .exited or r1.term.exited != 0) { std.debug.print("error: failed to decode provisioning profile: {s}\n", .{r1.stderr}); return error.SecurityCommandFailed; } // 2. plutil -extract Entitlements xml1 -o entitlements.plist profile.plist const r2 = std.process.run(allocator, io, .{ .argv = &.{ "plutil", "-extract", "Entitlements", "xml1", "-o", ent_path, profile_plist_path }, }) catch return error.PlutilCommandFailed; defer allocator.free(r2.stdout); defer allocator.free(r2.stderr); if (r2.term != .exited or r2.term.exited != 0) { std.debug.print("error: failed to extract entitlements: {s}\n", .{r2.stderr}); return error.PlutilCommandFailed; } // 3. Read the team identifier so we can resolve the wildcard. The profile // stores it as `ApplicationIdentifierPrefix.0` (an array). We use that // path because `com.apple.developer.team-identifier` would confuse // plutil — dots in plutil paths are interpreted as path separators. const r3 = std.process.run(allocator, io, .{ .argv = &.{ "plutil", "-extract", "ApplicationIdentifierPrefix.0", "raw", "-o", "-", profile_plist_path }, }) catch return error.PlutilCommandFailed; defer allocator.free(r3.stdout); defer allocator.free(r3.stderr); if (r3.term != .exited or r3.term.exited != 0) { std.debug.print("error: profile missing ApplicationIdentifierPrefix: {s}\n", .{r3.stderr}); return error.PlutilCommandFailed; } const team = std.mem.trimEnd(u8, r3.stdout, " \t\r\n"); const resolved_app_id = try std.fmt.allocPrint(allocator, "{s}.{s}", .{ team, bundle_id }); defer allocator.free(resolved_app_id); // 4. plutil -replace application-identifier -string "." entitlements.plist const r4 = std.process.run(allocator, io, .{ .argv = &.{ "plutil", "-replace", "application-identifier", "-string", resolved_app_id, ent_path }, }) catch return error.PlutilCommandFailed; defer allocator.free(r4.stdout); defer allocator.free(r4.stderr); if (r4.term != .exited or r4.term.exited != 0) { std.debug.print("error: failed to resolve application-identifier: {s}\n", .{r4.stderr}); return error.PlutilCommandFailed; } return try allocator.dupe(u8, ent_path); } fn buildInfoPlist(allocator: std.mem.Allocator, exe_name: []const u8, bundle_id: []const u8, target_config: TargetConfig) ![]const u8 { const min_os: []const u8 = "14.0"; const is_sim = target_config.isIOSSimulator(); const platform_key: []const u8 = if (is_sim) "iPhoneSimulator" else "iPhoneOS"; // UIApplicationSceneManifest opts the app into the iOS 13+ scene-based // lifecycle. Without it, iOS 26 boots the app in `[rb-legacy]` mode and // the CAMetalLayer never reaches the compositor. With the manifest // declared (no UISceneDelegate listed), iOS auto-connects an implicit // scene that our SxAppDelegate can find via `[app connectedScenes]`. return std.fmt.allocPrint(allocator, \\ \\ \\ \\ \\ CFBundleIdentifier \\ {s} \\ CFBundleName \\ {s} \\ CFBundleExecutable \\ {s} \\ CFBundlePackageType \\ APPL \\ CFBundleVersion \\ 1 \\ CFBundleShortVersionString \\ 0.1 \\ MinimumOSVersion \\ {s} \\ UIDeviceFamily \\ \\ 1 \\ \\ LSRequiresIPhoneOS \\ \\ UILaunchScreen \\ \\ UIApplicationSceneManifest \\ \\ UIApplicationSupportsMultipleScenes \\ \\ UISceneConfigurations \\ \\ UIWindowSceneSessionRoleApplication \\ \\ \\ UISceneConfigurationName \\ Default Configuration \\ UISceneDelegateClassName \\ SxSceneDelegate \\ \\ \\ \\ \\ DTPlatformName \\ {s} \\ \\ \\ , .{ bundle_id, exe_name, exe_name, min_os, platform_key }); } fn codesign(allocator: std.mem.Allocator, io: std.Io, bundle_path: []const u8, identity: []const u8, entitlements: ?[]const u8) !void { var argv = std.ArrayList([]const u8).empty; defer argv.deinit(allocator); try argv.appendSlice(allocator, &.{ "codesign", "--force", "--sign", identity, "--timestamp=none" }); if (entitlements) |ep| { try argv.appendSlice(allocator, &.{ "--entitlements", ep }); } try argv.append(allocator, bundle_path); const r = std.process.run(allocator, io, .{ .argv = argv.items }) catch |e| { std.debug.print("error: failed to run codesign: {}\n", .{e}); return error.CodesignFailed; }; defer allocator.free(r.stdout); defer allocator.free(r.stderr); if (r.term != .exited or r.term.exited != 0) { std.debug.print("codesign failed: {s}\n", .{r.stderr}); return error.CodesignFailed; } } /// After emcc produces HTML output, inject cache-busting hashes into the /// generated // Inject ?v=HASH into the src and prepend a Module.locateFile script. while (std.mem.indexOfPos(u8, html, pos, "src=\"")) |src_start| { const val_start = src_start + 5; // past src=" const val_end = std.mem.indexOfPos(u8, html, val_start, "\"") orelse break; const src_val = html[val_start..val_end]; if (!std.mem.endsWith(u8, src_val, ".js")) { // Not a .js src — skip past this attribute and keep searching pos = val_end + 1; continue; } // Find the opening < of this tag to inject locateFile before it const tag_start = if (std.mem.lastIndexOf(u8, html[pos..src_start], "<")) |off| pos + off else src_start; // Copy everything up to the tag start out.appendSlice(allocator, html[pos..tag_start]) catch return; // Inject Module.locateFile once, before the first .js script tag if (!injected_locateFile) { out.appendSlice(allocator, "\n") catch return; injected_locateFile = true; } // Copy tag up to the closing quote of src, inserting ?v=HASH out.appendSlice(allocator, html[tag_start..val_end]) catch return; out.appendSlice(allocator, "?v=") catch return; out.appendSlice(allocator, hash_hex) catch return; pos = val_end; } // Copy remaining HTML out.appendSlice(allocator, html[pos..]) catch return; const final = out.toOwnedSlice(allocator) catch return; std.Io.Dir.writeFile(.cwd(), io, .{ .sub_path = html_path, .data = final }) catch {}; } /// Common library paths for the host OS, computed at comptime. pub const host_lib_paths = blk: { const builtin = @import("builtin"); var paths: []const []const u8 = &.{}; if (builtin.os.tag == .macos) { if (builtin.cpu.arch == .aarch64) { // Apple Silicon Homebrew paths = &.{ "/opt/homebrew/lib", "/usr/local/lib" }; } else { // Intel Mac Homebrew paths = &.{"/usr/local/lib"}; } } else if (builtin.os.tag == .linux) { paths = &.{ "/usr/local/lib", "/usr/lib" }; } break :blk paths; };