Files
sx/library/modules/platform/bundle.sx
agra 12bf61a9fc std: restructure step 3 — ffi/ moves, build.sx, math dir spelling, fixtures
- objc.sx, objc_block.sx (from std/) + sdl3/opengl/raylib/stb/stb_truetype/
  wasm vendor bindings (from modules/ root) -> modules/ffi/
- std/uikit.sx deleted: platform/uikit.sx already declares UIApplicationMain
  and imports objc; '#framework "UIKit"' cannot live in a file imported on
  macOS targets (unconditional link directive, UIKit is iOS-only), so the
  three iOS-only examples carry the 3-line glue inline. 1607/1608/1616 also
  un-rotted (dead ns_string -> 'xx "..."' Into conversions, callconv(.c)
  msgSend fn-ptrs) — all three build for ios-sim/ios again.
- math/math.sx -> math/scalar.sx; one spelling '#import "modules/math"'
  everywhere (4 pinned IR snapshots regenerated: dir import adds Vec2/Mat4
  to the type tables).
- compiler.sx -> build.sx (imports, CLAUDE.md bundling table, specs.md).
- testpkg/ + test_c.sx -> tests/fixtures/ (resolve CWD-relative from repo
  root, same as vendors/).
- library-internal imports use full modules/... paths (std.sx tail,
  platform/bundle.sx, fixtures).
2026-06-11 08:37:22 +03:00

1138 lines
41 KiB
Plaintext

