From 632e64512b11fe981f29235740319cd85952510c Mon Sep 17 00:00:00 2001 From: agra Date: Sat, 23 May 2026 01:28:32 +0300 Subject: [PATCH] bundling: Android APK pipeline moved into sx; android.sx state-on-plat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 `.stage/lib/arm64-v8a/`, synthesizes AndroidManifest.xml (NativeActivity vs `#jni_main` Activity branch), writes each `#jni_main` decl's Java source under `/java//.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 ` 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 `CallMethod` 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. --- examples/124-inline-if-hoist-toplevel.sx | 24 + examples/ffi-jni-call-10-jfloat-return.sx | 28 + ...ffi-jni-call-11-unsupported-return-diag.sx | 33 + examples/ffi-jni-class-09-multi-float-args.sx | 49 ++ library/modules/compiler.sx | 19 + library/modules/platform/android.sx | 347 +++++----- library/modules/platform/bundle.sx | 605 ++++++++++++++++++ src/core.zig | 27 +- src/imports.zig | 150 ++++- src/ir/compiler_hooks.zig | 86 ++- src/ir/emit_llvm.zig | 3 + src/ir/interp.zig | 56 +- src/ir/jni_java_emit.zig | 24 + src/ir/lower.zig | 30 + src/main.zig | 52 +- src/target.zig | 415 +----------- .../124-inline-if-hoist-toplevel.exit | 1 + .../expected/124-inline-if-hoist-toplevel.txt | 1 + .../ffi-jni-call-10-jfloat-return.exit | 1 + .../ffi-jni-call-10-jfloat-return.txt | 1 + ...i-jni-call-11-unsupported-return-diag.exit | 1 + ...fi-jni-call-11-unsupported-return-diag.txt | 1 + .../ffi-jni-class-09-multi-float-args.exit | 1 + .../ffi-jni-class-09-multi-float-args.txt | 1 + .../ffi-objc-call-03-selector-sharing.ir | 24 + .../expected/ffi-objc-call-06-sret-return.ir | 24 + 26 files changed, 1437 insertions(+), 567 deletions(-) create mode 100644 examples/124-inline-if-hoist-toplevel.sx create mode 100644 examples/ffi-jni-call-10-jfloat-return.sx create mode 100644 examples/ffi-jni-call-11-unsupported-return-diag.sx create mode 100644 examples/ffi-jni-class-09-multi-float-args.sx create mode 100644 tests/expected/124-inline-if-hoist-toplevel.exit create mode 100644 tests/expected/124-inline-if-hoist-toplevel.txt create mode 100644 tests/expected/ffi-jni-call-10-jfloat-return.exit create mode 100644 tests/expected/ffi-jni-call-10-jfloat-return.txt create mode 100644 tests/expected/ffi-jni-call-11-unsupported-return-diag.exit create mode 100644 tests/expected/ffi-jni-call-11-unsupported-return-diag.txt create mode 100644 tests/expected/ffi-jni-class-09-multi-float-args.exit create mode 100644 tests/expected/ffi-jni-class-09-multi-float-args.txt diff --git a/examples/124-inline-if-hoist-toplevel.sx b/examples/124-inline-if-hoist-toplevel.sx new file mode 100644 index 0000000..64547a7 --- /dev/null +++ b/examples/124-inline-if-hoist-toplevel.sx @@ -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); } diff --git a/examples/ffi-jni-call-10-jfloat-return.sx b/examples/ffi-jni-call-10-jfloat-return.sx new file mode 100644 index 0000000..4652814 --- /dev/null +++ b/examples/ffi-jni-call-10-jfloat-return.sx @@ -0,0 +1,28 @@ +// Regression: `#jni_call(f32)` (jfloat return). +// Before the fix, the CallMethod 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; +} diff --git a/examples/ffi-jni-call-11-unsupported-return-diag.sx b/examples/ffi-jni-call-11-unsupported-return-diag.sx new file mode 100644 index 0000000..697ddf5 --- /dev/null +++ b/examples/ffi-jni-call-11-unsupported-return-diag.sx @@ -0,0 +1,33 @@ +// Regression: when a `#jni_call` method returns a type the +// `CallMethod` 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; +} diff --git a/examples/ffi-jni-class-09-multi-float-args.sx b/examples/ffi-jni-class-09-multi-float-args.sx new file mode 100644 index 0000000..3cae917 --- /dev/null +++ b/examples/ffi-jni-class-09-multi-float-args.sx @@ -0,0 +1,49 @@ +// Regression: `obj.method()` foreign-class dispatch with a `float` +// return type used to silently emit `LLVMGetUndef` because the +// `CallMethod` 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 CallMethod dispatches. + sx_consume_touch(ev.getAction(), ev.getX(), ev.getY()); + } +} + +main :: () -> s32 { + if g_should_call { + drive_touch(null, null); + } + print("ok\n"); + 0; +} diff --git a/library/modules/compiler.sx b/library/modules/compiler.sx index 25b911e..a421f14 100644 --- a/library/modules/compiler.sx +++ b/library/modules/compiler.sx @@ -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 + // `/java/.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; diff --git a/library/modules/platform/android.sx b/library/modules/platform/android.sx index 8abc641..c39dc37 100644 --- a/library/modules/platform/android.sx +++ b/library/modules/platform/android.sx @@ -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; } } } diff --git a/library/modules/platform/bundle.sx b/library/modules/platform/bundle.sx index 9f82d2a..2deeff3 100644 --- a/library/modules/platform/bundle.sx +++ b/library/modules/platform/bundle.sx @@ -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 `.stage/lib/arm64-v8a/`. +// 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 `/java//.java`, +// compile via `javac --release 11 -classpath android.jar`, then +// dex via `d8 --release --lib android.jar --output `. +// 5. `aapt2 link -I android.jar --manifest -o .unaligned`. +// 6. `zip lib/` (from stage cwd) + `zip classes.dex` if +// a dex was produced + zip each registered asset dir. +// 7. `zipalign -f 4 `. +// 8. Ensure debug keystore (via `keytool`) at $HOME/.android or +// `set_keystore_path()` override. +// 9. `apksigner sign --ks ... --out `. +// ===================================================================== + +// 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 && ...` 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 | 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 `` 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 + + + + + + + + + + + + +MANIFEST, pkg_esc, lib_esc, cls_esc, lib_esc); + } + // NativeActivity fallback — the .so provides ANativeActivity_onCreate. + format(#string 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 `, then dex +// the resulting class files via `d8 --release --lib +// --output ` so `/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`, 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 `` into the APK at `/`. Uses a +// staging copy under `.sx-tmp/apk-assets/` 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 ` 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; +} diff --git a/src/core.zig b/src/core.zig index 9dc5a6d..97e909c 100644 --- a/src/core.zig +++ b/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. diff --git a/src/imports.zig b/src/imports.zig index 25c909b..5a518aa 100644 --- a/src/imports.zig +++ b/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); diff --git a/src/ir/compiler_hooks.zig b/src/ir/compiler_hooks.zig index b99c274..7c69660 100644 --- a/src/ir/compiler_hooks.zig +++ b/src/ir/compiler_hooks.zig @@ -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 ` + /// 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 ` 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 + /// `/java//.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 `.framework` diff --git a/src/ir/emit_llvm.zig b/src/ir/emit_llvm.zig index b68c030..5aae5d7 100644 --- a/src/ir/emit_llvm.zig +++ b/src/ir/emit_llvm.zig @@ -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 => { diff --git a/src/ir/interp.zig b/src/ir/interp.zig index 3334c27..98308e5 100644 --- a/src/ir/interp.zig +++ b/src/ir/interp.zig @@ -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); diff --git a/src/ir/jni_java_emit.zig b/src/ir/jni_java_emit.zig index e8b2eae..e270878 100644 --- a/src/ir/jni_java_emit.zig +++ b/src/ir/jni_java_emit.zig @@ -55,6 +55,30 @@ pub const Options = struct { lib_name: ?[]const u8 = null, }; +/// Inject a `static { System.loadLibrary(""); }` 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( diff --git a/src/ir/lower.zig b/src/ir/lower.zig index 017f677..3f0a360 100644 --- a/src/ir/lower.zig +++ b/src/ir/lower.zig @@ -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 + // CallMethod 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 CallMethod +/// 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___sx_1`. JNI mangling: /// `/` → `_`, `_` → `_1`. The `sx_` prefix matches the Java-side diff --git a/src/main.zig b/src/main.zig index d4720b6..4fef3c2 100644 --- a/src/main.zig +++ b/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 ` 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 ` migration shim. The legacy Zig bundler diff --git a/src/target.zig b/src/target.zig index 9c9e9d6..1554df5 100644 --- a/src/target.zig +++ b/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 `/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 `/java//.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 `/java//.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 ` 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 `/` (matches the -/// "newest version" convention for `build-tools/` and -/// `platforms/android-`). Caller owns the joined slice. -fn findHighestSubdir(allocator: std.mem.Allocator, io: std.Io, root: []const u8, subdir: []const u8) ![]const u8 { - const parent = try std.fmt.allocPrint(allocator, "{s}/{s}", .{ root, subdir }); - var dir = std.Io.Dir.openDir(.cwd(), io, parent, .{ .iterate = true }) catch { - std.debug.print("error: no {s} under {s}\n", .{ subdir, root }); - return error.SdkNotFound; - }; - defer dir.close(io); - var best: ?[]const u8 = null; - var it = dir.iterate(); - while (it.next(io) catch null) |entry| { - if (entry.kind != .directory) continue; - if (best == null or std.mem.order(u8, entry.name, best.?) == .gt) { - best = try allocator.dupe(u8, entry.name); - } - } - const name = best orelse { - std.debug.print("error: no versions under {s}\n", .{parent}); - return error.SdkNotFound; - }; - return try std.fmt.allocPrint(allocator, "{s}/{s}", .{ parent, name }); -} - -/// Write each `JniMainEmission`'s `.java` source under `/java//`, -/// invoke `javac` to compile to `/classes/`, then `d8` to produce -/// `/classes.dex`. The caller bundles `classes.dex` into the APK. -/// -/// `javac` is discovered via `$JAVA_HOME/bin/javac` first, then via PATH; if -/// neither resolves, an error is reported pointing at the missing tool. The -/// `--release 11` target keeps the emitted class file version low enough for -/// every shipping d8 to consume without surprise. -fn compileJniMainSources( - allocator: std.mem.Allocator, - io: std.Io, - stage: []const u8, - emissions: []const JniMainEmission, - android_jar: []const u8, - d8: []const u8, -) !void { - const cwd = std.Io.Dir.cwd(); - const java_root = try std.fmt.allocPrint(allocator, "{s}/java", .{stage}); - const classes_root = try std.fmt.allocPrint(allocator, "{s}/classes", .{stage}); - try cwd.createDirPath(io, java_root); - try cwd.createDirPath(io, classes_root); - - var java_paths = std.ArrayList([]const u8).empty; - var class_paths = std.ArrayList([]const u8).empty; - for (emissions) |em| { - const split = splitForeignPath(em.foreign_path); - const pkg_dir = if (split.pkg.len > 0) - try std.fmt.allocPrint(allocator, "{s}/{s}", .{ java_root, split.pkg }) - else - try allocator.dupe(u8, java_root); - try cwd.createDirPath(io, pkg_dir); - - const java_path = try std.fmt.allocPrint(allocator, "{s}/{s}.java", .{ pkg_dir, split.cls }); - try cwd.writeFile(io, .{ .sub_path = java_path, .data = em.java_source }); - try java_paths.append(allocator, java_path); - - const class_path = if (split.pkg.len > 0) - try std.fmt.allocPrint(allocator, "{s}/{s}/{s}.class", .{ classes_root, split.pkg, split.cls }) - else - try std.fmt.allocPrint(allocator, "{s}/{s}.class", .{ classes_root, split.cls }); - try class_paths.append(allocator, class_path); - } - - const javac = try discoverJavac(allocator, io); - - var javac_argv = std.ArrayList([]const u8).empty; - try javac_argv.appendSlice(allocator, &.{ - javac, "-d", classes_root, - "-classpath", android_jar, - "--release", "11", - }); - for (java_paths.items) |p| try javac_argv.append(allocator, p); - try runProcess(allocator, io, try javac_argv.toOwnedSlice(allocator)); - - var d8_argv = std.ArrayList([]const u8).empty; - try d8_argv.appendSlice(allocator, &.{ - d8, "--release", - "--lib", android_jar, - "--output", stage, - }); - for (class_paths.items) |p| try d8_argv.append(allocator, p); - try runProcess(allocator, io, try d8_argv.toOwnedSlice(allocator)); -} - -/// Split a JNI foreign path like `co/swipelab/sxmain/SxApp` into -/// `{ pkg = "co/swipelab/sxmain", cls = "SxApp" }`. A path with no `/` is -/// the default Java package (`{ pkg = "", cls = path }`). -const PathParts = struct { pkg: []const u8, cls: []const u8 }; -fn splitForeignPath(foreign_path: []const u8) PathParts { - const last_slash = std.mem.lastIndexOfScalar(u8, foreign_path, '/') orelse { - return .{ .pkg = "", .cls = foreign_path }; - }; - return .{ - .pkg = foreign_path[0..last_slash], - .cls = foreign_path[last_slash + 1 ..], - }; -} - -/// Locate `javac`. Honors `$JAVA_HOME/bin/javac` first (the Android Studio -/// JDK install on macOS sets this), then falls back to PATH lookup via -/// `which`. Returns an absolute path so subsequent `runProcess` calls work -/// regardless of the CWD passed via `runProcessIn`. -fn discoverJavac(allocator: std.mem.Allocator, io: std.Io) ![]const u8 { - if (std.c.getenv("JAVA_HOME")) |env| { - const home = std.mem.span(env); - const candidate = try std.fmt.allocPrint(allocator, "{s}/bin/javac", .{home}); - if (std.Io.Dir.cwd().statFile(io, candidate, .{})) |_| { - return candidate; - } else |_| { - allocator.free(candidate); - } - } - const which = std.process.run(allocator, io, .{ .argv = &.{ "/usr/bin/which", "javac" } }) catch |e| { - std.debug.print("error: failed to locate javac via PATH: {}\n", .{e}); - return error.JavacNotFound; - }; - defer allocator.free(which.stderr); - errdefer allocator.free(which.stdout); - if (which.term != .exited or which.term.exited != 0) { - std.debug.print("error: javac not on PATH and $JAVA_HOME unset \u{2014} install a JDK (Android Studio bundles one at $ANDROID_STUDIO/Contents/jre)\n", .{}); - allocator.free(which.stdout); - return error.JavacNotFound; - } - const trimmed = std.mem.trimEnd(u8, which.stdout, " \t\r\n"); - const out = try allocator.dupe(u8, trimmed); - allocator.free(which.stdout); - return out; -} - -/// Wrap a linked Android `.so` into a debug-signed APK. Steps: -/// 1. Place the .so under `lib/arm64-v8a/` in a staging directory. -/// 2. Generate (or copy) AndroidManifest.xml. -/// 3. (Optional) Compile `#jni_main` Java sources to classes.dex. -/// 4. aapt2 link → empty APK with resources/manifest. -/// 5. Append the lib/ tree via `zip`. -/// 6. (Optional) Append classes.dex if step 3 produced one. -/// 7. zipalign → aligned APK. -/// 8. apksigner → final signed APK at `target_config.apk_path`. -pub fn createApk(allocator: std.mem.Allocator, io: std.Io, so_path: []const u8, target_config: TargetConfig, jni_main_decls: []const JniMainEmission) !void { - const apk_path = target_config.apk_path orelse return error.NoApkPath; - const bundle_id = target_config.bundle_id orelse { - std.debug.print("error: --apk requires --bundle-id (e.g. co.swipelab.myapp)\n", .{}); - return error.MissingBundleId; - }; - - const sdk_root = try discoverAndroidSdk(allocator, io); - const build_tools = try findHighestSubdir(allocator, io, sdk_root, "build-tools"); - const platform_dir = try findHighestSubdir(allocator, io, sdk_root, "platforms"); - const android_jar = try std.fmt.allocPrint(allocator, "{s}/android.jar", .{platform_dir}); - - const aapt2 = try std.fmt.allocPrint(allocator, "{s}/aapt2", .{build_tools}); - const zipalign = try std.fmt.allocPrint(allocator, "{s}/zipalign", .{build_tools}); - const apksigner = try std.fmt.allocPrint(allocator, "{s}/apksigner", .{build_tools}); - const d8 = try std.fmt.allocPrint(allocator, "{s}/d8", .{build_tools}); - - // Staging dir alongside the apk output. - const stage = try std.fmt.allocPrint(allocator, "{s}.stage", .{apk_path}); - const lib_dir = try std.fmt.allocPrint(allocator, "{s}/lib/arm64-v8a", .{stage}); - const cwd = std.Io.Dir.cwd(); - cwd.deleteTree(io, stage) catch {}; - try cwd.createDirPath(io, lib_dir); - - // libsxhello.so must literally start with "lib" for Android's loader. - // The user's -o path already does (e.g. lib/.../libsxhello.so). We copy - // by basename into the staging lib dir. - const so_basename = std.fs.path.basename(so_path); - const so_dest = try std.fs.path.join(allocator, &.{ lib_dir, so_basename }); - cwd.copyFile(so_path, cwd, so_dest, io, .{}) catch return error.ApkStageFailed; - - // Manifest: either user-supplied or auto-generated. When a `#jni_main` - // class is declared, the auto-generated manifest points its - // `` 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: `` 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, - \\ - \\ - \\ - \\ - \\ - \\ - \\ - \\ - \\ - \\ - \\ - \\ - \\ - , .{ 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, - \\ - \\ - \\ - \\ - \\ - \\ - \\ - \\ - \\ - \\ - \\ - \\ - \\ - \\ - , .{ 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 diff --git a/tests/expected/124-inline-if-hoist-toplevel.exit b/tests/expected/124-inline-if-hoist-toplevel.exit new file mode 100644 index 0000000..573541a --- /dev/null +++ b/tests/expected/124-inline-if-hoist-toplevel.exit @@ -0,0 +1 @@ +0 diff --git a/tests/expected/124-inline-if-hoist-toplevel.txt b/tests/expected/124-inline-if-hoist-toplevel.txt new file mode 100644 index 0000000..d81cc07 --- /dev/null +++ b/tests/expected/124-inline-if-hoist-toplevel.txt @@ -0,0 +1 @@ +42 diff --git a/tests/expected/ffi-jni-call-10-jfloat-return.exit b/tests/expected/ffi-jni-call-10-jfloat-return.exit new file mode 100644 index 0000000..573541a --- /dev/null +++ b/tests/expected/ffi-jni-call-10-jfloat-return.exit @@ -0,0 +1 @@ +0 diff --git a/tests/expected/ffi-jni-call-10-jfloat-return.txt b/tests/expected/ffi-jni-call-10-jfloat-return.txt new file mode 100644 index 0000000..9766475 --- /dev/null +++ b/tests/expected/ffi-jni-call-10-jfloat-return.txt @@ -0,0 +1 @@ +ok diff --git a/tests/expected/ffi-jni-call-11-unsupported-return-diag.exit b/tests/expected/ffi-jni-call-11-unsupported-return-diag.exit new file mode 100644 index 0000000..d00491f --- /dev/null +++ b/tests/expected/ffi-jni-call-11-unsupported-return-diag.exit @@ -0,0 +1 @@ +1 diff --git a/tests/expected/ffi-jni-call-11-unsupported-return-diag.txt b/tests/expected/ffi-jni-call-11-unsupported-return-diag.txt new file mode 100644 index 0000000..ddc299a --- /dev/null +++ b/tests/expected/ffi-jni-call-11-unsupported-return-diag.txt @@ -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 diff --git a/tests/expected/ffi-jni-class-09-multi-float-args.exit b/tests/expected/ffi-jni-class-09-multi-float-args.exit new file mode 100644 index 0000000..573541a --- /dev/null +++ b/tests/expected/ffi-jni-class-09-multi-float-args.exit @@ -0,0 +1 @@ +0 diff --git a/tests/expected/ffi-jni-class-09-multi-float-args.txt b/tests/expected/ffi-jni-class-09-multi-float-args.txt new file mode 100644 index 0000000..9766475 --- /dev/null +++ b/tests/expected/ffi-jni-class-09-multi-float-args.txt @@ -0,0 +1 @@ +ok diff --git a/tests/expected/ffi-objc-call-03-selector-sharing.ir b/tests/expected/ffi-objc-call-03-selector-sharing.ir index adc30d3..1e4b4a8 100644 --- a/tests/expected/ffi-objc-call-03-selector-sharing.ir +++ b/tests/expected/ffi-objc-call-03-selector-sharing.ir @@ -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 diff --git a/tests/expected/ffi-objc-call-06-sret-return.ir b/tests/expected/ffi-objc-call-06-sret-return.ir index f833031..564d1de 100644 --- a/tests/expected/ffi-objc-call-06-sret-return.ir +++ b/tests/expected/ffi-objc-call-06-sret-return.ir @@ -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