use std::io::{BufRead, BufReader};
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use crate::meta::{Project, find_project};
use crate::ops::{BuildOutcome, LaunchSpec, LogStream, emit_log, status};
use crate::targets::Target;
pub(crate) fn rustup_cargo() -> Result<(PathBuf, PathBuf), String> {
day_toolchain::rustup_cargo()
}
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"))
}
}
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() -> i32 {
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 => {
eprintln!(
"day xcode-backend: must run inside an Xcode build (BUILT_PRODUCTS_DIR unset)"
);
return 2;
}
};
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 = match find_project(Some(&root)) {
Ok(p) => p,
Err(e) => {
eprintln!("day xcode-backend: {e}");
return 2;
}
};
let profile = if configuration.to_lowercase().contains("release") {
"release"
} else {
"debug"
};
let triple = match platform.as_str() {
"iphonesimulator" => "aarch64-apple-ios-sim",
"iphoneos" => "aarch64-apple-ios",
other => {
eprintln!("day xcode-backend: unsupported PLATFORM_NAME {other:?}");
return 2;
}
};
let (cargo, bin) = match rustup_cargo() {
Ok(v) => v,
Err(e) => {
eprintln!("day xcode-backend: {e}");
return 3;
}
};
let name = project.manifest.app.name.clone();
let target_dir = project.root.join("build/day/cargo/ios-uikit").join(profile);
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)
.args([
"rustc",
"-p",
&name,
"--lib",
"--crate-type",
"staticlib",
"--no-default-features",
"--features",
&crate::ops::feature_selection(&project, "uikit"),
])
.args(["--target", triple]);
if profile == "release" {
cmd.arg("--release");
}
if run_logged(&mut cmd, "cargo (ios)").is_err() {
return 4;
}
let lib = target_dir
.join(triple)
.join(profile)
.join(format!("lib{name}.a"));
let out_dir = built_products.join("day"); if std::fs::create_dir_all(&out_dir).is_err() {
eprintln!("day xcode-backend: cannot create {}", out_dir.display());
return 4;
}
let dest = out_dir.join(format!("lib{name}.a"));
if let Err(e) = std::fs::copy(&lib, &dest) {
eprintln!(
"day xcode-backend: copy {} → {}: {e}",
lib.display(),
dest.display()
);
return 4;
}
if let (Some(tbd), Some(res)) = (
get("TARGET_BUILD_DIR"),
get("UNLOCALIZED_RESOURCES_FOLDER_PATH"),
) {
let src = project.root.join("assets");
if src.exists() {
let dst = PathBuf::from(tbd).join(res).join("assets");
let _ = std::fs::create_dir_all(&dst);
if let Ok(entries) = std::fs::read_dir(&src) {
for e in entries.flatten() {
let _ = std::fs::copy(e.path(), dst.join(e.file_name()));
}
}
}
}
eprintln!("day xcode-backend: staged {}", dest.display());
0
}
fn sync_uiappfonts(project: &Project) -> Result<(), String> {
let plist = [
"platform/ios/Runner/Info.plist",
"platform/ios/DayApp/Info.plist",
]
.iter()
.map(|rel| project.root.join(rel))
.find(|p| p.exists());
let Some(plist) = plist else {
return Ok(());
};
let fonts = crate::resources::scan_fonts(project)?;
let _ = Command::new("plutil")
.args(["-remove", "UIAppFonts"])
.arg(&plist)
.output();
if fonts.is_empty() {
return Ok(());
}
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 json = serde_json::to_string(&paths).expect("UIAppFonts json");
let out = Command::new("plutil")
.args(["-replace", "UIAppFonts", "-json", &json])
.arg(&plist)
.output()
.map_err(|e| format!("plutil: {e}"))?;
if !out.status.success() {
return Err(format!(
"could not update UIAppFonts in {}: {}",
plist.display(),
String::from_utf8_lossy(&out.stderr).trim()
));
}
Ok(())
}
pub fn build_ios(
project: &Project,
target: &'static Target,
profile: &str,
start: std::time::Instant,
) -> Result<BuildOutcome, String> {
let configuration = if profile == "release" {
"Release"
} else {
"Debug"
};
let symroot = absolute(&project.root.join("build/day/ios-uikit"))?;
let day_bin = std::env::current_exe().map_err(|e| e.to_string())?;
crate::pieces::write_ios_pieces(project)?;
sync_uiappfonts(project)?;
status(
"Building",
&format!("{} (xcodebuild {configuration})", target.name),
);
let xcodebuild = || {
let mut cmd = Command::new("xcodebuild");
cmd.current_dir(project.root.join("platform/ios"))
.args(["-project", "DayApp.xcodeproj", "-target", "Runner"])
.args([
"-configuration",
configuration,
"-sdk",
"iphonesimulator",
"-arch",
"arm64",
])
.arg(format!("SYMROOT={}", symroot.display()))
.arg(format!("DAY_BIN={}", day_bin.display()))
.arg("build")
.stdout(Stdio::piped())
.stderr(Stdio::piped());
cmd.output().map_err(|e| format!("xcodebuild: {e}"))
};
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() {
return Err(format!("xcodebuild failed:\n{}", diagnose_xcodebuild(&out)));
}
let products = symroot.join(format!("{configuration}-iphonesimulator"));
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()))?;
Ok(BuildOutcome {
target: target.name,
artifact: app,
seconds: start.elapsed().as_secs_f64(),
})
}
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()
}
pub fn launch_ios(
project: &Project,
outcome: &BuildOutcome,
spec: &LaunchSpec,
) -> Result<std::thread::JoinHandle<i32>, String> {
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 multi = sims.len() > 1;
let mut log_threads = Vec::new();
for udid in &sims {
run_logged(
Command::new("xcrun")
.args(["simctl", "install", udid])
.arg(&outcome.artifact),
&format!("simctl install ({udid})"),
)?;
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(|s| s.code().unwrap_or(0)).unwrap_or(1);
if let Some(t) = t1 {
let _ = t.join();
}
if let Some(t) = t2 {
let _ = t.join();
}
code
}));
} else {
run_logged(&mut cmd, &format!("simctl launch ({udid})"))?;
}
}
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() -> i32 {
let root = match std::env::var("DAY_PROJECT_ROOT") {
Ok(v) => PathBuf::from(v),
Err(_) => {
eprintln!("day gradle-backend: DAY_PROJECT_ROOT unset (run via the gradle scaffold)");
return 2;
}
};
let profile = std::env::var("DAY_PROFILE").unwrap_or_else(|_| "debug".into());
let out = std::env::var("DAY_OUT")
.map(PathBuf::from)
.unwrap_or_else(|_| root.join("build/day/jniLibs"));
let project = match find_project(Some(&root)) {
Ok(p) => p,
Err(e) => {
eprintln!("day gradle-backend: {e}");
return 2;
}
};
build_android_so(&project, &profile, &out, &android_build_abis())
.map(|_| 0)
.unwrap_or(4)
}
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> {
let forced = std::env::var("DAY_ANDROID_ABI").ok();
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; }
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()
}
fn android_build_abis() -> Vec<String> {
let mut abis: Vec<String> = android_devices().into_iter().map(|d| d.abi).collect();
abis.sort();
abis.dedup();
if abis.is_empty() {
abis.push(std::env::var("DAY_ANDROID_ABI").unwrap_or_else(|_| "arm64-v8a".into()));
}
abis
}
fn build_android_so(
project: &Project,
profile: &str,
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-widget")
.join(profile);
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)
.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, "widget"),
]);
if profile == "release" {
cmd.arg("--release");
}
run_logged(&mut cmd, "cargo ndk")?;
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: &str,
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 = if profile == "release" {
"assembleRelease"
} else {
"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)
.args([task, "-q", "--console=plain"]);
if std::env::var_os("JAVA_HOME").is_none()
&& let Some(jdk) = day_toolchain::jdk21_home()
{
cmd.env("JAVA_HOME", jdk);
}
let out = cmd.output().map_err(|e| format!("gradle: {e}"))?;
if !out.status.success() {
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 = if profile == "release" {
"app-release.apk"
} else {
"app-debug.apk"
};
let apk_dir = project
.root
.join("platform/android/app/build/outputs/apk")
.join(profile);
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();
if devices.is_empty() {
return Err("no Android device/emulator connected (check `adb devices`)".into());
}
let mut log_threads = Vec::new();
for dev in &devices {
run_logged(
adb(Some(&dev.serial))
.args(["install", "-r"])
.arg(&outcome.artifact),
&format!("adb install ({})", dev.serial),
)?;
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-widget ({app_id}) on {} ({})", dev.serial, dev.abi),
);
run_logged(&mut cmd, &format!("am start ({})", dev.serial))?;
if spec.attached {
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", "*: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(|s| s.code().unwrap_or(0)).unwrap_or(0)
})
}