#import "modules/std.sx";
#import "modules/build.sx";
#import "modules/std/fs.sx";
#import "modules/std/process.sx";
// =====================================================================
// platform.bundle — sx-side Apple `.app` bundler.
//
// Covers all three Apple targets from a single `bundle_main` entry:
// macOS, iOS simulator, iOS device. Per-platform branching is keyed
// off `BuildOptions.is_macos()` / `is_ios_simulator()` / `is_ios_device()`
// so the bundle layout, Info.plist shape, framework embedding,
// provisioning, entitlements, and codesigning ceremony all match what
// the Zig `createBundle` used to produce.
//
// Wiring: users opt in by registering `bundle_main` as the post-link
// callback in their own `#run` block. Example:
//
// #run {
// opts := build_options();
// opts.set_bundle_path("MyApp.app");
// opts.set_bundle_id("co.example.myapp");
// opts.set_post_link_callback(platform.bundle.bundle_main);
// }
// =====================================================================
bundle_main :: () -> bool {
opts := build_options();
binary := opts.binary_path();
bundle := opts.bundle_path();
bid := opts.bundle_id();
if bundle.len == 0 {
// No bundle requested — nothing to do. Build succeeded.
return true;
}
if bid.len == 0 {
out("error: bundle requires bundle_id (set via set_bundle_id() or --bundle-id)\n");
return false;
}
if binary.len == 0 {
out("error: bundle: empty binary_path (compiler bug)\n");
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.
if opts.is_ios_device() {
if opts.codesign_identity().len == 0 {
out("error: --target ios requires --codesign-identity (e.g. \"Apple Development: ...\") and --provisioning-profile <path>\n");
return false;
}
}
bundle_z := str_to_cstr(bundle);
// Clean previous bundle. `rm -rf` via shell until fs.sx grows
// `delete_dir_all`.
rm_cmd := concat("rm -rf ", bundle);
rm_z := str_to_cstr(rm_cmd);
if r := run(rm_z) {
if r.exit_code != 0 {
out("error: bundle: failed to clean ");
out(bundle);
out("\n");
return false;
}
}
if !create_dir_all(bundle_z) {
out("error: bundle: cannot create dir ");
out(bundle);
out("\n");
return false;
}
// Apple .app layout: macOS goes through `Contents/{MacOS,Resources}`;
// iOS / iOS-sim lay everything flat at the bundle root.
is_mac := opts.is_macos();
macos_dir := if is_mac then concat(bundle, "/Contents/MacOS") else bundle;
plist_dir := if is_mac then concat(bundle, "/Contents") else bundle;
asset_root := if is_mac then concat(bundle, "/Contents/Resources") else bundle;
if is_mac {
if !create_dir_all(str_to_cstr(macos_dir)) {
out("error: bundle: cannot create Contents/MacOS\n");
return false;
}
if !create_dir_all(str_to_cstr(asset_root)) {
out("error: bundle: cannot create Contents/Resources\n");
return false;
}
}
exe_name := basename(binary);
binary_z := str_to_cstr(binary);
exe_dest := concat(macos_dir, "/");
exe_dest = concat(exe_dest, exe_name);
exe_dest_z := str_to_cstr(exe_dest);
if !copy_file(binary_z, exe_dest_z) {
out("error: bundle: copy binary failed\n");
return false;
}
set_mode(exe_dest_z, 493); // 0o755 = preserve executable bit
// Write Info.plist. Per-target shape — iOS needs UIDeviceFamily +
// UIApplicationSceneManifest + DTPlatformName, macOS doesn't.
plist := build_info_plist(opts, exe_name, bid);
plist_path := concat(plist_dir, "/Info.plist");
plist_path_z := str_to_cstr(plist_path);
if !write_file(plist_path_z, plist) {
out("error: bundle: write Info.plist failed\n");
return false;
}
// Embed the provisioning profile if supplied. Required for device
// installs; harmless (and usually omitted) elsewhere.
profile := opts.provisioning_profile();
if profile.len > 0 {
if !embed_provisioning_profile(profile, bundle) {
return false;
}
}
// Copy any user-registered asset directories into the bundle.
// macOS: `<bundle>/Contents/Resources/<dest>/`. iOS: `<bundle>/<dest>/`.
// Android (Week 7) will zip them into the APK at the same relative path.
// Recursive copy shells out to `cp -R` until fs.sx grows `list_dir`.
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 !copy_asset_dir(src, dest, asset_root) {
out("error: bundle: failed to copy asset dir '");
out(src);
out("'\n");
return false;
}
j += 1;
}
// iOS apps load dynamic frameworks from
// `<bundle>.app/Frameworks/<Name>.framework/<Name>` via the
// `@executable_path/Frameworks` rpath set at link time. Recursive
// copy lives in `embed_framework` until fs.sx grows `list_dir`.
if opts.is_ios() {
fw_count := opts.framework_count();
if fw_count > 0 {
fw_dir := concat(bundle, "/Frameworks");
fw_dir_z := str_to_cstr(fw_dir);
if !create_dir_all(fw_dir_z) {
out("error: bundle: cannot create Frameworks dir\n");
return false;
}
i : s64 = 0;
while i < fw_count {
fw_name := opts.framework_at(i);
if !embed_framework(opts, fw_name, fw_dir) {
// embed_framework emits its own diagnostic; on
// failure we print a warning (matching the legacy
// Zig path) and continue — the link may still have
// resolved the framework against the SDK.
out("warning: framework '");
out(fw_name);
out("' not embedded; runtime load may fail\n");
}
i += 1;
}
}
}
// Codesign. Device builds need real identity + extracted
// entitlements; sim/macOS default to ad-hoc ("-").
identity := opts.codesign_identity();
if identity.len == 0 { identity = "-"; }
ent_path := "";
if opts.is_ios_device() {
if profile.len > 0 {
if e := extract_entitlements(profile, bid) {
ent_path = e;
} else {
out("error: bundle: failed to extract entitlements from provisioning profile\n");
return false;
}
}
}
if !codesign(bundle, identity, ent_path) {
return false;
}
out("bundled: ");
out(bundle);
out("\n");
true
}
// ── Helpers ──────────────────────────────────────────────────────────
// Copy a sx string (slice) into a freshly-allocated null-terminated
// buffer for libc / `[:0]u8` callees. Allocated from
// `context.allocator` like the rest of the bundling stage.
str_to_cstr :: (s: string) -> [:0]u8 {
buf := cstring(s.len);
memcpy(buf.ptr, s.ptr, s.len);
buf
}
// Minimum iOS version baked into the Info.plist — matches what the
// Zig path emitted for years. Lift to a setter when a real consumer
// needs a higher floor.
IOS_MIN_OS : string : "14.0";
// Build the Info.plist body for the current target. iOS-shaped plists
// carry the keys the iOS launcher needs (UIDeviceFamily,
// LSRequiresIPhoneOS, UIApplicationSceneManifest, DTPlatformName,
// MinimumOSVersion); macOS doesn't need any of those.
build_info_plist :: (opts: BuildOptions, exe_name: string, bundle_id: string) -> string {
if opts.is_ios() {
platform_key := if opts.is_ios_simulator() then "iPhoneSimulator" else "iPhoneOS";
return format(#string PLIST
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleIdentifier</key>
<string>{}</string>
<key>CFBundleName</key>
<string>{}</string>
<key>CFBundleExecutable</key>
<string>{}</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>CFBundleShortVersionString</key>
<string>0.1</string>
<key>MinimumOSVersion</key>
<string>{}</string>
<key>UIDeviceFamily</key>
<array>
<integer>1</integer>
</array>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UILaunchScreen</key>
<dict/>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<false/>
<key>UISceneConfigurations</key>
<dict>
<key>UIWindowSceneSessionRoleApplication</key>
<array>
<dict>
<key>UISceneConfigurationName</key>
<string>Default Configuration</string>
<key>UISceneDelegateClassName</key>
<string>SxSceneDelegate</string>
</dict>
</array>
</dict>
</dict>
<key>DTPlatformName</key>
<string>{}</string>
</dict>
</plist>
PLIST, xml.escape(bundle_id), xml.escape(exe_name), xml.escape(exe_name), IOS_MIN_OS, platform_key);
}
// macOS (and any non-iOS Apple target) — the minimal plist both
// launchers will accept.
format(#string PLIST
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleIdentifier</key>
<string>{}</string>
<key>CFBundleName</key>
<string>{}</string>
<key>CFBundleExecutable</key>
<string>{}</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>CFBundleShortVersionString</key>
<string>0.1</string>
</dict>
</plist>
PLIST, xml.escape(bundle_id), xml.escape(exe_name), xml.escape(exe_name))
}
// Read a `.mobileprovision` and write it to
// `<bundle>/embedded.mobileprovision`. iOS device installer rejects
// the app without this file when a profile-bound identity is used.
embed_provisioning_profile :: (profile: string, bundle: string) -> bool {
profile_z := str_to_cstr(profile);
if data := read_file(profile_z) {
dest := concat(bundle, "/embedded.mobileprovision");
dest_z := str_to_cstr(dest);
if !write_file(dest_z, data) {
out("error: bundle: failed to write embedded.mobileprovision\n");
return false;
}
return true;
}
out("error: bundle: cannot read provisioning profile: ");
out(profile);
out("\n");
false
}
// Recursive-copy `<src_dir>` (relative to the build CWD) into
// `<bundle>/<dest>/`. Creates intermediate dirs as needed. Returns
// true if `src_dir` doesn't exist (callers can register optional
// asset trees without failing the build). Shells out to `cp -R`
// because fs.sx Phase 1A doesn't expose `list_dir` / `walk` yet.
copy_asset_dir :: (src: string, dest: string, bundle: string) -> bool {
src_z := str_to_cstr(src);
if !exists(src_z) {
// Treating missing src as "nothing to do" lets a project
// register `add_asset_dir("assets", "assets")` unconditionally
// and only ship assets when the dir is present.
return true;
}
dest_full := concat(bundle, "/");
dest_full = concat(dest_full, dest);
// Parent of dest_full must exist for `cp -R src dest_full` to
// place src as dest_full's contents. We pre-create dest_full so cp
// works in "copy src contents into existing dir" mode by appending
// a trailing `/` to src.
dest_full_z := str_to_cstr(dest_full);
if !create_dir_all(dest_full_z) {
out("error: bundle: cannot create asset dest '");
out(dest_full);
out("'\n");
return false;
}
// `cp -R src/. dest/` copies the contents of src into dest. The
// `.` is critical: `cp -R src/ dest/` on macOS BSD cp places src
// *inside* dest as `dest/src/`, which is the wrong shape.
cmd := concat("cp -R \"", src);
cmd = concat(cmd, "/.\" \"");
cmd = concat(cmd, dest_full);
cmd = concat(cmd, "\" 2>&1");
cmd_z := str_to_cstr(cmd);
if r := run(cmd_z) {
if r.exit_code != 0 {
out("error: cp -R failed:\n");
out(r.stdout);
return false;
}
return true;
}
out("error: cp -R spawn failed\n");
false
}
// Recursive-copy `<name>.framework` from one of the user's `-F` search
// paths into `<dest_dir>`. Walks the framework paths in order; first
// hit wins. Falls back to a `cp -R` subprocess because fs.sx Phase 1A
// doesn't expose `list_dir` / `walk` yet.
embed_framework :: (opts: BuildOptions, name: string, dest_dir: string) -> bool {
subdir := concat(name, ".framework");
path_count := opts.framework_path_count();
i : s64 = 0;
while i < path_count {
base := opts.framework_path_at(i);
candidate := concat(base, "/");
candidate = concat(candidate, subdir);
candidate_z := str_to_cstr(candidate);
if exists(candidate_z) {
dest := concat(dest_dir, "/");
dest = concat(dest, subdir);
// Shell-quoting is conservative — paths may contain
// spaces (e.g. user's home dir on macOS). Wrap each path
// in double quotes; we trust them not to contain `"`.
cmd := concat("cp -R \"", candidate);
cmd = concat(cmd, "\" \"");
cmd = concat(cmd, dest);
cmd = concat(cmd, "\"");
cmd_z := str_to_cstr(cmd);
if r := run(cmd_z) {
if r.exit_code != 0 {
out("error: cp -R ");
out(candidate);
out(" -> ");
out(dest);
out(" failed\n");
return false;
}
return true;
}
out("error: cp -R failed to spawn\n");
return false;
}
i += 1;
}
false
}
// Extract entitlements XML from a `.mobileprovision` and resolve the
// `application-identifier` wildcard (`<TEAM>.*`) to the concrete
// bundle ID. Required for iOS device installs — without this
// substitution the device installer rejects the app with
// `MIInstallerErrorDomain error 13` / `0xe8008015`.
// Writes the resolved entitlements to `.sx-tmp/entitlements.plist`
// and returns that path on success.
extract_entitlements :: (profile: string, bundle_id: string) -> ?string {
sx_tmp := str_to_cstr(".sx-tmp");
create_dir_all(sx_tmp);
profile_plist := ".sx-tmp/profile.plist";
ent_path := ".sx-tmp/entitlements.plist";
// 1. security cms -D -i <profile> -o profile.plist
cmd1 := concat("security cms -D -i \"", profile);
cmd1 = concat(cmd1, "\" -o \"");
cmd1 = concat(cmd1, profile_plist);
cmd1 = concat(cmd1, "\" 2>&1");
cmd1_z := str_to_cstr(cmd1);
if r := run(cmd1_z) {
if r.exit_code != 0 {
out("error: failed to decode provisioning profile:\n");
out(r.stdout);
return null;
}
} else {
out("error: security cms spawn failed\n");
return null;
}
// 2. plutil -extract Entitlements xml1 -o entitlements.plist profile.plist
cmd2 := concat("plutil -extract Entitlements xml1 -o \"", ent_path);
cmd2 = concat(cmd2, "\" \"");
cmd2 = concat(cmd2, profile_plist);
cmd2 = concat(cmd2, "\" 2>&1");
cmd2_z := str_to_cstr(cmd2);
if r := run(cmd2_z) {
if r.exit_code != 0 {
out("error: failed to extract entitlements:\n");
out(r.stdout);
return null;
}
} else {
out("error: plutil extract spawn failed\n");
return null;
}
// 3. Read the team identifier from
// `ApplicationIdentifierPrefix.0`. Using
// `com.apple.developer.team-identifier` would confuse plutil —
// dots in plutil paths are interpreted as path separators.
cmd3 := concat("plutil -extract ApplicationIdentifierPrefix.0 raw -o - \"", profile_plist);
cmd3 = concat(cmd3, "\"");
cmd3_z := str_to_cstr(cmd3);
team := "";
if r := run(cmd3_z) {
if r.exit_code != 0 {
out("error: profile missing ApplicationIdentifierPrefix:\n");
out(r.stdout);
return null;
}
team = r.stdout;
// Strip trailing whitespace.
while team.len > 0 {
last := team[team.len - 1];
if last == 10 { team = substr(team, 0, team.len - 1); }
else if last == 13 { team = substr(team, 0, team.len - 1); }
else if last == 32 { team = substr(team, 0, team.len - 1); }
else if last == 9 { team = substr(team, 0, team.len - 1); }
else { break; }
}
} else {
out("error: plutil ApplicationIdentifierPrefix spawn failed\n");
return null;
}
if team.len == 0 {
out("error: provisioning profile has empty ApplicationIdentifierPrefix\n");
return null;
}
// 4. plutil -replace application-identifier -string "<team>.<bundle_id>" entitlements.plist
resolved_app_id := concat(team, ".");
resolved_app_id = concat(resolved_app_id, bundle_id);
cmd4 := concat("plutil -replace application-identifier -string \"", resolved_app_id);
cmd4 = concat(cmd4, "\" \"");
cmd4 = concat(cmd4, ent_path);
cmd4 = concat(cmd4, "\" 2>&1");
cmd4_z := str_to_cstr(cmd4);
if r := run(cmd4_z) {
if r.exit_code != 0 {
out("error: failed to resolve application-identifier:\n");
out(r.stdout);
return null;
}
} else {
out("error: plutil replace spawn failed\n");
return null;
}
ent_path
}
// Codesign the bundle. Empty `ent_path` means no `--entitlements`
// flag (macOS / iOS-sim / ad-hoc). Folds stderr into stdout so a
// failing run hands the user a useful diagnostic.
codesign :: (bundle: string, identity: string, ent_path: string) -> bool {
cmd := concat("codesign --force --sign \"", identity);
cmd = concat(cmd, "\" --timestamp=none");
if ent_path.len > 0 {
cmd = concat(cmd, " --entitlements \"");
cmd = concat(cmd, ent_path);
cmd = concat(cmd, "\"");
}
cmd = concat(cmd, " \"");
cmd = concat(cmd, bundle);
cmd = concat(cmd, "\" 2>&1");
cmd_z := str_to_cstr(cmd);
if r := run(cmd_z) {
if r.exit_code != 0 {
out("error: codesign failed:\n");
out(r.stdout);
return false;
}
return true;
}
out("error: codesign spawn failed\n");
false
}
// =====================================================================
// Android APK pipeline.
//
// Same shape as the legacy Zig `createApk`:
// 1. Discover SDK root + highest build-tools / platforms version.
// 2. Stage `<apk>.stage/lib/arm64-v8a/<libfoo.so>`.
// 3. Use the user-supplied AndroidManifest.xml or synthesize one
// (NativeActivity shape when no `#jni_main` decl; Activity-bound
// shape pointing at the user's `#jni_main` class otherwise).
// 4. For each `#jni_main` decl: write `<stage>/java/<pkg>/<Cls>.java`,
// compile via `javac --release 11 -classpath android.jar`, then
// dex via `d8 --release --lib android.jar --output <stage>`.
// 5. `aapt2 link -I android.jar --manifest <m> -o <apk>.unaligned`.
// 6. `zip <unaligned> lib/` (from stage cwd) + `zip classes.dex` if
// a dex was produced + zip each registered asset dir.
// 7. `zipalign -f 4 <unaligned> <aligned>`.
// 8. Ensure debug keystore (via `keytool`) at $HOME/.android or
// `set_keystore_path()` override.
// 9. `apksigner sign --ks ... --out <apk> <aligned>`.
// =====================================================================
// Resolve a relative path against the current working directory at call
// time, so it survives a later `cd` into a stage dir. Absolute paths
// (leading `/`) are returned unchanged. Empty input is preserved.
absolutify :: (path: string) -> string {
if path.len == 0 { return path; }
if path[0] == 47 { return path; }
if r := run(str_to_cstr("pwd")) {
if r.exit_code != 0 { return path; }
cwd := r.stdout;
// Strip trailing newline that `pwd` emits.
if cwd.len > 0 {
if cwd[cwd.len - 1] == 10 { cwd = substr(cwd, 0, cwd.len - 1); }
}
if cwd.len == 0 { return path; }
return path_join(cwd, path);
}
path
}
android_bundle_main :: (opts: BuildOptions, binary: string, apk_path: string, bundle_id: string) -> bool {
// The bundler `cd`s into the stage dir for `zip` steps, so any
// relative path the caller gave us would resolve against the wrong
// cwd. Pin everything to absolute paths up front.
apk_path = absolutify(apk_path);
binary = absolutify(binary);
sdk := discover_android_sdk();
if sdk.len == 0 {
out("error: cannot locate Android SDK \xe2\x80\x94 set $ANDROID_HOME\n");
return false;
}
build_tools := find_highest_subdir(path_join(sdk, "build-tools"));
if build_tools.len == 0 {
out("error: no build-tools under ");
out(sdk);
out("/build-tools\n");
return false;
}
platform_dir := find_highest_subdir(path_join(sdk, "platforms"));
if platform_dir.len == 0 {
out("error: no platforms under ");
out(sdk);
out("/platforms\n");
return false;
}
android_jar := path_join(platform_dir, "android.jar");
aapt2_path := path_join(build_tools, "aapt2");
zipalign_path := path_join(build_tools, "zipalign");
apksigner_path := path_join(build_tools, "apksigner");
d8_path := path_join(build_tools, "d8");
// Staging dir alongside the apk output.
stage := concat(apk_path, ".stage");
lib_dir := path_join(stage, "lib/arm64-v8a");
// Clean previous stage. `rm -rf` via shell until fs.sx grows
// `delete_dir_all`.
rm_cmd := concat("rm -rf \"", stage);
rm_cmd = concat(rm_cmd, "\"");
if r := run(str_to_cstr(rm_cmd)) {
if r.exit_code != 0 {
out("error: apk: failed to clean stage dir\n");
return false;
}
}
if !create_dir_all(str_to_cstr(lib_dir)) {
out("error: apk: cannot create stage lib dir\n");
return false;
}
// libsxhello.so must literally start with "lib" for Android's
// loader. The user's -o path already does (build_options enforces
// it). Copy by basename into the staging lib dir.
so_basename := basename(binary);
so_dest := path_join(lib_dir, so_basename);
if !copy_file(str_to_cstr(binary), str_to_cstr(so_dest)) {
out("error: apk: failed to copy .so into stage\n");
return false;
}
// Manifest: user-supplied or auto-generated.
manifest := opts.manifest_path();
manifest_used := "";
lib_name := lib_name_from_so_basename(so_basename);
if manifest.len > 0 {
manifest_used = manifest;
} else {
generated_xml := build_android_manifest(opts, bundle_id, lib_name);
generated_path := path_join(stage, "AndroidManifest.xml");
if !write_file(str_to_cstr(generated_path), generated_xml) {
out("error: apk: failed to write AndroidManifest.xml\n");
return false;
}
manifest_used = generated_path;
}
// Compile each `#jni_main` decl's Java source.
jm_count := opts.jni_main_count();
if jm_count > 0 {
if !compile_jni_main_sources(opts, stage, android_jar, d8_path) {
return false;
}
}
// aapt2 link → unaligned apk with manifest + resources.
unaligned := concat(apk_path, ".unaligned");
aapt_cmd := concat("\"", aapt2_path);
aapt_cmd = concat(aapt_cmd, "\" link -I \"");
aapt_cmd = concat(aapt_cmd, android_jar);
aapt_cmd = concat(aapt_cmd, "\" --manifest \"");
aapt_cmd = concat(aapt_cmd, manifest_used);
aapt_cmd = concat(aapt_cmd, "\" -o \"");
aapt_cmd = concat(aapt_cmd, unaligned);
aapt_cmd = concat(aapt_cmd, "\" 2>&1");
if r := run(str_to_cstr(aapt_cmd)) {
if r.exit_code != 0 {
out("error: aapt2 link failed:\n");
out(r.stdout);
return false;
}
} else {
out("error: aapt2 spawn failed\n");
return false;
}
// Append lib/ tree. Using the `zip` command rather than re-encoding
// the APK from scratch because aapt2 doesn't include arbitrary
// directories and zip is on every macOS/Linux host by default.
// Need to cd into stage so the relative `lib/` path is preserved
// in the zip archive.
if !run_in_dir(stage, concat("zip -q -r \"", concat(unaligned, "\" lib/"))) {
return false;
}
if jm_count > 0 {
if !run_in_dir(stage, concat("zip -q \"", concat(unaligned, "\" classes.dex"))) {
return false;
}
}
// Asset dirs go in at their `dest` path inside the APK. The Zig
// path used a hardcoded `assets/` walk; the sx form respects every
// `add_asset_dir(src, dest)` pair the user registered.
asset_count := opts.asset_dir_count();
j : s64 = 0;
while j < asset_count {
src := opts.asset_dir_src_at(j);
dest := opts.asset_dir_dest_at(j);
if !zip_asset_dir(src, dest, unaligned) {
return false;
}
j += 1;
}
// zipalign → aligned apk.
aligned := concat(apk_path, ".aligned");
align_cmd := concat("\"", zipalign_path);
align_cmd = concat(align_cmd, "\" -f 4 \"");
align_cmd = concat(align_cmd, unaligned);
align_cmd = concat(align_cmd, "\" \"");
align_cmd = concat(align_cmd, aligned);
align_cmd = concat(align_cmd, "\" 2>&1");
if r := run(str_to_cstr(align_cmd)) {
if r.exit_code != 0 {
out("error: zipalign failed:\n");
out(r.stdout);
return false;
}
} else {
out("error: zipalign spawn failed\n");
return false;
}
// Debug keystore (auto-generated on first use) + apksigner.
keystore := opts.keystore_path();
if keystore.len == 0 {
if home := env("HOME") {
keystore = path_join(home, ".android/debug.keystore");
} else {
out("error: apk: cannot locate $HOME for default keystore\n");
return false;
}
}
if !ensure_debug_keystore(keystore) {
return false;
}
sign_cmd := concat("\"", apksigner_path);
sign_cmd = concat(sign_cmd, "\" sign --ks \"");
sign_cmd = concat(sign_cmd, keystore);
sign_cmd = concat(sign_cmd, "\" --ks-pass pass:android --key-pass pass:android --ks-key-alias androiddebugkey --out \"");
sign_cmd = concat(sign_cmd, apk_path);
sign_cmd = concat(sign_cmd, "\" \"");
sign_cmd = concat(sign_cmd, aligned);
sign_cmd = concat(sign_cmd, "\" 2>&1");
if r := run(str_to_cstr(sign_cmd)) {
if r.exit_code != 0 {
out("error: apksigner failed:\n");
out(r.stdout);
return false;
}
} else {
out("error: apksigner spawn failed\n");
return false;
}
// Clean up intermediates (keep stage/ in case users want to
// inspect it).
delete_file(str_to_cstr(unaligned));
delete_file(str_to_cstr(aligned));
run(str_to_cstr(concat("rm -rf \"", concat(stage, "\""))));
out("apk: ");
out(apk_path);
out("\n");
true
}
// ── Android helpers ──────────────────────────────────────────────────
// Run `cmd` under a `cd <dir> && ...` shell wrapping. process.run
// doesn't have a cwd arg in Phase 1A, so we compose it via the shell.
// Output is folded via `2>&1` so failures hand the user one stream.
run_in_dir :: (dir: string, cmd: string) -> bool {
wrapped := concat("cd \"", dir);
wrapped = concat(wrapped, "\" && ");
wrapped = concat(wrapped, cmd);
wrapped = concat(wrapped, " 2>&1");
if r := run(str_to_cstr(wrapped)) {
if r.exit_code != 0 {
out("error: ");
out(cmd);
out(" failed:\n");
out(r.stdout);
return false;
}
return true;
}
out("error: shell spawn failed\n");
false
}
// Discover the Android SDK root. Honors $ANDROID_HOME /
// $ANDROID_SDK_ROOT, otherwise picks the default install location on
// macOS ($HOME/Library/Android/sdk).
discover_android_sdk :: () -> string {
if h := env("ANDROID_HOME") { return h; }
if h := env("ANDROID_SDK_ROOT") { return h; }
if home := env("HOME") {
candidate := path_join(home, "Library/Android/sdk");
if exists(str_to_cstr(candidate)) { return candidate; }
}
""
}
// Pick the lexicographically-highest subdir of `parent`. Equivalent to
// `ls -1 <parent> | sort -V | tail -1`. Returns the full path or "".
find_highest_subdir :: (parent: string) -> string {
cmd := concat("ls -1 \"", parent);
cmd = concat(cmd, "\" 2>/dev/null | sort -V | tail -1");
if r := run(str_to_cstr(cmd)) {
if r.exit_code != 0 { return ""; }
name := r.stdout;
// Strip trailing whitespace.
while name.len > 0 {
last := name[name.len - 1];
if last == 10 { name = substr(name, 0, name.len - 1); }
else if last == 13 { name = substr(name, 0, name.len - 1); }
else if last == 32 { name = substr(name, 0, name.len - 1); }
else if last == 9 { name = substr(name, 0, name.len - 1); }
else { break; }
}
if name.len == 0 { return ""; }
return path_join(parent, name);
}
""
}
// `libfoo.so` → `foo`. Android's `android.app.lib_name` meta-data
// wants the trimmed name; the loader prepends `lib` and appends `.so`
// at runtime.
lib_name_from_so_basename :: (basename: string) -> string {
name := basename;
if name.len > 3 {
if name[0] == 108 { // 'l'
if name[1] == 105 { // 'i'
if name[2] == 98 { // 'b'
name = substr(name, 3, name.len - 3);
}
}
}
}
if name.len > 3 {
last3 := name.len - 3;
if name[last3] == 46 { // '.'
if name[last3 + 1] == 115 { // 's'
if name[last3 + 2] == 111 { // 'o'
name = substr(name, 0, last3);
}
}
}
}
name
}
// AndroidManifest.xml synthesizer. When the program declares a
// `#jni_main` class, the manifest points its `<activity
// android:name>` at the user's class and flips
// `android:hasCode="true"` so Android loads the bundled classes.dex.
// Otherwise it falls back to the legacy NativeActivity shape with an
// `android.app.lib_name` meta-data entry pointing at the .so.
build_android_manifest :: (opts: BuildOptions, package: string, lib_name: string) -> string {
pkg_esc := xml.escape(package);
lib_esc := xml.escape(lib_name);
if opts.jni_main_count() > 0 {
// First `#jni_main` decl drives the Activity. The foreign_path
// uses `/` separators; Java fully-qualified class names use
// `.` so we rewrite.
foreign := opts.jni_main_foreign_path_at(0);
cls := slash_to_dot(foreign);
cls_esc := xml.escape(cls);
return format(#string MANIFEST
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="{}"
android:versionCode="1"
android:versionName="1.0">
<uses-sdk android:minSdkVersion="21" android:targetSdkVersion="34" />
<application android:label="{}" android:hasCode="true">
<activity
android:name="{}"
android:exported="true"
android:label="{}"
android:theme="@android:style/Theme.DeviceDefault.NoActionBar.Fullscreen"
android:configChanges="orientation|keyboardHidden|screenSize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
MANIFEST, pkg_esc, lib_esc, cls_esc, lib_esc);
}
// NativeActivity fallback — the .so provides ANativeActivity_onCreate.
format(#string MANIFEST
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="{}"
android:versionCode="1"
android:versionName="1.0">
<uses-sdk android:minSdkVersion="21" android:targetSdkVersion="34" />
<application android:label="{}" android:hasCode="false">
<activity
android:name="android.app.NativeActivity"
android:exported="true"
android:label="{}"
android:configChanges="orientation|keyboardHidden|screenSize">
<meta-data android:name="android.app.lib_name" android:value="{}" />
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
MANIFEST, pkg_esc, lib_esc, lib_esc, lib_esc)
}
// `co/swipelab/sxchess/SxApp` → `co.swipelab.sxchess.SxApp`.
slash_to_dot :: (path: string) -> string {
buf := cstring(path.len);
i := 0;
while i < path.len {
c := path[i];
buf[i] = if c == 47 then 46 else c; // 47 = '/', 46 = '.'
i += 1;
}
buf
}
// Last `/`-separated component of a forward-slash path (used to split
// JNI foreign paths into pkg + class). `co/swipelab/Foo` → `Foo`.
// `Foo` → `Foo`. `dir_part` returns the part before the last slash
// (or "" if none).
last_slash_component :: (path: string) -> string {
i := path.len;
while i > 0 {
if path[i - 1] == 47 { return substr(path, i, path.len - i); }
i -= 1;
}
path
}
dir_part :: (path: string) -> string {
i := path.len;
while i > 0 {
if path[i - 1] == 47 { return substr(path, 0, i - 1); }
i -= 1;
}
""
}
// Write each `#jni_main` decl's `.java` source, then compile to
// classes via `javac --release 11 -classpath <android.jar>`, then dex
// the resulting class files via `d8 --release --lib <android.jar>
// --output <stage>` so `<stage>/classes.dex` lands where the
// orchestrator can zip it into the APK.
compile_jni_main_sources :: (opts: BuildOptions, stage: string, android_jar: string, d8_path: string) -> bool {
java_root := path_join(stage, "java");
classes_root := path_join(stage, "classes");
if !create_dir_all(str_to_cstr(java_root)) {
out("error: apk: cannot create java root\n");
return false;
}
if !create_dir_all(str_to_cstr(classes_root)) {
out("error: apk: cannot create classes root\n");
return false;
}
javac := discover_javac();
if javac.len == 0 {
out("error: javac not on PATH and $JAVA_HOME unset \xe2\x80\x94 install a JDK (Android Studio bundles one at $ANDROID_STUDIO/Contents/jre)\n");
return false;
}
// Compose javac + d8 arg lists by walking jni_main_decls. Each
// decl: write `<java_root>/<pkg>/<Cls>.java`, append java path to
// javac argv + class path to d8 argv.
javac_files := "";
d8_files := "";
count := opts.jni_main_count();
i : s64 = 0;
while i < count {
foreign := opts.jni_main_foreign_path_at(i);
java_source := opts.jni_main_java_source_at(i);
pkg := dir_part(foreign);
cls := last_slash_component(foreign);
pkg_dir := if pkg.len > 0 then path_join(java_root, pkg) else java_root;
if !create_dir_all(str_to_cstr(pkg_dir)) {
out("error: apk: cannot create java pkg dir\n");
return false;
}
java_path := path_join(pkg_dir, concat(cls, ".java"));
if !write_file(str_to_cstr(java_path), java_source) {
out("error: apk: cannot write .java for ");
out(foreign);
out("\n");
return false;
}
if javac_files.len > 0 { javac_files = concat(javac_files, " "); }
javac_files = concat(javac_files, concat("\"", concat(java_path, "\"")));
class_subpath := if pkg.len > 0 then path_join(pkg, concat(cls, ".class")) else concat(cls, ".class");
class_path := path_join(classes_root, class_subpath);
if d8_files.len > 0 { d8_files = concat(d8_files, " "); }
d8_files = concat(d8_files, concat("\"", concat(class_path, "\"")));
i += 1;
}
javac_cmd := concat("\"", javac);
javac_cmd = concat(javac_cmd, "\" -d \"");
javac_cmd = concat(javac_cmd, classes_root);
javac_cmd = concat(javac_cmd, "\" -classpath \"");
javac_cmd = concat(javac_cmd, android_jar);
javac_cmd = concat(javac_cmd, "\" --release 11 ");
javac_cmd = concat(javac_cmd, javac_files);
javac_cmd = concat(javac_cmd, " 2>&1");
if r := run(str_to_cstr(javac_cmd)) {
if r.exit_code != 0 {
out("error: javac failed:\n");
out(r.stdout);
return false;
}
} else {
out("error: javac spawn failed\n");
return false;
}
d8_cmd := concat("\"", d8_path);
d8_cmd = concat(d8_cmd, "\" --release --lib \"");
d8_cmd = concat(d8_cmd, android_jar);
d8_cmd = concat(d8_cmd, "\" --output \"");
d8_cmd = concat(d8_cmd, stage);
d8_cmd = concat(d8_cmd, "\" ");
d8_cmd = concat(d8_cmd, d8_files);
d8_cmd = concat(d8_cmd, " 2>&1");
if r := run(str_to_cstr(d8_cmd)) {
if r.exit_code != 0 {
out("error: d8 failed:\n");
out(r.stdout);
return false;
}
} else {
out("error: d8 spawn failed\n");
return false;
}
true
}
// Locate `javac`. Honors `$JAVA_HOME/bin/javac` first (Android
// Studio's bundled JDK sets this on macOS), then falls back to a PATH
// lookup via `command -v`.
discover_javac :: () -> string {
if jh := env("JAVA_HOME") {
cand := path_join(jh, "bin/javac");
if exists(str_to_cstr(cand)) { return cand; }
}
if path := find_executable("javac") { return path; }
""
}
// Zip the contents of `<src>` into the APK at `<dest>/`. Uses a
// staging copy under `.sx-tmp/apk-assets/<dest>` so we can run `zip
// -r` from a temporary cwd that produces clean entries (no `../`
// noise). Missing src is treated as "nothing to do" so projects can
// register optional asset trees.
zip_asset_dir :: (src: string, dest: string, apk: string) -> bool {
if !exists(str_to_cstr(src)) { return true; }
asset_root := str_to_cstr(".sx-tmp/apk-assets");
create_dir_all(asset_root);
rm_cmd := concat("rm -rf .sx-tmp/apk-assets/", dest);
run(str_to_cstr(rm_cmd));
parent := if dir_part(dest).len > 0 then concat(".sx-tmp/apk-assets/", dir_part(dest)) else ".sx-tmp/apk-assets";
if !create_dir_all(str_to_cstr(parent)) {
out("error: apk: cannot create asset stage dir\n");
return false;
}
cp_cmd := concat("cp -R \"", src);
cp_cmd = concat(cp_cmd, "\" \".sx-tmp/apk-assets/");
cp_cmd = concat(cp_cmd, dest);
cp_cmd = concat(cp_cmd, "\" 2>&1");
if r := run(str_to_cstr(cp_cmd)) {
if r.exit_code != 0 {
out("error: cp -R asset dir failed:\n");
out(r.stdout);
return false;
}
} else {
out("error: cp -R asset dir spawn failed\n");
return false;
}
// Make apk path absolute-ish for the cd shell wrapping. The user
// typically gives a relative path; resolve via $(pwd) into an
// absolute one so `cd .sx-tmp/apk-assets && zip <apk>` still
// references the right file.
abs_apk := if apk.len > 0 then (if apk[0] == 47 then apk else concat("$(pwd)/", apk)) else apk;
zip_cmd := concat("zip -q -r \"", abs_apk);
zip_cmd = concat(zip_cmd, "\" \"");
zip_cmd = concat(zip_cmd, dest);
zip_cmd = concat(zip_cmd, "\"");
if !run_in_dir(".sx-tmp/apk-assets", zip_cmd) { return false; }
true
}
// Generate the Android debug keystore on first use. The defaults
// match what Android Studio creates: alias `androiddebugkey`, password
// `android` for both store and key, RSA-2048, 10000-day validity.
ensure_debug_keystore :: (keystore_path: string) -> bool {
if exists(str_to_cstr(keystore_path)) { return true; }
// mkdir -p the parent dir if needed.
parent := dir_part(keystore_path);
if parent.len > 0 {
create_dir_all(str_to_cstr(parent));
}
cmd := concat("keytool -genkeypair -keystore \"", keystore_path);
cmd = concat(cmd, "\" -storepass android -alias androiddebugkey -keypass android -keyalg RSA -keysize 2048 -validity 10000 -dname \"CN=Android Debug,O=Android,C=US\" 2>&1");
if r := run(str_to_cstr(cmd)) {
if r.exit_code != 0 {
out("error: keytool failed:\n");
out(r.stdout);
return false;
}
return true;
}
out("error: keytool spawn failed\n");
false
}