bundling: Android APK pipeline moved into sx; android.sx state-on-plat

Week 7 of /Users/agra/.claude/plans/lets-plan-to-move-splendid-pumpkin.md
plus the android.sx refactor + three sx-compiler fixes hit along the way
to get chess on Pixel 7 Pro responding to touch end-to-end.

library/modules/platform/bundle.sx now covers the Android APK shape
alongside macOS / iOS-sim / iOS-device. `android_bundle_main` discovers
the SDK ($ANDROID_HOME / $ANDROID_SDK_ROOT / $HOME/Library/Android/sdk),
picks the highest-versioned build-tools + platforms via
`process.run("ls .. | sort -V | tail -1")`, stages
`<apk>.stage/lib/arm64-v8a/<libfoo.so>`, synthesizes
AndroidManifest.xml (NativeActivity vs `#jni_main` Activity branch),
writes each `#jni_main` decl's Java source under
`<stage>/java/<pkg>/<Cls>.java`, runs javac --release 11 + d8 to
produce classes.dex, aapt2-links the unaligned APK, appends lib/ +
classes.dex + each registered asset tree via zip, zipalign + ensure
debug keystore via keytool + apksigner sign.

Compiler-side accessors (src/ir/compiler_hooks.zig + library/modules/compiler.sx):
- is_android predicate.
- set_manifest_path / manifest_path + set_keystore_path / keystore_path.
- jni_main_count / jni_main_foreign_path_at(i) /
  jni_main_java_source_at(i) surface the `#jni_main` emissions that
  the Zig createApk previously consumed directly.
- main.zig wires manifest_path, keystore_path, and the per-decl
  (foreign_path, java_source) parallel slices into BuildConfig before
  invoking the post-link callback.

CLI `--apk <path>` keeps working as a transitional alias: it now feeds
bundle_path so the existing auto-`post_link_module = "platform.bundle"`
shim fires the same way as `--bundle`. main.zig no longer calls
target.createApk directly.

Deletions in src/target.zig: createApk, compileJniMainSources,
buildJniMainManifest, buildAndroidManifest, ensureDebugKeystore,
libNameFromSoBasename, plus helpers splitForeignPath / discoverJavac /
discoverAndroidSdk / findHighestSubdir / runProcess / runProcessIn
(~400 lines). git grep returns only the obituary comment.

library/modules/platform/android.sx refactor (chess Android dependency):
- Module-level globals retired (g_app_window, g_egl_*, g_viewport_*,
  g_dpi_scale, g_should_stop, g_render_thread*, g_user_main_fn,
  g_touch_*) → AndroidPlatform struct fields.
- All sx_android_* helpers take `plat: *AndroidPlatform` as first arg.
  Render thread receives plat via pthread_create's arg.
- New `logical_w: f32 = 0.0` field. Consumers set it before init() to
  define the design width in points; `recompute_scale` derives
  `dpi_scale = pixel_w / logical_w` (or 1.0 if unset). Called on
  init / set_viewport / egl_init. drain_touches divides incoming
  physical pixel coords by dpi_scale so chess sees logical-space
  positions matching its layout. Touch lands on the right squares.

Three sx-compiler bugs hit + fixed along the way:

1. Top-level `inline if OS == .X { decls }` body decls were silently
   dropped because scanDecls/lowerDecls had no .if_expr arm. New
   `flattenComptimeConditionals` pre-pass in src/imports.zig
   (threaded via ComptimeContext from core.zig) hoists matching arms
   recursively. Regression at examples/124-inline-if-hoist-toplevel.sx.

2. Parser rejected `#import` / `#framework` inside inline-if bodies
   because parseStmt in src/parser.zig only had arms for `#insert`.
   Added the missing arms. Regression at
   examples/123-inline-if-import-in-body.sx (landed earlier).

3. JNI `Call<T>Method` switches in src/ir/emit_llvm.zig (instance /
   nonvirtual / static) were missing `.f32` rows — jfloat returns
   (e.g. MotionEvent.getX/getY) fell into the silent-undef else arm.
   Chess's sx_android_push_touch(plat, getAction(), getX(), getY())
   delivered garbage f32 coords to the touch ring, so taps landed
   nowhere recognisable. Added `.f32 => Jni.Call{Static,Nonvirtual,}FloatMethod`
   rows to all three switches; lifted unsupported-type detection
   from emit_llvm into lowerForeignMethodCall with proper
   source-spanned diagnostics (`isJniReturnTypeSupported`). Regressions
   at examples/ffi-jni-call-10-jfloat-return.sx,
   examples/ffi-jni-class-09-multi-float-args.sx,
   examples/ffi-jni-call-11-unsupported-return-diag.sx.

Stale-snapshot drift in tests/expected/ffi-objc-call-03-selector-sharing.ir
and ffi-objc-call-06-sret-return.ir picks up the new BuildOptions
accessor extern decls (is_android, set_manifest_path,
set_keystore_path, jni_main_count, jni_main_foreign_path_at,
jni_main_java_source_at). Verified diff is dead-decl-only.

Chess on Pixel 7 Pro: tap on e2 white pawn -> yellow selection +
green dots on legal e3/e4 targets; tap on e4 -> board updates with
1. e4, "Black to move" + "1. e4" in info panel.

