// `moui build` command — build a mobile app artifact (APK / .app / .hap)
// without installing or launching. Shares the same `build_android` /
// `build_ios` / `build_harmonyos` implementations as `moui run`'s build
// step; `moui run --build-only` is equivalent to `moui build` followed by
// skipping install/launch.
///|
priv struct ParsedBuild {
help : Bool
json : Bool
platform : String
app : String
options : BuildCommandOptions?
}
///|
priv struct BuildCommandOptions {
// `app` (positional arg) is not stored here because the platform
// `Build*Options` carry the parsed mobile configuration directly.
app_config_path : String?
workspace_root : String
moui_root : String
skia_root : String
build_dir : String
output : String?
renderer : String?
fallback_skia : Bool
prepare_only : Bool
release : Bool
// Platform-specific product override.
product_name : String?
abi : String?
compile_sdk : Int?
target_sdk : Int?
sdk : String?
arch : String?
deployment_target : String?
sdk_home : String?
hvigorw : String?
ohpm : String?
signing_config_file : String?
}
///|
fn build_usage() -> String {
(
#|Usage: moui build [options]
#|
#|Build a MoUI app artifact. Mobile platforms produce APK / .app / .hap
#|artifacts; desktop/web platforms build the entrypoint package in place.
#|Configuration defaults come from `moui config`.
#|
#|Arguments:
#| platform Target platform: macos, windows, linux, web, android,
#| ios, or harmonyos.
#| app Project directory holding the entrypoint packages (or
#| the entrypoint package itself) for desktop/web; the
#| application id matching moui.mobile.json for mobile.
#|
#|Options:
#| --mobile-config PATH Path to moui.mobile.json. Defaults to ./moui.mobile.json.
#| --workspace-root PATH Workspace root. Defaults to the current directory.
#| --moui-root PATH MoUI package root. Defaults from `moui config`.
#| --skia-root PATH Skia package root. Defaults from `moui config`.
#| --build-dir PATH Build output directory. Defaults to ./artifacts/.
#| --output PATH Output artifact path. Defaults to /..
#| --renderer NAME Renderer override (auto / skia / wgpu / sun for
#| desktop/web; auto / skia / wgpu for mobile).
#| --release Desktop/web: build in release mode with --strip.
#| --fallback-skia Allow Skia fallback when primary renderer fails.
#| --prepare-only Stop after preparing native build inputs.
#| --product-name NAME iOS product name override.
#| --abi ABI Android ABI (e.g. arm64-v8a). Defaults from `moui config`.
#| --compile-sdk VERSION Android compileSdk. Defaults from `moui config`.
#| --target-sdk VERSION Android targetSdk. Defaults from `moui config`.
#| --sdk NAME iOS SDK name (e.g. iphoneos). Defaults from `moui config`.
#| --arch ARCH iOS arch (e.g. arm64). Defaults from `moui config`.
#| --deployment-target VER iOS deployment target (e.g. 15.0). Defaults from `moui config`.
#| --sdk-home PATH HarmonyOS SDK home. Defaults from `moui config`.
#| --hvigorw PATH hvigorw executable. Defaults from `moui config`.
#| --ohpm PATH ohpm executable. Defaults from `moui config`.
#| --signing-config-file PATH HarmonyOS signing config file.
#| --json Emit machine-readable output.
#| -h, --help Show this help.
)
}
///|
fn parse_build_args(
args : Array[String],
cwd : String,
host : HostPlatform,
) -> Result[ParsedBuild, String] {
let _ = host
let help = Ref(false)
let json = Ref(false)
let positional : Array[String] = []
let app_config : Ref[String?] = Ref(None)
let workspace_root : Ref[String?] = Ref(None)
let moui_root : Ref[String?] = Ref(None)
let skia_root : Ref[String?] = Ref(None)
let build_dir : Ref[String?] = Ref(None)
let output : Ref[String?] = Ref(None)
let renderer : Ref[String?] = Ref(None)
let fallback_skia = Ref(false)
let prepare_only = Ref(false)
let release = Ref(false)
let product_name : Ref[String?] = Ref(None)
let abi : Ref[String?] = Ref(None)
let compile_sdk : Ref[Int?] = Ref(None)
let target_sdk : Ref[Int?] = Ref(None)
let sdk : Ref[String?] = Ref(None)
let arch : Ref[String?] = Ref(None)
let deployment_target : Ref[String?] = Ref(None)
let sdk_home : Ref[String?] = Ref(None)
let hvigorw : Ref[String?] = Ref(None)
let ohpm : Ref[String?] = Ref(None)
let signing_config_file : Ref[String?] = Ref(None)
let mut index = 0
while index < args.length() {
let argument = args[index]
let (name, inline) = option_parts(argument)
match name {
"--json" =>
match flag_without_value(inline, name) {
Ok(_) => json.val = true
Err(message) => return Err(message)
}
"-h" | "--help" =>
match flag_without_value(inline, name) {
Ok(_) => help.val = true
Err(message) => return Err(message)
}
"--fallback-skia" | "--prepare-only" | "--release" =>
match flag_without_value(inline, name) {
Ok(_) =>
match name {
"--fallback-skia" => fallback_skia.val = true
"--prepare-only" => prepare_only.val = true
"--release" => release.val = true
_ => ()
}
Err(message) => return Err(message)
}
"--mobile-config"
| "--workspace-root"
| "--moui-root"
| "--skia-root"
| "--build-dir"
| "--output"
| "--renderer"
| "--product-name"
| "--abi"
| "--sdk"
| "--arch"
| "--deployment-target"
| "--sdk-home"
| "--hvigorw"
| "--ohpm"
| "--signing-config-file" =>
match option_value(args, index, inline, name) {
Ok((value, next)) => {
match name {
"--mobile-config" => app_config.val = Some(value)
"--workspace-root" => workspace_root.val = Some(value)
"--moui-root" => moui_root.val = Some(value)
"--skia-root" => skia_root.val = Some(value)
"--build-dir" => build_dir.val = Some(value)
"--output" => output.val = Some(value)
"--renderer" => renderer.val = Some(value)
"--product-name" => product_name.val = Some(value)
"--abi" => abi.val = Some(value)
"--sdk" => sdk.val = Some(value)
"--arch" => arch.val = Some(value)
"--deployment-target" => deployment_target.val = Some(value)
"--sdk-home" => sdk_home.val = Some(value)
"--hvigorw" => hvigorw.val = Some(value)
"--ohpm" => ohpm.val = Some(value)
"--signing-config-file" => signing_config_file.val = Some(value)
_ => ()
}
index = next
continue
}
Err(message) => return Err(message)
}
"--compile-sdk" | "--target-sdk" =>
match option_value(args, index, inline, name) {
Ok((value, next)) => {
match parse_int(value) {
Ok(num) =>
match name {
"--compile-sdk" => compile_sdk.val = Some(num)
"--target-sdk" => target_sdk.val = Some(num)
_ => ()
}
Err(_) => return Err("invalid integer for " + name + ": " + value)
}
index = next
continue
}
Err(message) => return Err(message)
}
_ if argument.has_prefix("-") =>
return Err("unknown option for moui build: " + name)
_ => positional.push(argument)
}
index += 1
}
if help.val {
return Ok({
help: true,
json: json.val,
platform: "",
app: "",
options: None,
})
}
if positional.length() < 2 {
return Err("usage: moui build [options]")
}
if positional.length() > 2 {
return Err("moui build accepts exactly two positional arguments")
}
let platform = positional[0]
let app = positional[1]
let is_mobile = platform == "android" ||
platform == "ios" ||
platform == "harmonyos"
let is_desktop = desktop_platform_supported(platform)
if !is_mobile && !is_desktop {
return Err(
"unsupported platform for moui build: " +
platform +
" (expected macos, windows, linux, web, android, ios, or harmonyos)",
)
}
// Mobile builds carry the parsed mobile configuration; desktop builds
// resolve the positional `app` path at dispatch time.
Ok({
help: false,
json: json.val,
platform,
app,
options: Some({
app_config_path: app_config.val,
workspace_root: workspace_root.val.unwrap_or(cwd),
moui_root: moui_root.val.unwrap_or(""),
skia_root: skia_root.val.unwrap_or(""),
build_dir: build_dir.val.unwrap_or(""),
output: output.val,
renderer: renderer.val,
fallback_skia: fallback_skia.val,
prepare_only: prepare_only.val,
release: release.val,
product_name: product_name.val,
abi: abi.val,
compile_sdk: compile_sdk.val,
target_sdk: target_sdk.val,
sdk: sdk.val,
arch: arch.val,
deployment_target: deployment_target.val,
sdk_home: sdk_home.val,
hvigorw: hvigorw.val,
ohpm: ohpm.val,
signing_config_file: signing_config_file.val,
}),
})
}
///|
/// Parse a decimal integer. Returns `Err` on empty input or non-digit characters.
fn parse_int(value : String) -> Result[Int, String] {
if value == "" {
return Err("empty integer")
}
let mut num = 0
for ch in value {
if ch >= '0' && ch <= '9' {
num = num * 10 + (ch.to_int() - '0'.to_int())
} else {
return Err("invalid digit: " + value.to_string())
}
}
Ok(num)
}
///|
/// Resolve default `moui_root` / `skia_root` from `CliConfig` when the user
/// did not pass `--moui-root` / `--skia-root` on the command line. The
/// `workspace_root` is the anchor for relative path resolution.
fn resolve_build_roots(
options : BuildCommandOptions,
config : CliConfig,
) -> (String, String) {
let moui_root = if options.moui_root != "" {
options.moui_root
} else {
match config.paths.moui_root {
Some(p) => p
None => join_path(options.workspace_root, "moui")
}
}
let skia_root = if options.skia_root != "" {
options.skia_root
} else {
match config.paths.skia_root {
Some(p) => p
None => join_path(options.workspace_root, "moui_skia")
}
}
(moui_root, skia_root)
}
///|
/// Resolve the default build directory for the given platform.
fn default_build_dir(platform : String, workspace_root : String) -> String {
join_path(join_path(workspace_root, "artifacts"), platform)
}
///|
/// Construct `BuildAndroidOptions` from the user-facing `BuildCommandOptions`
/// and `CliConfig`. Caller ensures `platform == "android"`.
fn build_android_options_from_command(
options : BuildCommandOptions,
app : MobileApp,
config : CliConfig,
) -> BuildAndroidOptions {
let (_, skia_root) = resolve_build_roots(options, config)
let build_dir = if options.build_dir != "" {
options.build_dir
} else {
default_build_dir("android", options.workspace_root)
}
let output_apk = match options.output {
Some(p) => p
None => join_path(build_dir, app.id + ".apk")
}
let abi = match options.abi {
Some(a) => a
None =>
if config.android.abi.length() > 0 {
config.android.abi[0]
} else {
"arm64-v8a"
}
}
let compile_sdk = match options.compile_sdk {
Some(v) => v
None =>
match config.android.compile_sdk {
Some(v) => v
None => 34
}
}
let target_sdk = match options.target_sdk {
Some(v) => v
None =>
match config.android.target_sdk {
Some(v) => v
None => 34
}
}
let renderer = match options.renderer {
Some(r) => r
None => "auto"
}
{
app,
workspace_root: options.workspace_root,
skia_root,
abi,
compile_sdk,
target_sdk,
renderer,
build_dir,
output_apk,
fallback_skia: options.fallback_skia,
prepare_only: options.prepare_only,
}
}
///|
/// Construct `BuildIosOptions` from the user-facing `BuildCommandOptions`
/// and `CliConfig`. Caller ensures `platform == "ios"`.
fn build_ios_options_from_command(
options : BuildCommandOptions,
app : MobileApp,
config : CliConfig,
) -> BuildIosOptions {
let (_, _) = resolve_build_roots(options, config)
let build_dir = if options.build_dir != "" {
options.build_dir
} else {
default_build_dir("ios", options.workspace_root)
}
let output_app = match options.output {
Some(p) => p
None => join_path(build_dir, app.id + ".app")
}
let sdk = match options.sdk {
Some(s) => s
None =>
match config.ios.sdk {
Some(s) => s
None => "iphoneos"
}
}
let arch = match options.arch {
Some(a) => a
None =>
match config.ios.arch {
Some(a) => a
None => "arm64"
}
}
let deployment_target : String? = match options.deployment_target {
Some(t) => Some(t)
None =>
match config.ios.deployment_target {
Some(v) => Some(v.to_string())
None => None
}
}
let renderer = match options.renderer {
Some(r) => r
None => "auto"
}
{
app,
product_name: options.product_name,
workspace_root: options.workspace_root,
sdk,
arch,
deployment_target,
renderer,
build_dir,
output_app,
fallback_skia: options.fallback_skia,
prepare_only: options.prepare_only,
}
}
///|
/// Construct `BuildHarmonyosOptions` from the user-facing `BuildCommandOptions`
/// and `CliConfig`. Caller ensures `platform == "harmonyos"`.
fn build_harmonyos_options_from_command(
options : BuildCommandOptions,
app : MobileApp,
config : CliConfig,
) -> BuildHarmonyosOptions {
let (_, skia_root) = resolve_build_roots(options, config)
let build_dir = if options.build_dir != "" {
options.build_dir
} else {
default_build_dir("harmonyos", options.workspace_root)
}
let output_hap = match options.output {
Some(p) => p
None => join_path(build_dir, app.id + ".hap")
}
let arch = match options.arch {
Some(a) => a
None => "arm64"
}
let renderer = match options.renderer {
Some(r) => r
None => "auto"
}
let sdk_home = match options.sdk_home {
Some(p) => Some(p)
None => config.harmonyos.sdk_home
}
let hvigorw = match options.hvigorw {
Some(p) => Some(p)
None => config.harmonyos.hvigorw
}
let ohpm = match options.ohpm {
Some(p) => Some(p)
None => config.harmonyos.ohpm
}
let signing_config_file = match options.signing_config_file {
Some(p) => Some(p)
None => config.harmonyos.signing_config_file
}
{
app,
workspace_root: options.workspace_root,
skia_root,
arch,
renderer,
build_dir,
output_hap,
sdk_home,
hvigorw,
ohpm,
fallback_skia: options.fallback_skia,
prepare_only: options.prepare_only,
signing_config_file,
}
}
///|
/// Dispatch to the platform-specific `build_*` function. Shared by
/// `moui build` and `moui run`'s build step so they stay in sync.
fn run_build_internal(
options : BuildCommandOptions,
platform : String,
host : HostPlatform,
app : MobileApp,
config : CliConfig,
) -> Result[BuildResult, String] {
match platform {
"android" =>
build_android(
build_android_options_from_command(options, app, config),
host,
)
"ios" =>
build_ios(build_ios_options_from_command(options, app, config), host)
"harmonyos" =>
build_harmonyos(
build_harmonyos_options_from_command(options, app, config),
host,
)
_ => Err("unsupported platform: " + platform)
}
}
///|
/// Render the build result as JSON for the `--json` output mode.
fn build_result_to_json(
platform : String,
app_id : String,
result : BuildResult,
) -> Json {
let fields : Map[String, Json] = Map([])
fields["schemaVersion"] = Json::number(1.0)
fields["ok"] = Json::boolean(true)
fields["platform"] = Json::string(platform)
fields["appId"] = Json::string(app_id)
fields["buildDir"] = Json::string(result.build_dir)
fields["artifact"] = Json::string(result.output_artifact)
Json::object(fields)
}
///|
/// Render the build result as plain text for the default output mode.
fn build_result_to_text(
platform : String,
app_id : String,
result : BuildResult,
) -> String {
let mut text = "[build] " + platform + " app=" + app_id + "\n"
text += " build_dir: " + result.build_dir + "\n"
text += " artifact: " + result.output_artifact + "\n"
match result.output_artifact {
"" => text + " status: prepared\n"
_ => text + " status: built\n"
}
}
///|
fn run_build(parsed : ParsedBuild, host : HostPlatform) -> CliOutcome {
if parsed.help {
return stdout_outcome(build_usage())
}
guard parsed.options is Some(options) else {
return failed_outcome(
"build command options are unavailable",
parsed.json,
build_usage(),
)
}
if desktop_platform_supported(parsed.platform) {
return run_build_desktop(parsed, host)
}
// 1. Read mobile metadata from the supplied path or workspace root.
let app_result = match options.app_config_path {
Some(path) => read_mobile_app_at(path)
None => read_mobile_app(options.workspace_root)
}
let app = match app_result {
Ok(a) => a
Err(message) =>
return failed_outcome(
"could not read moui.mobile.json: " + message,
parsed.json,
build_usage(),
)
}
// 2. Read CLI config for SDK / NDK / Xcode defaults.
let config = match read_config(host) {
Ok(c) => c
Err(message) => return failed_outcome(message, parsed.json, build_usage())
}
// 3. Dispatch to platform build_*.
let result = match
run_build_internal(options, parsed.platform, host, app, config) {
Ok(r) => r
Err(message) => return failed_outcome(message, parsed.json, build_usage())
}
// 4. Emit output.
if parsed.json {
stdout_outcome(
build_result_to_json(parsed.platform, app.id, result).stringify(indent=2) +
"\n",
)
} else {
stdout_outcome(build_result_to_text(parsed.platform, app.id, result))
}
}