// `moui devices` command — list connected Android / iOS / HarmonyOS devices.
//
// On each platform we look up the canonical tool (`adb`, `xcrun`, `hdc`) on
// PATH, run it with `process_exec_argv`, and parse the well-known output into
// a unified `Device` struct. `list_all_devices` skips platforms whose tool is
// not installed (no error), so `moui devices` is always safe to invoke.
///|
priv struct Device {
platform : String
id : String
name : String
state : String
kind : String // emulator | physical | unknown
}
///|
priv struct ParsedDevices {
help : Bool
json : Bool
platform : String?
}
///|
fn devices_usage() -> String {
(
#|Usage: moui devices [options]
#|
#|Lists connected Android, iOS, and HarmonyOS devices/emulators. Platforms
#|without their tooling installed (adb / xcrun / hdc) are silently skipped.
#|
#|Options:
#| --platform PLATFORM Only list devices for the given platform.
#| Values: android, ios, harmonyos.
#| --json Emit machine-readable output.
#| -h, --help Show this help.
)
}
///|
fn parse_devices_args(args : Array[String]) -> Result[ParsedDevices, String] {
let help = Ref(false)
let json = Ref(false)
let platform : 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)
}
"--platform" =>
match option_value(args, index, inline, name) {
Ok((value, next)) => {
platform.val = Some(value)
index = next
}
Err(message) => return Err(message)
}
_ if argument.has_prefix("-") =>
return Err("unknown option for moui devices: " + name)
_ => return Err("moui devices does not take positional arguments")
}
index += 1
}
match platform.val {
Some(value) =>
if value != "android" && value != "ios" && value != "harmonyos" {
Err("unsupported --platform for moui devices: " + value)
} else {
Ok({ help: help.val, json: json.val, platform: Some(value), })
}
None => Ok({ help: help.val, json: json.val, platform: None, })
}
}
///|
/// Parse `adb devices -l` output into a list of `Device` records.
/// Each line after the `List of devices attached` header is parsed as:
/// ` key:value key:value ...`
/// Only lines with at least two whitespace-separated tokens are considered.
/// `kind` is detected from `transport_id` presence or device name heuristics.
fn parse_adb_devices(output : String) -> Array[Device] {
let devices : Array[Device] = []
let lines = output.split("\n").to_array()
let mut in_devices = false
for line in lines {
let trimmed = line.trim().to_owned()
if trimmed == "" ||
trimmed.has_prefix("* daemon") ||
trimmed.has_prefix("List of devices") {
if trimmed == "List of devices attached" {
in_devices = true
}
continue
}
if !in_devices {
continue
}
// Split on 2+ whitespace runs.
let tokens = trimmed.split(" ").to_array().filter(v => v != "")
if tokens.length() < 2 {
continue
}
let id = tokens[0].to_owned()
let state = tokens[1].to_owned()
// Look for `device`/`model:`/`transport_id:` keys in remaining tokens.
let mut name = id
let mut is_emulator = false
let mut i = 2
while i < tokens.length() {
let token = tokens[i]
match token.split_once(":") {
Some((key, value)) =>
if key == "model" {
name = value.to_owned()
} else if key == "device" {
// `device:emulator-5554` heuristic
if value.contains("emulator") {
is_emulator = true
}
} else if key == "transport_id" {
// Both emulators and physicals have transport_id; not authoritative.
()
}
None => ()
}
i += 1
}
// Heuristic: emulator serials look like `emulator-` or `localhost:`.
if id.has_prefix("emulator-") || id.has_prefix("localhost:") {
is_emulator = true
}
let kind = if is_emulator { "emulator" } else { "physical" }
devices.push({ platform: "android", id, name, state, kind, })
}
devices
}
///|
/// Parse `xcrun simctl list devices` output into a list of `Device` records.
/// Lines look like:
/// ` iPhone 15 (XXX-XXX-XXX-XXXX-XXXX) (Shutdown)`
/// Platform headers like `== Device Types ==` and `-- iOS 17.0 --` set the
/// current OS section; we capture every device inside the `== Devices ==`
/// section that has a `()` suffix.
fn parse_simctl_list(output : String) -> Array[Device] {
let devices : Array[Device] = []
let lines = output.split("\n").to_array()
let mut in_devices_section = false
for line in lines {
let trimmed = line.trim().to_owned()
if trimmed == "" {
continue
}
if trimmed.has_prefix("== ") && trimmed.has_suffix(" ==") {
in_devices_section = trimmed == "== Devices =="
continue
}
if !in_devices_section {
continue
}
// Skip section separators like `-- iOS 17.0 --`.
if trimmed.has_prefix("-- ") && trimmed.has_suffix(" --") {
continue
}
// Match: `iPhone 15 (UUID) (State)`. Find the LAST `(` ... `)` pair using
// `String::find` + manual reverse scan via char array.
match last_paren_pair(trimmed) {
Some((state_open, state)) => {
// head is `trimmed` with the trailing ` (state)` removed.
let head = trimmed[:state_open].trim().to_owned()
match last_paren_pair(head) {
Some((uuid_open, uuid)) =>
if head.has_suffix(")") {
let name = head[:uuid_open].trim().to_owned()
let kind = if name.contains("Simulator") ||
state.contains("Booted") ||
state.contains("Shutdown") {
"emulator"
} else {
"unknown"
}
devices.push({ platform: "ios", id: uuid, name, state, kind, })
}
None => ()
}
}
None => ()
}
}
devices
}
///|
/// Find the last `(...)` pair in `text`. Returns `Some((open_index, value))`
/// where `value` is the substring between `open_index+1` and `length-1`, and
/// the closing `)` is the last character of `text`. Returns `None` when the
/// text does not end with `)` or has no `(`.
fn last_paren_pair(text : String) -> (Int, String)? {
if !text.has_suffix(")") {
return None
}
// Find the last `(` by scanning from the end.
let chars = text.to_array()
let mut i = chars.length() - 1
// Skip trailing `)`.
let mut depth = 1
i -= 1
while i >= 0 {
if chars[i] == ')' {
depth += 1
} else if chars[i] == '(' {
depth -= 1
if depth == 0 {
let value = text[i + 1:chars.length() - 1].to_owned()
return Some((i, value))
}
}
i -= 1
}
None
}
///|
/// Parse `hdc list targets` output into a list of `Device` records.
/// Output format:
/// ` device_id state`
/// or just `device_id` (assumed state=`connected`).
fn parse_hdc_targets(output : String) -> Array[Device] {
let devices : Array[Device] = []
let lines = output.split("\n").to_array()
for line in lines {
let trimmed = line.trim().to_owned()
if trimmed == "" ||
trimmed.has_prefix("[Empty]") ||
trimmed.has_prefix("No target") {
continue
}
let tokens = trimmed.split(" ").to_array().filter(v => v != "")
if tokens.is_empty() {
continue
}
let id = tokens[0].to_owned()
let state = if tokens.length() >= 2 {
tokens[1].to_owned()
} else {
"connected"
}
// hdc does not expose model in `list targets`; default kind=unknown.
let kind = if id.has_prefix("emulator") || id.contains("-emulator-") {
"emulator"
} else {
"unknown"
}
devices.push({ platform: "harmonyos", id, name: id, state, kind, })
}
devices
}
///|
/// List Android devices by invoking `adb devices -l`. Returns `Err` when
/// `adb` cannot be located or the command fails; callers should treat this as
/// "Android tooling unavailable" and continue scanning other platforms.
fn list_android_devices(host : HostPlatform) -> Result[Array[Device], String] {
let _ = host
match find_in_path("adb", detect_host_platform()) {
None => Err("adb was not found on PATH")
Some(adb) =>
match process_exec_argv([adb, "devices", "-l"], capture=true) {
Ok(result) if result.exit_code == 0 =>
Ok(parse_adb_devices(result.stdout))
Ok(result) =>
Err(
"adb devices -l failed (exit " + result.exit_code.to_string() + ")",
)
Err(message) => Err(message)
}
}
}
///|
/// List iOS devices by invoking `xcrun simctl list devices`. Returns `Err`
/// when `xcrun` is unavailable (non-macOS host or missing Xcode). Physical
/// device listing via `xctrace list devices` is merged in but tolerated when
/// unavailable.
fn list_ios_devices(host : HostPlatform) -> Result[Array[Device], String] {
match host {
Macos => ()
_ => return Err("iOS devices require macOS with Xcode installed")
}
match find_in_path("xcrun", host) {
None => Err("xcrun was not found on PATH")
Some(xcrun) =>
match
process_exec_argv([xcrun, "simctl", "list", "devices"], capture=true) {
Ok(result) if result.exit_code == 0 =>
Ok(parse_simctl_list(result.stdout))
Ok(result) =>
Err(
"xcrun simctl list devices failed (exit " +
result.exit_code.to_string() +
")",
)
Err(message) => Err(message)
}
}
}
///|
/// List HarmonyOS devices by invoking `hdc list targets`. Returns `Err` when
/// `hdc` cannot be located (typical on hosts without DevEco Studio).
fn list_harmonyos_devices(host : HostPlatform) -> Result[Array[Device], String] {
let _ = host
match find_in_path("hdc", detect_host_platform()) {
None => Err("hdc was not found on PATH")
Some(hdc) =>
match process_exec_argv([hdc, "list", "targets"], capture=true) {
Ok(result) if result.exit_code == 0 =>
Ok(parse_hdc_targets(result.stdout))
Ok(result) =>
Err(
"hdc list targets failed (exit " +
result.exit_code.to_string() +
")",
)
Err(message) => Err(message)
}
}
}
///|
/// List all devices across platforms, skipping any platform whose toolchain
/// is unavailable. Always returns successfully; callers can display an empty
/// list when no platform tooling is installed.
fn list_all_devices(host : HostPlatform) -> Array[Device] {
let devices : Array[Device] = []
match list_android_devices(host) {
Ok(values) =>
for device in values {
devices.push(device)
}
Err(_) => ()
}
match list_ios_devices(host) {
Ok(values) =>
for device in values {
devices.push(device)
}
Err(_) => ()
}
match list_harmonyos_devices(host) {
Ok(values) =>
for device in values {
devices.push(device)
}
Err(_) => ()
}
devices
}
///|
fn run_devices(parsed : ParsedDevices, host : HostPlatform) -> CliOutcome {
if parsed.help {
return stdout_outcome(devices_usage())
}
let devices = match parsed.platform {
Some(platform) =>
match platform {
"android" =>
match list_android_devices(host) {
Ok(values) => values
Err(message) =>
return failed_outcome(message, parsed.json, devices_usage())
}
"ios" =>
match list_ios_devices(host) {
Ok(values) => values
Err(message) =>
return failed_outcome(message, parsed.json, devices_usage())
}
"harmonyos" =>
match list_harmonyos_devices(host) {
Ok(values) => values
Err(message) =>
return failed_outcome(message, parsed.json, devices_usage())
}
"macos" | "windows" | "linux" => desktop_host_devices(platform, host)
_ =>
return failed_outcome(
"unsupported platform: " + platform,
parsed.json,
"",
)
}
None => {
let all = list_all_devices(host)
let host_name = host.name()
if host_name != "unknown" {
for entry in desktop_host_devices(host_name, host) {
all.push(entry)
}
}
all
}
}
if parsed.json {
let entries : Array[Json] = []
for device in devices {
entries.push({
"platform": Json::string(device.platform),
"id": Json::string(device.id),
"name": Json::string(device.name),
"state": Json::string(device.state),
"kind": Json::string(device.kind),
})
}
let value : Json = {
"schemaVersion": 1,
"ok": true,
"devices": Json::array(entries),
}
stdout_outcome(value.stringify(indent=2) + "\n")
} else {
if devices.is_empty() {
return stdout_outcome("(no devices found)\n")
}
let mut text = ""
for device in devices {
text += device.platform +
"\t" +
device.id +
"\t" +
device.state +
"\t" +
device.kind +
"\t" +
device.name +
"\n"
}
stdout_outcome(text)
}
}
///|
/// Report the local host as a desktop run target for `moui devices`.
/// Desktop targets are always available on a matching host; the caller
/// supplies the requested platform (or the detected host name).
fn desktop_host_devices(
platform : String,
host : HostPlatform,
) -> Array[Device] {
let host_matches = (platform == "macos" && host == Macos) ||
(platform == "windows" && host == Windows) ||
(platform == "linux" && host == Linux)
if !host_matches {
return []
}
[
{
platform,
id: "host",
name: "This computer",
state: "online",
kind: "desktop",
},
]
}