use std::io::{BufRead, BufReader};
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::time::Duration;
use crate::cli::{CliError, Profile};
use crate::meta::{Project, find_project};
use crate::ops::{
BuildOutcome, INSTALL_TIMEOUT, LAUNCH_TIMEOUT, LaunchSpec, LogStream, emit_log, status,
};
use crate::targets::Target;
pub(crate) const STAGED_STATICLIB: &str = "libdayapp.a";
pub(crate) fn rustup_cargo() -> Result<(PathBuf, PathBuf), String> {
day_toolchain::rustup_cargo()
}
pub(crate) fn run_quiet(cmd: &mut Command, what: &str, limit: Duration) -> Result<(), String> {
let out = crate::ops::run_capture_within(cmd, what, limit)?;
if out.status.success() {
return Ok(());
}
if crate::ops::verbose() {
return Err(format!("{what} failed"));
}
Err(format!(
"{what} failed:\n{}{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
))
}
pub(crate) fn run_logged(cmd: &mut Command, what: &str) -> Result<(), String> {
let out = cmd.status().map_err(|e| format!("{what}: {e}"))?;
if out.success() {
Ok(())
} else {
Err(format!("{what} failed"))
}
}
pub(crate) fn run_logged_within(
cmd: &mut Command,
what: &str,
limit: Duration,
) -> Result<(), String> {
match crate::ops::status_within(cmd, limit) {
Some(out) if out.success() => Ok(()),
Some(_) => Err(format!("{what} failed")),
None => Err(crate::ops::timeout_message(what, limit)),
}
}
fn absolute(path: &Path) -> Result<PathBuf, String> {
if path.is_absolute() {
Ok(path.to_path_buf())
} else {
Ok(std::env::current_dir()
.map_err(|e| e.to_string())?
.join(path))
}
}
fn is_stale_bundle_failure(out: &std::process::Output) -> bool {
let all = format!(
"{}{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
)
.to_lowercase();
all.contains(".bundle") && all.contains("no such file")
}
fn diagnose_xcodebuild(out: &std::process::Output) -> String {
let stdout = String::from_utf8_lossy(&out.stdout);
let stderr = String::from_utf8_lossy(&out.stderr);
let mut errors: Vec<String> = stdout
.lines()
.chain(stderr.lines())
.map(str::trim)
.filter(|l| l.starts_with("error:") || l.contains(": error:"))
.map(str::to_string)
.collect();
errors.dedup();
let mut msg = if errors.is_empty() {
let tail: Vec<&str> = stdout
.lines()
.filter(|l| !l.trim_start().starts_with("export "))
.rev()
.take(20)
.collect();
tail.into_iter().rev().collect::<Vec<_>>().join("\n")
} else {
errors.join("\n")
};
let lower = format!("{stdout}{stderr}").to_lowercase();
if lower.contains(".bundle") && lower.contains("no such file") {
msg.push_str(
"\n\nhint: a SwiftPM package resource bundle wasn't where the app target expected it. \
This is usually a stale or split build tree — remove build/day/ios-uikit and retry \
(day launch does this automatically on a resource-bundle failure).",
);
}
msg
}
pub fn xcode_backend_build() -> Result<(), CliError> {
let get = |k: &str| std::env::var(k).ok();
let configuration = get("CONFIGURATION").unwrap_or_else(|| "Debug".into());
let built_products = match get("BUILT_PRODUCTS_DIR") {
Some(v) => PathBuf::from(v),
None => {
return Err(CliError::usage(
"day xcode-backend: must run inside an Xcode build (BUILT_PRODUCTS_DIR unset)",
));
}
};
let platform = get("PLATFORM_NAME").unwrap_or_else(|| "iphonesimulator".into());
let project_dir = get("PROJECT_DIR").map(PathBuf::from).unwrap_or_default();
let root = project_dir.join("../..");
let project = find_project(Some(&root))
.map_err(|e| CliError::usage(format!("day xcode-backend: {e}")))?;
let profile = if configuration.to_lowercase().contains("release") {
Profile::Release
} else {
Profile::Debug
};
let xc_platform = if platform.contains("macos") {
"macos"
} else {
"ios"
};
let xc_path = project
.root
.join("build/day/xcconfig")
.join(format!("{xc_platform}.xcconfig"));
let xc_before = std::fs::read_to_string(&xc_path).ok();
crate::xcconfig::write_generated(&project, xc_platform)
.map_err(|e| CliError::usage(format!("day xcode-backend: {e}")))?;
if let Some(before) = xc_before
&& std::fs::read_to_string(&xc_path).ok().as_deref() != Some(before.as_str())
{
return Err(CliError::env(
"day xcode-backend: app metadata changed since Xcode read it (Day.toml id/version/\
build) — build again to pick up the refreshed values",
));
}
let (triples, toolkit_feature, target_dir_name): (Vec<&str>, &str, &str) =
match platform.as_str() {
"iphonesimulator" => (vec!["aarch64-apple-ios-sim"], "uikit", "ios-uikit"),
"iphoneos" => (vec!["aarch64-apple-ios"], "uikit", "ios-uikit"),
"macosx" => {
let archs = get("ARCHS").unwrap_or_else(|| "arm64".into());
let mut t = Vec::new();
for arch in archs.split_whitespace() {
match arch {
"arm64" => t.push("aarch64-apple-darwin"),
"x86_64" => t.push("x86_64-apple-darwin"),
other => {
return Err(CliError::usage(format!(
"day xcode-backend: unsupported ARCHS entry {other:?}"
)));
}
}
}
(t, "appkit", "macos-appkit")
}
other => {
return Err(CliError::usage(format!(
"day xcode-backend: unsupported PLATFORM_NAME {other:?}"
)));
}
};
let (cargo, bin) =
rustup_cargo().map_err(|e| CliError::env(format!("day xcode-backend: {e}")))?;
let name = project.manifest.app.name.clone();
let target_dir = project
.root
.join("build/day/cargo")
.join(target_dir_name)
.join(profile.as_str());
let mut arch_libs: Vec<PathBuf> = Vec::new();
let ident = name.replace('-', "_");
for triple in &triples {
let mut cmd = Command::new(&cargo);
for var in [
"SDKROOT",
"LIBRARY_PATH",
"CPATH",
"IPHONEOS_DEPLOYMENT_TARGET",
"MACOSX_DEPLOYMENT_TARGET",
] {
cmd.env_remove(var);
}
let home = std::env::var("HOME").unwrap_or_default();
cmd.current_dir(&project.root)
.env(
"PATH",
format!(
"{}:{home}/.cargo/bin:/usr/bin:/bin:/usr/sbin:/sbin",
bin.display()
),
)
.env("CARGO_TARGET_DIR", &target_dir);
crate::ops::apply_app_identity(&mut cmd, &project);
crate::bridge::apply_staged(&mut cmd, &project, target_dir_name);
cmd
.args([
"rustc",
"-p",
&name,
"--lib",
"--crate-type",
"staticlib",
"--no-default-features",
"--features",
&crate::ops::feature_selection(&project, toolkit_feature),
])
.args(["--target", triple]);
if profile == Profile::Release {
cmd.arg("--release");
}
run_logged(&mut cmd, "cargo (xcode)").map_err(CliError::build)?;
arch_libs.push(
target_dir
.join(triple)
.join(profile.as_str())
.join(format!("lib{}.a", project.lib_name())),
);
}
let out_dir = built_products.join("day"); if std::fs::create_dir_all(&out_dir).is_err() {
return Err(CliError::build(format!(
"day xcode-backend: cannot create {}",
out_dir.display()
)));
}
let dest = out_dir.join(STAGED_STATICLIB);
let staged = if arch_libs.len() == 1 {
std::fs::copy(&arch_libs[0], &dest)
.map(|_| ())
.map_err(|e| format!("copy {} → {}: {e}", arch_libs[0].display(), dest.display()))
} else {
let mut lipo = Command::new("lipo");
lipo.arg("-create")
.args(&arch_libs)
.arg("-output")
.arg(&dest);
match lipo.status() {
Ok(s) if s.success() => Ok(()),
Ok(s) => Err(format!("lipo exited with {s}")),
Err(e) => Err(format!("lipo: {e}")),
}
};
staged.map_err(|e| CliError::build(format!("day xcode-backend: {e}")))?;
if let (Some(tbd), Some(res)) = (
get("TARGET_BUILD_DIR"),
get("UNLOCALIZED_RESOURCES_FOLDER_PATH"),
) {
let src = project.root.join("resource/assets");
if src.exists() {
let dst = PathBuf::from(tbd).join(res).join("assets");
let _ = std::fs::remove_dir_all(&dst);
copy_tree_flat(&src, &dst)
.map_err(|e| CliError::build(format!("day xcode-backend: stage assets: {e}")))?;
}
}
let alias = out_dir.join(format!("lib{ident}.a"));
if alias != dest {
let _ = std::fs::remove_file(&alias);
if std::fs::hard_link(&dest, &alias).is_err() {
let _ = std::fs::copy(&dest, &alias);
}
}
eprintln!("day xcode-backend: staged {}", dest.display());
Ok(())
}
pub fn xcode_backend_stage_resources() -> Result<(), CliError> {
let get = |k: &str| std::env::var(k).ok();
let (Some(tbd), Some(res)) = (
get("TARGET_BUILD_DIR"),
get("UNLOCALIZED_RESOURCES_FOLDER_PATH"),
) else {
return Err(CliError::usage(
"day xcode-backend: must run inside an Xcode build (TARGET_BUILD_DIR unset)",
));
};
let project_dir = get("PROJECT_DIR").map(PathBuf::from).unwrap_or_default();
let project = find_project(Some(&project_dir.join("../..")))
.map_err(|e| CliError::usage(format!("day xcode-backend: {e}")))?;
let vectors = crate::resources::prepare_vectors(&project)
.map_err(|e| CliError::build(format!("day xcode-backend: vectors: {e}")))?;
crate::resources::write_vector_fallbacks(&project, "appkit", &vectors)
.map_err(|e| CliError::build(format!("day xcode-backend: vectors: {e}")))?;
let resources = PathBuf::from(tbd).join(res);
let pairs: [(PathBuf, &str); 5] = [
(project.root.join("resource/images"), "images"),
(project.root.join("resource/assets"), "assets"),
(project.root.join("resource/fonts"), "fonts"),
(
crate::resources::vector_fallback_dir(&project, "appkit"),
"vectors/raster",
),
(crate::resources::vector_svg_dir(&project), "vectors/svg"),
];
for (src, sub) in pairs {
let dst = resources.join(sub);
let _ = std::fs::remove_dir_all(&dst);
if !src.is_dir() {
continue;
}
copy_tree_flat(&src, &dst)
.map_err(|e| CliError::build(format!("day xcode-backend: stage {sub}: {e}")))?;
}
eprintln!(
"day xcode-backend: staged resources → {}",
resources.display()
);
Ok(())
}
pub fn xcode_backend_stage_strings() -> Result<(), CliError> {
let get = |k: &str| std::env::var(k).ok();
let (Some(tbd), Some(res)) = (
get("TARGET_BUILD_DIR"),
get("UNLOCALIZED_RESOURCES_FOLDER_PATH"),
) else {
return Err(CliError::usage(
"day xcode-backend: must run inside an Xcode build (TARGET_BUILD_DIR unset)",
));
};
let project_dir = get("PROJECT_DIR").map(PathBuf::from).unwrap_or_default();
let project = find_project(Some(&project_dir.join("../..")))
.map_err(|e| CliError::usage(format!("day xcode-backend: {e}")))?;
let bundle = PathBuf::from(tbd).join(res);
crate::shortcuts::stage_ios_strings(&project, &bundle)
.map_err(|e| CliError::build(format!("day xcode-backend: stage-strings: {e}")))?;
Ok(())
}
fn copy_tree_flat(src: &Path, dst: &Path) -> Result<(), String> {
std::fs::create_dir_all(dst).map_err(|e| format!("mkdir {}: {e}", dst.display()))?;
let rd = std::fs::read_dir(src).map_err(|e| format!("{}: {e}", src.display()))?;
for entry in rd.flatten() {
let from = entry.path();
let to = dst.join(entry.file_name());
if from.is_dir() {
copy_tree_flat(&from, &to)?;
} else {
std::fs::copy(&from, &to).map_err(|e| format!("{}: {e}", from.display()))?;
}
}
Ok(())
}
pub fn macos_xcode_enabled(project: &Project) -> bool {
if std::env::var("DAY_MACOS_XCODE").is_ok_and(|v| v == "0") {
return false;
}
project
.root
.join("platform/macos/DayApp.xcodeproj")
.is_dir()
}
fn oso_prefix_setting(project_root: &Path) -> String {
let root = std::fs::canonicalize(project_root).unwrap_or_else(|_| project_root.to_path_buf());
format!(
"OTHER_LDFLAGS=$(inherited) -Wl,-oso_prefix,{}/ -Wl,-objc_stubs_small",
root.display()
)
}
pub fn build_macos_xcode(
project: &Project,
target: &'static Target,
profile: Profile,
start: std::time::Instant,
) -> Result<BuildOutcome, String> {
let configuration = match profile {
Profile::Release => "Release",
Profile::Debug => "Debug",
};
let symroot = absolute(&project.root.join("build/day/macos-appkit"))?;
let day_bin = std::env::current_exe().map_err(|e| e.to_string())?;
crate::xcconfig::ensure_split(project, "macos")?;
crate::xcconfig::write_generated(project, "macos")?;
crate::pieces::write_macos_pieces(project, true)?;
status(
"Building",
&format!("{} (xcodebuild {configuration}, macosx)", target.name),
);
let mut cmd = Command::new("xcodebuild");
crate::ops::apply_determinism(&mut cmd);
cmd.current_dir(project.root.join("platform/macos"))
.args(["-project", "DayApp.xcodeproj", "-target", "Runner"])
.args(["-configuration", configuration, "-sdk", "macosx"]);
if std::env::var("DAY_MACOS_UNIVERSAL").is_ok_and(|v| v == "1") {
} else {
let arch = match std::env::consts::ARCH {
"aarch64" => "arm64",
other => other,
};
cmd.args(["-arch", arch]);
}
cmd.arg(format!("SYMROOT={}", symroot.display()))
.arg(format!("DAY_BIN={}", day_bin.display()))
.arg(oso_prefix_setting(&project.root))
.arg("build");
let out = crate::ops::run_capture(&mut cmd, "xcodebuild")?;
if !out.status.success() {
return Err(format!("xcodebuild failed:\n{}", diagnose_xcodebuild(&out)));
}
let products = symroot.join(configuration);
let app = std::fs::read_dir(&products)
.map_err(|e| format!("reading {}: {e}", products.display()))?
.flatten()
.map(|e| e.path())
.find(|p| p.extension().and_then(|x| x.to_str()) == Some("app"))
.ok_or_else(|| format!("no .app under {}", products.display()))?;
Ok(BuildOutcome {
target: target.name,
artifact: app,
seconds: start.elapsed().as_secs_f64(),
})
}
pub(crate) fn ios_info_plist(project: &Project) -> Option<PathBuf> {
[
"platform/ios/Runner/Info.plist",
"platform/ios/DayApp/Info.plist",
]
.iter()
.map(|rel| project.root.join(rel))
.find(|p| p.exists())
}
pub(crate) fn sync_uiappfonts(project: &Project) -> Result<(), String> {
let Some(plist) = ios_info_plist(project) else {
return Ok(());
};
let fonts = crate::resources::scan_fonts(project)?;
let paths: Vec<String> = fonts
.iter()
.filter_map(|f| f.path.file_name().and_then(|n| n.to_str()))
.map(|n| format!("DayPieces_DayPieces.bundle/fonts/{n}"))
.collect();
let before =
std::fs::read_to_string(&plist).map_err(|e| format!("{}: {e}", plist.display()))?;
let values = if paths.is_empty() {
None
} else {
Some(paths.as_slice())
};
let after = crate::plist::apply_array_key(&before, "UIAppFonts", values)
.map_err(|e| format!("{}: {e}", plist.display()))?;
if after != before {
std::fs::write(&plist, after).map_err(|e| format!("{}: {e}", plist.display()))?;
}
Ok(())
}
pub(crate) fn app_info_plist(project: &Project) -> Option<std::path::PathBuf> {
[
"platform/ios/Runner/Info.plist",
"platform/ios/DayApp/Info.plist",
]
.iter()
.map(|rel| project.root.join(rel))
.find(|p| p.exists())
}
pub(crate) fn sync_usage_descriptions(project: &Project, macos: bool) -> Result<(), String> {
let Some(plist) = app_info_plist(project) else {
return Ok(());
};
let platform = if macos { "macos" } else { "ios" };
let contributed = crate::pieces::contributed_permissions(project, &["uikit"]);
let plan = crate::permissions::resolve(&project.manifest, platform, &contributed)
.map_err(|e| format!("Day.toml: {e}"))?;
let want = crate::permissions::apple_keys(&plan, macos);
let mut managed = crate::permissions::apple_managed_keys(macos);
managed.extend(plan.raw_apple.keys().cloned());
let remove: std::collections::BTreeSet<String> = managed
.difference(&want.keys().cloned().collect())
.cloned()
.collect();
let before =
std::fs::read_to_string(&plist).map_err(|e| format!("{}: {e}", plist.display()))?;
let after = crate::plist::apply_string_keys(&before, &want, &remove)
.map_err(|e| format!("{}: {e}", plist.display()))?;
if after == before {
return Ok(()); }
std::fs::write(&plist, &after).map_err(|e| format!("{}: {e}", plist.display()))?;
if cfg!(target_os = "macos")
&& let Ok(out) = Command::new("plutil").arg("-lint").arg(&plist).output()
&& !out.status.success()
{
let _ = std::fs::write(&plist, &before);
return Err(format!(
"generated Info.plist failed `plutil -lint` and was restored: {}",
String::from_utf8_lossy(&out.stdout).trim()
));
}
Ok(())
}
pub(crate) struct InstalledProfile {
pub name: String,
pub path: PathBuf,
}
pub(crate) fn installed_profile(app_id: &str) -> Option<InstalledProfile> {
let dir = dirs_home()?.join("Library/MobileDevice/Provisioning Profiles");
for entry in std::fs::read_dir(dir).ok()?.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("mobileprovision") {
continue;
}
let Ok(out) = Command::new("security")
.args(["cms", "-D", "-i"])
.arg(&path)
.output()
else {
continue;
};
if !out.status.success() {
continue;
}
let text = String::from_utf8_lossy(&out.stdout);
let Some(after) = text.split("application-identifier").nth(1) else {
continue;
};
let Some(value) = after
.split("<string>")
.nth(1)
.and_then(|v| v.split("</string>").next())
else {
continue;
};
let value = value.trim();
if let Some((team, id)) = value.split_once('.')
&& id == app_id
{
let name = text
.split("<key>Name</key>")
.nth(1)
.and_then(|v| v.split("<string>").nth(1))
.and_then(|v| v.split("</string>").next())
.unwrap_or_default()
.trim()
.to_string();
let _ = team;
return Some(InstalledProfile {
name,
path: path.clone(),
});
}
}
None
}
fn dirs_home() -> Option<std::path::PathBuf> {
std::env::var_os("HOME").map(std::path::PathBuf::from)
}
pub(crate) fn ios_wants_push(project: &Project) -> Result<bool, String> {
let contributed = crate::pieces::contributed_permissions(project, &["uikit"]);
let plan = crate::permissions::resolve(&project.manifest, "ios", &contributed)
.map_err(|e| format!("Day.toml: {e}"))?;
Ok(plan.resolved.iter().any(|r| r.spec.name == "notifications"))
}
pub(crate) fn prepare_ios(project: &Project) -> Result<Option<String>, String> {
crate::xcconfig::ensure_split(project, "ios")?;
crate::xcconfig::write_generated(project, "ios")?;
let floor = crate::pieces::write_ios_pieces(project)?;
sync_uiappfonts(project)?;
sync_usage_descriptions(project, false)?;
if let Some(plist) = ios_info_plist(project) {
crate::shortcuts::sync_ios(project, &plist)?;
}
crate::shortcuts::ensure_ios_strings_phase(project)?;
if let Some(f) = &floor {
status(
"Raising",
&format!(
"iOS deployment target to {f} (a piece requires it; raise it in \
platform/ios/DayApp.xcodeproj for Xcode-IDE builds)"
),
);
}
Ok(floor)
}
pub fn build_ios(
project: &Project,
target: &'static Target,
profile: Profile,
start: std::time::Instant,
) -> Result<BuildOutcome, String> {
build_ios_for(project, target, profile, start, false)
}
pub fn build_ios_for(
project: &Project,
target: &'static Target,
profile: Profile,
start: std::time::Instant,
physical: bool,
) -> Result<BuildOutcome, String> {
let configuration = match profile {
Profile::Release => "Release",
Profile::Debug => "Debug",
};
let symroot = absolute(&project.root.join("build/day/ios-uikit"))?;
let sdk = if physical {
"iphoneos"
} else {
"iphonesimulator"
};
let day_bin = std::env::current_exe().map_err(|e| e.to_string())?;
let floor = prepare_ios(project)?;
let prov = if physical {
installed_profile(&project.manifest.app.id)
} else {
None
};
status(
"Building",
&format!("{} (xcodebuild {configuration}, {sdk})", target.name),
);
let xcodebuild = || {
let mut cmd = Command::new("xcodebuild");
crate::ops::apply_determinism(&mut cmd);
cmd.current_dir(project.root.join("platform/ios"))
.args(["-project", "DayApp.xcodeproj", "-target", "Runner"])
.args([
"-configuration",
configuration,
"-sdk",
sdk,
"-arch",
"arm64",
])
.arg(format!("SYMROOT={}", symroot.display()))
.arg(format!("DAY_BIN={}", day_bin.display()))
.arg(oso_prefix_setting(&project.root));
if let Some(f) = &floor {
cmd.arg(format!("IPHONEOS_DEPLOYMENT_TARGET={f}"));
}
if physical {
cmd.arg("CODE_SIGNING_ALLOWED=NO")
.arg("CODE_SIGNING_REQUIRED=NO");
}
cmd.arg("build");
crate::ops::run_capture(&mut cmd, "xcodebuild")
};
let mut out = xcodebuild()?;
if !out.status.success() && is_stale_bundle_failure(&out) {
status("Rebuilding", "ios-uikit (clearing stale build tree)");
let _ = std::fs::remove_dir_all(&symroot);
out = xcodebuild()?;
}
if !out.status.success() {
if physical {
let _ = std::fs::remove_dir_all(symroot.join(format!("{configuration}-{sdk}")));
}
return Err(format!("xcodebuild failed:\n{}", diagnose_xcodebuild(&out)));
}
let products = symroot.join(format!("{configuration}-{sdk}"));
let app = std::fs::read_dir(&products)
.map_err(|e| format!("reading {}: {e}", products.display()))?
.flatten()
.map(|e| e.path())
.find(|p| p.extension().and_then(|x| x.to_str()) == Some("app"))
.ok_or_else(|| format!("no .app bundle in {}", products.display()))?;
if physical {
let p = prov.ok_or_else(|| {
format!(
"no installed provisioning profile covers {}. Create a development profile for \
that app id and install it (double-click the .mobileprovision), then retry.",
project.manifest.app.id
)
})?;
sign_ios_bundle(project, &app, &p)?;
}
Ok(BuildOutcome {
target: target.name,
artifact: app,
seconds: start.elapsed().as_secs_f64(),
})
}
fn sign_ios_bundle(project: &Project, app: &Path, prof: &InstalledProfile) -> Result<(), String> {
let tmp = std::env::temp_dir().join("day-ios-sign");
let _ = std::fs::create_dir_all(&tmp);
let plist = tmp.join("profile.plist");
let out = Command::new("security")
.args(["cms", "-D", "-i"])
.arg(&prof.path)
.arg("-o")
.arg(&plist)
.output()
.map_err(|e| format!("security cms: {e}"))?;
if !out.status.success() {
return Err(format!(
"could not decode {}: {}",
prof.path.display(),
String::from_utf8_lossy(&out.stderr).trim()
));
}
let ents = tmp.join("signing.entitlements");
run_logged(
Command::new("plutil")
.args(["-extract", "Entitlements", "xml1", "-o"])
.arg(&ents)
.arg(&plist),
"plutil -extract Entitlements",
)?;
if ios_wants_push(project)? {
let text = std::fs::read_to_string(&ents).unwrap_or_default();
if !text.contains("aps-environment") {
return Err(format!(
"Day.toml declares `notifications`, but the profile {:?} does not grant \
aps-environment. Enable Push Notifications on the App ID for {} and regenerate \
the profile.",
prof.name, project.manifest.app.id
));
}
}
let der = tmp.join("signer.der");
run_logged(
Command::new("plutil")
.args(["-extract", "DeveloperCertificates.0", "raw", "-o"])
.arg(tmp.join("signer.b64"))
.arg(&plist),
"plutil -extract DeveloperCertificates",
)?;
let b64 = std::fs::read_to_string(tmp.join("signer.b64")).map_err(|e| e.to_string())?;
let decoded = Command::new("base64")
.args(["-d"])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.spawn()
.and_then(|mut c| {
use std::io::Write;
c.stdin.take().unwrap().write_all(b64.as_bytes())?;
c.wait_with_output()
})
.map_err(|e| format!("base64: {e}"))?;
std::fs::write(&der, &decoded.stdout).map_err(|e| e.to_string())?;
let fp = Command::new("openssl")
.args(["x509", "-inform", "DER", "-in"])
.arg(&der)
.args(["-noout", "-fingerprint", "-sha1"])
.output()
.map_err(|e| format!("openssl: {e}"))?;
let sha1 = String::from_utf8_lossy(&fp.stdout)
.split('=')
.nth(1)
.map(|v| v.trim().replace(':', ""))
.ok_or("could not read the signing certificate's fingerprint")?;
std::fs::copy(&prof.path, app.join("embedded.mobileprovision"))
.map_err(|e| format!("embedding the profile: {e}"))?;
let mut nested: Vec<PathBuf> = Vec::new();
for sub in ["Frameworks", "PlugIns"] {
if let Ok(rd) = std::fs::read_dir(app.join(sub)) {
nested.extend(rd.flatten().map(|e| e.path()));
}
}
if let Ok(rd) = std::fs::read_dir(app) {
nested.extend(
rd.flatten()
.map(|e| e.path())
.filter(|p| p.extension().and_then(|x| x.to_str()) == Some("bundle")),
);
}
nested.sort();
for item in &nested {
run_logged(
Command::new("codesign")
.args(["--force", "--timestamp=none", "--sign", &sha1])
.arg(item),
&format!(
"codesign {}",
item.file_name().unwrap_or_default().to_string_lossy()
),
)?;
}
status(
"Signing",
&format!(
"{} ({})",
app.file_name().unwrap_or_default().to_string_lossy(),
prof.name
),
);
run_logged(
Command::new("codesign")
.args([
"--force",
"--timestamp=none",
"--sign",
&sha1,
"--entitlements",
])
.arg(&ents)
.arg(app),
"codesign (app)",
)?;
Ok(())
}
pub(crate) fn booted_sims() -> Vec<String> {
let out = match Command::new("xcrun")
.args(["simctl", "list", "devices", "booted"])
.output()
{
Ok(o) if o.status.success() => o,
_ => return Vec::new(),
};
String::from_utf8_lossy(&out.stdout)
.lines()
.filter(|l| l.contains("(Booted)"))
.filter_map(|l| {
l.split(['(', ')'])
.map(str::trim)
.find(|t| t.len() == 36 && t.split('-').count() == 5)
.map(str::to_string)
})
.collect()
}
fn select_sim(booted: &[String], want: &str) -> Result<Vec<String>, String> {
if booted.iter().any(|u| u.eq_ignore_ascii_case(want)) {
return Ok(vec![want.to_string()]);
}
let listing = Command::new("xcrun")
.args(["simctl", "list", "devices", "booted"])
.output()
.map_err(|e| format!("simctl list: {e}"))?;
let named: Vec<String> = String::from_utf8_lossy(&listing.stdout)
.lines()
.filter(|l| l.contains("(Booted)"))
.filter(|l| {
l.split_once('(')
.map(|(name, _)| name.trim().eq_ignore_ascii_case(want))
.unwrap_or(false)
})
.filter_map(|l| {
l.split(['(', ')'])
.map(str::trim)
.find(|t| t.len() == 36 && t.split('-').count() == 5)
.map(str::to_string)
})
.collect();
if named.is_empty() {
return Err(format!(
"--ios-simulator {want:?} is not a booted iOS simulator (booted: {}). Boot it first: \
`xcrun simctl boot {want:?}`",
if booted.is_empty() {
"none".to_string()
} else {
booted.join(", ")
}
));
}
Ok(named)
}
pub(crate) fn physical_ios_devices() -> Vec<(String, String)> {
let tmp = std::env::temp_dir().join("day-devicectl-devices.json");
let ok = Command::new("xcrun")
.args(["devicectl", "list", "devices", "--json-output"])
.arg(&tmp)
.output()
.map(|o| o.status.success())
.unwrap_or(false);
if !ok {
return Vec::new();
}
let Ok(text) = std::fs::read_to_string(&tmp) else {
return Vec::new();
};
let Ok(json) = serde_json::from_str::<serde_json::Value>(&text) else {
return Vec::new();
};
let mut out = Vec::new();
for d in json["result"]["devices"].as_array().into_iter().flatten() {
let udid = d["hardwareProperties"]["udid"].as_str().unwrap_or_default();
let platform = d["hardwareProperties"]["platform"]
.as_str()
.unwrap_or_default();
let name = d["deviceProperties"]["name"].as_str().unwrap_or_default();
if platform == "iOS" && udid.len() == 25 {
out.push((udid.to_string(), name.to_string()));
}
}
out
}
fn launch_ios_device(
project: &Project,
outcome: &BuildOutcome,
spec: &LaunchSpec,
) -> Result<std::thread::JoinHandle<i32>, String> {
let bundle_id = project.manifest.app.id.clone();
let devices = physical_ios_devices();
if devices.is_empty() {
return Err(
"no physical iOS device is paired and reachable. Connect it (or bring it onto \
the same network for a wireless pair) and check `xcrun devicectl list devices`."
.to_string(),
);
}
let (udid, name) = match spec.ios_device.as_deref() {
None if devices.len() == 1 => devices[0].clone(),
None => {
return Err(format!(
"several iOS devices are available — name one with --ios-device: {}",
devices
.iter()
.map(|(_, n)| n.as_str())
.collect::<Vec<_>>()
.join(", ")
));
}
Some(want) => devices
.iter()
.find(|(u, n)| u.eq_ignore_ascii_case(want) || n.eq_ignore_ascii_case(want))
.cloned()
.ok_or_else(|| {
format!(
"--ios-device {want:?} is not a paired iOS device (available: {})",
devices
.iter()
.map(|(_, n)| n.as_str())
.collect::<Vec<_>>()
.join(", ")
)
})?,
};
status("Installing", &format!("{} on {name}", outcome.target));
run_quiet(
Command::new("xcrun")
.args(["devicectl", "device", "install", "app", "--device", &udid])
.arg(&outcome.artifact),
&format!("devicectl install ({name})"),
INSTALL_TIMEOUT,
)?;
status(
"Launching",
&format!("{} ({bundle_id}) on device {name}", outcome.target),
);
let mut launch = Command::new("xcrun");
launch.args(devicectl_launch_args(&udid, &bundle_id, spec));
if !spec.attached {
run_logged_within(
&mut launch,
&format!("devicectl launch ({name})"),
LAUNCH_TIMEOUT,
)?;
return Ok(std::thread::spawn(|| 0));
}
launch
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped());
let mut child = launch
.spawn()
.map_err(|e| format!("devicectl launch: {e}"))?;
crate::signals::register_child(child.id());
let stdout = child.stdout.take();
let stderr = child.stderr.take();
let label = outcome.target.to_string();
Ok(std::thread::spawn(move || {
let l2 = label.clone();
let t1 = stdout.map(|s| stream_devicectl(label, LogStream::Out, s));
let t2 = stderr.map(|s| stream_devicectl(l2, LogStream::Err, s));
let code = child.wait().map(crate::ops::exit_code_of).unwrap_or(1);
if let Some(t) = t1 {
let _ = t.join();
}
if let Some(t) = t2 {
let _ = t.join();
}
code
}))
}
fn devicectl_launch_args(udid: &str, bundle_id: &str, spec: &LaunchSpec) -> Vec<String> {
let mut args: Vec<String> = ["devicectl", "device", "process", "launch", "--device", udid]
.iter()
.map(|s| (*s).to_string())
.collect();
if spec.attached {
args.push("--console".into());
args.push("--terminate-existing".into());
}
let mut env = serde_json::Map::new();
for (k, v) in &spec.envs {
env.insert(k.clone(), serde_json::Value::String(v.clone()));
}
if let Some(loc) = &spec.locale {
env.insert("DAY_LOCALE".into(), serde_json::Value::String(loc.clone()));
}
if !env.is_empty() {
args.push("--environment-variables".into());
args.push(serde_json::Value::Object(env).to_string());
}
args.push(bundle_id.to_string());
args
}
fn stream_devicectl(
label: String,
stream: LogStream,
src: impl std::io::Read + Send + 'static,
) -> std::thread::JoinHandle<()> {
std::thread::spawn(move || {
let mut failure: Vec<String> = Vec::new();
for line in BufReader::new(src).lines().map_while(Result::ok) {
let t = line.trim().to_string();
if !failure.is_empty() || t.starts_with("ERROR:") {
failure.push(t);
continue;
}
let noise = t.is_empty()
|| t.starts_with("Launched application with")
|| t.starts_with("Waiting for the application to terminate")
|| t.starts_with("App installed:")
|| t.starts_with('•')
|| t.starts_with("The app is now running")
|| t.starts_with("Application terminated");
if !noise {
emit_log(&label, stream, &line);
}
}
if !failure.is_empty() {
emit_log(
&label,
LogStream::Err,
&summarize_devicectl_failure(&failure),
);
}
})
}
fn summarize_devicectl_failure(lines: &[String]) -> String {
let joined = lines.join(" ");
if joined.contains("could not be, unlocked")
|| joined.contains("BSErrorCodeDescription = Locked")
{
return "the device is locked — unlock it and run again (iOS will not launch an app onto \
a locked screen)"
.to_string();
}
for l in lines {
if let Some(reason) = l.strip_prefix("NSLocalizedFailureReason = ") {
return format!("launch failed: {}", reason.trim());
}
}
lines
.first()
.map(|l| l.trim_start_matches("ERROR: ").to_string())
.unwrap_or_else(|| "launch failed".to_string())
}
pub fn launch_ios(
project: &Project,
outcome: &BuildOutcome,
spec: &LaunchSpec,
) -> Result<std::thread::JoinHandle<i32>, String> {
if spec.wants_ios_device() {
return launch_ios_device(project, outcome, spec);
}
let bundle_id = project.manifest.app.id.clone();
let sims = booted_sims();
if sims.is_empty() {
return Err(
"no booted iOS simulator (open Simulator.app or `xcrun simctl boot <device>`); \
physical devices need code signing and aren't supported here"
.into(),
);
}
let sims = match spec.ios_simulator.as_deref() {
Some(want) => select_sim(&sims, want)?,
None => sims,
};
if let [only] = sims.as_slice() {
crate::ops::remember_ios_simulator(only.clone());
}
let multi = sims.len() > 1;
let mut log_threads = Vec::new();
for udid in &sims {
run_logged_within(
Command::new("xcrun")
.args(["simctl", "install", udid])
.arg(&outcome.artifact),
&format!("simctl install ({udid})"),
INSTALL_TIMEOUT,
)?;
let _ = Command::new("xcrun")
.args(["simctl", "terminate", udid, &bundle_id])
.status();
let mut cmd = Command::new("xcrun");
cmd.args(["simctl", "launch"]);
if spec.attached {
cmd.arg("--console");
}
cmd.args([udid.as_str(), &bundle_id]);
for (k, v) in &spec.envs {
cmd.env(format!("SIMCTL_CHILD_{k}"), v);
}
if let Some(locale) = &spec.locale {
cmd.env("SIMCTL_CHILD_DAY_LOCALE", locale);
}
status(
"Launching",
&format!("ios-uikit ({bundle_id}) on simulator {udid}"),
);
if spec.attached {
cmd.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped());
let mut child = cmd.spawn().map_err(|e| format!("simctl launch: {e}"))?;
crate::signals::register_child(child.id());
let stdout = child.stdout.take();
let stderr = child.stderr.take();
let (out_label, err_label) = if multi {
(
format!("{}:{}", outcome.target, udid),
format!("{}:{}", outcome.target, udid),
)
} else {
(outcome.target.to_string(), outcome.target.to_string())
};
log_threads.push(std::thread::spawn(move || {
let t1 = stdout.map(|s| stream_logs_labeled(out_label, LogStream::Out, s));
let t2 = stderr.map(|s| stream_logs_labeled(err_label, LogStream::Err, s));
let code = child.wait().map(crate::ops::exit_code_of).unwrap_or(1);
if let Some(t) = t1 {
let _ = t.join();
}
if let Some(t) = t2 {
let _ = t.join();
}
code
}));
} else {
run_logged_within(&mut cmd, &format!("simctl launch ({udid})"), LAUNCH_TIMEOUT)?;
}
}
Ok(std::thread::spawn(move || {
let mut code = 0;
for t in log_threads {
if let Ok(c) = t.join()
&& c != 0
&& code == 0
{
code = c;
}
}
code
}))
}
fn stream_logs_labeled(
label: String,
stream: LogStream,
src: impl std::io::Read + Send + 'static,
) -> std::thread::JoinHandle<()> {
std::thread::spawn(move || {
for line in BufReader::new(src).lines().map_while(Result::ok) {
emit_log(&label, stream, &line);
}
})
}
pub fn gradle_backend_build() -> Result<(), CliError> {
let root = match std::env::var("DAY_PROJECT_ROOT") {
Ok(v) => PathBuf::from(v),
Err(_) => {
return Err(CliError::usage(
"day gradle-backend: DAY_PROJECT_ROOT unset (run via the gradle scaffold)",
));
}
};
let profile = match std::env::var("DAY_PROFILE").as_deref() {
Ok("release") => Profile::Release,
_ => Profile::Debug,
};
let out = std::env::var("DAY_OUT")
.map(PathBuf::from)
.unwrap_or_else(|_| root.join("build/day/jniLibs"));
let project = find_project(Some(&root))
.map_err(|e| CliError::usage(format!("day gradle-backend: {e}")))?;
build_android_so(&project, profile, &out, &android_build_abis()).map_err(CliError::build)
}
pub(crate) struct AndroidDevice {
pub serial: String,
pub abi: String,
}
fn adb(serial: Option<&str>) -> Command {
let mut c = Command::new("adb");
if let Some(s) = serial {
c.args(["-s", s]);
}
c
}
pub(crate) fn android_devices() -> Vec<AndroidDevice> {
android_devices_for(crate::ops::selected_android_serial())
}
pub(crate) fn android_devices_for(want: Option<&str>) -> Vec<AndroidDevice> {
let forced = std::env::var("DAY_ANDROID_ABI")
.ok()
.and_then(|v| parse_abi_list(&v).into_iter().next());
let only = want.map(str::to_string).or_else(|| {
std::env::var("ANDROID_SERIAL")
.ok()
.filter(|s| !s.is_empty())
});
let out = match Command::new("adb").arg("devices").output() {
Ok(o) if o.status.success() => o,
_ => return Vec::new(),
};
String::from_utf8_lossy(&out.stdout)
.lines()
.skip(1) .filter_map(|l| {
let mut it = l.split_whitespace();
let serial = it.next()?;
if it.next() != Some("device") {
return None; }
if let Some(want) = &only
&& serial != want
{
return None;
}
let abi = forced.clone().unwrap_or_else(|| {
Command::new("adb")
.args(["-s", serial, "shell", "getprop", "ro.product.cpu.abi"])
.output()
.ok()
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
.filter(|s| !s.is_empty())
.unwrap_or_else(|| "arm64-v8a".into())
});
Some(AndroidDevice {
serial: serial.to_string(),
abi,
})
})
.collect()
}
pub(crate) fn android_build_abis() -> Vec<String> {
if let Ok(v) = std::env::var("DAY_ANDROID_ABI") {
let mut abis = parse_abi_list(&v);
abis.sort();
abis.dedup();
if !abis.is_empty() {
return abis;
}
}
let mut abis: Vec<String> = android_devices().into_iter().map(|d| d.abi).collect();
abis.sort();
abis.dedup();
if abis.is_empty() {
abis.push("arm64-v8a".into());
}
abis
}
fn parse_abi_list(v: &str) -> Vec<String> {
v.split([',', ' ', '\t'])
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string)
.collect()
}
fn build_android_so(
project: &Project,
profile: Profile,
out: &Path,
abis: &[String],
) -> Result<(), String> {
let (cargo, bin) = rustup_cargo()?;
let name = project.manifest.app.name.clone();
let ndk_home = find_ndk()?;
let target_dir = project
.root
.join("build/day/cargo/android-mdc")
.join(profile.as_str());
let mut cmd = Command::new(&cargo);
cmd.current_dir(&project.root)
.env(
"PATH",
format!(
"{}:{}/.cargo/bin:{}",
bin.display(),
std::env::var("HOME").unwrap_or_default(),
std::env::var("PATH").unwrap_or_default()
),
)
.env("CARGO_TARGET_DIR", &target_dir)
.env("ANDROID_NDK_HOME", &ndk_home);
crate::ops::apply_app_identity(&mut cmd, project);
crate::bridge::apply_staged(&mut cmd, project, "android-mdc");
cmd.arg("ndk");
for abi in abis {
cmd.args(["-t", abi]);
}
cmd.arg("-o")
.arg(out)
.arg("rustc")
.args([
"-p",
&name,
"--lib",
"--crate-type",
"cdylib",
"--no-default-features",
"--features",
&crate::ops::feature_selection(project, "mdc"),
]);
if profile == Profile::Release {
cmd.arg("--release");
}
run_logged(&mut cmd, "cargo ndk")?;
let built = format!("lib{}.so", project.lib_name());
for abi in abis {
let Ok(entries) = std::fs::read_dir(out.join(abi)) else {
continue;
};
for path in entries.flatten().map(|e| e.path()) {
let is_other_lib = path
.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.starts_with("lib") && n.ends_with(".so") && n != built);
if is_other_lib {
let _ = std::fs::remove_file(&path);
}
}
}
Ok(())
}
pub(crate) fn android_sdk_dir() -> PathBuf {
day_toolchain::android_sdk_dir()
}
pub(crate) fn find_ndk() -> Result<PathBuf, String> {
if let Ok(v) = std::env::var("ANDROID_NDK_HOME") {
return Ok(PathBuf::from(v));
}
let sdk = android_sdk_dir();
let ndk_dir = sdk.join("ndk");
let mut versions: Vec<_> = std::fs::read_dir(&ndk_dir)
.map_err(|_| "no Android NDK found (set ANDROID_NDK_HOME)")?
.flatten()
.map(|e| e.path())
.collect();
versions.sort();
versions.pop().ok_or_else(|| "empty ndk dir".into())
}
pub fn build_android(
project: &Project,
target: &'static Target,
profile: Profile,
start: std::time::Instant,
) -> Result<BuildOutcome, String> {
let jni_out = project.root.join("build/day/jniLibs");
let abis = android_build_abis();
status(
"Building",
&format!("{} (cargo-ndk {})", target.name, abis.join(" ")),
);
build_android_so(project, profile, &jni_out, &abis)?;
crate::pack::android::write_app_properties(project)?;
crate::pieces::write_android_manifest(project)?;
let task = match profile {
Profile::Release => "assembleRelease",
Profile::Debug => "assembleDebug",
};
status("Building", &format!("{} (gradle {task})", target.name));
let day_bin = std::env::current_exe().map_err(|e| e.to_string())?;
let mut cmd = Command::new("gradle");
cmd.current_dir(project.root.join("platform/android"))
.env("DAY_BIN", &day_bin)
.env("DAY_PROJECT_ROOT", &project.root)
.env("DAY_PROFILE", profile.as_str())
.args([task, "--console=plain"]);
if !crate::ops::verbose() {
cmd.arg("-q");
}
if std::env::var_os("JAVA_HOME").is_none()
&& let Some(jdk) = day_toolchain::jdk_home()
{
cmd.env("JAVA_HOME", jdk);
}
let out = crate::ops::run_capture_within(&mut cmd, "gradle", crate::ops::BUILD_TIMEOUT)?;
if !out.status.success() {
if crate::ops::verbose() {
return Err("gradle failed".into());
}
let text = String::from_utf8_lossy(&out.stderr);
let tail: Vec<&str> = text.lines().rev().take(30).collect();
return Err(format!(
"gradle failed:\n{}",
tail.into_iter().rev().collect::<Vec<_>>().join("\n")
));
}
let apk_name = match profile {
Profile::Release => "app-release.apk",
Profile::Debug => "app-debug.apk",
};
let apk_dir = project
.root
.join("platform/android/app/build/outputs/apk")
.join(profile.as_str());
let conventional = apk_dir.join(apk_name);
let apk = if conventional.exists() {
conventional
} else {
std::fs::read_dir(&apk_dir)
.ok()
.and_then(|entries| {
entries
.flatten()
.map(|e| e.path())
.find(|p| p.extension().and_then(|x| x.to_str()) == Some("apk"))
})
.unwrap_or(conventional)
};
Ok(BuildOutcome {
target: target.name,
artifact: apk,
seconds: start.elapsed().as_secs_f64(),
})
}
pub fn launch_android(
project: &Project,
outcome: &BuildOutcome,
spec: &LaunchSpec,
) -> Result<std::thread::JoinHandle<i32>, String> {
let app_id = project.manifest.app.id.clone();
let devices = android_devices_for(spec.android_device.as_deref());
if devices.is_empty() {
return Err(match spec.android_device.as_deref() {
Some(serial) => {
format!("--android-device {serial:?} is not connected (check `adb devices`)")
}
None => "no Android device/emulator connected (check `adb devices`)".into(),
});
}
if let [only] = devices.as_slice() {
crate::ops::remember_android_serial(only.serial.clone());
}
let mut log_threads = Vec::new();
for dev in &devices {
status(
"Installing",
&format!("{} on {}", outcome.target, dev.serial),
);
run_quiet(
adb(Some(&dev.serial))
.args(["install", "-r"])
.arg(&outcome.artifact),
&format!("adb install ({})", dev.serial),
INSTALL_TIMEOUT,
)?;
run_quiet(
adb(Some(&dev.serial)).args(["shell", "am", "force-stop", &app_id]),
&format!("am force-stop ({})", dev.serial),
LAUNCH_TIMEOUT,
)?;
if dev.serial.starts_with("emulator-") {
let _ = adb(Some(&dev.serial))
.args([
"shell",
"settings",
"put",
"global",
"hide_error_dialogs",
"1",
])
.output();
}
if let Some(theme) = spec
.envs
.iter()
.find(|(k, _)| k == "DAY_THEME")
.map(|(_, v)| v)
{
let night = match theme.as_str() {
"dark" => Some("yes"),
"light" => Some("no"),
_ => None,
};
if let Some(night) = night {
let cur = adb(Some(&dev.serial))
.args(["shell", "cmd", "uimode", "night"])
.output()
.ok()
.map(|o| String::from_utf8_lossy(&o.stdout).to_lowercase())
.unwrap_or_default();
if !cur.contains(&format!(": {night}")) {
run_logged_within(
adb(Some(&dev.serial)).args(["shell", "cmd", "uimode", "night", night]),
&format!("uimode night {night} ({})", dev.serial),
LAUNCH_TIMEOUT,
)?;
std::thread::sleep(std::time::Duration::from_millis(1500));
}
}
}
let mut cmd = adb(Some(&dev.serial));
cmd.args([
"shell",
"am",
"start",
"-n",
&format!("{app_id}/dev.daybrite.day.bridge.DayActivity"),
]);
for (k, v) in &spec.envs {
let quoted = format!("'{}'", v.replace('\'', ""));
if k == "AUTODRIVE" {
cmd.args(["--es", "day.autodrive", "ed]);
} else {
cmd.args(["--es", &format!("day.env.{k}"), "ed]);
}
}
if let Some(locale) = &spec.locale {
cmd.args(["--es", "day.locale", &format!("'{locale}'")]);
}
status(
"Launching",
&format!("android-mdc ({app_id}) on {} ({})", dev.serial, dev.abi),
);
run_quiet(
&mut cmd,
&format!("am start ({})", dev.serial),
LAUNCH_TIMEOUT,
)?;
if spec.attached {
crate::signals::register_remote_stop(
[
"adb".to_string(),
"-s".into(),
dev.serial.clone(),
"shell".into(),
"am".into(),
"force-stop".into(),
app_id.clone(),
]
.to_vec(),
);
let label = if devices.len() > 1 {
format!("{}:{}", outcome.target, dev.serial)
} else {
outcome.target.to_string()
};
log_threads.push(stream_logcat(dev.serial.clone(), app_id.clone(), label));
}
}
Ok(std::thread::spawn(move || {
let mut code = 0;
for t in log_threads {
if let Ok(c) = t.join()
&& c != 0
&& code == 0
{
code = c;
}
}
code
}))
}
fn stream_logcat(serial: String, app_id: String, label: String) -> std::thread::JoinHandle<i32> {
std::thread::spawn(move || {
let pid = (0..20)
.find_map(|_| {
let p = adb(Some(&serial))
.args(["shell", "pidof", "-s", &app_id])
.output()
.ok()
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
.unwrap_or_default();
if p.is_empty() {
std::thread::sleep(std::time::Duration::from_millis(250));
None
} else {
Some(p)
}
})
.unwrap_or_default();
if pid.is_empty() {
emit_log(
&label,
LogStream::Err,
"app pid not found; logs unavailable",
);
return 1;
}
let _ = adb(Some(&serial)).args(["logcat", "-c"]).status();
let mut child = match adb(Some(&serial))
.args([
"logcat", "--pid", &pid, "-v", "tag", "Day:V", "day:V", "*:S",
])
.stdout(Stdio::piped())
.spawn()
{
Ok(c) => c,
Err(e) => {
emit_log(&label, LogStream::Err, &format!("adb logcat: {e}"));
return 1;
}
};
crate::signals::register_child(child.id());
if let Some(out) = child.stdout.take() {
for line in BufReader::new(out).lines().map_while(Result::ok) {
let (prio, msg) = match line.split_once(':') {
Some((head, rest)) => {
(head.trim().chars().next().unwrap_or('I'), rest.trim_start())
}
None => ('I', line.as_str()),
};
let stream = if prio == 'E' || prio == 'F' || prio == 'W' {
LogStream::Err
} else {
LogStream::Out
};
emit_log(&label, stream, msg);
}
}
child.wait().map(crate::ops::exit_code_of).unwrap_or(0)
})
}
#[cfg(test)]
mod abi_tests {
use super::{android_build_abis, devicectl_launch_args, parse_abi_list};
use crate::ops::LaunchSpec;
use std::sync::Mutex;
#[test]
fn devicectl_options_precede_the_bundle_id() {
let spec = LaunchSpec {
locale: Some("fr".into()),
envs: vec![
("DAY_LOG".into(), "trace".into()),
("WITH_QUOTE".into(), "a\"b".into()),
],
attached: true,
ios_device: None,
ios_simulator: None,
android_device: None,
ohos_device: None,
};
let args = devicectl_launch_args("UDID-1", "dev.daybrite.app", &spec);
let bundle = args
.iter()
.position(|a| a == "dev.daybrite.app")
.expect("bundle id");
assert_eq!(
bundle,
args.len() - 1,
"the bundle id must be LAST: {args:?}"
);
for opt in [
"--console",
"--terminate-existing",
"--environment-variables",
] {
let at = args
.iter()
.position(|a| a == opt)
.unwrap_or_else(|| panic!("{opt} missing"));
assert!(at < bundle, "{opt} must precede the bundle id: {args:?}");
}
assert_eq!(
args.iter()
.filter(|a| *a == "--environment-variables")
.count(),
1,
"environment must be one JSON object: {args:?}"
);
let json: serde_json::Value =
serde_json::from_str(&args[args.len() - 2]).expect("valid JSON");
assert_eq!(json["DAY_LOG"], "trace");
assert_eq!(json["DAY_LOCALE"], "fr");
assert_eq!(
json["WITH_QUOTE"], "a\"b",
"values are escaped, not hand-formatted"
);
}
#[test]
fn a_detached_device_launch_does_not_take_the_console() {
let spec = LaunchSpec {
locale: None,
envs: Vec::new(),
attached: false,
ios_device: None,
ios_simulator: None,
android_device: None,
ohos_device: None,
};
let args = devicectl_launch_args("UDID-1", "dev.daybrite.app", &spec);
assert!(!args.iter().any(|a| a == "--console"), "{args:?}");
assert!(
!args.iter().any(|a| a == "--terminate-existing"),
"{args:?}"
);
assert_eq!(args.last().unwrap(), "dev.daybrite.app");
}
static ENV_LOCK: Mutex<()> = Mutex::new(());
#[test]
fn abi_list_parses_commas_spaces_and_empties() {
assert_eq!(parse_abi_list("arm64-v8a"), vec!["arm64-v8a"]);
assert_eq!(
parse_abi_list("arm64-v8a,x86_64"),
vec!["arm64-v8a", "x86_64"]
);
assert_eq!(
parse_abi_list("arm64-v8a x86_64"),
vec!["arm64-v8a", "x86_64"]
);
assert_eq!(
parse_abi_list(" arm64-v8a , x86_64 "),
vec!["arm64-v8a", "x86_64"]
);
assert!(parse_abi_list("").is_empty());
assert!(parse_abi_list(" , ").is_empty());
}
#[test]
fn day_android_abi_overrides_connected_devices() {
let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
unsafe { std::env::set_var("DAY_ANDROID_ABI", "x86_64,arm64-v8a,x86_64") };
assert_eq!(android_build_abis(), vec!["arm64-v8a", "x86_64"]);
unsafe { std::env::set_var("DAY_ANDROID_ABI", "x86_64") };
assert_eq!(android_build_abis(), vec!["x86_64"]);
unsafe { std::env::remove_var("DAY_ANDROID_ABI") };
}
}