#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; } // 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 \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; } // Copy the linked binary into the bundle as ``. Flat // layout (binary + Info.plist at bundle root) matches the legacy // Zig path for every Apple target — the canonical macOS // `Contents/MacOS/` layout is a follow-up. exe_name := basename(binary); binary_z := str_to_cstr(binary); exe_dest := concat(bundle, "/"); 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(bundle, "/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. // Apple .app puts them at `//`. 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, bundle) { out("error: bundle: failed to copy asset dir '"); out(src); out("'\n"); return false; } j += 1; } // iOS apps load dynamic frameworks from // `.app/Frameworks/.framework/` 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 CFBundleIdentifier {} CFBundleName {} CFBundleExecutable {} CFBundlePackageType APPL CFBundleVersion 1 CFBundleShortVersionString 0.1 MinimumOSVersion {} UIDeviceFamily 1 LSRequiresIPhoneOS UILaunchScreen UIApplicationSceneManifest UIApplicationSupportsMultipleScenes UISceneConfigurations UIWindowSceneSessionRoleApplication UISceneConfigurationName Default Configuration UISceneDelegateClassName SxSceneDelegate DTPlatformName {} 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 CFBundleIdentifier {} CFBundleName {} CFBundleExecutable {} CFBundlePackageType APPL CFBundleVersion 1 CFBundleShortVersionString 0.1 PLIST, xml_escape(bundle_id), xml_escape(exe_name), xml_escape(exe_name)); } // Read a `.mobileprovision` and write it to // `/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 `` (relative to the build CWD) into // `//`. 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 `.framework` from one of the user's `-F` search // paths into ``. 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 (`.*`) 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 -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 "." 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; }