// Window-hosted iOS packaging.
// iOS .app packaging now uses the window-hosted template at
// `window/modules/window/ios/template/` +
// `examples//ios_window_hosted` entrypoint.
//
// `build_ios` generates MoonBit C, stages the window-hosted UIKit template
// (Sources/main.m + Sources/MBWHostedAppDelegate.{h,m} + Info.plist), applies
// app identity (CFBundleIdentifier / CFBundleName / MinimumOSVersion), then
// invokes `clang`/`clang++` via `xcrun --sdk iphonesimulator|iphoneos` to
// compile and link the executable directly — no xcodebuild. Mirrors
// `window/modules/window/ios/scripts/build-hosted-sim-app.sh`.
///|
priv struct BuildIosOptions {
app : MobileApp
product_name : String?
workspace_root : String
sdk : String
arch : String
deployment_target : String?
renderer : String
build_dir : String
output_app : String
fallback_skia : Bool
prepare_only : Bool
}
///|
/// Locate the Xcode version via `xcodebuild -version`. Returns the first
/// numeric version token (e.g. `"15.4"`). Empty string when `xcodebuild` is
/// unavailable.
fn xcode_version_string() -> String {
match process_exec_argv(["xcodebuild", "-version"], capture=true) {
Ok(result) if result.exit_code == 0 => {
let line = result.stdout.split("\n").to_array()[0].to_owned()
// "Xcode 15.4\n..." -> take the second whitespace-delimited token.
let parts = line.split(" ").to_array()
if parts.length() >= 2 {
parts[1].to_owned()
} else {
""
}
}
_ => ""
}
}
///|
/// Parse a dotted numeric version into an array of integers. Trailing
/// non-numeric parts are dropped. Empty array when parsing fails.
fn parse_dotted_version(value : String) -> Array[Int] {
let parts = value.split(".").to_array()
let result : Array[Int] = []
for part in parts {
let trimmed = part.trim()
if trimmed == "" {
return []
}
let mut num = 0
let mut ok = true
for ch in trimmed {
if ch >= '0' && ch <= '9' {
num = num * 10 + (ch.to_int() - '0'.to_int())
} else {
ok = false
break
}
}
if !ok {
return []
}
result.push(num)
}
result
}
///|
/// Returns `true` when `actual >= floor` using dotted numeric comparison.
fn version_at_least(actual : String, floor : String) -> Bool {
let left = parse_dotted_version(actual)
let right = parse_dotted_version(floor)
if left.is_empty() || right.is_empty() {
return false
}
let length = if left.length() > right.length() {
left.length()
} else {
right.length()
}
for i in 0.. r {
return true
}
if l < r {
return false
}
}
true
}
///|
/// Resolve `xcrun --sdk --show-sdk-path` to the iOS SDK root. Returns
/// an empty string when the lookup fails.
fn ios_sdk_path(sdk : String) -> String {
match
process_exec_argv(["xcrun", "--sdk", sdk, "--show-sdk-path"], capture=true) {
Ok(result) if result.exit_code == 0 => result.stdout.trim().to_owned()
_ => ""
}
}
///|
/// Resolve `xcrun --sdk --find ` to a tool path. Returns an
/// empty string when the lookup fails.
fn ios_find_tool(sdk : String, tool : String) -> String {
match
process_exec_argv(["xcrun", "--sdk", sdk, "--find", tool], capture=true) {
Ok(result) if result.exit_code == 0 => result.stdout.trim().to_owned()
_ => ""
}
}
///|
/// Stage the window-hosted iOS template into `target_root` and write the
/// `.moui-window-hosted-stage` marker so future runs can replace it safely.
/// The template lives at `window/modules/window/ios/template/` and ships `Sources/`,
/// `Info.plist`, and `README.md`.
fn stage_ios_window_hosted_project(
template_root : String,
target_root : String,
) -> Result[Unit, String] {
let stage_marker = join_path(target_root, ".moui-window-hosted-stage")
if path_is_dir(target_root) && !path_is_file(stage_marker) {
return Err("refusing to replace an unowned iOS project: " + target_root)
}
remove_tree(target_root)
match mkdir_p(target_root) {
Ok(_) => ()
Err(message) => return Err(message)
}
match copy_tree(template_root, target_root) {
Ok(_) => ()
Err(message) => return Err(message)
}
match write_file_bytes_result(stage_marker, b"") {
Ok(_) => Ok(())
Err(message) => Err(message)
}
}
///|
/// Patch `key` inside an XML plist body. Replaces the first
/// `key` followed by a `old` sibling with the new
/// value. When the key is absent the plist is returned unchanged.
fn patch_plist_string_value(
text : String,
key : String,
new_value : String,
) -> String {
let key_pattern = "" + key + ""
let key_pos = match text.find(key_pattern) {
Some(pos) => pos
None => return text
}
// After the key, find the next `...` sibling and rewrite it.
let search_from = key_pos + key_pattern.length()
let after = text[search_from:].to_owned()
let open = match after.find("<") {
Some(pos) => pos
None => return text
}
let close = match after[open:].find(">") {
Some(pos) => pos
None => return text
}
// Tag name inside `<...>` (skipping attributes if any — Info.plist has none).
// `close` is the offset of `>` within `after[open:]`, so the
// tag name (relative to `after`) spans `[open + 1, open + close)`.
let tag_body = after[open + 1:open + close].to_owned()
let tag_name = tag_body.trim().to_owned()
let value_open = open + close + 1
let value_close = match after[value_open:].find("" + tag_name + ">") {
Some(pos) => pos
None => return text
}
let prefix = text[:search_from + value_open].to_owned()
let suffix = after[value_open + value_close:].to_owned()
prefix + new_value + suffix
}
///|
/// Apply mobile app identity onto a staged window-hosted iOS template.
/// Patches `Info.plist`:
/// - CFBundleExecutable -> product_name
/// - CFBundleName -> product_name
/// - CFBundleIdentifier -> bundle_id
/// - MinimumOSVersion -> deployment_target
fn apply_ios_window_hosted_identity(
project_root : String,
app : MobileApp,
product_name : String,
deployment_target : String,
) -> Result[Unit, String] {
let ios = match app.ios {
Some(value) => value
None => return Err("moui.mobile.json missing ios section")
}
let bundle_id = ios.bundle_id
let plist_path = join_path(project_root, "Info.plist")
let plist_text = match read_file_string_optional(plist_path) {
Some(value) => value
None => return Err("could not read: " + plist_path)
}
let patched = patch_plist_string_value(
plist_text, "CFBundleExecutable", product_name,
)
let patched = patch_plist_string_value(patched, "CFBundleName", product_name)
let patched = patch_plist_string_value(
patched, "CFBundleIdentifier", bundle_id,
)
let patched = patch_plist_string_value(
patched, "MinimumOSVersion", deployment_target,
)
match
write_file_bytes_result(plist_path, @utf8.encode(patched[:], bom=false)) {
Ok(_) => ()
Err(message) => return Err(message)
}
log_info(
"[moui-window-hosted-ios] Applied identity CFBundleIdentifier=" +
bundle_id +
" CFBundleExecutable=" +
product_name +
" MinimumOSVersion=" +
deployment_target,
)
Ok(())
}
///|
/// Build the common compile flags for an iOS window-hosted build. Mirrors
/// the `COMMON_FLAGS` array in
/// `window/modules/window/ios/scripts/build-hosted-sim-app.sh`.
/// `target_triple` already encodes the SDK (simulator vs device) and
/// deployment target.
fn ios_common_compile_flags(
sdk_path : String,
target_triple : String,
moon_home : String,
window_ios_root : String,
workspace_root : String,
) -> Array[String] {
let flags : Array[String] = [
"-isysroot",
sdk_path,
"-target",
target_triple,
"-fobjc-arc",
"-O0",
"-g",
"-I" + moon_home + "/include",
"-I" + window_ios_root,
"-I" + window_ios_root + "/native/include",
"-I" + workspace_root + "/moui_skia/native",
"-D__APPLE__",
"-DTARGET_OS_IPHONE=1",
"-DMBW_IOS_MOONBIT_MAIN=mbw_ios_moonbit_main",
]
flags
}
///|
/// Compile a single source file to an object file. Returns `Err` when the
/// compiler invocation fails. `lang` controls `-x objective-c` / `-x c++`
/// injection when the source extension is ambiguous; pass an empty string to
/// rely on the file extension.
fn compile_ios_object(
compiler : String,
common_flags : Array[String],
source : String,
output : String,
lang : String,
extra_flags : Array[String],
) -> Result[Unit, String] {
let argv : Array[String] = [compiler]
for flag in common_flags {
argv.push(flag)
}
if lang != "" {
argv.push("-x")
argv.push(lang)
}
for flag in extra_flags {
argv.push(flag)
}
argv.push("-c")
argv.push(source)
argv.push("-o")
argv.push(output)
log_command("compile_ios_object", argv)
match process_exec_argv(argv, capture=false) {
Ok(result) if result.exit_code == 0 => Ok(())
Ok(result) =>
Err(
"clang failed (exit " +
result.exit_code.to_string() +
") compiling " +
source,
)
Err(message) => Err(message)
}
}
///|
/// Build the iOS .app bundle. Mirrors
/// `window/modules/window/ios/scripts/build-hosted-sim-app.sh`. On macOS only.
fn build_ios(
options : BuildIosOptions,
host : HostPlatform,
) -> Result[BuildResult, String] {
match host {
Macos => ()
_ => return Err("iOS builds require macOS with Xcode installed")
}
// Make build_dir + workspace_root absolute so paths passed to clang and
// referenced in BuildResult stay valid regardless of the user's cwd.
let workspace_root_abs = make_absolute(
options.workspace_root,
@env.get_env_var("PWD").unwrap_or("."),
)
let build_dir_abs = make_absolute(options.build_dir, workspace_root_abs)
// 2. Xcode version floor (clang + iOS SDK must be present).
let found = xcode_version_string()
if !version_at_least(found, "15.4") {
return Err(
"MoUI iOS window-hosted build requires Xcode 15.4 or newer; found " +
(if found == "" { "unknown" } else { found }),
)
}
// 3. Resolve the upstream window package template and native glue.
let template_root = window_template_root(workspace_root_abs, "ios")
let ios_pkg_root = window_platform_root(workspace_root_abs, "ios")
if !path_is_file(join_path(template_root, "Info.plist")) ||
!path_is_file(join_path(template_root, "Sources/main.m")) ||
!path_is_file(join_path(template_root, "Sources/MBWHostedAppDelegate.m")) {
return Err(
"MoUI window-hosted iOS template is incomplete: " + template_root,
)
}
if !path_is_file(join_path(ios_pkg_root, "native_ios_host.c")) {
return Err(
"MoUI window-hosted iOS package sources missing: " +
join_path(ios_pkg_root, "native_ios_host.c"),
)
}
// 4. Generate MoonBit C from the entrypoint package.
let entrypoint_package = match
mobile_entrypoint_package(
options.app.metadata_path,
"ios",
workspace_root_abs,
) {
Ok(value) => value
Err(message) => return Err(message)
}
let moonbit_c = match
generate_moonbit_c(
workspace_root_abs,
entrypoint_package,
base_name(entrypoint_package) + ".c",
build_dir_abs,
) {
Ok(value) => value
Err(message) => return Err(message)
}
if !path_is_file(moonbit_c) {
return Err("MoonBit generated C was not found: " + moonbit_c)
}
// Resolve real Skia link flags via `node moui_skia/build.js`. Mirrors the
// legacy `prepare_ios` path: when fallback_skia is false, build.js returns
// `-DMOUI_SKIA_HAS_SKIA ...` (stub_flags) + `libskia.a ...`
// (link_flags). When fallback_skia is true, returns empty SkiaLink and the
// stubs link without `MOUI_SKIA_HAS_SKIA`, so `skia_available()` returns
// false at runtime — the stub-only build-evidence path.
let skia_platform = match
lookup_static_map(ios_sdk_to_skia_platform, options.sdk) {
Some(value) => value
None => return Err("unsupported iOS SDK: " + options.sdk)
}
let skia_arch = match lookup_static_map(ios_arch_to_skia_arch, options.arch) {
Some(value) => value
None => return Err("unsupported iOS arch: " + options.arch)
}
let link_mode = @env.get_env_var("MOUI_SKIA_LINK_MODE").unwrap_or("static")
let renderer_selected = if options.fallback_skia {
"skia-raster"
} else if options.renderer == "skia-raster" {
"skia-raster"
} else {
"skia-gpu"
}
let gpu_env : Array[(String, String)] = if renderer_selected == "skia-gpu" {
[("MOUI_SKIA_ENABLE_GPU_METAL", "1")]
} else {
[]
}
let skia_root_abs = make_absolute(
join_path(workspace_root_abs, "moui_skia"),
workspace_root_abs,
)
let skia_link = match
resolve_skia(
skia_root_abs,
skia_platform,
skia_arch,
link_mode,
options.fallback_skia,
gpu_env,
) {
Ok(value) => value
Err(message) => return Err(message)
}
// 5. Stage the window-hosted template into /ios-project.
let ios_project = join_path(build_dir_abs, "ios-project")
match stage_ios_window_hosted_project(template_root, ios_project) {
Ok(_) => ()
Err(message) => return Err(message)
}
// Resolve product_name + deployment_target first so the identity patch
// matches the eventual executable name.
let product_name = match options.product_name {
Some(value) => value
None =>
match options.app.ios {
Some(ios) => ios.product_name.to_string()
None => options.app.id.to_string()
}
}
let deployment_target = options.deployment_target.unwrap_or("15.0")
match
apply_ios_window_hosted_identity(
ios_project,
options.app,
product_name,
deployment_target,
) {
Ok(_) => ()
Err(message) => return Err(message)
}
if options.prepare_only {
log_info(
"[moui-window-hosted-ios] Prepared " +
build_dir_abs +
" (moonbit_c=" +
moonbit_c +
")",
)
return Ok({ build_dir: build_dir_abs, output_artifact: "", })
}
// 6. Resolve iOS SDK + clang/clang++ via xcrun.
let sdk = options.sdk
let sdk_path = ios_sdk_path(sdk)
if sdk_path == "" {
return Err(
"xcrun --sdk " +
sdk +
" --show-sdk-path failed; install Xcode and run xcode-select",
)
}
let clang = ios_find_tool(sdk, "clang")
if clang == "" {
return Err("xcrun --sdk " + sdk + " --find clang failed")
}
let clangxx = ios_find_tool(sdk, "clang++")
if clangxx == "" {
return Err("xcrun --sdk " + sdk + " --find clang++ failed")
}
// 7. Build target triple + common compile flags.
// iphonesimulator -> arm64-apple-ios{MIN}-simulator
// iphoneos -> arm64-apple-ios{MIN}
let target_triple = if sdk == "iphonesimulator" {
"arm64-apple-ios" + deployment_target + "-simulator"
} else {
"arm64-apple-ios" + deployment_target
}
let moon_home = make_absolute(
@env.get_env_var("MOON_HOME").unwrap_or_else(() => {
let home = @env.get_env_var("HOME").unwrap_or("")
join_path(home, ".moon")
}),
workspace_root_abs,
)
let common_flags = ios_common_compile_flags(
sdk_path, target_triple, moon_home, ios_pkg_root, workspace_root_abs,
)
// 8. Compile every source to /obj/*.o.
let obj_dir = join_path(build_dir_abs, "obj")
remove_tree(obj_dir)
match mkdir_p(obj_dir) {
Ok(_) => ()
Err(message) => return Err(message)
}
let objs : Array[String] = []
let mut ok = true
// MoonBit generated C — `main` is renamed to `mbw_ios_moonbit_main`.
let moonbit_o = join_path(obj_dir, "moonbit_entry.o")
let moonbit_extra : Array[String] = ["-Dmain=mbw_ios_moonbit_main"]
match
compile_ios_object(
clang, common_flags, moonbit_c, moonbit_o, "", moonbit_extra,
) {
Ok(_) => objs.push(moonbit_o)
Err(message) => {
ok = false
return Err(message)
}
}
// Moon runtime.
let runtime_c = join_path(moon_home, "lib/runtime.c")
if path_is_file(runtime_c) {
let runtime_o = join_path(obj_dir, "runtime.o")
let runtime_extra : Array[String] = ["-Dgetentropy=mbw_ios_getentropy"]
match
compile_ios_object(
clang, common_flags, runtime_c, runtime_o, "", runtime_extra,
) {
Ok(_) => objs.push(runtime_o)
Err(message) => {
ok = false
return Err(message)
}
}
} else {
log_warn("Moon runtime not found at " + runtime_c)
}
// Window iOS host glue (Objective-C).
let native_ios_host_c = join_path(ios_pkg_root, "native_ios_host.c")
if path_is_file(native_ios_host_c) {
let obj = join_path(obj_dir, "native_ios_host.o")
match
compile_ios_object(
clang,
common_flags,
native_ios_host_c,
obj,
"objective-c",
[],
) {
Ok(_) => objs.push(obj)
Err(message) => {
ok = false
return Err(message)
}
}
}
// App entry / compat / fs_native / main / delegate.
// NOTE: window/modules/window/ios/native/mbw_ios_main.m is intentionally NOT compiled —
// it is a deprecated duplicate of template/Sources/main.m. The reference
// build script (window/modules/window/ios/scripts/build-hosted-sim-app.sh) only compiles
// template/Sources/main.m and template/Sources/MBWHostedAppDelegate.m.
let entry_pairs = [
(
join_path(ios_pkg_root, "native/mbw_ios_app_entry.c"),
"mbw_ios_app_entry.o",
"",
),
(join_path(ios_pkg_root, "native/mbw_ios_compat.c"), "mbw_ios_compat.o", ""),
(join_path(ios_project, "Sources/main.m"), "mbw_ios_main.o", "objective-c"),
(
join_path(ios_project, "Sources/MBWHostedAppDelegate.m"),
"mbw_hosted_app_delegate.o",
"objective-c",
),
]
for entry in entry_pairs {
let (source, obj_name, lang) = entry
if path_is_file(source) {
let obj_path = join_path(obj_dir, obj_name)
match
compile_ios_object(clang, common_flags, source, obj_path, lang, []) {
Ok(_) => objs.push(obj_path)
Err(message) => {
ok = false
return Err(message)
}
}
}
}
// moonbitlang/x/fs fs_native.c (if vendored under .mooncakes).
let fs_native = join_path(
workspace_root_abs, ".mooncakes/moonbitlang/x/fs/fs_native.c",
)
if path_is_file(fs_native) {
let fs_o = join_path(obj_dir, "fs_native.o")
match compile_ios_object(clang, common_flags, fs_native, fs_o, "", []) {
Ok(_) => objs.push(fs_o)
Err(message) => {
ok = false
return Err(message)
}
}
}
// Skia stubs (.cpp) + iOS Skia glue (.mm). The stub_flags from `resolve_skia`
// carry `-DMOUI_SKIA_HAS_SKIA` (plus paragraph / GPU Metal defines and the
// Skia include path), which the stubs need to expose `moonbit_skia_available`
// returning 1 and to wire real Ganesh / paragraph entrypoints.
let skia_root = join_path(workspace_root_abs, "moui_skia/native")
let skia_stubs = [
"skia_stub.cpp", "skia_stub_common.cpp", "skia_stub_surface_image_data.cpp",
"skia_stub_canvas.cpp", "skia_stub_path.cpp", "skia_stub_text_font.cpp", "skia_stub_paragraph.cpp",
"skia_stub_shader_filter.cpp", "skia_stub_picture.cpp", "skia_stub_gpu_worker.cpp",
]
let cxx_flags : Array[String] = [
"-std=c++17",
..split_flags(skia_link.stub_flags),
]
for stub in skia_stubs {
let source = join_path(skia_root, stub)
if path_is_file(source) {
let obj_name = stub[:stub.length() - ".cpp".length()].to_owned() + ".o"
let obj_path = join_path(obj_dir, obj_name)
match
compile_ios_object(
clangxx, common_flags, source, obj_path, "", cxx_flags,
) {
Ok(_) => objs.push(obj_path)
Err(message) => {
ok = false
return Err(message)
}
}
}
}
let mm_glue = [
join_path(workspace_root_abs, "moui/backend/ios/ios_present.mm"),
]
for glue in mm_glue {
if path_is_file(glue) {
let base = basename(glue)
let stem = if base.has_suffix(".mm") {
base[:base.length() - ".mm".length()].to_owned()
} else {
base
}
let obj_path = join_path(obj_dir, stem + ".o")
let mm_flags : Array[String] = ["-std=c++17", "-ObjC++"]
match
compile_ios_object(clangxx, common_flags, glue, obj_path, "", mm_flags) {
Ok(_) => objs.push(obj_path)
Err(message) => {
ok = false
return Err(message)
}
}
}
}
if !ok {
return Err("iOS window-hosted compile step failed")
}
// 9. Link the executable into /ios-project/.app.
let app_dir = if options.output_app == "" {
join_path(ios_project, product_name + ".app")
} else {
options.output_app
}
match mkdir_p(app_dir) {
Ok(_) => ()
Err(message) => return Err(message)
}
let executable = join_path(app_dir, product_name)
let link_argv = [clangxx, ..common_flags]
link_argv.push("-o")
link_argv.push(executable)
for obj in objs {
link_argv.push(obj)
}
// Skia link flags from `resolve_skia`. When fallback_skia is false this
// carries the real `libskia.a` / `libskia_ganesh_ext.a` absolute paths,
// paragraph sub-libs, and the Metal/CoreGraphics/UIKit frameworks that
// Skia depends on. When fallback_skia is true this is empty.
for flag in split_flags(skia_link.link_flags) {
link_argv.push(flag)
}
// System frameworks / libs required by the window-hosted template itself.
link_argv.push("-framework")
link_argv.push("MetalKit")
link_argv.push("-lc++")
log_command("clang++ (link)", link_argv)
match process_exec_argv(link_argv, capture=false) {
Ok(result) if result.exit_code == 0 => ()
Ok(result) =>
return Err(
"clang++ link failed with exit code " + result.exit_code.to_string(),
)
Err(message) => return Err(message)
}
// 10. Copy patched Info.plist into the .app bundle.
let plist_dst = join_path(app_dir, "Info.plist")
match copy_file_atomic(join_path(ios_project, "Info.plist"), plist_dst) {
Ok(_) => ()
Err(message) => return Err(message)
}
// Ensure the executable has +x.
match process_exec_argv(["chmod", "+x", executable], capture=true) {
Ok(_) => ()
Err(message) => return Err(message)
}
// 11. Resolve output_app path (may differ from app_dir when --output set).
let final_output = if options.output_app == "" {
app_dir
} else {
options.output_app
}
if !path_is_dir(final_output) {
return Err("iOS .app bundle was not produced: " + final_output)
}
log_info("[moui-window-hosted-ios] Wrote " + final_output)
log_info(
"This is build/install evidence only until a simulator/device smoke proves first frame, input, resize, and lifecycle.",
)
Ok({ build_dir: build_dir_abs, output_artifact: final_output, })
}