A block's value is now its last statement ONLY when that statement is a trailing expression with no `;`. A trailing `;` discards the value, leaving the block void. This makes value-vs-statement explicit and lets the compiler reject "this block was supposed to produce a value". Compiler: - Parser records `Block.produces_value` (last stmt is a no-`;` trailing expression) + `Block.discarded_semi` (the `;` that discarded a value), via `expectSemicolonAfter`. A trailing expression before `}` may now omit its `;` (previously a parse error). Match-arm and else-arm bodies are built value-producing regardless of the arm `;` (arms are exempt — the `;` is an arm terminator). - Lowering: `lowerBlockValue` / the block-expr path / `inferExprType` respect `produces_value`. A value-position block that discards its value is a hard error (`lowerValueBody` for function bodies; the value-context `.block` path for if/else branches, `catch` bodies, value bindings, match arms). Pure-failable `-> !` bodies (value rides the error channel) and a value-if whose branches are void are handled without false errors. - `defer`/`onfail` cleanup bodies lower as statements (void), so a trailing `;` there is fine. Migration (behavior-preserving — output unchanged): - stdlib + ~210 examples: dropped the trailing `;` on value-position last expressions. `format` now ends with an explicit `#insert "return result;"` (it relied on `#insert`-as-block-value, which `;` discards). - Two `main :: () -> s32` examples that relied on the old silent default-return got an explicit trailing `0`. - Rejection snapshots 0412 / 1013 regenerated (their quoted source lines lost a `;`); the diagnostics themselves are unchanged. Docs/tests: specs.md "Block values" section; examples 0040 (rules) + 0041 (rejection); 3 parser unit tests. Filed issue 0066 (pre-existing match-arm negated-literal phi-width quirk, surfaced not caused here). Gates: zig build, zig build test, run_examples.sh -> 343 passed, cross_compile.sh -> 7 passed (also refreshed its stale example names).
1138 lines
41 KiB
Plaintext
1138 lines
41 KiB
Plaintext
#import "../std.sx";
|
|
#import "../compiler.sx";
|
|
#import "../fs.sx";
|
|
#import "../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
|
|
}
|