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:
24
examples/124-inline-if-hoist-toplevel.sx
Normal file
24
examples/124-inline-if-hoist-toplevel.sx
Normal file
@@ -0,0 +1,24 @@
|
||||
// Regression: top-level `inline if OS == .X { ... }` body decls get
|
||||
// hoisted to the actual top level. Before this commit, the `if_expr`
|
||||
// landed in `root.decls` but `scanDecls` had no `.if_expr` arm, so the
|
||||
// body was silently dropped — chess's
|
||||
// `inline if OS == .android { SxApp :: #jni_main #jni_class(...) { ... } }`
|
||||
// was invisible to the compiler. Fix:
|
||||
// `imports.flattenComptimeConditionals` runs at the head of
|
||||
// `resolveImports` and replaces matching arms with their body stmts
|
||||
// (recursively, so a nested `inline if` inside a hoisted arm also
|
||||
// hoists).
|
||||
//
|
||||
// Three patterns covered: a global `var_decl`, an `#import`, and a
|
||||
// nested `inline if` whose else arm fires on host macOS.
|
||||
|
||||
#import "modules/std.sx";
|
||||
|
||||
inline if OS == .android {
|
||||
#import "modules/std.sx";
|
||||
g_value : s64 = 99;
|
||||
} else {
|
||||
g_value : s64 = 42;
|
||||
}
|
||||
|
||||
main :: () { print("{}\n", g_value); }
|
||||
28
examples/ffi-jni-call-10-jfloat-return.sx
Normal file
28
examples/ffi-jni-call-10-jfloat-return.sx
Normal file
@@ -0,0 +1,28 @@
|
||||
// Regression: `#jni_call(f32)` (jfloat return).
|
||||
// Before the fix, the Call<T>Method switch in `src/ir/emit_llvm.zig`
|
||||
// only handled `.f64` (jdouble), so any JNI method returning `float`
|
||||
// fell through to the `else` arm and emitted `LLVMGetUndef` — a
|
||||
// silent-undef footgun that shipped on Android (chess
|
||||
// `MotionEvent.getX()` / `getY()` came through as `undef` arguments
|
||||
// to `sx_android_push_touch`, breaking every touch).
|
||||
//
|
||||
// This test exercises the `.f32` slot (CallFloatMethod, vtable 55) +
|
||||
// proves the build doesn't error out the JNI dispatch path for it.
|
||||
|
||||
#import "modules/std.sx";
|
||||
|
||||
g_should_call : bool = false;
|
||||
|
||||
read_float :: (env: *void, target: *void) -> f32 {
|
||||
#jni_env(env) {
|
||||
#jni_call(f32)(target, "getValue", "()F");
|
||||
}
|
||||
}
|
||||
|
||||
main :: () -> s32 {
|
||||
if g_should_call {
|
||||
_ := read_float(null, null);
|
||||
}
|
||||
print("ok\n");
|
||||
0;
|
||||
}
|
||||
33
examples/ffi-jni-call-11-unsupported-return-diag.sx
Normal file
33
examples/ffi-jni-call-11-unsupported-return-diag.sx
Normal file
@@ -0,0 +1,33 @@
|
||||
// Regression: when a `#jni_call` method returns a type the
|
||||
// `Call<T>Method` switch in `emit_llvm.zig` can't dispatch
|
||||
// (anything outside void/bool/s32/s64/f32/f64/pointer), the
|
||||
// compiler must emit a DIAGNOSTIC at lower time rather than
|
||||
// silently producing `LLVMGetUndef` at codegen time. Without
|
||||
// this guard, the chess Android touch bug shipped: an
|
||||
// unsupported return type silently became `undef` and showed
|
||||
// up as garbage arguments downstream.
|
||||
//
|
||||
// Here we declare a JNI method returning `s8` (jbyte, not yet
|
||||
// wired into the call-method switch). The compile must fail
|
||||
// with a clear message naming the method + return type.
|
||||
|
||||
#import "modules/std.sx";
|
||||
|
||||
Buf :: #foreign #jni_class("java/nio/ByteBuffer") {
|
||||
get :: (self: *Self) -> s8;
|
||||
}
|
||||
|
||||
g_should_call : bool = false;
|
||||
|
||||
unused :: (env: *void, b: *Buf) {
|
||||
#jni_env(env) {
|
||||
_ := b.get();
|
||||
}
|
||||
}
|
||||
|
||||
main :: () -> s32 {
|
||||
if g_should_call {
|
||||
unused(null, null);
|
||||
}
|
||||
0;
|
||||
}
|
||||
49
examples/ffi-jni-class-09-multi-float-args.sx
Normal file
49
examples/ffi-jni-class-09-multi-float-args.sx
Normal file
@@ -0,0 +1,49 @@
|
||||
// Regression: `obj.method()` foreign-class dispatch with a `float`
|
||||
// return type used to silently emit `LLVMGetUndef` because the
|
||||
// `Call<T>Method` switch in `emit_llvm.zig` didn't cover `.f32`.
|
||||
// Combined with multiple such calls inlined as args to a single
|
||||
// outer call (`f(o.a(), o.b(), o.c())`), every arg after the first
|
||||
// went out as `undef` — exactly the chess Android touch failure
|
||||
// (`MotionEvent.getX()` + `getY()` came through as `undef`s into
|
||||
// `sx_android_push_touch`).
|
||||
//
|
||||
// This test exercises BOTH the `.f32` jdispatch slot AND the
|
||||
// "multiple foreign-class method calls as args to one outer call"
|
||||
// pattern. The bodies are gated behind a runtime-false flag so the
|
||||
// JNI lookups never execute (no JVM in the test runtime), but the
|
||||
// codegen path still has to emit the calls correctly.
|
||||
|
||||
#import "modules/std.sx";
|
||||
|
||||
MotionEvent :: #foreign #jni_class("android/view/MotionEvent") {
|
||||
getAction :: (self: *Self) -> s32;
|
||||
getX :: (self: *Self) -> f32;
|
||||
getY :: (self: *Self) -> f32;
|
||||
}
|
||||
|
||||
sx_consume_touch :: (action: s32, x: f32, y: f32) {
|
||||
// Black-hole call so the args aren't dead-stripped before LLVM
|
||||
// verification gets a chance to look at the call site.
|
||||
if action == 0 and x == 0.0 and y == 0.0 {
|
||||
print("zero\n");
|
||||
}
|
||||
}
|
||||
|
||||
g_should_call : bool = false;
|
||||
|
||||
drive_touch :: (env: *void, ev: *MotionEvent) {
|
||||
#jni_env(env) {
|
||||
// The bug: getX() / getY() lowered to `undef` floats and the
|
||||
// call to sx_consume_touch passed garbage. Post-fix, all three
|
||||
// JNI calls emit proper Call<T>Method dispatches.
|
||||
sx_consume_touch(ev.getAction(), ev.getX(), ev.getY());
|
||||
}
|
||||
}
|
||||
|
||||
main :: () -> s32 {
|
||||
if g_should_call {
|
||||
drive_touch(null, null);
|
||||
}
|
||||
print("ok\n");
|
||||
0;
|
||||
}
|
||||
@@ -59,6 +59,7 @@ BuildOptions :: struct #compiler {
|
||||
is_ios :: (self: BuildOptions) -> bool;
|
||||
is_ios_device :: (self: BuildOptions) -> bool;
|
||||
is_ios_simulator :: (self: BuildOptions) -> bool;
|
||||
is_android :: (self: BuildOptions) -> bool;
|
||||
|
||||
// Framework list accessors. The bundler walks `framework_count() *
|
||||
// framework_at(i)` to find each `-framework` name and recursively
|
||||
@@ -70,6 +71,24 @@ BuildOptions :: struct #compiler {
|
||||
framework_at :: (self: BuildOptions, i: s64) -> string;
|
||||
framework_path_count :: (self: BuildOptions) -> s64;
|
||||
framework_path_at :: (self: BuildOptions, i: s64) -> string;
|
||||
|
||||
// Android APK bundling parameters. `manifest_path` overrides the
|
||||
// bundler's auto-generated AndroidManifest.xml; `keystore_path`
|
||||
// overrides the default `$HOME/.android/debug.keystore`. Accessors
|
||||
// return "" when unset.
|
||||
set_manifest_path :: (self: BuildOptions, path: [:0]u8);
|
||||
set_keystore_path :: (self: BuildOptions, path: [:0]u8);
|
||||
manifest_path :: (self: BuildOptions) -> string;
|
||||
keystore_path :: (self: BuildOptions) -> string;
|
||||
|
||||
// `#jni_main #jni_class("path") { ... }` decls collected during
|
||||
// lowering. The Android bundler walks `0..jni_main_count()` and
|
||||
// for each entry writes a `.java` file at
|
||||
// `<stage>/java/<foreign_path>.java`, compiles via javac + d8, and
|
||||
// bundles the resulting classes.dex into the APK.
|
||||
jni_main_count :: (self: BuildOptions) -> s64;
|
||||
jni_main_foreign_path_at :: (self: BuildOptions, i: s64) -> string;
|
||||
jni_main_java_source_at :: (self: BuildOptions, i: s64) -> string;
|
||||
}
|
||||
|
||||
build_options :: () -> BuildOptions #compiler;
|
||||
|
||||
@@ -19,6 +19,13 @@
|
||||
// (action,x,y) tuple onto a mutex-guarded queue. `poll_events`
|
||||
// drains the queue into the platform's standard `Event` shape.
|
||||
//
|
||||
// State model: every piece of mutable Android-backend state lives on
|
||||
// `AndroidPlatform` (the EGL handles, the ANativeWindow, the render
|
||||
// thread, the touch ring, the frame closure, the user main fn, the
|
||||
// touch mutex). Module-level globals are out — they shadow consumer
|
||||
// globals on `#import` and produced an integer/float-shadowing render
|
||||
// regression on chess before we eliminated them.
|
||||
//
|
||||
// Vulkan-compatible: same ANativeWindow drives `vkCreate*SurfaceKHR`
|
||||
// without changing the lifecycle.
|
||||
|
||||
@@ -117,44 +124,75 @@ eglDestroyContext :: (d: *void, ctx: *void) -> u32 #foreign;
|
||||
eglDestroySurface :: (d: *void, surface: *void) -> u32 #foreign;
|
||||
eglTerminate :: (d: *void) -> u32 #foreign;
|
||||
|
||||
// ── Module-level state ──────────────────────────────────────────────────
|
||||
// ── Touch ring ──────────────────────────────────────────────────────────
|
||||
|
||||
g_activity : *void = null; // global ref to the SxApp jobject (saved env can't outlive scope)
|
||||
g_app_window : *void = null; // ANativeWindow from surfaceCreated
|
||||
g_egl_display : *void = null;
|
||||
g_egl_context : *void = null;
|
||||
g_egl_surface : *void = null;
|
||||
g_egl_config : *void = null;
|
||||
g_viewport_w : s32 = 0;
|
||||
g_viewport_h : s32 = 0;
|
||||
// Defaults to 1.0 until a proper density query lands. Chess's pipeline
|
||||
// uses `viewport_w/h` as the layout space and `dpi_scale` to scale
|
||||
// rendering; mismatches cause layout drift / shrinking.
|
||||
g_dpi_scale : f32 = 1.0;
|
||||
g_should_stop : bool = false;
|
||||
g_render_thread_started : bool = false;
|
||||
g_render_thread : u64 = 0;
|
||||
g_frame_fn : Closure() = ---;
|
||||
g_frame_fn_set : bool = false;
|
||||
|
||||
// Touch event queue. Single-producer (Java UI thread) / single-consumer
|
||||
// (render thread); a small ring buffer guarded by a pthread mutex is
|
||||
// enough — chess only generates touches on user interaction so contention
|
||||
// is rare.
|
||||
TouchEvent :: struct {
|
||||
action: s32;
|
||||
x: f32;
|
||||
y: f32;
|
||||
}
|
||||
|
||||
g_touch_queue : [64]TouchEvent = ---;
|
||||
g_touch_head : u32 = 0;
|
||||
g_touch_tail : u32 = 0;
|
||||
// pthread_mutex_t is 40 bytes on bionic (NDK 26+); over-size to 64 for safety.
|
||||
g_touch_mutex_storage : [64]u8 = ---;
|
||||
g_touch_mutex_inited : bool = false;
|
||||
// ── AndroidPlatform ─────────────────────────────────────────────────────
|
||||
//
|
||||
// Every per-instance piece of state — EGL handles, ANativeWindow, render
|
||||
// thread, touch ring + mutex, frame closure, user main fn — lives here.
|
||||
// No module-level globals: a previous shape had `g_viewport_w : s32` at
|
||||
// module scope, which silently shadowed chess's own
|
||||
// `g_viewport_w : f32` on `#import` and caused the renderer to receive
|
||||
// a logical width cast to s32 instead of the physical pixel width.
|
||||
//
|
||||
// `logical_w` is the consumer's design width in points (e.g. chess sets
|
||||
// 414 to match an iPhone 12 layout). `begin_frame` derives `dpi_scale`
|
||||
// from `pixel_w / logical_w` so the renderer sees the design canvas
|
||||
// regardless of physical density. Touch coords are divided by the same
|
||||
// scale before delivery so layout-side hit-testing matches.
|
||||
|
||||
// ── #jni_main Activity ──────────────────────────────────────────────────
|
||||
AndroidPlatform :: struct {
|
||||
title: [:0]u8 = "";
|
||||
width: s32 = 0;
|
||||
height: s32 = 0;
|
||||
|
||||
// Set by consumer code BEFORE `init` if a fixed design width is
|
||||
// wanted (chess uses 414). When 0, the platform reports
|
||||
// viewport = pixel (1:1 logical/physical) — same as the legacy
|
||||
// hardcoded `dpi_scale = 1.0` behaviour.
|
||||
logical_w: f32 = 0.0;
|
||||
|
||||
// ANativeWindow + EGL state (set up by sx_android_attach_window +
|
||||
// egl_init on the render thread).
|
||||
app_window: *void = null;
|
||||
egl_display: *void = null;
|
||||
egl_context: *void = null;
|
||||
egl_surface: *void = null;
|
||||
egl_config: *void = null;
|
||||
|
||||
// Pixel-size from ANativeWindow_get{Width,Height}; derived dpi_scale
|
||||
// (pixel_w / logical_w when logical_w > 0, else 1.0).
|
||||
pixel_w: s32 = 0;
|
||||
pixel_h: s32 = 0;
|
||||
dpi_scale: f32 = 1.0;
|
||||
|
||||
// Render thread lifecycle. `user_main_fn` is the consumer's `main`
|
||||
// entry, invoked once EGL is current. `should_stop` is checked in
|
||||
// the frame loop; `stop()` flips it.
|
||||
render_thread: u64 = 0;
|
||||
render_thread_started: bool = false;
|
||||
user_main_fn: () -> void = ---;
|
||||
should_stop: bool = false;
|
||||
frame_closure: ?Closure() = null;
|
||||
|
||||
events: List(Event) = .{};
|
||||
|
||||
// Touch ring: single-producer (Java UI thread via sx_android_push_touch)
|
||||
// / single-consumer (render thread via sx_android_drain_touches).
|
||||
// pthread_mutex_t is 40 bytes on bionic (NDK 26+); over-size to 64
|
||||
// for safety. `touch_mutex_inited` tracks lazy init on first use.
|
||||
touch_queue: [64]TouchEvent = ---;
|
||||
touch_head: u32 = 0;
|
||||
touch_tail: u32 = 0;
|
||||
touch_mutex_storage: [64]u8 = ---;
|
||||
touch_mutex_inited: bool = false;
|
||||
}
|
||||
|
||||
// ── User-facing helpers for the consumer's `#jni_main` Activity ────────
|
||||
//
|
||||
@@ -164,11 +202,12 @@ g_touch_mutex_inited : bool = false;
|
||||
// those methods call:
|
||||
//
|
||||
// - `sx_android_forward_assets(env, activity)` from onCreate.
|
||||
// - `sx_android_attach_window(env, holder)` from surfaceCreated.
|
||||
// - `sx_android_detach_window()` from surfaceDestroyed.
|
||||
// - `sx_android_set_viewport(w, h)` from surfaceChanged.
|
||||
// - `sx_android_start_render_thread(main_fn)` once the surface is up.
|
||||
// - `sx_android_push_touch(action, x, y)` from onTouchEvent.
|
||||
// - `sx_android_attach_window(plat, env, holder)` from surfaceCreated.
|
||||
// - `sx_android_detach_window(plat)` from surfaceDestroyed.
|
||||
// - `sx_android_set_viewport(plat, w, h)` from surfaceChanged.
|
||||
// - `sx_android_start_render_thread(plat, main_fn)` once the surface
|
||||
// is up.
|
||||
// - `sx_android_push_touch(plat, action, x, y)` from onTouchEvent.
|
||||
|
||||
// Extract the AAssetManager from the Activity and install it into the
|
||||
// C file_utils so `read_file_bytes` can route through `AAssetManager_open`.
|
||||
@@ -181,67 +220,69 @@ sx_android_forward_assets :: (env: *void, activity: *JContext) {
|
||||
}
|
||||
}
|
||||
|
||||
// Extract the ANativeWindow from a SurfaceHolder. Call this from your
|
||||
// Activity's `surfaceCreated`. The window stays valid until
|
||||
// `sx_android_detach_window` runs (typically in `surfaceDestroyed`).
|
||||
sx_android_attach_window :: (env: *void, holder: *SurfaceHolder) {
|
||||
// Extract the ANativeWindow from a SurfaceHolder and stash it on `plat`.
|
||||
// Call this from your Activity's `surfaceCreated`. The window stays
|
||||
// valid until `sx_android_detach_window` runs (typically in
|
||||
// `surfaceDestroyed`).
|
||||
sx_android_attach_window :: (plat: *AndroidPlatform, env: *void, holder: *SurfaceHolder) {
|
||||
#jni_env(env) {
|
||||
surface := holder.getSurface();
|
||||
g_app_window = ANativeWindow_fromSurface(env, xx surface);
|
||||
plat.app_window = ANativeWindow_fromSurface(env, xx surface);
|
||||
}
|
||||
}
|
||||
|
||||
sx_android_detach_window :: () {
|
||||
if g_app_window != null {
|
||||
ANativeWindow_release(g_app_window);
|
||||
g_app_window = null;
|
||||
sx_android_detach_window :: (plat: *AndroidPlatform) {
|
||||
if plat.app_window != null {
|
||||
ANativeWindow_release(plat.app_window);
|
||||
plat.app_window = null;
|
||||
}
|
||||
}
|
||||
|
||||
sx_android_set_viewport :: (w: s32, h: s32) {
|
||||
g_viewport_w = w;
|
||||
g_viewport_h = h;
|
||||
sx_android_set_viewport :: (plat: *AndroidPlatform, w: s32, h: s32) {
|
||||
plat.pixel_w = w;
|
||||
plat.pixel_h = h;
|
||||
sx_android_recompute_scale(plat);
|
||||
}
|
||||
|
||||
// Start the render thread that brings up EGL on `g_app_window` and calls
|
||||
// the user-supplied `entry_fn` (typically the user's `main`). Safe to
|
||||
// call once after `sx_android_attach_window` has set the window.
|
||||
sx_android_start_render_thread :: (entry_fn: () -> void) {
|
||||
if g_render_thread_started { return; }
|
||||
g_user_main_fn = entry_fn;
|
||||
pthread_create(@g_render_thread, null, sx_android_render_thread_entry, null);
|
||||
g_render_thread_started = true;
|
||||
// Start the render thread that brings up EGL on `plat.app_window` and
|
||||
// calls `entry_fn` (typically the consumer's `main`). Safe to call once
|
||||
// after `sx_android_attach_window` has set the window.
|
||||
sx_android_start_render_thread :: (plat: *AndroidPlatform, entry_fn: () -> void) {
|
||||
if plat.render_thread_started { return; }
|
||||
plat.user_main_fn = entry_fn;
|
||||
pthread_create(@plat.render_thread, null, sx_android_render_thread_entry, xx plat);
|
||||
plat.render_thread_started = true;
|
||||
}
|
||||
|
||||
g_user_main_fn : () -> void = null;
|
||||
|
||||
sx_android_render_thread_entry :: (arg: *void) -> *void {
|
||||
while g_app_window == null and !g_should_stop {
|
||||
plat : *AndroidPlatform = xx arg;
|
||||
while plat.app_window == null and !plat.should_stop {
|
||||
usleep(1000);
|
||||
}
|
||||
if g_should_stop { return null; }
|
||||
if plat.should_stop { return null; }
|
||||
|
||||
if !sx_android_egl_init() {
|
||||
if !sx_android_egl_init(plat) {
|
||||
__android_log_print(6, "sxapp".ptr, "EGL bootstrap failed\n".ptr);
|
||||
return null;
|
||||
}
|
||||
|
||||
if g_user_main_fn != null {
|
||||
g_user_main_fn();
|
||||
if plat.user_main_fn != null {
|
||||
fn := plat.user_main_fn;
|
||||
fn();
|
||||
}
|
||||
null;
|
||||
}
|
||||
|
||||
// Bring up EGL on g_app_window. Sets g_egl_display / g_egl_context /
|
||||
// g_egl_surface and makes the context current. Returns false on any
|
||||
// failure — caller bails on the render thread.
|
||||
sx_android_egl_init :: () -> bool {
|
||||
g_egl_display = eglGetDisplay(EGL_DEFAULT_DISPLAY);
|
||||
if g_egl_display == EGL_NO_DISPLAY { return false; }
|
||||
// Bring up EGL on `plat.app_window`. Sets the egl_* fields and makes
|
||||
// the context current. Returns false on any failure — caller bails on
|
||||
// the render thread.
|
||||
sx_android_egl_init :: (plat: *AndroidPlatform) -> bool {
|
||||
plat.egl_display = eglGetDisplay(EGL_DEFAULT_DISPLAY);
|
||||
if plat.egl_display == EGL_NO_DISPLAY { return false; }
|
||||
|
||||
major : s32 = 0;
|
||||
minor : s32 = 0;
|
||||
if eglInitialize(g_egl_display, @major, @minor) == EGL_FALSE { return false; }
|
||||
if eglInitialize(plat.egl_display, @major, @minor) == EGL_FALSE { return false; }
|
||||
|
||||
cfg_attrs : [13]s32 = .{
|
||||
EGL_RENDERABLE_TYPE, EGL_OPENGL_ES3_BIT,
|
||||
@@ -253,50 +294,65 @@ sx_android_egl_init :: () -> bool {
|
||||
EGL_NONE,
|
||||
};
|
||||
num_cfg : s32 = 0;
|
||||
if eglChooseConfig(g_egl_display, @cfg_attrs[0], @g_egl_config, 1, @num_cfg) == EGL_FALSE { return false; }
|
||||
if eglChooseConfig(plat.egl_display, @cfg_attrs[0], @plat.egl_config, 1, @num_cfg) == EGL_FALSE { return false; }
|
||||
if num_cfg < 1 { return false; }
|
||||
|
||||
visual_id : s32 = 0;
|
||||
eglGetConfigAttrib(g_egl_display, g_egl_config, EGL_NATIVE_VISUAL_ID, @visual_id);
|
||||
ANativeWindow_setBuffersGeometry(g_app_window, 0, 0, visual_id);
|
||||
eglGetConfigAttrib(plat.egl_display, plat.egl_config, EGL_NATIVE_VISUAL_ID, @visual_id);
|
||||
ANativeWindow_setBuffersGeometry(plat.app_window, 0, 0, visual_id);
|
||||
|
||||
ctx_attrs : [3]s32 = .{ EGL_CONTEXT_CLIENT_VERSION, 3, EGL_NONE };
|
||||
g_egl_context = eglCreateContext(g_egl_display, g_egl_config, EGL_NO_CONTEXT, @ctx_attrs[0]);
|
||||
if g_egl_context == EGL_NO_CONTEXT { return false; }
|
||||
plat.egl_context = eglCreateContext(plat.egl_display, plat.egl_config, EGL_NO_CONTEXT, @ctx_attrs[0]);
|
||||
if plat.egl_context == EGL_NO_CONTEXT { return false; }
|
||||
|
||||
g_egl_surface = eglCreateWindowSurface(g_egl_display, g_egl_config, g_app_window, null);
|
||||
if g_egl_surface == EGL_NO_SURFACE { return false; }
|
||||
plat.egl_surface = eglCreateWindowSurface(plat.egl_display, plat.egl_config, plat.app_window, null);
|
||||
if plat.egl_surface == EGL_NO_SURFACE { return false; }
|
||||
|
||||
if eglMakeCurrent(g_egl_display, g_egl_surface, g_egl_surface, g_egl_context) == EGL_FALSE { return false; }
|
||||
if eglMakeCurrent(plat.egl_display, plat.egl_surface, plat.egl_surface, plat.egl_context) == EGL_FALSE { return false; }
|
||||
|
||||
g_viewport_w = ANativeWindow_getWidth(g_app_window);
|
||||
g_viewport_h = ANativeWindow_getHeight(g_app_window);
|
||||
plat.pixel_w = ANativeWindow_getWidth(plat.app_window);
|
||||
plat.pixel_h = ANativeWindow_getHeight(plat.app_window);
|
||||
sx_android_recompute_scale(plat);
|
||||
true;
|
||||
}
|
||||
|
||||
// Recompute `dpi_scale` from `pixel_w / logical_w`. Called on viewport
|
||||
// changes and after EGL bringup populates pixel_w/h. Falls back to 1.0
|
||||
// when consumer didn't set logical_w (1:1 logical/physical mode).
|
||||
sx_android_recompute_scale :: (plat: *AndroidPlatform) {
|
||||
if plat.logical_w > 0.0 and plat.pixel_w > 0 {
|
||||
plat.dpi_scale = xx plat.pixel_w / plat.logical_w;
|
||||
} else {
|
||||
plat.dpi_scale = 1.0;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Touch event queue ───────────────────────────────────────────────────
|
||||
|
||||
sx_android_push_touch :: (action: s32, x: f32, y: f32) {
|
||||
sx_android_ensure_touch_mutex();
|
||||
pthread_mutex_lock(xx @g_touch_mutex_storage[0]);
|
||||
next := (g_touch_tail + 1) % 64;
|
||||
if next != g_touch_head { // drop on full
|
||||
g_touch_queue[g_touch_tail] = TouchEvent.{ action = action, x = x, y = y };
|
||||
g_touch_tail = next;
|
||||
sx_android_push_touch :: (plat: *AndroidPlatform, action: s32, x: f32, y: f32) {
|
||||
sx_android_ensure_touch_mutex(plat);
|
||||
pthread_mutex_lock(xx @plat.touch_mutex_storage[0]);
|
||||
next := (plat.touch_tail + 1) % 64;
|
||||
if next != plat.touch_head { // drop on full
|
||||
plat.touch_queue[plat.touch_tail] = TouchEvent.{ action = action, x = x, y = y };
|
||||
plat.touch_tail = next;
|
||||
}
|
||||
pthread_mutex_unlock(xx @g_touch_mutex_storage[0]);
|
||||
pthread_mutex_unlock(xx @plat.touch_mutex_storage[0]);
|
||||
}
|
||||
|
||||
sx_android_drain_touches :: (out: *List(Event)) {
|
||||
sx_android_ensure_touch_mutex();
|
||||
pthread_mutex_lock(xx @g_touch_mutex_storage[0]);
|
||||
while g_touch_head != g_touch_tail {
|
||||
t := g_touch_queue[g_touch_head];
|
||||
g_touch_head = (g_touch_head + 1) % 64;
|
||||
sx_android_drain_touches :: (plat: *AndroidPlatform, out: *List(Event)) {
|
||||
sx_android_ensure_touch_mutex(plat);
|
||||
pthread_mutex_lock(xx @plat.touch_mutex_storage[0]);
|
||||
inv : f32 = if plat.dpi_scale > 0.0 then 1.0 / plat.dpi_scale else 1.0;
|
||||
while plat.touch_head != plat.touch_tail {
|
||||
t := plat.touch_queue[plat.touch_head];
|
||||
plat.touch_head = (plat.touch_head + 1) % 64;
|
||||
// MotionEvent actions: 0=DOWN, 1=UP, 2=MOVE. Map onto chess's
|
||||
// existing mouse Event variants — touch becomes a left-button
|
||||
// mouse on the same screen coords; delta unused on Android.
|
||||
pos : Point = .{ x = t.x, y = t.y };
|
||||
// mouse on the same screen coords. Coords come in as physical
|
||||
// pixels; divide by dpi_scale so layout-side hit-testing
|
||||
// matches its own logical-coord frames.
|
||||
pos : Point = .{ x = t.x * inv, y = t.y * inv };
|
||||
if t.action == 0 {
|
||||
out.append(.mouse_down(.{ position = pos, button = .left }));
|
||||
} else if t.action == 1 {
|
||||
@@ -305,59 +361,75 @@ sx_android_drain_touches :: (out: *List(Event)) {
|
||||
out.append(.mouse_moved(.{ position = pos, delta = .{ x = 0, y = 0 } }));
|
||||
}
|
||||
}
|
||||
pthread_mutex_unlock(xx @g_touch_mutex_storage[0]);
|
||||
pthread_mutex_unlock(xx @plat.touch_mutex_storage[0]);
|
||||
}
|
||||
|
||||
sx_android_ensure_touch_mutex :: () {
|
||||
if g_touch_mutex_inited { return; }
|
||||
pthread_mutex_init(xx @g_touch_mutex_storage[0], null);
|
||||
g_touch_mutex_inited = true;
|
||||
sx_android_ensure_touch_mutex :: (plat: *AndroidPlatform) {
|
||||
if plat.touch_mutex_inited { return; }
|
||||
pthread_mutex_init(xx @plat.touch_mutex_storage[0], null);
|
||||
plat.touch_mutex_inited = true;
|
||||
}
|
||||
|
||||
// ── AndroidPlatform ─────────────────────────────────────────────────────
|
||||
|
||||
AndroidPlatform :: struct {
|
||||
title: [:0]u8 = "";
|
||||
width: s32 = 0;
|
||||
height: s32 = 0;
|
||||
events: List(Event) = .{};
|
||||
}
|
||||
// ── Platform impl ───────────────────────────────────────────────────────
|
||||
|
||||
impl Platform for AndroidPlatform {
|
||||
init :: (self: *AndroidPlatform, title: [:0]u8, w: s32, h: s32) -> bool {
|
||||
self.title = title;
|
||||
self.width = w;
|
||||
self.height = h;
|
||||
sx_android_recompute_scale(self);
|
||||
true;
|
||||
}
|
||||
|
||||
// NOTE: method order must match the `Platform` protocol declaration
|
||||
// in modules/platform/api.sx. The vtable is built in impl source
|
||||
// order; mismatched order silently routes calls to the wrong method
|
||||
// (chess on Android lost touch entirely because poll_events sat in
|
||||
// begin_frame's slot, etc.).
|
||||
|
||||
run_frame_loop :: (self: *AndroidPlatform, frame_fn: Closure()) {
|
||||
self.frame_closure = frame_fn;
|
||||
// `frame_fn` is expected to call `g_plat.end_frame()` which does
|
||||
// the `eglSwapBuffers` — don't swap again here or the back buffer
|
||||
// is presented twice per render, alternating with the previous
|
||||
// frame's contents → visible flicker.
|
||||
while !self.should_stop {
|
||||
frame_fn();
|
||||
usleep(1000);
|
||||
}
|
||||
}
|
||||
|
||||
poll_events :: (self: *AndroidPlatform) -> []Event {
|
||||
self.events.len = 0;
|
||||
sx_android_drain_touches(self, @self.events);
|
||||
result : []Event = ---;
|
||||
result.ptr = self.events.items;
|
||||
result.len = self.events.len;
|
||||
result;
|
||||
}
|
||||
|
||||
begin_frame :: (self: *AndroidPlatform) -> FrameContext {
|
||||
// viewport_* is the logical canvas the renderer + layout see —
|
||||
// pixel_w/h divided by dpi_scale. With logical_w unset (1.0
|
||||
// scale) viewport == pixel.
|
||||
inv : f32 = if self.dpi_scale > 0.0 then 1.0 / self.dpi_scale else 1.0;
|
||||
FrameContext.{
|
||||
viewport_w = xx g_viewport_w,
|
||||
viewport_h = xx g_viewport_h,
|
||||
pixel_w = g_viewport_w,
|
||||
pixel_h = g_viewport_h,
|
||||
dpi_scale = g_dpi_scale,
|
||||
viewport_w = xx self.pixel_w * inv,
|
||||
viewport_h = xx self.pixel_h * inv,
|
||||
pixel_w = self.pixel_w,
|
||||
pixel_h = self.pixel_h,
|
||||
dpi_scale = self.dpi_scale,
|
||||
delta_time = 0.016,
|
||||
target_present_time = 0.0,
|
||||
};
|
||||
}
|
||||
|
||||
end_frame :: (self: *AndroidPlatform) {
|
||||
if g_egl_display != null and g_egl_surface != null {
|
||||
eglSwapBuffers(g_egl_display, g_egl_surface);
|
||||
if self.egl_display != null and self.egl_surface != null {
|
||||
eglSwapBuffers(self.egl_display, self.egl_surface);
|
||||
}
|
||||
}
|
||||
|
||||
poll_events :: (self: *AndroidPlatform) -> []Event {
|
||||
self.events.len = 0;
|
||||
sx_android_drain_touches(@self.events);
|
||||
result : []Event = ---;
|
||||
result.ptr = self.events.items;
|
||||
result.len = self.events.len;
|
||||
result;
|
||||
}
|
||||
|
||||
safe_insets :: (self: *AndroidPlatform) -> EdgeInsets {
|
||||
EdgeInsets.{};
|
||||
}
|
||||
@@ -368,30 +440,17 @@ impl Platform for AndroidPlatform {
|
||||
show_keyboard :: (self: *AndroidPlatform) { }
|
||||
hide_keyboard :: (self: *AndroidPlatform) { }
|
||||
|
||||
run_frame_loop :: (self: *AndroidPlatform, frame_fn: Closure()) {
|
||||
g_frame_fn = frame_fn;
|
||||
g_frame_fn_set = true;
|
||||
// `frame_fn` is expected to call `g_plat.end_frame()` which does
|
||||
// the `eglSwapBuffers` — don't swap again here or the back buffer
|
||||
// is presented twice per render, alternating with the previous
|
||||
// frame's contents → visible flicker.
|
||||
while !g_should_stop {
|
||||
frame_fn();
|
||||
usleep(1000);
|
||||
}
|
||||
}
|
||||
|
||||
stop :: (self: *AndroidPlatform) {
|
||||
g_should_stop = true;
|
||||
self.should_stop = true;
|
||||
}
|
||||
|
||||
shutdown :: (self: *AndroidPlatform) {
|
||||
if g_egl_display != null {
|
||||
eglMakeCurrent(g_egl_display, EGL_NO_SURFACE, EGL_NO_SURFACE, EGL_NO_CONTEXT);
|
||||
if g_egl_surface != null { eglDestroySurface(g_egl_display, g_egl_surface); g_egl_surface = null; }
|
||||
if g_egl_context != null { eglDestroyContext(g_egl_display, g_egl_context); g_egl_context = null; }
|
||||
eglTerminate(g_egl_display);
|
||||
g_egl_display = null;
|
||||
if self.egl_display != null {
|
||||
eglMakeCurrent(self.egl_display, EGL_NO_SURFACE, EGL_NO_SURFACE, EGL_NO_CONTEXT);
|
||||
if self.egl_surface != null { eglDestroySurface(self.egl_display, self.egl_surface); self.egl_surface = null; }
|
||||
if self.egl_context != null { eglDestroyContext(self.egl_display, self.egl_context); self.egl_context = null; }
|
||||
eglTerminate(self.egl_display);
|
||||
self.egl_display = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +43,10 @@ bundle_main :: () -> bool {
|
||||
return false;
|
||||
}
|
||||
|
||||
if opts.is_android() {
|
||||
return android_bundle_main(opts, binary, bundle, bid);
|
||||
}
|
||||
|
||||
// Device builds without a real identity will be rejected by the
|
||||
// device, so fail fast with a clear hint — matches what the legacy
|
||||
// Zig path did at the top of createBundle.
|
||||
@@ -517,3 +521,604 @@ codesign :: (bundle: string, identity: string, ent_path: string) -> bool {
|
||||
out("error: codesign spawn failed\n");
|
||||
false;
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Android APK pipeline.
|
||||
//
|
||||
// Same shape as the legacy Zig `createApk`:
|
||||
// 1. Discover SDK root + highest build-tools / platforms version.
|
||||
// 2. Stage `<apk>.stage/lib/arm64-v8a/<libfoo.so>`.
|
||||
// 3. Use the user-supplied AndroidManifest.xml or synthesize one
|
||||
// (NativeActivity shape when no `#jni_main` decl; Activity-bound
|
||||
// shape pointing at the user's `#jni_main` class otherwise).
|
||||
// 4. For each `#jni_main` decl: write `<stage>/java/<pkg>/<Cls>.java`,
|
||||
// compile via `javac --release 11 -classpath android.jar`, then
|
||||
// dex via `d8 --release --lib android.jar --output <stage>`.
|
||||
// 5. `aapt2 link -I android.jar --manifest <m> -o <apk>.unaligned`.
|
||||
// 6. `zip <unaligned> lib/` (from stage cwd) + `zip classes.dex` if
|
||||
// a dex was produced + zip each registered asset dir.
|
||||
// 7. `zipalign -f 4 <unaligned> <aligned>`.
|
||||
// 8. Ensure debug keystore (via `keytool`) at $HOME/.android or
|
||||
// `set_keystore_path()` override.
|
||||
// 9. `apksigner sign --ks ... --out <apk> <aligned>`.
|
||||
// =====================================================================
|
||||
|
||||
// Resolve a relative path against the current working directory at call
|
||||
// time, so it survives a later `cd` into a stage dir. Absolute paths
|
||||
// (leading `/`) are returned unchanged. Empty input is preserved.
|
||||
absolutify :: (path: string) -> string {
|
||||
if path.len == 0 { return path; }
|
||||
if path[0] == 47 { return path; }
|
||||
if r := run(str_to_cstr("pwd")) {
|
||||
if r.exit_code != 0 { return path; }
|
||||
cwd := r.stdout;
|
||||
// Strip trailing newline that `pwd` emits.
|
||||
if cwd.len > 0 {
|
||||
if cwd[cwd.len - 1] == 10 { cwd = substr(cwd, 0, cwd.len - 1); }
|
||||
}
|
||||
if cwd.len == 0 { return path; }
|
||||
return path_join(cwd, path);
|
||||
}
|
||||
path;
|
||||
}
|
||||
|
||||
android_bundle_main :: (opts: BuildOptions, binary: string, apk_path: string, bundle_id: string) -> bool {
|
||||
// The bundler `cd`s into the stage dir for `zip` steps, so any
|
||||
// relative path the caller gave us would resolve against the wrong
|
||||
// cwd. Pin everything to absolute paths up front.
|
||||
apk_path = absolutify(apk_path);
|
||||
binary = absolutify(binary);
|
||||
|
||||
sdk := discover_android_sdk();
|
||||
if sdk.len == 0 {
|
||||
out("error: cannot locate Android SDK \xe2\x80\x94 set $ANDROID_HOME\n");
|
||||
return false;
|
||||
}
|
||||
|
||||
build_tools := find_highest_subdir(path_join(sdk, "build-tools"));
|
||||
if build_tools.len == 0 {
|
||||
out("error: no build-tools under ");
|
||||
out(sdk);
|
||||
out("/build-tools\n");
|
||||
return false;
|
||||
}
|
||||
platform_dir := find_highest_subdir(path_join(sdk, "platforms"));
|
||||
if platform_dir.len == 0 {
|
||||
out("error: no platforms under ");
|
||||
out(sdk);
|
||||
out("/platforms\n");
|
||||
return false;
|
||||
}
|
||||
android_jar := path_join(platform_dir, "android.jar");
|
||||
|
||||
aapt2_path := path_join(build_tools, "aapt2");
|
||||
zipalign_path := path_join(build_tools, "zipalign");
|
||||
apksigner_path := path_join(build_tools, "apksigner");
|
||||
d8_path := path_join(build_tools, "d8");
|
||||
|
||||
// Staging dir alongside the apk output.
|
||||
stage := concat(apk_path, ".stage");
|
||||
lib_dir := path_join(stage, "lib/arm64-v8a");
|
||||
|
||||
// Clean previous stage. `rm -rf` via shell until fs.sx grows
|
||||
// `delete_dir_all`.
|
||||
rm_cmd := concat("rm -rf \"", stage);
|
||||
rm_cmd = concat(rm_cmd, "\"");
|
||||
if r := run(str_to_cstr(rm_cmd)) {
|
||||
if r.exit_code != 0 {
|
||||
out("error: apk: failed to clean stage dir\n");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if !create_dir_all(str_to_cstr(lib_dir)) {
|
||||
out("error: apk: cannot create stage lib dir\n");
|
||||
return false;
|
||||
}
|
||||
|
||||
// libsxhello.so must literally start with "lib" for Android's
|
||||
// loader. The user's -o path already does (build_options enforces
|
||||
// it). Copy by basename into the staging lib dir.
|
||||
so_basename := basename(binary);
|
||||
so_dest := path_join(lib_dir, so_basename);
|
||||
if !copy_file(str_to_cstr(binary), str_to_cstr(so_dest)) {
|
||||
out("error: apk: failed to copy .so into stage\n");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Manifest: user-supplied or auto-generated.
|
||||
manifest := opts.manifest_path();
|
||||
manifest_used := "";
|
||||
lib_name := lib_name_from_so_basename(so_basename);
|
||||
if manifest.len > 0 {
|
||||
manifest_used = manifest;
|
||||
} else {
|
||||
generated_xml := build_android_manifest(opts, bundle_id, lib_name);
|
||||
generated_path := path_join(stage, "AndroidManifest.xml");
|
||||
if !write_file(str_to_cstr(generated_path), generated_xml) {
|
||||
out("error: apk: failed to write AndroidManifest.xml\n");
|
||||
return false;
|
||||
}
|
||||
manifest_used = generated_path;
|
||||
}
|
||||
|
||||
// Compile each `#jni_main` decl's Java source.
|
||||
jm_count := opts.jni_main_count();
|
||||
if jm_count > 0 {
|
||||
if !compile_jni_main_sources(opts, stage, android_jar, d8_path) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// aapt2 link → unaligned apk with manifest + resources.
|
||||
unaligned := concat(apk_path, ".unaligned");
|
||||
aapt_cmd := concat("\"", aapt2_path);
|
||||
aapt_cmd = concat(aapt_cmd, "\" link -I \"");
|
||||
aapt_cmd = concat(aapt_cmd, android_jar);
|
||||
aapt_cmd = concat(aapt_cmd, "\" --manifest \"");
|
||||
aapt_cmd = concat(aapt_cmd, manifest_used);
|
||||
aapt_cmd = concat(aapt_cmd, "\" -o \"");
|
||||
aapt_cmd = concat(aapt_cmd, unaligned);
|
||||
aapt_cmd = concat(aapt_cmd, "\" 2>&1");
|
||||
if r := run(str_to_cstr(aapt_cmd)) {
|
||||
if r.exit_code != 0 {
|
||||
out("error: aapt2 link failed:\n");
|
||||
out(r.stdout);
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
out("error: aapt2 spawn failed\n");
|
||||
return false;
|
||||
}
|
||||
|
||||
// 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.
|
||||
// Need to cd into stage so the relative `lib/` path is preserved
|
||||
// in the zip archive.
|
||||
if !run_in_dir(stage, concat("zip -q -r \"", concat(unaligned, "\" lib/"))) {
|
||||
return false;
|
||||
}
|
||||
if jm_count > 0 {
|
||||
if !run_in_dir(stage, concat("zip -q \"", concat(unaligned, "\" classes.dex"))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Asset dirs go in at their `dest` path inside the APK. The Zig
|
||||
// path used a hardcoded `assets/` walk; the sx form respects every
|
||||
// `add_asset_dir(src, dest)` pair the user registered.
|
||||
asset_count := opts.asset_dir_count();
|
||||
j : s64 = 0;
|
||||
while j < asset_count {
|
||||
src := opts.asset_dir_src_at(j);
|
||||
dest := opts.asset_dir_dest_at(j);
|
||||
if !zip_asset_dir(src, dest, unaligned) {
|
||||
return false;
|
||||
}
|
||||
j += 1;
|
||||
}
|
||||
|
||||
// zipalign → aligned apk.
|
||||
aligned := concat(apk_path, ".aligned");
|
||||
align_cmd := concat("\"", zipalign_path);
|
||||
align_cmd = concat(align_cmd, "\" -f 4 \"");
|
||||
align_cmd = concat(align_cmd, unaligned);
|
||||
align_cmd = concat(align_cmd, "\" \"");
|
||||
align_cmd = concat(align_cmd, aligned);
|
||||
align_cmd = concat(align_cmd, "\" 2>&1");
|
||||
if r := run(str_to_cstr(align_cmd)) {
|
||||
if r.exit_code != 0 {
|
||||
out("error: zipalign failed:\n");
|
||||
out(r.stdout);
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
out("error: zipalign spawn failed\n");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Debug keystore (auto-generated on first use) + apksigner.
|
||||
keystore := opts.keystore_path();
|
||||
if keystore.len == 0 {
|
||||
if home := env("HOME") {
|
||||
keystore = path_join(home, ".android/debug.keystore");
|
||||
} else {
|
||||
out("error: apk: cannot locate $HOME for default keystore\n");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if !ensure_debug_keystore(keystore) {
|
||||
return false;
|
||||
}
|
||||
sign_cmd := concat("\"", apksigner_path);
|
||||
sign_cmd = concat(sign_cmd, "\" sign --ks \"");
|
||||
sign_cmd = concat(sign_cmd, keystore);
|
||||
sign_cmd = concat(sign_cmd, "\" --ks-pass pass:android --key-pass pass:android --ks-key-alias androiddebugkey --out \"");
|
||||
sign_cmd = concat(sign_cmd, apk_path);
|
||||
sign_cmd = concat(sign_cmd, "\" \"");
|
||||
sign_cmd = concat(sign_cmd, aligned);
|
||||
sign_cmd = concat(sign_cmd, "\" 2>&1");
|
||||
if r := run(str_to_cstr(sign_cmd)) {
|
||||
if r.exit_code != 0 {
|
||||
out("error: apksigner failed:\n");
|
||||
out(r.stdout);
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
out("error: apksigner spawn failed\n");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Clean up intermediates (keep stage/ in case users want to
|
||||
// inspect it).
|
||||
delete_file(str_to_cstr(unaligned));
|
||||
delete_file(str_to_cstr(aligned));
|
||||
run(str_to_cstr(concat("rm -rf \"", concat(stage, "\""))));
|
||||
|
||||
out("apk: ");
|
||||
out(apk_path);
|
||||
out("\n");
|
||||
true;
|
||||
}
|
||||
|
||||
// ── Android helpers ──────────────────────────────────────────────────
|
||||
|
||||
// Run `cmd` under a `cd <dir> && ...` shell wrapping. process.run
|
||||
// doesn't have a cwd arg in Phase 1A, so we compose it via the shell.
|
||||
// Output is folded via `2>&1` so failures hand the user one stream.
|
||||
run_in_dir :: (dir: string, cmd: string) -> bool {
|
||||
wrapped := concat("cd \"", dir);
|
||||
wrapped = concat(wrapped, "\" && ");
|
||||
wrapped = concat(wrapped, cmd);
|
||||
wrapped = concat(wrapped, " 2>&1");
|
||||
if r := run(str_to_cstr(wrapped)) {
|
||||
if r.exit_code != 0 {
|
||||
out("error: ");
|
||||
out(cmd);
|
||||
out(" failed:\n");
|
||||
out(r.stdout);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
out("error: shell spawn failed\n");
|
||||
false;
|
||||
}
|
||||
|
||||
// Discover the Android SDK root. Honors $ANDROID_HOME /
|
||||
// $ANDROID_SDK_ROOT, otherwise picks the default install location on
|
||||
// macOS ($HOME/Library/Android/sdk).
|
||||
discover_android_sdk :: () -> string {
|
||||
if h := env("ANDROID_HOME") { return h; }
|
||||
if h := env("ANDROID_SDK_ROOT") { return h; }
|
||||
if home := env("HOME") {
|
||||
candidate := path_join(home, "Library/Android/sdk");
|
||||
if exists(str_to_cstr(candidate)) { return candidate; }
|
||||
}
|
||||
"";
|
||||
}
|
||||
|
||||
// Pick the lexicographically-highest subdir of `parent`. Equivalent to
|
||||
// `ls -1 <parent> | sort -V | tail -1`. Returns the full path or "".
|
||||
find_highest_subdir :: (parent: string) -> string {
|
||||
cmd := concat("ls -1 \"", parent);
|
||||
cmd = concat(cmd, "\" 2>/dev/null | sort -V | tail -1");
|
||||
if r := run(str_to_cstr(cmd)) {
|
||||
if r.exit_code != 0 { return ""; }
|
||||
name := r.stdout;
|
||||
// Strip trailing whitespace.
|
||||
while name.len > 0 {
|
||||
last := name[name.len - 1];
|
||||
if last == 10 { name = substr(name, 0, name.len - 1); }
|
||||
else if last == 13 { name = substr(name, 0, name.len - 1); }
|
||||
else if last == 32 { name = substr(name, 0, name.len - 1); }
|
||||
else if last == 9 { name = substr(name, 0, name.len - 1); }
|
||||
else { break; }
|
||||
}
|
||||
if name.len == 0 { return ""; }
|
||||
return path_join(parent, name);
|
||||
}
|
||||
"";
|
||||
}
|
||||
|
||||
// `libfoo.so` → `foo`. Android's `android.app.lib_name` meta-data
|
||||
// wants the trimmed name; the loader prepends `lib` and appends `.so`
|
||||
// at runtime.
|
||||
lib_name_from_so_basename :: (basename: string) -> string {
|
||||
name := basename;
|
||||
if name.len > 3 {
|
||||
if name[0] == 108 { // 'l'
|
||||
if name[1] == 105 { // 'i'
|
||||
if name[2] == 98 { // 'b'
|
||||
name = substr(name, 3, name.len - 3);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if name.len > 3 {
|
||||
last3 := name.len - 3;
|
||||
if name[last3] == 46 { // '.'
|
||||
if name[last3 + 1] == 115 { // 's'
|
||||
if name[last3 + 2] == 111 { // 'o'
|
||||
name = substr(name, 0, last3);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
name;
|
||||
}
|
||||
|
||||
// AndroidManifest.xml synthesizer. When the program declares a
|
||||
// `#jni_main` class, the manifest points its `<activity
|
||||
// android:name>` at the user's class and flips
|
||||
// `android:hasCode="true"` so Android loads the bundled classes.dex.
|
||||
// Otherwise it falls back to the legacy NativeActivity shape with an
|
||||
// `android.app.lib_name` meta-data entry pointing at the .so.
|
||||
build_android_manifest :: (opts: BuildOptions, package: string, lib_name: string) -> string {
|
||||
pkg_esc := xml_escape(package);
|
||||
lib_esc := xml_escape(lib_name);
|
||||
if opts.jni_main_count() > 0 {
|
||||
// First `#jni_main` decl drives the Activity. The foreign_path
|
||||
// uses `/` separators; Java fully-qualified class names use
|
||||
// `.` so we rewrite.
|
||||
foreign := opts.jni_main_foreign_path_at(0);
|
||||
cls := slash_to_dot(foreign);
|
||||
cls_esc := xml_escape(cls);
|
||||
return format(#string MANIFEST
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
package="{}"
|
||||
android:versionCode="1"
|
||||
android:versionName="1.0">
|
||||
<uses-sdk android:minSdkVersion="21" android:targetSdkVersion="34" />
|
||||
<application android:label="{}" android:hasCode="true">
|
||||
<activity
|
||||
android:name="{}"
|
||||
android:exported="true"
|
||||
android:label="{}"
|
||||
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>
|
||||
MANIFEST, pkg_esc, lib_esc, cls_esc, lib_esc);
|
||||
}
|
||||
// NativeActivity fallback — the .so provides ANativeActivity_onCreate.
|
||||
format(#string MANIFEST
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
package="{}"
|
||||
android:versionCode="1"
|
||||
android:versionName="1.0">
|
||||
<uses-sdk android:minSdkVersion="21" android:targetSdkVersion="34" />
|
||||
<application android:label="{}" android:hasCode="false">
|
||||
<activity
|
||||
android:name="android.app.NativeActivity"
|
||||
android:exported="true"
|
||||
android:label="{}"
|
||||
android:configChanges="orientation|keyboardHidden|screenSize">
|
||||
<meta-data android:name="android.app.lib_name" android:value="{}" />
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
</manifest>
|
||||
MANIFEST, pkg_esc, lib_esc, lib_esc, lib_esc);
|
||||
}
|
||||
|
||||
// `co/swipelab/sxchess/SxApp` → `co.swipelab.sxchess.SxApp`.
|
||||
slash_to_dot :: (path: string) -> string {
|
||||
buf := cstring(path.len);
|
||||
i := 0;
|
||||
while i < path.len {
|
||||
c := path[i];
|
||||
buf[i] = if c == 47 then 46 else c; // 47 = '/', 46 = '.'
|
||||
i += 1;
|
||||
}
|
||||
buf;
|
||||
}
|
||||
|
||||
// Last `/`-separated component of a forward-slash path (used to split
|
||||
// JNI foreign paths into pkg + class). `co/swipelab/Foo` → `Foo`.
|
||||
// `Foo` → `Foo`. `dir_part` returns the part before the last slash
|
||||
// (or "" if none).
|
||||
last_slash_component :: (path: string) -> string {
|
||||
i := path.len;
|
||||
while i > 0 {
|
||||
if path[i - 1] == 47 { return substr(path, i, path.len - i); }
|
||||
i -= 1;
|
||||
}
|
||||
path;
|
||||
}
|
||||
|
||||
dir_part :: (path: string) -> string {
|
||||
i := path.len;
|
||||
while i > 0 {
|
||||
if path[i - 1] == 47 { return substr(path, 0, i - 1); }
|
||||
i -= 1;
|
||||
}
|
||||
"";
|
||||
}
|
||||
|
||||
// Write each `#jni_main` decl's `.java` source, then compile to
|
||||
// classes via `javac --release 11 -classpath <android.jar>`, then dex
|
||||
// the resulting class files via `d8 --release --lib <android.jar>
|
||||
// --output <stage>` so `<stage>/classes.dex` lands where the
|
||||
// orchestrator can zip it into the APK.
|
||||
compile_jni_main_sources :: (opts: BuildOptions, stage: string, android_jar: string, d8_path: string) -> bool {
|
||||
java_root := path_join(stage, "java");
|
||||
classes_root := path_join(stage, "classes");
|
||||
if !create_dir_all(str_to_cstr(java_root)) {
|
||||
out("error: apk: cannot create java root\n");
|
||||
return false;
|
||||
}
|
||||
if !create_dir_all(str_to_cstr(classes_root)) {
|
||||
out("error: apk: cannot create classes root\n");
|
||||
return false;
|
||||
}
|
||||
|
||||
javac := discover_javac();
|
||||
if javac.len == 0 {
|
||||
out("error: javac not on PATH and $JAVA_HOME unset \xe2\x80\x94 install a JDK (Android Studio bundles one at $ANDROID_STUDIO/Contents/jre)\n");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Compose javac + d8 arg lists by walking jni_main_decls. Each
|
||||
// decl: write `<java_root>/<pkg>/<Cls>.java`, append java path to
|
||||
// javac argv + class path to d8 argv.
|
||||
javac_files := "";
|
||||
d8_files := "";
|
||||
count := opts.jni_main_count();
|
||||
i : s64 = 0;
|
||||
while i < count {
|
||||
foreign := opts.jni_main_foreign_path_at(i);
|
||||
java_source := opts.jni_main_java_source_at(i);
|
||||
pkg := dir_part(foreign);
|
||||
cls := last_slash_component(foreign);
|
||||
|
||||
pkg_dir := if pkg.len > 0 then path_join(java_root, pkg) else java_root;
|
||||
if !create_dir_all(str_to_cstr(pkg_dir)) {
|
||||
out("error: apk: cannot create java pkg dir\n");
|
||||
return false;
|
||||
}
|
||||
java_path := path_join(pkg_dir, concat(cls, ".java"));
|
||||
if !write_file(str_to_cstr(java_path), java_source) {
|
||||
out("error: apk: cannot write .java for ");
|
||||
out(foreign);
|
||||
out("\n");
|
||||
return false;
|
||||
}
|
||||
if javac_files.len > 0 { javac_files = concat(javac_files, " "); }
|
||||
javac_files = concat(javac_files, concat("\"", concat(java_path, "\"")));
|
||||
|
||||
class_subpath := if pkg.len > 0 then path_join(pkg, concat(cls, ".class")) else concat(cls, ".class");
|
||||
class_path := path_join(classes_root, class_subpath);
|
||||
if d8_files.len > 0 { d8_files = concat(d8_files, " "); }
|
||||
d8_files = concat(d8_files, concat("\"", concat(class_path, "\"")));
|
||||
i += 1;
|
||||
}
|
||||
|
||||
javac_cmd := concat("\"", javac);
|
||||
javac_cmd = concat(javac_cmd, "\" -d \"");
|
||||
javac_cmd = concat(javac_cmd, classes_root);
|
||||
javac_cmd = concat(javac_cmd, "\" -classpath \"");
|
||||
javac_cmd = concat(javac_cmd, android_jar);
|
||||
javac_cmd = concat(javac_cmd, "\" --release 11 ");
|
||||
javac_cmd = concat(javac_cmd, javac_files);
|
||||
javac_cmd = concat(javac_cmd, " 2>&1");
|
||||
if r := run(str_to_cstr(javac_cmd)) {
|
||||
if r.exit_code != 0 {
|
||||
out("error: javac failed:\n");
|
||||
out(r.stdout);
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
out("error: javac spawn failed\n");
|
||||
return false;
|
||||
}
|
||||
|
||||
d8_cmd := concat("\"", d8_path);
|
||||
d8_cmd = concat(d8_cmd, "\" --release --lib \"");
|
||||
d8_cmd = concat(d8_cmd, android_jar);
|
||||
d8_cmd = concat(d8_cmd, "\" --output \"");
|
||||
d8_cmd = concat(d8_cmd, stage);
|
||||
d8_cmd = concat(d8_cmd, "\" ");
|
||||
d8_cmd = concat(d8_cmd, d8_files);
|
||||
d8_cmd = concat(d8_cmd, " 2>&1");
|
||||
if r := run(str_to_cstr(d8_cmd)) {
|
||||
if r.exit_code != 0 {
|
||||
out("error: d8 failed:\n");
|
||||
out(r.stdout);
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
out("error: d8 spawn failed\n");
|
||||
return false;
|
||||
}
|
||||
true;
|
||||
}
|
||||
|
||||
// Locate `javac`. Honors `$JAVA_HOME/bin/javac` first (Android
|
||||
// Studio's bundled JDK sets this on macOS), then falls back to a PATH
|
||||
// lookup via `command -v`.
|
||||
discover_javac :: () -> string {
|
||||
if jh := env("JAVA_HOME") {
|
||||
cand := path_join(jh, "bin/javac");
|
||||
if exists(str_to_cstr(cand)) { return cand; }
|
||||
}
|
||||
if path := find_executable("javac") { return path; }
|
||||
"";
|
||||
}
|
||||
|
||||
// Zip the contents of `<src>` into the APK at `<dest>/`. Uses a
|
||||
// staging copy under `.sx-tmp/apk-assets/<dest>` so we can run `zip
|
||||
// -r` from a temporary cwd that produces clean entries (no `../`
|
||||
// noise). Missing src is treated as "nothing to do" so projects can
|
||||
// register optional asset trees.
|
||||
zip_asset_dir :: (src: string, dest: string, apk: string) -> bool {
|
||||
if !exists(str_to_cstr(src)) { return true; }
|
||||
asset_root := str_to_cstr(".sx-tmp/apk-assets");
|
||||
create_dir_all(asset_root);
|
||||
rm_cmd := concat("rm -rf .sx-tmp/apk-assets/", dest);
|
||||
run(str_to_cstr(rm_cmd));
|
||||
parent := if dir_part(dest).len > 0 then concat(".sx-tmp/apk-assets/", dir_part(dest)) else ".sx-tmp/apk-assets";
|
||||
if !create_dir_all(str_to_cstr(parent)) {
|
||||
out("error: apk: cannot create asset stage dir\n");
|
||||
return false;
|
||||
}
|
||||
cp_cmd := concat("cp -R \"", src);
|
||||
cp_cmd = concat(cp_cmd, "\" \".sx-tmp/apk-assets/");
|
||||
cp_cmd = concat(cp_cmd, dest);
|
||||
cp_cmd = concat(cp_cmd, "\" 2>&1");
|
||||
if r := run(str_to_cstr(cp_cmd)) {
|
||||
if r.exit_code != 0 {
|
||||
out("error: cp -R asset dir failed:\n");
|
||||
out(r.stdout);
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
out("error: cp -R asset dir spawn failed\n");
|
||||
return false;
|
||||
}
|
||||
// Make apk path absolute-ish for the cd shell wrapping. The user
|
||||
// typically gives a relative path; resolve via $(pwd) into an
|
||||
// absolute one so `cd .sx-tmp/apk-assets && zip <apk>` still
|
||||
// references the right file.
|
||||
abs_apk := if apk.len > 0 then (if apk[0] == 47 then apk else concat("$(pwd)/", apk)) else apk;
|
||||
zip_cmd := concat("zip -q -r \"", abs_apk);
|
||||
zip_cmd = concat(zip_cmd, "\" \"");
|
||||
zip_cmd = concat(zip_cmd, dest);
|
||||
zip_cmd = concat(zip_cmd, "\"");
|
||||
if !run_in_dir(".sx-tmp/apk-assets", zip_cmd) { return false; }
|
||||
true;
|
||||
}
|
||||
|
||||
// Generate the Android debug keystore on first use. The defaults
|
||||
// match what Android Studio creates: alias `androiddebugkey`, password
|
||||
// `android` for both store and key, RSA-2048, 10000-day validity.
|
||||
ensure_debug_keystore :: (keystore_path: string) -> bool {
|
||||
if exists(str_to_cstr(keystore_path)) { return true; }
|
||||
// mkdir -p the parent dir if needed.
|
||||
parent := dir_part(keystore_path);
|
||||
if parent.len > 0 {
|
||||
create_dir_all(str_to_cstr(parent));
|
||||
}
|
||||
cmd := concat("keytool -genkeypair -keystore \"", keystore_path);
|
||||
cmd = concat(cmd, "\" -storepass android -alias androiddebugkey -keypass android -keyalg RSA -keysize 2048 -validity 10000 -dname \"CN=Android Debug,O=Android,C=US\" 2>&1");
|
||||
if r := run(str_to_cstr(cmd)) {
|
||||
if r.exit_code != 0 {
|
||||
out("error: keytool failed:\n");
|
||||
out(r.stdout);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
out("error: keytool spawn failed\n");
|
||||
false;
|
||||
}
|
||||
|
||||
27
src/core.zig
27
src/core.zig
@@ -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.
|
||||
|
||||
150
src/imports.zig
150
src/imports.zig
@@ -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);
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
52
src/main.zig
52
src/main.zig
@@ -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
|
||||
|
||||
415
src/target.zig
415
src/target.zig
@@ -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
|
||||
|
||||
1
tests/expected/124-inline-if-hoist-toplevel.exit
Normal file
1
tests/expected/124-inline-if-hoist-toplevel.exit
Normal file
@@ -0,0 +1 @@
|
||||
0
|
||||
1
tests/expected/124-inline-if-hoist-toplevel.txt
Normal file
1
tests/expected/124-inline-if-hoist-toplevel.txt
Normal file
@@ -0,0 +1 @@
|
||||
42
|
||||
1
tests/expected/ffi-jni-call-10-jfloat-return.exit
Normal file
1
tests/expected/ffi-jni-call-10-jfloat-return.exit
Normal file
@@ -0,0 +1 @@
|
||||
0
|
||||
1
tests/expected/ffi-jni-call-10-jfloat-return.txt
Normal file
1
tests/expected/ffi-jni-call-10-jfloat-return.txt
Normal file
@@ -0,0 +1 @@
|
||||
ok
|
||||
@@ -0,0 +1 @@
|
||||
1
|
||||
@@ -0,0 +1 @@
|
||||
/Users/agra/projects/sx/examples/ffi-jni-call-11-unsupported-return-diag.sx:24:14: error: JNI method 'Buf.get' returns 's8', which isn't supported by the JNI call-method lowering yet — only void/bool/s32/s64/f32/f64 and pointers are wired up
|
||||
1
tests/expected/ffi-jni-class-09-multi-float-args.exit
Normal file
1
tests/expected/ffi-jni-class-09-multi-float-args.exit
Normal file
@@ -0,0 +1 @@
|
||||
0
|
||||
1
tests/expected/ffi-jni-class-09-multi-float-args.txt
Normal file
1
tests/expected/ffi-jni-class-09-multi-float-args.txt
Normal file
@@ -0,0 +1 @@
|
||||
ok
|
||||
@@ -279,6 +279,9 @@ declare i1 @BuildOptions.is_ios_device(i64) #0
|
||||
; Function Attrs: nounwind
|
||||
declare i1 @BuildOptions.is_ios_simulator(i64) #0
|
||||
|
||||
; Function Attrs: nounwind
|
||||
declare i1 @BuildOptions.is_android(i64) #0
|
||||
|
||||
; Function Attrs: nounwind
|
||||
declare i64 @BuildOptions.framework_count(i64) #0
|
||||
|
||||
@@ -291,6 +294,27 @@ declare i64 @BuildOptions.framework_path_count(i64) #0
|
||||
; Function Attrs: nounwind
|
||||
declare ptr @BuildOptions.framework_path_at(i64, i64) #0
|
||||
|
||||
; Function Attrs: nounwind
|
||||
declare void @BuildOptions.set_manifest_path(i64, ptr) #0
|
||||
|
||||
; Function Attrs: nounwind
|
||||
declare void @BuildOptions.set_keystore_path(i64, ptr) #0
|
||||
|
||||
; Function Attrs: nounwind
|
||||
declare ptr @BuildOptions.manifest_path(i64) #0
|
||||
|
||||
; Function Attrs: nounwind
|
||||
declare ptr @BuildOptions.keystore_path(i64) #0
|
||||
|
||||
; Function Attrs: nounwind
|
||||
declare i64 @BuildOptions.jni_main_count(i64) #0
|
||||
|
||||
; Function Attrs: nounwind
|
||||
declare ptr @BuildOptions.jni_main_foreign_path_at(i64, i64) #0
|
||||
|
||||
; Function Attrs: nounwind
|
||||
declare ptr @BuildOptions.jni_main_java_source_at(i64, i64) #0
|
||||
|
||||
; Function Attrs: nounwind
|
||||
declare i64 @build_options() #0
|
||||
|
||||
|
||||
@@ -1366,6 +1366,9 @@ declare i1 @BuildOptions.is_ios_device(i64) #0
|
||||
; Function Attrs: nounwind
|
||||
declare i1 @BuildOptions.is_ios_simulator(i64) #0
|
||||
|
||||
; Function Attrs: nounwind
|
||||
declare i1 @BuildOptions.is_android(i64) #0
|
||||
|
||||
; Function Attrs: nounwind
|
||||
declare i64 @BuildOptions.framework_count(i64) #0
|
||||
|
||||
@@ -1378,6 +1381,27 @@ declare i64 @BuildOptions.framework_path_count(i64) #0
|
||||
; Function Attrs: nounwind
|
||||
declare ptr @BuildOptions.framework_path_at(i64, i64) #0
|
||||
|
||||
; Function Attrs: nounwind
|
||||
declare void @BuildOptions.set_manifest_path(i64, ptr) #0
|
||||
|
||||
; Function Attrs: nounwind
|
||||
declare void @BuildOptions.set_keystore_path(i64, ptr) #0
|
||||
|
||||
; Function Attrs: nounwind
|
||||
declare ptr @BuildOptions.manifest_path(i64) #0
|
||||
|
||||
; Function Attrs: nounwind
|
||||
declare ptr @BuildOptions.keystore_path(i64) #0
|
||||
|
||||
; Function Attrs: nounwind
|
||||
declare i64 @BuildOptions.jni_main_count(i64) #0
|
||||
|
||||
; Function Attrs: nounwind
|
||||
declare ptr @BuildOptions.jni_main_foreign_path_at(i64, i64) #0
|
||||
|
||||
; Function Attrs: nounwind
|
||||
declare ptr @BuildOptions.jni_main_java_source_at(i64, i64) #0
|
||||
|
||||
; Function Attrs: nounwind
|
||||
declare i64 @build_options() #0
|
||||
|
||||
|
||||
Reference in New Issue
Block a user