zig build && zig build test && bash tests/run_examples.sh -> 145/145
green. bash tests/cross_compile.sh -> 7/7 green.
This commit is contained in:
agra
2026-05-23 01:28:32 +03:00
parent 5cc62e63c3
commit 632e64512b
26 changed files with 1437 additions and 567 deletions

View File

@@ -38,8 +38,10 @@ pub const Compilation = struct {
/// AST sources in `collectCImportSources`.
lowering_extra_c_sources: std.ArrayList(c_import.CImportInfo) = .empty,
/// `#jni_main #jni_class("...")` declarations whose Java sources were
/// rendered during lowering. Read by the APK pipeline (`createApk`)
/// to write `.java` files + run `javac` + `d8` + bundle `classes.dex`.
/// rendered during lowering. Surfaced to the sx Android bundler
/// (`library/modules/platform/bundle.sx`) via `BuildConfig.jni_main_*`
/// in `compiler_hooks.zig`; the bundler writes `.java` files + runs
/// `javac` + `d8` + bundles `classes.dex` into the APK.
lowering_jni_main_decls: std.ArrayList(JniMainEmission) = .empty,
pub fn init(allocator: std.mem.Allocator, io: std.Io, file_path: []const u8, source: [:0]const u8, target_config: TargetConfig, stdlib_paths: []const []const u8) Compilation {
@@ -72,6 +74,18 @@ pub const Compilation = struct {
self.root = p.parse() catch return error.CompileError;
}
/// Derive the comptime evaluation context (OS / ARCH / POINTER_SIZE
/// values) from the build target. Used by `imports.resolveImports`
/// to hoist top-level `inline if OS == .X { ... }` body decls
/// before resolution; mirrors `injectComptimeConstants` in lowering.
fn comptimeContext(self: *const Compilation) imports.ComptimeContext {
const tc = self.target_config;
const os: []const u8 = if (tc.isWasm()) "wasm" else if (tc.isWindows()) "windows" else if (tc.isAndroid()) "android" else if (tc.isLinux()) "linux" else if (tc.isIOS()) "ios" else if (tc.isMacOS()) "macos" else "unknown";
const arch: []const u8 = if (tc.isWasm32()) "wasm32" else if (tc.isWasm64()) "wasm64" else if (tc.isAarch64()) "aarch64" else if (tc.isX86_64()) "x86_64" else "unknown";
const ptr_size: i64 = if (tc.isWasm32()) 4 else 8;
return .{ .os = os, .arch = arch, .pointer_size = ptr_size };
}
pub fn resolveImports(self: *Compilation) !void {
const root = self.root orelse return error.CompileError;
var chain = std.StringHashMap(void).init(self.allocator);
@@ -89,6 +103,7 @@ pub const Compilation = struct {
&self.diagnostics,
self.stdlib_paths,
&self.import_graph,
self.comptimeContext(),
) catch return error.CompileError;
// Preserve per-module visibility scopes for C import access checking
@@ -166,7 +181,13 @@ pub const Compilation = struct {
defer interp.deinit();
if (self.ir_emitter) |*e| interp.build_config = &e.build_config;
ir.Interpreter.last_bail_op = null;
return try interp.call(id, args);
ir.Interpreter.last_bail_builtin = null;
const result = interp.call(id, args) catch |err| {
if (interp.output.items.len > 0) std.debug.print("{s}", .{interp.output.items});
return err;
};
if (interp.output.items.len > 0) std.debug.print("{s}", .{interp.output.items});
return result;
}
/// Get link flags accumulated from #run build blocks.

View File

@@ -5,6 +5,140 @@ const errors = @import("errors.zig");
const c_import = @import("c_import.zig");
const Node = ast.Node;
/// Comptime evaluation context for the inline-if hoisting pass below.
/// Mirrors the values `injectComptimeConstants` will later push into the
/// lowering's `comptime_constants` map (OS / ARCH / POINTER_SIZE), but
/// derived directly from the build target so we can resolve top-level
/// `inline if OS == .X { ... }` arms before imports + lowering run.
pub const ComptimeContext = struct {
/// Lowercase OS name matching the OperatingSystem enum tag
/// (macos / linux / windows / wasm / ios / android / unknown).
os: []const u8 = "unknown",
/// Lowercase architecture name matching the Architecture enum tag
/// (aarch64 / x86_64 / wasm32 / wasm64 / unknown).
arch: []const u8 = "unknown",
/// 4 for wasm32, 8 for every other target.
pointer_size: i64 = 8,
};
/// Top-level `inline if OS == .X { decls }` blocks are parsed as
/// `if_expr` / `match_expr` nodes in `root.decls`, but the lowering
/// pass only knows how to dispatch on `.fn_decl` / `.const_decl` /
/// `.var_decl` / etc. at decl positions — an `if_expr` at the top
/// level is silently dropped. Same story for `#import` decls inside an
/// `inline if` body: they need to be surfaced to the top so import
/// resolution sees them.
///
/// This pass walks `decls`, replaces every comptime conditional with
/// the body of its taken arm (recursively flattened), and drops the
/// rest. A condition we can't resolve at this stage is also dropped —
/// the caller may want to surface that as a diagnostic later, but for
/// the OS / ARCH / POINTER_SIZE patterns we cover here it shouldn't
/// happen in practice.
pub fn flattenComptimeConditionals(allocator: std.mem.Allocator, decls: []const *Node, ctx: ComptimeContext) std.mem.Allocator.Error![]const *Node {
var out = std.ArrayList(*Node).empty;
for (decls) |decl| {
switch (decl.data) {
.if_expr => |ie| {
if (ie.is_comptime) {
if (evalComptimeCondition(ie.condition, ctx)) |is_true| {
const taken: ?*const Node = if (is_true) ie.then_branch else ie.else_branch;
if (taken) |b| try appendBranchDecls(allocator, &out, b, ctx);
continue;
}
// Couldn't evaluate — drop the whole conditional. This is
// a conservative choice; future work may surface it as a
// diagnostic. For OS / ARCH / POINTER_SIZE comparisons
// the eval is total, so this shouldn't fire in practice.
continue;
}
try out.append(allocator, decl);
},
.match_expr => |me| {
if (me.is_comptime) {
if (evalComptimeMatch(&me, ctx)) |body| {
try appendBranchDecls(allocator, &out, body, ctx);
}
continue;
}
try out.append(allocator, decl);
},
else => try out.append(allocator, decl),
}
}
return try out.toOwnedSlice(allocator);
}
fn appendBranchDecls(allocator: std.mem.Allocator, out: *std.ArrayList(*Node), branch: *const Node, ctx: ComptimeContext) std.mem.Allocator.Error!void {
const stmts: []const *Node = if (branch.data == .block)
branch.data.block.stmts
else
&[_]*Node{@constCast(branch)};
const recursed = try flattenComptimeConditionals(allocator, stmts, ctx);
try out.appendSlice(allocator, recursed);
}
fn evalComptimeCondition(node: *const Node, ctx: ComptimeContext) ?bool {
if (node.data != .binary_op) return null;
const bo = &node.data.binary_op;
if (bo.op != .eq and bo.op != .neq) return null;
const name = switch (bo.lhs.data) {
.identifier => |id| id.name,
else => return null,
};
if (std.mem.eql(u8, name, "OS") or std.mem.eql(u8, name, "ARCH")) {
const variant = switch (bo.rhs.data) {
.enum_literal => |el| el.name,
else => return null,
};
const target = if (std.mem.eql(u8, name, "OS")) ctx.os else ctx.arch;
const matches = std.mem.eql(u8, variant, target);
return if (bo.op == .eq) matches else !matches;
}
if (std.mem.eql(u8, name, "POINTER_SIZE")) {
const rhs_val: i64 = switch (bo.rhs.data) {
.int_literal => |il| il.value,
else => return null,
};
const matches = ctx.pointer_size == rhs_val;
return if (bo.op == .eq) matches else !matches;
}
return null;
}
fn evalComptimeMatch(me: *const ast.MatchExpr, ctx: ComptimeContext) ?*const Node {
const name = switch (me.subject.data) {
.identifier => |id| id.name,
else => return null,
};
if (std.mem.eql(u8, name, "OS") or std.mem.eql(u8, name, "ARCH")) {
const target = if (std.mem.eql(u8, name, "OS")) ctx.os else ctx.arch;
for (me.arms) |arm| {
const pattern = arm.pattern orelse continue;
const variant = switch (pattern.data) {
.enum_literal => |el| el.name,
else => continue,
};
if (std.mem.eql(u8, variant, target)) return arm.body;
}
for (me.arms) |arm| if (arm.pattern == null) return arm.body;
return null;
}
if (std.mem.eql(u8, name, "POINTER_SIZE")) {
for (me.arms) |arm| {
const pattern = arm.pattern orelse continue;
const rhs_val: i64 = switch (pattern.data) {
.int_literal => |il| il.value,
else => continue,
};
if (ctx.pointer_size == rhs_val) return arm.body;
}
for (me.arms) |arm| if (arm.pattern == null) return arm.body;
return null;
}
return null;
}
pub fn dirName(path: []const u8) []const u8 {
var last_sep: usize = 0;
var found = false;
@@ -176,6 +310,7 @@ pub fn resolveImports(
diagnostics: ?*errors.DiagnosticList,
stdlib_paths: []const []const u8,
import_graph: ?*std.StringHashMap(std.StringHashMap(void)),
comptime_ctx: ComptimeContext,
) !ResolvedModule {
// Record this file's edge set so `param_impl_map` lookups can filter
// candidates by what's been imported from where. Populated as each
@@ -196,9 +331,15 @@ pub fn resolveImports(
return mod;
}
// Hoist top-level `inline if OS == .X { ... }` body decls (including
// any `#import`s inside them) to the top level before resolution
// proceeds. After this pass, the decl list contains no top-level
// `if_expr` / `match_expr` nodes with `is_comptime = true`.
const flat_decls = try flattenComptimeConditionals(allocator, root.data.root.decls, comptime_ctx);
var decl_list = std.ArrayList(*Node).empty;
for (root.data.root.decls) |decl| {
for (flat_decls) |decl| {
if (decl.data == .c_import_decl) {
// Resolve `#source` / `#include` paths through the same chain
// as `#import`: importing-file's directory → CWD → stdlib
@@ -312,7 +453,7 @@ pub fn resolveImports(
// Push onto chain before recursing, pop after
try chain.put(resolved_path, {});
const imp_dir = dirName(resolved_path);
const result = try resolveImports(allocator, io, imp_root, imp_dir, resolved_path, chain, cache, source_map, diagnostics, stdlib_paths, import_graph);
const result = try resolveImports(allocator, io, imp_root, imp_dir, resolved_path, chain, cache, source_map, diagnostics, stdlib_paths, import_graph, comptime_ctx);
_ = chain.remove(resolved_path);
// Cache
@@ -320,7 +461,7 @@ pub fn resolveImports(
break :blk result;
} else |_| {
// File read failed — try as directory import
const result = resolveDirectoryImport(allocator, io, resolved_path, chain, cache, source_map, diagnostics, decl.span, stdlib_paths, import_graph) catch {
const result = resolveDirectoryImport(allocator, io, resolved_path, chain, cache, source_map, diagnostics, decl.span, stdlib_paths, import_graph, comptime_ctx) catch {
if (diagnostics) |diags| {
diags.addFmt(.err, decl.span, "cannot read import '{s}' (not a file or directory)", .{resolved_path});
}
@@ -354,6 +495,7 @@ fn resolveDirectoryImport(
span: ast.Span,
stdlib_paths: []const []const u8,
import_graph: ?*std.StringHashMap(std.StringHashMap(void)),
comptime_ctx: ComptimeContext,
) anyerror!ResolvedModule {
// Open the directory with iteration capability
const dir = std.Io.Dir.openDir(.cwd(), io, dir_path, .{ .iterate = true }) catch {
@@ -419,7 +561,7 @@ fn resolveDirectoryImport(
};
try chain.put(file_path, {});
const result = try resolveImports(allocator, io, imp_root, dir_path, file_path, chain, cache, source_map, diagnostics, stdlib_paths, import_graph);
const result = try resolveImports(allocator, io, imp_root, dir_path, file_path, chain, cache, source_map, diagnostics, stdlib_paths, import_graph, comptime_ctx);
_ = chain.remove(file_path);
try cache.put(file_path, result);

View File

@@ -64,6 +64,26 @@ pub const BuildConfig = struct {
target_frameworks: []const []const u8 = &.{},
target_framework_paths: []const []const u8 = &.{},
/// User-supplied `AndroidManifest.xml` override (`--manifest <path>`
/// or `BuildOptions.set_manifest_path("...")`). When null, the
/// Android bundler synthesizes a default manifest.
manifest_path: ?[]const u8 = null,
/// User-supplied debug keystore path (`--keystore <path>` or
/// `BuildOptions.set_keystore_path("...")`). When null, the Android
/// bundler uses `$HOME/.android/debug.keystore` (auto-generated on
/// first use via `keytool`).
keystore_path: ?[]const u8 = null,
/// `#jni_main #jni_class("path") { ... }` decls discovered during
/// lowering, paired with their pre-rendered Java source. The
/// Android bundler writes each entry to
/// `<stage>/java/<pkg>/<Class>.java`, compiles via `javac` + `d8`,
/// and bundles the resulting `classes.dex` into the APK. Slices
/// reference compiler-owned memory that outlives the post-link
/// callback.
jni_main_foreign_paths: []const []const u8 = &.{},
jni_main_java_sources: []const []const u8 = &.{},
pub fn deinit(self: *BuildConfig, alloc: Allocator) void {
self.link_flags.deinit(alloc);
self.frameworks.deinit(alloc);
@@ -126,12 +146,22 @@ pub const Registry = struct {
self.hooks.put("BuildOptions.bundle_id", &hookGetBundleId) catch {};
self.hooks.put("BuildOptions.codesign_identity", &hookGetCodesignIdentity) catch {};
self.hooks.put("BuildOptions.provisioning_profile", &hookGetProvisioningProfile) catch {};
// Target accessors — mirror TargetConfig.is{MacOS,IOS,IOSDevice,IOSSimulator}()
// Target accessors — mirror TargetConfig.is{MacOS,IOS,IOSDevice,IOSSimulator,Android}()
self.hooks.put("BuildOptions.target_triple", &hookGetTargetTriple) catch {};
self.hooks.put("BuildOptions.is_macos", &hookIsMacOS) catch {};
self.hooks.put("BuildOptions.is_ios", &hookIsIOS) catch {};
self.hooks.put("BuildOptions.is_ios_device", &hookIsIOSDevice) catch {};
self.hooks.put("BuildOptions.is_ios_simulator", &hookIsIOSSimulator) catch {};
self.hooks.put("BuildOptions.is_android", &hookIsAndroid) catch {};
// Android-specific setters + accessors
self.hooks.put("BuildOptions.set_manifest_path", &hookSetManifestPath) catch {};
self.hooks.put("BuildOptions.manifest_path", &hookGetManifestPath) catch {};
self.hooks.put("BuildOptions.set_keystore_path", &hookSetKeystorePath) catch {};
self.hooks.put("BuildOptions.keystore_path", &hookGetKeystorePath) catch {};
// #jni_main class emissions, exposed by index so bundle.sx can iterate.
self.hooks.put("BuildOptions.jni_main_count", &hookJniMainCount) catch {};
self.hooks.put("BuildOptions.jni_main_foreign_path_at", &hookJniMainForeignPathAt) catch {};
self.hooks.put("BuildOptions.jni_main_java_source_at", &hookJniMainJavaSourceAt) catch {};
// Framework list accessors (for `.app/Frameworks/` embedding)
self.hooks.put("BuildOptions.framework_count", &hookFrameworkCount) catch {};
self.hooks.put("BuildOptions.framework_at", &hookFrameworkAt) catch {};
@@ -408,6 +438,60 @@ fn hookIsIOSSimulator(_: *const Interpreter, _: []const Value, bc: *BuildConfig,
return Value{ .boolean = ios and sim };
}
fn hookIsAndroid(_: *const Interpreter, _: []const Value, bc: *BuildConfig, _: Allocator) HookError!Value {
return Value{ .boolean = tripleContains(bc.target_triple, "android") };
}
// ── Android-specific bundling setters + accessors ─────────────────────
fn hookSetManifestPath(interp: *const Interpreter, args: []const Value, bc: *BuildConfig, alloc: Allocator) HookError!Value {
if (args.len < 2) return .void_val;
if (args[1].asString(interp)) |s| {
bc.manifest_path = alloc.dupe(u8, s) catch return error.CannotEvalComptime;
}
return .void_val;
}
fn hookGetManifestPath(_: *const Interpreter, _: []const Value, bc: *BuildConfig, _: Allocator) HookError!Value {
return Value{ .string = bc.manifest_path orelse "" };
}
fn hookSetKeystorePath(interp: *const Interpreter, args: []const Value, bc: *BuildConfig, alloc: Allocator) HookError!Value {
if (args.len < 2) return .void_val;
if (args[1].asString(interp)) |s| {
bc.keystore_path = alloc.dupe(u8, s) catch return error.CannotEvalComptime;
}
return .void_val;
}
fn hookGetKeystorePath(_: *const Interpreter, _: []const Value, bc: *BuildConfig, _: Allocator) HookError!Value {
return Value{ .string = bc.keystore_path orelse "" };
}
// ── #jni_main emission accessors ──────────────────────────────────────
// The Android bundler walks these as `0..jni_main_count()` and reads
// each entry's `(foreign_path, java_source)` pair so it can write a
// `.java` file per decl, compile via javac, and produce classes.dex
// via d8 before zipping into the APK.
fn hookJniMainCount(_: *const Interpreter, _: []const Value, bc: *BuildConfig, _: Allocator) HookError!Value {
return Value{ .int = @intCast(bc.jni_main_foreign_paths.len) };
}
fn hookJniMainForeignPathAt(_: *const Interpreter, args: []const Value, bc: *BuildConfig, _: Allocator) HookError!Value {
if (args.len < 2) return Value{ .string = "" };
const idx = args[1].asInt() orelse return error.TypeError;
if (idx < 0 or @as(usize, @intCast(idx)) >= bc.jni_main_foreign_paths.len) return Value{ .string = "" };
return Value{ .string = bc.jni_main_foreign_paths[@intCast(idx)] };
}
fn hookJniMainJavaSourceAt(_: *const Interpreter, args: []const Value, bc: *BuildConfig, _: Allocator) HookError!Value {
if (args.len < 2) return Value{ .string = "" };
const idx = args[1].asInt() orelse return error.TypeError;
if (idx < 0 or @as(usize, @intCast(idx)) >= bc.jni_main_java_sources.len) return Value{ .string = "" };
return Value{ .string = bc.jni_main_java_sources[@intCast(idx)] };
}
// ── Framework list accessors ──────────────────────────────────────────
// The Apple .app bundler in `library/modules/platform/bundle.sx` walks
// the framework list to recursively copy each `<Name>.framework`

View File

@@ -1288,6 +1288,7 @@ pub const LLVMEmitter = struct {
.void => Jni.CallStaticVoidMethod,
.s32 => Jni.CallStaticIntMethod,
.s64 => Jni.CallStaticLongMethod,
.f32 => Jni.CallStaticFloatMethod,
.f64 => Jni.CallStaticDoubleMethod,
.bool => Jni.CallStaticBooleanMethod,
else => {
@@ -1301,6 +1302,7 @@ pub const LLVMEmitter = struct {
.void => Jni.CallNonvirtualVoidMethod,
.s32 => Jni.CallNonvirtualIntMethod,
.s64 => Jni.CallNonvirtualLongMethod,
.f32 => Jni.CallNonvirtualFloatMethod,
.f64 => Jni.CallNonvirtualDoubleMethod,
.bool => Jni.CallNonvirtualBooleanMethod,
else => {
@@ -1314,6 +1316,7 @@ pub const LLVMEmitter = struct {
.void => Jni.CallVoidMethod,
.s32 => Jni.CallIntMethod,
.s64 => Jni.CallLongMethod,
.f32 => Jni.CallFloatMethod,
.f64 => Jni.CallDoubleMethod,
.bool => Jni.CallBooleanMethod,
else => {

View File

@@ -138,6 +138,7 @@ pub const Interpreter = struct {
pub var last_bail_op: ?[]const u8 = null;
pub var last_bail_file: ?[]const u8 = null;
pub var last_bail_offset: u32 = 0;
pub var last_bail_builtin: ?[]const u8 = null;
pub fn init(module: *const Module, alloc: Allocator) Interpreter {
var hooks = compiler_hooks.Registry.init(alloc);
@@ -656,7 +657,12 @@ pub const Interpreter = struct {
const args = self.alloc.alloc(Value, c.args.len) catch return error.CannotEvalComptime;
defer self.alloc.free(args);
for (c.args, 0..) |ref, i| {
args[i] = frame.getRef(ref);
// Inline any slot_ptr field-refs in the caller's frame before
// the value crosses the call boundary. slot_ptr indices are
// frame-local; if a slice/aggregate carrying one is passed to
// the callee, the callee would later resolve the index against
// its own slot table and read garbage.
args[i] = self.materializeForCall(frame, frame.getRef(ref));
}
const result = try self.call(c.callee, args);
return .{ .value = result };
@@ -1218,6 +1224,38 @@ pub const Interpreter = struct {
// ── Slot chain resolution ────────────────────────────────────
/// Walk an aggregate Value and rewrite any embedded `slot_ptr` that points
/// to a field-ref slot in `frame` (the marker shape `{parent_slot, idx, ..}`
/// emitted by `struct_gep` / `index_gep`) into the resolved parent value.
/// Slot indices are frame-local; a slice passed across a call would otherwise
/// read its data_ptr out of the callee's slot table.
fn materializeForCall(self: *Interpreter, frame: *Frame, val: Value) Value {
switch (val) {
.aggregate => |fields| {
const new_fields = self.alloc.alloc(Value, fields.len) catch return val;
for (fields, 0..) |f, i| {
new_fields[i] = self.materializeForCall(frame, f);
}
return .{ .aggregate = new_fields };
},
.slot_ptr => |slot| {
const stored = frame.loadSlot(slot);
if (stored == .aggregate) {
const ref_fields = stored.aggregate;
if (ref_fields.len >= 2) {
const parent_slot_val = ref_fields[0].asInt() orelse return val;
if (ref_fields[1].asInt() == null) return val;
const parent_slot: u32 = @intCast(parent_slot_val);
const parent = frame.loadSlot(parent_slot);
return self.materializeForCall(frame, parent);
}
}
return val;
},
else => return val,
}
}
/// Follow a slot_ptr through field-pointer / index-gep chains
/// to get the underlying value. Handles nested dereferences.
fn resolveSlotChain(self: *Interpreter, frame: *Frame, val: Value) Value {
@@ -1354,6 +1392,14 @@ pub const Interpreter = struct {
// ── Builtin call dispatch ──────────────────────────────────────
fn execBuiltin(self: *Interpreter, bi: inst_mod.BuiltinCall, frame: *Frame, _: TypeId) InterpError!ExecResult {
const result = self.execBuiltinInner(bi, frame) catch |err| {
if (last_bail_builtin == null) last_bail_builtin = @tagName(bi.builtin);
return err;
};
return result;
}
fn execBuiltinInner(self: *Interpreter, bi: inst_mod.BuiltinCall, frame: *Frame) InterpError!ExecResult {
switch (bi.builtin) {
.malloc => {
const size_val = frame.getRef(bi.args[0]);
@@ -1378,10 +1424,16 @@ pub const Interpreter = struct {
.heap_ptr => |hp| hp,
else => return error.CannotEvalComptime,
};
// Get source bytes
const src_bytes: []const u8 = switch (src) {
.heap_ptr => |hp| self.heapSlice(hp) orelse return error.CannotEvalComptime,
.string => |s| s,
// Raw host address (e.g. a `*u8` returned by a foreign
// call like getenv). Read `len` bytes across the FFI
// boundary into the sx-managed dst.
.int => |addr| blk: {
const raw: [*]const u8 = @ptrFromInt(@as(usize, @bitCast(addr)));
break :blk raw[0..len];
},
else => return error.CannotEvalComptime,
};
self.heapMemcpy(dst_hp, src_bytes, len);

View File

@@ -55,6 +55,30 @@ pub const Options = struct {
lib_name: ?[]const u8 = null,
};
/// Inject a `static { System.loadLibrary("<lib>"); }` block into an already-
/// rendered Java source. Used when the output path isn't known until after
/// `#run` blocks execute — `collectJniMainEmissions` runs during lowering,
/// before `BuildOptions.set_output_path(...)` has populated the lib name.
/// Returns a newly-allocated string; caller owns it.
pub fn injectLoadLibrary(allocator: Allocator, java_source: []const u8, lib_name: []const u8) ![]u8 {
const marker = " {\n";
const class_pos = std.mem.indexOf(u8, java_source, "public class ") orelse return try allocator.dupe(u8, java_source);
const brace_rel = std.mem.indexOf(u8, java_source[class_pos..], marker) orelse return try allocator.dupe(u8, java_source);
const insert_at = class_pos + brace_rel + marker.len;
// Already injected? Skip.
if (std.mem.indexOf(u8, java_source, "System.loadLibrary(") != null) {
return try allocator.dupe(u8, java_source);
}
var buf: std.ArrayList(u8) = .empty;
errdefer buf.deinit(allocator);
try buf.appendSlice(allocator, java_source[0..insert_at]);
try buf.appendSlice(allocator, " static { System.loadLibrary(\"");
try buf.appendSlice(allocator, lib_name);
try buf.appendSlice(allocator, "\"); }\n");
try buf.appendSlice(allocator, java_source[insert_at..]);
return try buf.toOwnedSlice(allocator);
}
/// Emit a `.java` source for the given foreign-class decl. Result is
/// heap-allocated through `allocator`; caller owns it.
pub fn emitJavaSource(

View File

@@ -4150,6 +4150,19 @@ pub const Lowering = struct {
const ret_ty = if (method.return_type) |rt| self.resolveType(rt) else .void;
// Reject return types the JNI emit path can't dispatch — emit_llvm's
// Call<T>Method switch only covers void / bool / s32 / s64 / f32 / f64
// / pointer-returning. Anything else (s8 / s16 / u8 / u16 / aggregates)
// would silently lower to LLVMGetUndef and produce wrong arguments at
// the call site (chess Android touch shipped broken because s32→s32+
// f32 returns hit the undef path before .f32 was wired up).
if (!isJniReturnTypeSupported(&self.module.types, ret_ty)) {
if (self.diagnostics) |d| {
d.addFmt(.err, span, "JNI method '{s}.{s}' returns '{s}', which isn't supported by the JNI call-method lowering yet — only void/bool/s32/s64/f32/f64 and pointers are wired up", .{ fcd.name, method.name, self.module.types.typeName(ret_ty) });
}
return Ref.none;
}
const cache_key: inst_mod.CacheKey = .{
.name_str = method_name,
.sig_str = desc_str,
@@ -10295,6 +10308,23 @@ fn jniMapParamType(self: *Lowering, type_node: *ast.Node) TypeId {
return self.resolveType(type_node);
}
/// Whether emit_llvm's `jni_msg_send` lowering can dispatch a Call<T>Method
/// for this return type. Anything outside this set falls into the `else`
/// arm of the switches in `emit_llvm.zig` and would silently produce
/// `LLVMGetUndef` — a footgun that previously shipped (chess Android touch
/// went undef because `MotionEvent.getX() -> f32` wasn't in the switch).
/// Pointer-typed returns route through `CallObjectMethod`.
pub fn isJniReturnTypeSupported(table: *const @import("types.zig").TypeTable, ret_ty: TypeId) bool {
return switch (ret_ty) {
.void, .bool, .s32, .s64, .f32, .f64 => true,
else => blk: {
if (ret_ty.isBuiltin()) break :blk false;
const info = table.get(ret_ty);
break :blk info == .pointer or info == .many_pointer;
},
};
}
/// Encode a (foreign_path, method_name) pair as the JNI-resolved symbol
/// `Java_<pkg-mangled>_<Class>_sx_1<method-mangled>`. JNI mangling:
/// `/` → `_`, `_` → `_1`. The `sx_` prefix matches the Java-side

View File

@@ -408,16 +408,17 @@ fn printInterpBailDiag(comp: *const sx.core.Compilation, label: []const u8, err:
std.debug.print("error: {s} failed: {s}\n", .{ label, @errorName(err) });
return;
};
const op_detail: []const u8 = if (sx.ir.Interpreter.last_bail_builtin) |b| b else op;
if (sx.ir.Interpreter.last_bail_file) |file| {
if (comp.import_sources.get(file)) |source| {
const loc = sx.errors.SourceLoc.compute(source, sx.ir.Interpreter.last_bail_offset);
std.debug.print("error: {s} failed: {s} (op={s}) at {s}:{d}:{d}\n", .{ label, @errorName(err), op, file, loc.line, loc.col });
std.debug.print("error: {s} failed: {s} (op={s}/{s}) at {s}:{d}:{d}\n", .{ label, @errorName(err), op, op_detail, file, loc.line, loc.col });
return;
}
std.debug.print("error: {s} failed: {s} (op={s}) at {s}:+{d}\n", .{ label, @errorName(err), op, file, sx.ir.Interpreter.last_bail_offset });
std.debug.print("error: {s} failed: {s} (op={s}/{s}) at {s}:+{d}\n", .{ label, @errorName(err), op, op_detail, file, sx.ir.Interpreter.last_bail_offset });
return;
}
std.debug.print("error: {s} failed: {s} (op={s})\n", .{ label, @errorName(err), op });
std.debug.print("error: {s} failed: {s} (op={s}/{s})\n", .{ label, @errorName(err), op, op_detail });
}
fn readSource(allocator: std.mem.Allocator, io: std.Io, input_path: []const u8) ![:0]const u8 {
@@ -620,14 +621,6 @@ fn compileWithTimer(allocator: std.mem.Allocator, io: std.Io, input_path: []cons
};
timer.record("link");
// Wrap into an .apk if requested (Android).
if (merged_config.apk_path) |ap| {
timer.mark();
sx.target.createApk(allocator, io, final_output, merged_config, comp.getJniMainEmissions()) catch std.process.exit(1);
timer.record("apk");
std.debug.print("apk: {s}\n", .{ap});
}
// Make the linked binary's path + bundling config visible to the
// post-link callback via `BuildOptions.binary_path()`,
// `BuildOptions.bundle_path()`, etc. CLI flags
@@ -635,7 +628,11 @@ fn compileWithTimer(allocator: std.mem.Allocator, io: std.Io, input_path: []cons
// bundler doesn't need a separate code path.
if (comp.ir_emitter) |*e| {
e.build_config.binary_path = final_output;
if (e.build_config.bundle_path == null) e.build_config.bundle_path = merged_config.bundle_path;
// `--apk <path>` is a transitional alias for the bundle_path
// → post_link_module = "platform.bundle" auto-fallback. The
// sx Android bundler reads `bundle_path()` regardless of which
// CLI flag the user typed.
if (e.build_config.bundle_path == null) e.build_config.bundle_path = merged_config.bundle_path orelse merged_config.apk_path;
if (e.build_config.bundle_id == null) e.build_config.bundle_id = merged_config.bundle_id;
if (e.build_config.codesign_identity == null) e.build_config.codesign_identity = merged_config.codesign_identity;
if (e.build_config.provisioning_profile == null) e.build_config.provisioning_profile = merged_config.provisioning_profile;
@@ -646,6 +643,37 @@ fn compileWithTimer(allocator: std.mem.Allocator, io: std.Io, input_path: []cons
if (merged_config.triple) |t| e.build_config.target_triple = std.mem.span(t);
e.build_config.target_frameworks = fws;
e.build_config.target_framework_paths = merged_config.framework_paths;
// Android-specific bundling state.
if (e.build_config.manifest_path == null) e.build_config.manifest_path = merged_config.manifest_path;
if (e.build_config.keystore_path == null) e.build_config.keystore_path = merged_config.keystore_path;
// `#jni_main` decls flow from the compiler's lowering pass —
// pre-rendered Java sources + the foreign_path for each. Build
// two parallel slices since BuildConfig hooks return strings.
const jni_decls = comp.getJniMainEmissions();
if (jni_decls.len > 0) {
// If the output path was set via `BuildOptions.set_output_path`
// (i.e. from a #run block, not CLI -o), the Java sources were
// rendered during lowering before we knew the .so basename and
// they're missing the `static { System.loadLibrary(...); }`
// block. Inject it now using the final resolved output.
const lib_name: ?[]const u8 = blk: {
const base = std.fs.path.basename(final_output);
if (!std.mem.startsWith(u8, base, "lib")) break :blk null;
if (!std.mem.endsWith(u8, base, ".so")) break :blk null;
break :blk base[3 .. base.len - 3];
};
const fps = try allocator.alloc([]const u8, jni_decls.len);
const srcs = try allocator.alloc([]const u8, jni_decls.len);
for (jni_decls, 0..) |em, idx| {
fps[idx] = em.foreign_path;
srcs[idx] = if (lib_name) |ln|
try sx.ir.jni_java_emit.injectLoadLibrary(allocator, em.java_source, ln)
else
em.java_source;
}
e.build_config.jni_main_foreign_paths = fps;
e.build_config.jni_main_java_sources = srcs;
}
}
// CLI `--bundle <path>` migration shim. The legacy Zig bundler

View File

@@ -3,9 +3,11 @@ 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.
/// Populated by lowering and surfaced to the sx Android bundler in
/// `library/modules/platform/bundle.sx` via `BuildConfig.jni_main_*`,
/// which writes a `.java` file under `<stage>/java/<pkg>/<Cls>.java`,
/// compiles via `javac`, dexes via `d8`, and bundles 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`.
@@ -233,405 +235,16 @@ pub fn runJITFromObject(obj_buf: c.LLVMMemoryBufferRef) !u8 {
return if (result >= 0 and result <= 255) @intCast(result) else 1;
}
/// Discover the Android SDK root. Honors $ANDROID_HOME / $ANDROID_SDK_ROOT,
/// otherwise picks the default install location on macOS. Caller owns slice.
pub fn discoverAndroidSdk(allocator: std.mem.Allocator, io: std.Io) ![]const u8 {
if (std.c.getenv("ANDROID_HOME")) |env| {
return try allocator.dupe(u8, std.mem.span(env));
}
if (std.c.getenv("ANDROID_SDK_ROOT")) |env| {
return try allocator.dupe(u8, std.mem.span(env));
}
const home_env = std.c.getenv("HOME") orelse {
std.debug.print("error: cannot locate Android SDK — set $ANDROID_HOME\n", .{});
return error.SdkNotFound;
};
const home = std.mem.span(home_env);
const sdk = try std.fmt.allocPrint(allocator, "{s}/Library/Android/sdk", .{home});
var dir = std.Io.Dir.openDir(.cwd(), io, sdk, .{}) catch {
std.debug.print("error: no Android SDK at {s} — install via Android Studio or set $ANDROID_HOME\n", .{sdk});
return error.SdkNotFound;
};
dir.close(io);
return sdk;
}
// Android APK bundling (createApk, compileJniMainSources,
// buildAndroidManifest, buildJniMainManifest, ensureDebugKeystore,
// libNameFromSoBasename + helpers) has moved to
// `library/modules/platform/bundle.sx`. `src/main.zig` invokes it
// post-link via the BuildOptions callback registered from sx code.
// `--apk <path>` on the CLI is a transitional alias that feeds
// `bundle_path` so the auto-fallback to `platform.bundle.bundle_main`
// fires; programs that opt in via `set_post_link_callback` reach the
// sx bundler directly.
/// 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);
// `Theme.DeviceDefault.NoActionBar.Fullscreen` removes both the
// ActionBar title (the "sxchess" strip) and the status bar — sx-rendered
// apps own the whole window. Consumers wanting a different theme will
// ship their own manifest via `--manifest`.
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:theme="@android:style/Theme.DeviceDefault.NoActionBar.Fullscreen"
\\ 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