When `Compilation.lowering_jni_main_decls` is non-empty, `createApk` synthesises a manifest whose `<activity android:name>` points at the user's `#jni_main` class (dotted form of the foreign path), sets `android:hasCode="true"` so Android loads the bundled classes.dex, and drops the `android.app.lib_name` meta-data (that's the NativeActivity- specific autoload mechanism — Java-driven Activities load the .so via `System.loadLibrary` from a Java static initializer slice R.3 will emit). The legacy NativeActivity path stays as the fallback when no `#jni_main` decl is present. `jni_java_emit.zig`'s default superclass moves from `android.app.NativeActivity` to `android.app.Activity` — the former requires native_app_glue's `ANativeActivity_onCreate` to be in the .so, which the next slice (R.2) will stop linking by default. Verified end-to-end on the slice 2 smoke APK: `aapt2 dump xmltree` shows `android:name="co.swipelab.sxjnimain.SxApp"` + `hasCode="true"`, and `dexdump -l plain` confirms SxApp now extends `Landroid/app/Activity;`. 99-android-egl-clear's APK still uses the NativeActivity manifest as before (legacy path intact for R.2-R.5).
1231 lines
56 KiB
Zig
1231 lines
56 KiB
Zig
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 `<stage>/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 `<stage>/java/<pkg>/<Class>.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 `<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 });
|
|
}
|
|
|
|
/// Write each `JniMainEmission`'s `.java` source under `<stage>/java/<pkg>/`,
|
|
/// invoke `javac` to compile to `<stage>/classes/`, then `d8` to produce
|
|
/// `<stage>/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. When a `#jni_main`
|
|
// class is declared, the auto-generated manifest points its
|
|
// `<activity android:name="...">` at the user's class and flips
|
|
// `android:hasCode="true"` so Android loads the bundled classes.dex.
|
|
// Otherwise we fall back to the legacy NativeActivity shape.
|
|
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 = if (jni_main_decls.len > 0)
|
|
try buildJniMainManifest(allocator, bundle_id, lib_name, jni_main_decls[0].foreign_path)
|
|
else
|
|
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;
|
|
}
|
|
|
|
/// Manifest for a `#jni_main` Activity: `<activity android:name>` points
|
|
/// at the user's class, `android:hasCode="true"` so the bundled
|
|
/// classes.dex is loaded, and the `android.app.lib_name` meta-data is
|
|
/// dropped (that's a NativeActivity-only mechanism — Java-driven
|
|
/// Activities load the .so via `System.loadLibrary` from a static
|
|
/// initializer the Java emitter will synthesize once slice R.3 lands).
|
|
fn buildJniMainManifest(allocator: std.mem.Allocator, package: []const u8, lib_name: []const u8, foreign_path: []const u8) ![]const u8 {
|
|
var class_name = std.ArrayList(u8).empty;
|
|
for (foreign_path) |ch| {
|
|
try class_name.append(allocator, if (ch == '/') '.' else ch);
|
|
}
|
|
const activity_name = try class_name.toOwnedSlice(allocator);
|
|
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="true">
|
|
\\ <activity
|
|
\\ android:name="{s}"
|
|
\\ android:exported="true"
|
|
\\ android:label="{s}"
|
|
\\ android:configChanges="orientation|keyboardHidden|screenSize">
|
|
\\ <intent-filter>
|
|
\\ <action android:name="android.intent.action.MAIN" />
|
|
\\ <category android:name="android.intent.category.LAUNCHER" />
|
|
\\ </intent-filter>
|
|
\\ </activity>
|
|
\\ </application>
|
|
\\</manifest>
|
|
\\
|
|
, .{ package, lib_name, activity_name, lib_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 {
|
|
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<name>")` 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 `<bundle_path>` 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 <path>\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 `<bundle>.app/Frameworks/<Name>.framework/<Name>` 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 `<name>.framework` in one of `framework_paths` and copy it into
|
|
/// `<dest_dir>/<name>.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 (`<TEAM>.*`) to the concrete bundle ID
|
|
/// (`<TEAM>.<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 <profile> -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 "<team>.<bundle_id>" 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,
|
|
\\<?xml version="1.0" encoding="UTF-8"?>
|
|
\\<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
\\<plist version="1.0">
|
|
\\<dict>
|
|
\\ <key>CFBundleIdentifier</key>
|
|
\\ <string>{s}</string>
|
|
\\ <key>CFBundleName</key>
|
|
\\ <string>{s}</string>
|
|
\\ <key>CFBundleExecutable</key>
|
|
\\ <string>{s}</string>
|
|
\\ <key>CFBundlePackageType</key>
|
|
\\ <string>APPL</string>
|
|
\\ <key>CFBundleVersion</key>
|
|
\\ <string>1</string>
|
|
\\ <key>CFBundleShortVersionString</key>
|
|
\\ <string>0.1</string>
|
|
\\ <key>MinimumOSVersion</key>
|
|
\\ <string>{s}</string>
|
|
\\ <key>UIDeviceFamily</key>
|
|
\\ <array>
|
|
\\ <integer>1</integer>
|
|
\\ </array>
|
|
\\ <key>LSRequiresIPhoneOS</key>
|
|
\\ <true/>
|
|
\\ <key>UILaunchScreen</key>
|
|
\\ <dict/>
|
|
\\ <key>UIApplicationSceneManifest</key>
|
|
\\ <dict>
|
|
\\ <key>UIApplicationSupportsMultipleScenes</key>
|
|
\\ <false/>
|
|
\\ <key>UISceneConfigurations</key>
|
|
\\ <dict>
|
|
\\ <key>UIWindowSceneSessionRoleApplication</key>
|
|
\\ <array>
|
|
\\ <dict>
|
|
\\ <key>UISceneConfigurationName</key>
|
|
\\ <string>Default Configuration</string>
|
|
\\ <key>UISceneDelegateClassName</key>
|
|
\\ <string>SxSceneDelegate</string>
|
|
\\ </dict>
|
|
\\ </array>
|
|
\\ </dict>
|
|
\\ </dict>
|
|
\\ <key>DTPlatformName</key>
|
|
\\ <string>{s}</string>
|
|
\\</dict>
|
|
\\</plist>
|
|
\\
|
|
, .{ 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 <script> tag and add Module.locateFile for .wasm/.data files.
|
|
pub fn postProcessWasmHtml(allocator: std.mem.Allocator, io: std.Io, html_path: []const u8) void {
|
|
const base = if (std.mem.endsWith(u8, html_path, ".html"))
|
|
html_path[0 .. html_path.len - 5]
|
|
else
|
|
return;
|
|
|
|
// Hash build output contents (.js + .wasm + optional .data)
|
|
var hash: u64 = 0;
|
|
const exts = [_][]const u8{ ".js", ".wasm", ".data" };
|
|
for (exts) |ext| {
|
|
const path = std.fmt.allocPrint(allocator, "{s}{s}", .{ base, ext }) catch continue;
|
|
if (std.Io.Dir.readFileAlloc(.cwd(), io, path, allocator, .limited(64 * 1024 * 1024))) |data| {
|
|
hash = std.hash.Wyhash.hash(hash, data);
|
|
} else |_| {}
|
|
}
|
|
|
|
const hash_hex = std.fmt.allocPrint(allocator, "{x:0>8}", .{@as(u32, @truncate(hash))}) catch return;
|
|
|
|
// Read the final HTML produced by emcc
|
|
const html = std.Io.Dir.readFileAlloc(.cwd(), io, html_path, allocator, .limited(10 * 1024 * 1024)) catch return;
|
|
|
|
var out = std.ArrayList(u8).empty;
|
|
var pos: usize = 0;
|
|
var injected_locateFile = false;
|
|
|
|
// Find emcc's generated script tag: <script ...src="*.js"></script>
|
|
// 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, "<script>Module.locateFile=function(p){return p+'?v=") catch return;
|
|
out.appendSlice(allocator, hash_hex) catch return;
|
|
out.appendSlice(allocator, "'}</script>\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;
|
|
};
|