use cargo_metadata::MetadataCommand;
use colored::Colorize;
use pax_manifest::PaxManifest;
use crate::dev_session::{
self, now_ms, project_designtime_manifest_file, project_dev_dir, write_project_active_session,
write_registered_session, DevSession,
};
use crate::helpers::{
configure_pax_build_env, pax_project_feature_args, BUILD_DIR_NAME, DIR_IGNORE_LIST_MACOS,
ERR_SPAWN, INTERFACE_DIR_NAME, PAX_BADGE,
};
use crate::project_metadata::PaxProjectMetadata;
use crate::{
copy_dir_recursively, prepare_cartridge_sources, wait_with_output, BuildTimings, HotReloadMode,
RunContext, RunLifecycleNotifier, RunTarget,
};
use color_eyre::eyre;
use eyre::eyre;
use serde_json::Value;
use std::collections::HashMap;
use std::fs;
use std::net::{IpAddr, Ipv6Addr};
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Output};
use std::sync::{mpsc, Arc, Mutex};
use std::thread;
use std::time::{Duration, Instant};
#[cfg(unix)]
use std::os::unix::fs as unix_fs;
#[cfg(unix)]
use std::os::unix::process::CommandExt;
const PORTABLE_DYLIB_INSTALL_NAME: &str = "@rpath/PaxCartridge.framework/PaxCartridge";
const XCODE_MACOS_TARGET_DEBUG: &str = "Pax macOS (Development)";
const XCODE_MACOS_TARGET_RELEASE: &str = "Pax macOS (Release)";
const XCODE_IOS_TARGET: &str = "Pax iOS (Development)";
const MACOS_MULTIARCH_PACKAGE_ID: &str = "macos-arm64_x86_64";
const IOS_SIMULATOR_MULTIARCH_PACKAGE_ID: &str = "ios-arm64_x86_64-simulator";
const IOS_PACKAGE_ID: &str = "ios-arm64";
const PAX_CARTRIDGE_FRAMEWORK_BINARY: &str = "PaxCartridge";
const DESIGN_SERVER_ADVERTISE_HOST_ENV: &str = "PAX_DESIGN_SERVER_ADVERTISE_HOST";
const IOS_LOCAL_NETWORK_USAGE_DESCRIPTION: &str =
"Pax uses the local network during development to receive live template updates.";
const MOBILE_APP_READY_TIMEOUT: Duration = Duration::from_secs(30);
const MOBILE_APP_READY_POLL_INTERVAL: Duration = Duration::from_millis(25);
struct AppReadyMarkerWatcher {
cancel: Option<mpsc::Sender<()>>,
handle: Option<thread::JoinHandle<()>>,
}
impl Drop for AppReadyMarkerWatcher {
fn drop(&mut self) {
if let Some(cancel) = self.cancel.take() {
let _ = cancel.send(());
}
if let Some(handle) = self.handle.take() {
let _ = handle.join();
}
}
}
fn watch_app_ready_marker(
marker: &Path,
timeout: Duration,
on_ready: impl FnOnce() + Send + 'static,
) -> Option<AppReadyMarkerWatcher> {
let marker = marker.to_path_buf();
let (cancel, cancelled) = mpsc::channel();
let handle = thread::Builder::new()
.name("pax-app-ready".to_string())
.spawn(move || {
let deadline = Instant::now() + timeout;
loop {
if marker.is_file() {
on_ready();
return;
}
let remaining = deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
return;
}
match cancelled.recv_timeout(remaining.min(MOBILE_APP_READY_POLL_INTERVAL)) {
Ok(()) | Err(mpsc::RecvTimeoutError::Disconnected) => return,
Err(mpsc::RecvTimeoutError::Timeout) => {}
}
}
})
.ok()?;
Some(AppReadyMarkerWatcher {
cancel: Some(cancel),
handle: Some(handle),
})
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum AppleTargetKind {
MacOS,
IosSimulator,
IosDevice,
}
#[derive(Clone, Copy)]
struct AppleTargetMapping {
rust_target: &'static str,
packaged_arch: &'static str,
xcode_arch: &'static str,
kind: AppleTargetKind,
}
struct AppleBuildResult {
packaged_arch: String,
dylib_path: String,
output: Output,
kind: AppleTargetKind,
}
struct IosAppIdentity {
bundle_identifier: String,
display_name: String,
}
const MACOS_ARM64_TARGET: AppleTargetMapping = AppleTargetMapping {
rust_target: "aarch64-apple-darwin",
packaged_arch: "macos-arm64",
xcode_arch: "arm64",
kind: AppleTargetKind::MacOS,
};
const MACOS_X86_64_TARGET: AppleTargetMapping = AppleTargetMapping {
rust_target: "x86_64-apple-darwin",
packaged_arch: "macos-x86_64",
xcode_arch: "x86_64",
kind: AppleTargetKind::MacOS,
};
const IOS_DEVICE_ARM64_TARGET: AppleTargetMapping = AppleTargetMapping {
rust_target: "aarch64-apple-ios",
packaged_arch: "ios-arm64",
xcode_arch: "arm64",
kind: AppleTargetKind::IosDevice,
};
const IOS_SIMULATOR_X86_64_TARGET: AppleTargetMapping = AppleTargetMapping {
rust_target: "x86_64-apple-ios",
packaged_arch: "iossimulator-x86_64",
xcode_arch: "x86_64",
kind: AppleTargetKind::IosSimulator,
};
const IOS_SIMULATOR_ARM64_TARGET: AppleTargetMapping = AppleTargetMapping {
rust_target: "aarch64-apple-ios-sim",
packaged_arch: "iossimulator-arm64",
xcode_arch: "arm64",
kind: AppleTargetKind::IosSimulator,
};
fn select_apple_target_mappings(
target: &RunTarget,
is_release: bool,
resolved_ios_device: Option<&ResolvedIosDevice>,
host_arch: &str,
) -> Vec<AppleTargetMapping> {
match target {
RunTarget::macOS => {
if is_release {
vec![MACOS_ARM64_TARGET, MACOS_X86_64_TARGET]
} else {
match host_arch {
"x86_64" => vec![MACOS_X86_64_TARGET],
"aarch64" | "arm64" => vec![MACOS_ARM64_TARGET],
_ => vec![MACOS_ARM64_TARGET, MACOS_X86_64_TARGET],
}
}
}
RunTarget::iOS | RunTarget::iPadOS => {
match ios_build_destination(is_release, resolved_ios_device) {
IosDeviceKind::Physical => vec![IOS_DEVICE_ARM64_TARGET],
IosDeviceKind::Simulator => match host_arch {
"x86_64" => vec![IOS_SIMULATOR_X86_64_TARGET],
"aarch64" | "arm64" => vec![IOS_SIMULATOR_ARM64_TARGET],
_ => vec![IOS_SIMULATOR_ARM64_TARGET, IOS_SIMULATOR_X86_64_TARGET],
},
}
}
RunTarget::Web => vec![],
}
}
fn ios_build_destination(
is_release: bool,
resolved_ios_device: Option<&ResolvedIosDevice>,
) -> IosDeviceKind {
match resolved_ios_device.map(|device| device.kind) {
Some(kind) => kind,
None if is_release => IosDeviceKind::Physical,
None => IosDeviceKind::Simulator,
}
}
fn xcode_archs_for_target_mappings(target_mappings: &[AppleTargetMapping]) -> String {
let mut archs = Vec::new();
for target_mapping in target_mappings {
if !archs.contains(&target_mapping.xcode_arch) {
archs.push(target_mapping.xcode_arch);
}
}
archs.join(" ")
}
fn copy_or_lipo_dylibs(
process_child_ids: &Arc<Mutex<Vec<u64>>>,
label: &str,
input_paths: &[String],
output_path: &Path,
) -> Result<(), eyre::Report> {
if input_paths.is_empty() {
return Err(eyre!(
"No architecture-specific binaries were available for {} packaging.",
label
));
}
if let Some(parent) = output_path.parent() {
fs::create_dir_all(parent)?;
}
if input_paths.len() == 1 {
fs::copy(&input_paths[0], output_path)?;
return Ok(());
}
let mut lipo_command = Command::new("lipo");
lipo_command
.arg("-create")
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped());
for path in input_paths {
lipo_command.arg(path);
}
lipo_command.arg("-output").arg(output_path);
#[cfg(unix)]
unsafe {
lipo_command.pre_exec(crate::pre_exec_hook);
}
let child = lipo_command.spawn().expect(ERR_SPAWN);
let output = wait_with_output(process_child_ids, child);
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(eyre!(
"Failed to combine {} dylibs with lipo. {}",
label,
stderr.trim()
));
}
Ok(())
}
fn remove_path_if_exists(path: &Path) -> Result<(), eyre::Report> {
match fs::symlink_metadata(path) {
Ok(metadata) => {
if metadata.file_type().is_dir() && !metadata.file_type().is_symlink() {
fs::remove_dir_all(path)?;
} else {
fs::remove_file(path)?;
}
Ok(())
}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(err) => Err(err.into()),
}
}
#[cfg(unix)]
fn replace_symlink(link_path: &Path, target: &Path) -> Result<(), eyre::Report> {
remove_path_if_exists(link_path)?;
unix_fs::symlink(target, link_path)?;
Ok(())
}
fn move_into_versions_if_needed(src: &Path, dst: &Path) -> Result<(), eyre::Report> {
match fs::symlink_metadata(src) {
Ok(metadata) => {
if metadata.file_type().is_symlink() {
return Ok(());
}
if let Some(parent) = dst.parent() {
fs::create_dir_all(parent)?;
}
if fs::symlink_metadata(dst).is_ok() {
remove_path_if_exists(dst)?;
}
fs::rename(src, dst)?;
Ok(())
}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(err) => Err(err.into()),
}
}
fn normalize_macos_framework_bundle(framework_dir: &Path) -> Result<(), eyre::Report> {
let versions_dir = framework_dir.join("Versions");
let version_dir = versions_dir.join("A");
let resources_dir = version_dir.join("Resources");
fs::create_dir_all(resources_dir.as_path())?;
move_into_versions_if_needed(
framework_dir.join(PAX_CARTRIDGE_FRAMEWORK_BINARY).as_path(),
version_dir.join(PAX_CARTRIDGE_FRAMEWORK_BINARY).as_path(),
)?;
move_into_versions_if_needed(
framework_dir.join("Headers").as_path(),
version_dir.join("Headers").as_path(),
)?;
move_into_versions_if_needed(
framework_dir.join("Modules").as_path(),
version_dir.join("Modules").as_path(),
)?;
move_into_versions_if_needed(
framework_dir.join("Resources").as_path(),
version_dir.join("Resources").as_path(),
)?;
move_into_versions_if_needed(
framework_dir.join("Info.plist").as_path(),
resources_dir.join("Info.plist").as_path(),
)?;
#[cfg(unix)]
{
replace_symlink(versions_dir.join("Current").as_path(), Path::new("A"))?;
replace_symlink(
framework_dir.join(PAX_CARTRIDGE_FRAMEWORK_BINARY).as_path(),
Path::new("Versions/Current/PaxCartridge"),
)?;
replace_symlink(
framework_dir.join("Headers").as_path(),
Path::new("Versions/Current/Headers"),
)?;
replace_symlink(
framework_dir.join("Modules").as_path(),
Path::new("Versions/Current/Modules"),
)?;
replace_symlink(
framework_dir.join("Resources").as_path(),
Path::new("Versions/Current/Resources"),
)?;
}
Ok(())
}
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
enum SimulatorVariantRank {
Other = 0,
Base = 1,
ProMax = 2,
Pro = 3,
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum IosDeviceKind {
Simulator,
Physical,
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum AppleMobileTarget {
Phone,
Tablet,
}
#[derive(Clone)]
struct ResolvedIosDevice {
kind: IosDeviceKind,
name: String,
identifier: String,
}
struct SimulatorDevice {
name: String,
udid: String,
state: String,
}
struct PhysicalDevice {
name: String,
identifier: String,
}
fn apple_mobile_target(target: &RunTarget) -> Option<AppleMobileTarget> {
match target {
RunTarget::iOS => Some(AppleMobileTarget::Phone),
RunTarget::iPadOS => Some(AppleMobileTarget::Tablet),
_ => None,
}
}
pub struct MacosLogicReloadBuild {
pub manifest: PaxManifest,
pub build_id: String,
pub dylib_path: PathBuf,
}
pub fn build_apple_project_with_cartridge(
ctx: &RunContext,
pax_dir: &PathBuf,
process_child_ids: Arc<Mutex<Vec<u64>>>,
assets_dirs: Vec<String>,
manifest: PaxManifest,
project_metadata: &PaxProjectMetadata,
_timings: &mut BuildTimings,
) -> Result<(), eyre::Report> {
let lifecycle_notifier = RunLifecycleNotifier::new(ctx);
let target: &RunTarget = &ctx.target;
let target_str: &str = target.into();
let target_str_lower = &target_str.to_lowercase();
let pax_dir = PathBuf::from(pax_dir.to_str().unwrap());
let project_path = ctx.project_path.clone();
let is_release: bool = ctx.is_release;
let apple_mobile_target = apple_mobile_target(target);
let ios_app_identity = if apple_mobile_target.is_some() {
Some(resolve_ios_app_identity(
&project_path, project_metadata, target,
)?)
} else {
None
};
let resolved_ios_device = if let Some(apple_mobile_target) = apple_mobile_target {
if ctx.should_also_run || ctx.ios_device.is_some() {
Some(resolve_ios_device(
ctx.ios_device.as_deref(),
apple_mobile_target,
&process_child_ids,
)?)
} else {
None
}
} else {
None
};
let build_mode_name: &str = if is_release { "release" } else { "debug" };
let should_run_designtime = ctx.should_run_designtime;
let target_mappings = select_apple_target_mappings(
target,
is_release,
resolved_ios_device.as_ref(),
std::env::consts::ARCH,
);
let release_target_triples: Vec<&str> = target_mappings
.iter()
.map(|mapping| mapping.rust_target)
.collect();
crate::validate_release_cargo_feature_boundary(ctx, &release_target_triples)?;
let dylib_file_name = resolve_dylib_file_name(&project_path)?;
let mut handles = Vec::new();
let build_results: Arc<Mutex<HashMap<u32, AppleBuildResult>>> =
Arc::new(Mutex::new(HashMap::new()));
let targets_single_string = target_mappings
.iter()
.map(|tm| tm.packaged_arch.to_string())
.collect::<Vec<String>>()
.join(", ")
.bold();
println!(
"{} 🧶 Compiling targets {{{}}} in {} mode using {} threads...\n",
*PAX_BADGE,
&targets_single_string,
&build_mode_name.to_string().bold(),
target_mappings.len()
);
for (index, target_mapping) in target_mappings.iter().copied().enumerate() {
let project_path = project_path.clone();
let pax_dir = pax_dir.clone();
let dylib_file_name = dylib_file_name.clone();
let process_child_ids_threadsafe = process_child_ids.clone();
let build_results_threadsafe = build_results.clone();
let mut requested_features = vec![if let RunTarget::macOS = &target {
"macos"
} else {
"ios"
}];
let pax_build_target = if matches!(target, RunTarget::iPadOS) {
"ipados"
} else {
requested_features[0]
};
if should_run_designtime {
requested_features.push("designtime");
}
let cargo_features = pax_project_feature_args(&project_path, &requested_features);
let handle = thread::spawn(move || {
let mut cmd = Command::new("cargo");
cmd.current_dir(&project_path)
.arg("build")
.arg("--color")
.arg("always")
.arg("--target")
.arg(target_mapping.rust_target)
.arg("--features")
.arg(cargo_features.join(","))
.env("PAX_DIR", &pax_dir)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped());
configure_pax_build_env(&mut cmd, pax_build_target, should_run_designtime);
if is_release {
cmd.arg("--release");
}
#[cfg(unix)]
unsafe {
cmd.pre_exec(crate::pre_exec_hook);
}
let child = cmd.spawn().expect(ERR_SPAWN);
let output = wait_with_output(&process_child_ids_threadsafe, child);
let dylib_src = project_path
.join("target")
.join(target_mapping.rust_target)
.join(build_mode_name)
.join(&dylib_file_name);
let new_val = AppleBuildResult {
packaged_arch: target_mapping.packaged_arch.to_string(),
dylib_path: dylib_src.to_str().unwrap().to_string(),
output,
kind: target_mapping.kind,
};
build_results_threadsafe
.lock()
.unwrap()
.insert(index as u32, new_val);
});
handles.push(handle);
}
for handle in handles {
handle.join().unwrap();
}
let results = build_results.lock().unwrap();
let mut should_abort = false;
for i in 0..target_mappings.len() {
let result = results.get(&(i as u32)).unwrap();
let target = &result.packaged_arch;
let output = &result.output;
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
if stdout != "" || stderr != "" {
println!("{} build finished with output:", &target);
}
if stdout != "" {
println!("{}", &stdout);
}
if stderr != "" {
eprintln!("{}", &stderr);
}
if !output.status.success() {
should_abort = true;
}
}
if should_abort {
return Err(eyre!(
"Failed to build one or more targets with Cargo. Aborting."
));
}
let result = results.iter().try_for_each(|res: (&u32, &AppleBuildResult)| {
let dylib_path = &res.1.dylib_path;
let mut cmd = Command::new("install_name_tool");
cmd
.arg("-id")
.arg(PORTABLE_DYLIB_INSTALL_NAME)
.arg(dylib_path);
#[cfg(unix)]
unsafe {
cmd.pre_exec(crate::pre_exec_hook);
}
let child = cmd.spawn().unwrap();
let output = wait_with_output(&process_child_ids, child);
if !output.status.success() {
return Err(eyre!("Failed to rewrite dynamic library (path:{}) install name with install_name_tool. Aborting.", dylib_path));
}
Ok(())
});
match result {
Err(r) => {
return Err(r);
}
_ => {}
};
let macos_framework_dir = pax_dir
.join(INTERFACE_DIR_NAME)
.join("common")
.join("pax-swift-cartridge")
.join("PaxCartridge.xcframework")
.join(MACOS_MULTIARCH_PACKAGE_ID)
.join("PaxCartridge.framework");
let macos_dylib_dest = macos_framework_dir
.join("Versions")
.join("A")
.join(PAX_CARTRIDGE_FRAMEWORK_BINARY);
let simulator_dylib_dest = pax_dir
.join(INTERFACE_DIR_NAME)
.join("common")
.join("pax-swift-cartridge")
.join("PaxCartridge.xcframework")
.join(IOS_SIMULATOR_MULTIARCH_PACKAGE_ID)
.join("PaxCartridge.framework")
.join("PaxCartridge");
let iphone_native_dylib_dest = pax_dir
.join(INTERFACE_DIR_NAME)
.join("common")
.join("pax-swift-cartridge")
.join("PaxCartridge.xcframework")
.join(IOS_PACKAGE_ID)
.join("PaxCartridge.framework")
.join("PaxCartridge");
if let RunTarget::macOS = target {
normalize_macos_framework_bundle(&macos_framework_dir)?;
}
if is_release || apple_mobile_target.is_some() {
println!(
"{} 🖇️ Packaging architecture-specific binaries...",
*PAX_BADGE
);
if let RunTarget::macOS = target {
let lipo_input_paths = results
.iter()
.filter(|res| res.1.kind == AppleTargetKind::MacOS)
.map(|res| res.1.dylib_path.clone())
.collect::<Vec<String>>();
copy_or_lipo_dylibs(
&process_child_ids,
"macOS",
&lipo_input_paths,
&macos_dylib_dest,
)?;
} else {
let simulator_builds = results
.iter()
.filter(|res| res.1.kind == AppleTargetKind::IosSimulator)
.collect::<Vec<_>>();
let device_build = results
.iter()
.filter(|res| res.1.kind == AppleTargetKind::IosDevice)
.collect::<Vec<_>>();
let simulator_input_paths = simulator_builds
.iter()
.map(|res| res.1.dylib_path.clone())
.collect::<Vec<String>>();
let device_input_paths = device_build
.iter()
.map(|res| res.1.dylib_path.clone())
.collect::<Vec<String>>();
if !simulator_input_paths.is_empty() {
copy_or_lipo_dylibs(
&process_child_ids,
"iOS simulator",
&simulator_input_paths,
&simulator_dylib_dest,
)?;
}
if !device_input_paths.is_empty() {
copy_or_lipo_dylibs(
&process_child_ids,
"iOS device",
&device_input_paths,
&iphone_native_dylib_dest,
)?;
}
}
} else {
let result = results.iter().next().unwrap();
let src = &result.1.dylib_path;
let dest = macos_dylib_dest;
let _ = fs::copy(src, dest);
}
if let RunTarget::macOS = target {
normalize_macos_framework_layout(
&pax_dir
.join(INTERFACE_DIR_NAME)
.join("common")
.join("pax-swift-cartridge")
.join("PaxCartridge.xcframework")
.join(MACOS_MULTIARCH_PACKAGE_ID)
.join("PaxCartridge.framework"),
)?;
}
let (xcodeproj_path, scheme) = if let RunTarget::macOS = target {
(
pax_dir
.join(INTERFACE_DIR_NAME)
.join("macos")
.join("pax-app-macos")
.join("pax-app-macos.xcodeproj"),
if is_release {
XCODE_MACOS_TARGET_RELEASE
} else {
XCODE_MACOS_TARGET_DEBUG
},
)
} else {
(
pax_dir
.join(INTERFACE_DIR_NAME)
.join("ios")
.join("pax-app-ios")
.join("pax-app-ios.xcodeproj"),
XCODE_IOS_TARGET,
)
};
let configuration = if is_release { "Release" } else { "Debug" };
if apple_mobile_target.is_some() && should_run_designtime {
enable_ios_designtime_local_network_access(&pax_dir)?;
}
normalize_apple_package_paths(&xcodeproj_path)?;
let build_dest_base = pax_dir
.join(BUILD_DIR_NAME)
.join(build_mode_name)
.join(target_str_lower);
let executable_output_dir_path = build_dest_base.join("app");
let executable_dot_app_path = executable_output_dir_path.join(&format!("{}.app", &scheme));
let executable_resources_bundle_path =
executable_output_dir_path.join("PaxSwiftCartridge_PaxCartridgeAssets.bundle");
let derived_data_path = build_dest_base.join("derived-data");
let source_packages_path = build_dest_base.join("source-packages");
let _ = fs::create_dir_all(&executable_output_dir_path);
let _ = fs::create_dir_all(&derived_data_path);
let _ = fs::create_dir_all(&source_packages_path);
let _ = remove_path_if_exists(&executable_resources_bundle_path);
let _ = remove_path_if_exists(&executable_dot_app_path);
let asset_dest = pax_dir
.join(INTERFACE_DIR_NAME)
.join("common")
.join("pax-swift-cartridge")
.join("Sources")
.join("PaxCartridgeAssets")
.join("Resources")
.join("assets");
fs::create_dir_all(&asset_dest)?;
for asset_src in assets_dirs {
let asset_src = PathBuf::from(asset_src);
if asset_src.exists() {
copy_dir_recursively(&asset_src, &asset_dest, &[])
.map_err(|err| eyre!("Error copying assets: {}", err))?;
}
}
let build_for_physical_device = matches!(
resolved_ios_device.as_ref().map(|device| device.kind),
Some(IosDeviceKind::Physical)
);
let sdk = if apple_mobile_target.is_some() {
if build_for_physical_device || (is_release && resolved_ios_device.is_none()) {
"iphoneos"
} else {
"iphonesimulator"
}
} else {
"macosx"
};
let xcode_archs = if apple_mobile_target.is_some() {
Some(xcode_archs_for_target_mappings(&target_mappings))
} else {
None
};
println!("{} 💻 Building xcodeproject...", *PAX_BADGE);
let mut cmd = Command::new("xcodebuild");
cmd.arg("-configuration")
.arg(configuration)
.arg("-project")
.arg(&xcodeproj_path)
.arg("-scheme")
.arg(scheme)
.arg("-sdk")
.arg(sdk)
.arg("-derivedDataPath")
.arg(&derived_data_path)
.arg("-clonedSourcePackagesDirPath")
.arg(&source_packages_path)
.arg(&format!(
"CONFIGURATION_BUILD_DIR={}",
executable_output_dir_path.to_str().unwrap()
))
.stdout(std::process::Stdio::inherit())
.stderr(std::process::Stdio::piped());
if let Some(xcode_archs) = xcode_archs.as_deref() {
cmd.arg(format!("ARCHS={xcode_archs}"));
}
if let Some(identity) = ios_app_identity.as_ref() {
cmd.arg(format!(
"PRODUCT_BUNDLE_IDENTIFIER={}",
identity.bundle_identifier
))
.arg(format!(
"INFOPLIST_KEY_CFBundleDisplayName={}",
identity.display_name
));
}
if let Some(device) = resolved_ios_device.as_ref() {
cmd.arg("-destination")
.arg(format!("id={}", device.identifier));
}
if apple_mobile_target.is_some() {
configure_ios_local_signing(&mut cmd, sdk, build_for_physical_device);
} else if !is_release {
cmd.arg("CODE_SIGNING_REQUIRED=NO")
.arg("CODE_SIGN_IDENTITY=");
}
for (key, value) in project_metadata.apple_xcode_build_settings(target) {
cmd.arg(format!("{key}={value}"));
}
let development_team = ctx
.ios_development_team
.clone()
.or_else(|| project_metadata.apple_development_team(target));
if let Some(team_id) = development_team.as_deref() {
cmd.arg(format!("DEVELOPMENT_TEAM={team_id}"));
}
if !ctx.verbose {
cmd.arg("-quiet");
cmd.arg("GCC_WARN_INHIBIT_ALL_WARNINGS=YES");
}
#[cfg(unix)]
unsafe {
cmd.pre_exec(crate::pre_exec_hook);
}
let child = cmd.spawn().expect(ERR_SPAWN);
let output = wait_with_output(&process_child_ids, child);
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
if ctx.verbose {
println!("{}", stderr);
} else {
let mut skip_lines = 0;
for line in stderr.lines() {
if line.starts_with("Details: createItemModels") {
skip_lines = 5; }
if skip_lines > 0 {
skip_lines -= 1;
continue;
}
println!("{}", line);
}
}
if !output.status.success() {
if build_for_physical_device && stderr.contains("Developer Mode disabled") {
let device_name = resolved_ios_device
.as_ref()
.map(|device| device.name.as_str())
.unwrap_or("the selected device");
return Err(eyre!(
"Xcode can see `{}`, but Developer Mode is disabled. Enable Developer Mode on the phone, restart it if prompted, reconnect/trust it, and run again.",
device_name
));
}
return Err(eyre!("Failed to build project with xcodebuild. Aborting."));
}
let swift_cart_src = pax_dir
.join(INTERFACE_DIR_NAME)
.join("common")
.join("pax-swift-cartridge");
let swift_common_src = pax_dir
.join(INTERFACE_DIR_NAME)
.join("common")
.join("pax-swift-common");
let swift_cart_build_dest = build_dest_base.join("common").join("pax-swift-cartridge");
let swift_common_build_dest = build_dest_base.join("common").join("pax-swift-common");
let (app_xcodeproj_src, app_xcodeproj_build_dest) = if let RunTarget::macOS = target {
(
pax_dir
.join(INTERFACE_DIR_NAME)
.join("macos")
.join("pax-app-macos"),
build_dest_base.join("pax-app-macos"),
)
} else {
(
pax_dir
.join(INTERFACE_DIR_NAME)
.join("ios")
.join("pax-app-ios"),
build_dest_base.join("pax-app-ios"),
)
};
let _ = fs::create_dir_all(&swift_cart_build_dest);
let _ = fs::create_dir_all(&swift_common_build_dest);
let _ = fs::create_dir_all(&app_xcodeproj_build_dest);
let _ = copy_dir_recursively(
&swift_cart_src,
&swift_cart_build_dest,
&DIR_IGNORE_LIST_MACOS,
);
let _ = copy_dir_recursively(
&swift_common_src,
&swift_common_build_dest,
&DIR_IGNORE_LIST_MACOS,
);
let _ = copy_dir_recursively(
&app_xcodeproj_src,
&app_xcodeproj_build_dest,
&DIR_IGNORE_LIST_MACOS,
);
let target_str: &str = target.into();
if ctx.should_also_run {
if ctx.should_run_designtime {
println!(
"{} 🐇 Running Pax {} with designtime...",
*PAX_BADGE, target_str,
);
} else {
println!("{} 🐇 Running Pax {}...", *PAX_BADGE, target_str);
}
if let RunTarget::macOS = target {
let mut dev_session = if ctx.should_run_designtime {
Some(prepare_dev_session(&project_path, &pax_dir)?)
} else {
None
};
let mut designtime_server = if let Some(session) = dev_session.as_mut() {
let ready_file = session
.session_dir
.as_ref()
.unwrap()
.join("design-server-url.txt");
let server = Some(spawn_designtime_server_process(
&pax_dir,
&project_path,
&manifest,
Some(session.session_dir.as_ref().unwrap()),
ctx.hot_reload.unwrap_or_default(),
&ready_file,
process_child_ids.clone(),
)?);
session.design_server_addr = Some(wait_for_designtime_ready(
&ready_file,
Duration::from_secs(5),
)?);
server
} else {
None
};
if let Some(session) = dev_session.as_mut() {
session.location = Some("local-window".to_string());
session.last_seen_ms = now_ms();
write_project_active_session(&pax_dir, session)?;
write_registered_session(session)?;
}
let mut cmd = Command::new("open");
cmd.arg("-W").arg("-n");
if let Some(session) = &dev_session {
add_launchservices_environment(
&mut cmd,
"PAX_DEV_SESSION_DIR",
session.session_dir.as_ref().unwrap().to_str().unwrap(),
);
add_launchservices_environment(
&mut cmd,
"PAX_DEV_REGISTRY_FILE",
dev_session::global_session_registry_file(&session.session_id)?
.to_str()
.unwrap(),
);
add_launchservices_environment(
&mut cmd,
"PAX_DEV_PROJECT_ROOT",
session.project_root.as_ref().unwrap().to_str().unwrap(),
);
add_launchservices_environment(
&mut cmd,
"PAX_DESIGN_SERVER_ADDR",
session.design_server_addr.as_ref().unwrap(),
);
}
cmd.arg(&executable_dot_app_path);
#[cfg(unix)]
unsafe {
cmd.pre_exec(crate::pre_exec_hook);
}
let mut child = cmd.spawn().expect("failed to launch the app bundle");
let open_pid = child.id() as u64;
process_child_ids.lock().unwrap().push(open_pid);
let launched_app_pid = if let Some(session) = dev_session.as_mut() {
let launched_app_pid = wait_for_registered_session_app_pid(
&session.session_id,
Duration::from_secs(5),
)?;
if let Some(app_pid) = launched_app_pid {
session.app_pid = Some(app_pid);
session.location = Some(format!("local-window pid:{app_pid}"));
session.last_seen_ms = now_ms();
write_project_active_session(&pax_dir, session)?;
process_child_ids.lock().unwrap().push(app_pid as u64);
}
launched_app_pid
} else {
None
};
if launched_app_pid.is_some() {
lifecycle_notifier.notify();
}
let status = child.wait().expect("failed to wait for the app");
process_child_ids
.lock()
.unwrap()
.retain(|&id| id != open_pid);
process_child_ids
.lock()
.unwrap()
.retain(|&id| launched_app_pid.map(|pid| pid as u64) != Some(id));
if let Some(session) = &dev_session {
cleanup_dev_session(&pax_dir, session)?;
}
if let Some(server) = designtime_server.as_mut() {
stop_designtime_server_process(server, &process_child_ids);
}
println!("App exited with: {:?}", status);
} else {
let device = resolved_ios_device
.as_ref()
.ok_or_else(|| eyre!("Missing resolved iOS target device."))?;
let app_identity = ios_app_identity
.as_ref()
.ok_or_else(|| eyre!("Missing resolved iOS app identity."))?;
let mut designtime_server = None;
let mut designtime_run_dir = None;
let run_result = (|| -> Result<(), eyre::Report> {
let (design_server_addr, app_ready_marker) = if ctx.should_run_designtime {
let run_dir = project_dev_dir(&pax_dir).join("sessions").join(format!(
"{}-templates-{}-{}",
target_str_lower,
now_ms(),
std::process::id()
));
let ready_file = run_dir.join("design-server-url.txt");
fs::create_dir_all(&run_dir)?;
let app_ready_marker =
crate::design_server::prepare_app_ready_marker(&ready_file);
designtime_run_dir = Some(run_dir);
designtime_server = Some(spawn_designtime_server_process(
&pax_dir,
&project_path,
&manifest,
None,
ctx.hot_reload.unwrap_or_default(),
&ready_file,
process_child_ids.clone(),
)?);
let loopback_addr =
wait_for_designtime_ready(&ready_file, Duration::from_secs(5))?;
let app_addr = mobile_design_server_address(device.kind, &loopback_addr)?;
if ctx.hot_reload.unwrap_or_default().reloads_pax() {
println!(
"{} 📡 Pax hot reload available at {}. Logic changes require rebuilding the app.",
*PAX_BADGE,
app_addr.bold()
);
} else {
println!(
"{} 📡 Hot reload is off; the designtime server remains available at {} for inspection.",
*PAX_BADGE,
app_addr.bold()
);
}
(Some(app_addr), app_ready_marker)
} else {
(None, None)
};
match device.kind {
IosDeviceKind::Simulator => run_on_simulator(
&device.identifier,
&device.name,
&executable_output_dir_path,
&executable_dot_app_path,
&app_identity.bundle_identifier,
design_server_addr.as_deref(),
app_ready_marker.as_deref(),
&lifecycle_notifier,
&process_child_ids,
),
IosDeviceKind::Physical => run_on_physical_device(
&device.identifier,
&device.name,
&executable_output_dir_path,
&executable_dot_app_path,
&app_identity.bundle_identifier,
design_server_addr.as_deref(),
app_ready_marker.as_deref(),
&lifecycle_notifier,
&process_child_ids,
),
}?;
Ok(())
})();
if let Some(server) = designtime_server.as_mut() {
stop_designtime_server_process(server, &process_child_ids);
}
if let Some(run_dir) = designtime_run_dir {
let _ = fs::remove_dir_all(run_dir);
}
run_result?;
}
} else {
let build_path = executable_output_dir_path.to_str().unwrap().bold();
println!(
"{} 🗂️ Done: {} {} build available at {}",
*PAX_BADGE, target_str, build_mode_name, build_path
);
}
Ok(())
}
fn resolve_ios_device(
selector: Option<&str>,
apple_mobile_target: AppleMobileTarget,
process_child_ids: &Arc<Mutex<Vec<u64>>>,
) -> Result<ResolvedIosDevice, eyre::Report> {
let selector = selector.map(str::trim).filter(|value| !value.is_empty());
let (kind_hint, query) = parse_ios_device_selector(selector);
match query {
None => match kind_hint.unwrap_or(IosDeviceKind::Simulator) {
IosDeviceKind::Simulator => {
choose_best_simulator(apple_mobile_target, process_child_ids)
}
IosDeviceKind::Physical => choose_single_physical_device(process_child_ids),
},
Some(query) => resolve_named_ios_device(kind_hint, &query, process_child_ids),
}
}
fn parse_ios_device_selector(selector: Option<&str>) -> (Option<IosDeviceKind>, Option<String>) {
match selector {
None => (Some(IosDeviceKind::Simulator), None),
Some("simulator") => (Some(IosDeviceKind::Simulator), None),
Some("device") => (Some(IosDeviceKind::Physical), None),
Some(value) => {
if let Some(query) = value.strip_prefix("simulator:") {
(
Some(IosDeviceKind::Simulator),
Some(query.trim().to_string()),
)
} else if let Some(query) = value.strip_prefix("device:") {
(
Some(IosDeviceKind::Physical),
Some(query.trim().to_string()),
)
} else {
(None, Some(value.to_string()))
}
}
}
}
fn choose_best_simulator(
apple_mobile_target: AppleMobileTarget,
process_child_ids: &Arc<Mutex<Vec<u64>>>,
) -> Result<ResolvedIosDevice, eyre::Report> {
let simulators = list_available_ios_simulators(process_child_ids)?;
let Some(simulator) = best_simulator_for_target(&simulators, apple_mobile_target) else {
let simulator_label = match apple_mobile_target {
AppleMobileTarget::Phone => "iPhone",
AppleMobileTarget::Tablet => "iPad",
};
return Err(eyre!(
"No installed {} simulators found on this system. Install at least one {} simulator through Xcode and try again.",
simulator_label,
simulator_label,
));
};
Ok(ResolvedIosDevice {
kind: IosDeviceKind::Simulator,
name: simulator.name.clone(),
identifier: simulator.udid.clone(),
})
}
fn choose_single_physical_device(
process_child_ids: &Arc<Mutex<Vec<u64>>>,
) -> Result<ResolvedIosDevice, eyre::Report> {
let devices = list_connected_physical_devices(process_child_ids)?;
match devices.as_slice() {
[] => Err(eyre!(
"No connected iOS devices found. Attach and trust a phone, or pass --ios-device=simulator."
)),
[device] => Ok(ResolvedIosDevice {
kind: IosDeviceKind::Physical,
name: device.name.clone(),
identifier: device.identifier.clone(),
}),
_ => Err(eyre!(
"Multiple physical iOS devices are connected: {}. Pass --ios-device=device:<name-or-udid> to choose one.",
devices
.iter()
.map(|device| format!("{} ({})", device.name, device.identifier))
.collect::<Vec<_>>()
.join(", ")
)),
}
}
fn resolve_named_ios_device(
kind_hint: Option<IosDeviceKind>,
query: &str,
process_child_ids: &Arc<Mutex<Vec<u64>>>,
) -> Result<ResolvedIosDevice, eyre::Report> {
let mut candidates = Vec::new();
if kind_hint != Some(IosDeviceKind::Physical) {
candidates.extend(
list_available_ios_simulators(process_child_ids)?
.into_iter()
.map(|simulator| ResolvedIosDevice {
kind: IosDeviceKind::Simulator,
name: simulator.name,
identifier: simulator.udid,
}),
);
}
if kind_hint != Some(IosDeviceKind::Simulator) {
candidates.extend(
list_connected_physical_devices(process_child_ids)?
.into_iter()
.map(|device| ResolvedIosDevice {
kind: IosDeviceKind::Physical,
name: device.name,
identifier: device.identifier,
}),
);
}
match_ios_device_query(query, candidates)
}
fn match_ios_device_query(
query: &str,
candidates: Vec<ResolvedIosDevice>,
) -> Result<ResolvedIosDevice, eyre::Report> {
if candidates.is_empty() {
return Err(eyre!("No matching iOS destinations are available."));
}
let query_lower = query.to_ascii_lowercase();
for match_mode in [
MatchMode::IdentifierExact,
MatchMode::NameExact,
MatchMode::NameContains,
] {
let matches = candidates
.iter()
.filter(|candidate| match_mode.matches(candidate, &query_lower))
.cloned()
.collect::<Vec<_>>();
match matches.as_slice() {
[] => continue,
[device] => return Ok(device.clone()),
_ => {
return Err(eyre!(
"Ambiguous iOS destination `{}`. Matches: {}",
query,
matches
.iter()
.map(|device| format!("{} ({})", device.name, device.identifier))
.collect::<Vec<_>>()
.join(", ")
));
}
}
}
Err(eyre!(
"No iOS destination matched `{}`. Available destinations: {}",
query,
candidates
.iter()
.map(|device| format!("{} ({})", device.name, device.identifier))
.collect::<Vec<_>>()
.join(", ")
))
}
fn list_available_ios_simulators(
process_child_ids: &Arc<Mutex<Vec<u64>>>,
) -> Result<Vec<SimulatorDevice>, eyre::Report> {
let mut cmd = Command::new("xcrun");
cmd.arg("simctl")
.arg("list")
.arg("-j")
.arg("devices")
.arg("available")
.stdout(std::process::Stdio::piped());
#[cfg(unix)]
unsafe {
cmd.pre_exec(crate::pre_exec_hook);
}
let child = cmd.spawn().expect(ERR_SPAWN);
let output = wait_with_output(process_child_ids, child);
let output_str = std::str::from_utf8(&output.stdout)
.map_err(|_| eyre!("Failed to parse stdout for xcrun simctl list devices"))?;
let parsed: Value = serde_json::from_str(output_str)
.map_err(|_| eyre!("Failed to deserialize xcrun simctl list devices JSON."))?;
let devices = parsed["devices"]
.as_object()
.ok_or_else(|| eyre!("Invalid JSON format for simulator devices."))?;
let mut simulators = Vec::new();
for device_list in devices.values() {
let Some(device_array) = device_list.as_array() else {
continue;
};
for device in device_array {
let Some(name) = device["name"].as_str() else {
continue;
};
let Some(udid) = device["udid"].as_str() else {
continue;
};
let Some(state) = device["state"].as_str() else {
continue;
};
simulators.push(SimulatorDevice {
name: name.to_string(),
udid: udid.to_string(),
state: state.to_string(),
});
}
}
Ok(simulators)
}
fn list_connected_physical_devices(
process_child_ids: &Arc<Mutex<Vec<u64>>>,
) -> Result<Vec<PhysicalDevice>, eyre::Report> {
if let Ok(devices) = list_connected_physical_devices_via_xcdevice(process_child_ids) {
if !devices.is_empty() {
return Ok(devices);
}
}
list_connected_physical_devices_via_xctrace(process_child_ids)
}
fn list_connected_physical_devices_via_xcdevice(
process_child_ids: &Arc<Mutex<Vec<u64>>>,
) -> Result<Vec<PhysicalDevice>, eyre::Report> {
let mut cmd = Command::new("xcrun");
cmd.arg("xcdevice")
.arg("list")
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped());
#[cfg(unix)]
unsafe {
cmd.pre_exec(crate::pre_exec_hook);
}
let child = cmd.spawn().expect(ERR_SPAWN);
let output = wait_with_output(process_child_ids, child);
if !output.status.success() {
return Err(eyre!(
"Failed to list connected iOS devices with xcrun xcdevice list."
));
}
let output_str = std::str::from_utf8(&output.stdout)
.map_err(|_| eyre!("Failed to parse stdout for xcrun xcdevice list"))?;
let parsed: Value = serde_json::from_str(output_str)
.map_err(|err| eyre!("Failed to parse xcrun xcdevice list JSON: {}", err))?;
let devices = parsed
.as_array()
.ok_or_else(|| eyre!("Unexpected xcdevice list output format"))?
.iter()
.filter(|device| device.get("simulator").and_then(Value::as_bool) == Some(false))
.filter(|device| {
device.get("platform").and_then(Value::as_str) == Some("com.apple.platform.iphoneos")
})
.filter(|device| {
device
.get("available")
.and_then(Value::as_bool)
.unwrap_or(false)
})
.filter_map(|device| {
let name = device.get("name").and_then(Value::as_str)?;
let identifier = device.get("identifier").and_then(Value::as_str)?;
Some(PhysicalDevice {
name: name.to_string(),
identifier: identifier.to_string(),
})
})
.collect::<Vec<_>>();
Ok(devices)
}
fn list_connected_physical_devices_via_xctrace(
process_child_ids: &Arc<Mutex<Vec<u64>>>,
) -> Result<Vec<PhysicalDevice>, eyre::Report> {
let mut cmd = Command::new("xcrun");
cmd.arg("xctrace")
.arg("list")
.arg("devices")
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped());
#[cfg(unix)]
unsafe {
cmd.pre_exec(crate::pre_exec_hook);
}
let child = cmd.spawn().expect(ERR_SPAWN);
let output = wait_with_output(process_child_ids, child);
if !output.status.success() {
return Err(eyre!(
"Failed to list connected iOS devices with xcrun xctrace."
));
}
let output_str = std::str::from_utf8(&output.stdout)
.map_err(|_| eyre!("Failed to parse stdout for xcrun xctrace list devices"))?;
let mut in_devices_section = false;
let mut devices = Vec::new();
for line in output_str.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
if line.starts_with("== ") && line.ends_with(" ==") {
in_devices_section = line == "== Devices ==";
continue;
}
if !in_devices_section {
continue;
}
let Some((name, identifier)) = parse_named_identifier_line(line) else {
continue;
};
devices.push(PhysicalDevice { name, identifier });
}
Ok(devices)
}
fn parse_named_identifier_line(line: &str) -> Option<(String, String)> {
let id_start = line.rfind(" (")?;
let id_end = line.strip_suffix(')')?;
let identifier = &id_end[id_start + 2..];
let prefix = &id_end[..id_start];
if !prefix.ends_with(')') {
return None;
}
let version_start = prefix.rfind(" (")?;
let version = &prefix[version_start + 2..prefix.len() - 1];
if !version.chars().any(|ch| ch.is_ascii_digit()) {
return None;
}
let name = prefix[..version_start].trim();
if name.is_empty() {
return None;
}
Some((name.to_string(), identifier.to_string()))
}
fn enable_ios_designtime_local_network_access(pax_dir: &Path) -> Result<(), eyre::Report> {
let info_plist = pax_dir
.join(INTERFACE_DIR_NAME)
.join("ios")
.join("pax-app-ios")
.join("pax-app-ios")
.join("Info.plist");
let contents = fs::read_to_string(&info_plist).map_err(|err| {
eyre!(
"failed to read the iOS Info.plist at {} before enabling designtime local-network access: {err}",
info_plist.display()
)
})?;
let updated = ios_info_plist_with_local_network_usage_description(&contents)?;
if updated != contents {
fs::write(&info_plist, updated).map_err(|err| {
eyre!(
"failed to update the iOS Info.plist at {} for designtime local-network access: {err}",
info_plist.display()
)
})?;
}
Ok(())
}
fn ios_info_plist_with_local_network_usage_description(
contents: &str,
) -> Result<String, eyre::Report> {
if contents.contains("<key>NSLocalNetworkUsageDescription</key>") {
return Ok(contents.to_string());
}
let insertion_point = contents.rfind("</dict>").ok_or_else(|| {
eyre!("the iOS Info.plist has no root dictionary to receive NSLocalNetworkUsageDescription")
})?;
let mut updated = contents.to_string();
updated.insert_str(
insertion_point,
&format!(
"\t<key>NSLocalNetworkUsageDescription</key>\n\t<string>{IOS_LOCAL_NETWORK_USAGE_DESCRIPTION}</string>\n"
),
);
Ok(updated)
}
fn mobile_design_server_address(
device_kind: IosDeviceKind,
loopback_addr: &str,
) -> Result<String, eyre::Report> {
match device_kind {
IosDeviceKind::Simulator => Ok(loopback_addr.to_string()),
IosDeviceKind::Physical => {
let host = physical_device_design_server_host()?;
design_server_address_with_host(loopback_addr, &host)
}
}
}
fn physical_device_design_server_host() -> Result<String, eyre::Report> {
if let Ok(host) = std::env::var(DESIGN_SERVER_ADVERTISE_HOST_ENV) {
return normalize_design_server_host(&host);
}
crate::design_server::local_network_ip()
.map(url_host_for_ip)
.ok_or_else(|| {
eyre!(
"could not determine a LAN address for iOS designtime; connect the Mac and device to the same network or set {DESIGN_SERVER_ADVERTISE_HOST_ENV} to the Mac's reachable host name or IP address"
)
})
}
fn normalize_design_server_host(host: &str) -> Result<String, eyre::Report> {
let host = host.trim();
if host.is_empty() {
return Err(eyre!(
"{DESIGN_SERVER_ADVERTISE_HOST_ENV} must name a host, not an empty string"
));
}
if host.contains("://") || host.contains('/') {
return Err(eyre!(
"{DESIGN_SERVER_ADVERTISE_HOST_ENV} must contain only a host name or IP address, without a URL scheme, path, or port"
));
}
if host.starts_with('[') && host.ends_with(']') {
host[1..host.len() - 1].parse::<Ipv6Addr>().map_err(|_| {
eyre!("invalid bracketed IPv6 address in {DESIGN_SERVER_ADVERTISE_HOST_ENV}")
})?;
return Ok(host.to_string());
}
if let Ok(ipv6) = host.parse::<Ipv6Addr>() {
return Ok(format!("[{ipv6}]"));
}
if host.contains(':') {
return Err(eyre!(
"{DESIGN_SERVER_ADVERTISE_HOST_ENV} accepts a host only; the design server port is selected automatically"
));
}
Ok(host.to_string())
}
fn url_host_for_ip(ip: IpAddr) -> String {
match ip {
IpAddr::V4(ip) => ip.to_string(),
IpAddr::V6(ip) => format!("[{ip}]"),
}
}
fn design_server_address_with_host(
loopback_addr: &str,
advertised_host: &str,
) -> Result<String, eyre::Report> {
let (scheme, authority_and_path) = loopback_addr
.split_once("://")
.ok_or_else(|| eyre!("invalid design-server ready address: {loopback_addr}"))?;
if !matches!(scheme, "ws" | "wss") {
return Err(eyre!(
"unsupported design-server ready address scheme in {loopback_addr}"
));
}
let authority = authority_and_path.split('/').next().unwrap_or_default();
let port = authority
.rsplit_once(':')
.and_then(|(_, port)| port.parse::<u16>().ok())
.ok_or_else(|| eyre!("design-server ready address has no valid port: {loopback_addr}"))?;
Ok(format!("{scheme}://{advertised_host}:{port}"))
}
fn add_launchservices_environment(command: &mut Command, key: &str, value: &str) {
command.arg("--env").arg(format!("{key}={value}"));
}
fn simulator_launch_command(
device_udid: &str,
bundle_identifier: &str,
design_server_addr: Option<&str>,
) -> Command {
let mut cmd = Command::new("xcrun");
cmd.arg("simctl")
.arg("launch")
.arg("--console")
.arg("--terminate-running-process")
.arg(device_udid)
.arg(bundle_identifier)
.stdout(std::process::Stdio::inherit())
.stderr(std::process::Stdio::inherit());
if let Some(design_server_addr) = design_server_addr {
cmd.env("SIMCTL_CHILD_PAX_DESIGN_SERVER_ADDR", design_server_addr);
} else {
cmd.env_remove("SIMCTL_CHILD_PAX_DESIGN_SERVER_ADDR");
}
cmd
}
fn physical_device_launch_command(
device_identifier: &str,
bundle_identifier: &str,
design_server_addr: Option<&str>,
) -> Command {
let mut cmd = Command::new("xcrun");
cmd.arg("devicectl")
.arg("device")
.arg("process")
.arg("launch")
.arg("--device")
.arg(device_identifier)
.arg("--console")
.arg("--terminate-existing")
.arg(bundle_identifier)
.stdout(std::process::Stdio::inherit())
.stderr(std::process::Stdio::inherit());
if let Some(design_server_addr) = design_server_addr {
cmd.env("DEVICECTL_CHILD_PAX_DESIGN_SERVER_ADDR", design_server_addr);
} else {
cmd.env_remove("DEVICECTL_CHILD_PAX_DESIGN_SERVER_ADDR");
}
cmd
}
fn run_on_simulator(
device_udid: &str,
device_name: &str,
executable_output_dir_path: &PathBuf,
executable_dot_app_path: &PathBuf,
bundle_identifier: &str,
design_server_addr: Option<&str>,
app_ready_marker: Option<&Path>,
lifecycle_notifier: &RunLifecycleNotifier,
process_child_ids: &Arc<Mutex<Vec<u64>>>,
) -> Result<(), eyre::Report> {
let mut cmd = Command::new("open");
cmd.arg("-a")
.arg("Simulator")
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped());
#[cfg(unix)]
unsafe {
cmd.pre_exec(crate::pre_exec_hook);
}
let child = cmd.spawn().expect(ERR_SPAWN);
let output = wait_with_output(process_child_ids, child);
if !output.status.success() {
return Err(eyre!("Error opening iOS simulator. Aborting."));
}
let mut cmd = Command::new("xcrun");
cmd.arg("simctl")
.arg("boot")
.arg(device_udid)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped());
#[cfg(unix)]
unsafe {
cmd.pre_exec(crate::pre_exec_hook);
}
let child = cmd.spawn().expect(ERR_SPAWN);
let _ = wait_with_output(process_child_ids, child);
let mut cmd = Command::new("xcrun");
cmd.arg("simctl")
.arg("spawn")
.arg(device_udid)
.arg("launchctl")
.arg("print")
.arg("system")
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::inherit());
#[cfg(unix)]
unsafe {
cmd.pre_exec(crate::pre_exec_hook);
}
let child = cmd.spawn().expect(ERR_SPAWN);
let output = wait_with_output(process_child_ids, child);
if !output.status.success() {
return Err(eyre!("Error spawning iOS simulator. Aborting."));
}
let max_retries = 5;
let retry_period_secs = 5;
let mut retries = 0;
while !is_simulator_booted(device_udid, process_child_ids) && retries < max_retries {
println!("{} 💤 Waiting for simulator to boot...", *PAX_BADGE);
std::thread::sleep(std::time::Duration::from_secs(retry_period_secs));
retries += 1;
}
if retries == max_retries {
return Err(eyre!(
"Failed to boot the simulator within the expected time. Aborting."
));
}
println!(
"{} 📤 Installing and running app from {} on simulator {}...",
*PAX_BADGE,
executable_output_dir_path.to_str().unwrap(),
device_name
);
let mut cmd = Command::new("xcrun");
cmd.arg("simctl")
.arg("install")
.arg(device_udid)
.arg(executable_dot_app_path)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped());
#[cfg(unix)]
unsafe {
cmd.pre_exec(crate::pre_exec_hook);
}
let child = cmd.spawn().expect(ERR_SPAWN);
let output = wait_with_output(process_child_ids, child);
if !output.status.success() {
return Err(eyre!("Error installing app on iOS simulator. Aborting."));
}
let mut cmd = simulator_launch_command(device_udid, bundle_identifier, design_server_addr);
#[cfg(unix)]
unsafe {
cmd.pre_exec(crate::pre_exec_hook);
}
let child = cmd.spawn().expect(ERR_SPAWN);
let _app_ready_watcher = app_ready_marker.and_then(|marker| {
let lifecycle_notifier = lifecycle_notifier.clone();
watch_app_ready_marker(marker, MOBILE_APP_READY_TIMEOUT, move || {
lifecycle_notifier.notify()
})
});
let output = wait_with_output(process_child_ids, child);
if !output.status.success() {
return Err(eyre!("Error launching app on iOS simulator. Aborting."));
}
println!(
"{} 🚀 App launched on simulator {}.",
*PAX_BADGE, device_name
);
Ok(())
}
fn run_on_physical_device(
device_identifier: &str,
device_name: &str,
executable_output_dir_path: &PathBuf,
executable_dot_app_path: &PathBuf,
bundle_identifier: &str,
design_server_addr: Option<&str>,
app_ready_marker: Option<&Path>,
lifecycle_notifier: &RunLifecycleNotifier,
process_child_ids: &Arc<Mutex<Vec<u64>>>,
) -> Result<(), eyre::Report> {
println!(
"{} 📤 Installing and running app from {} on device {}...",
*PAX_BADGE,
executable_output_dir_path.to_str().unwrap(),
device_name
);
let mut cmd = Command::new("xcrun");
cmd.arg("devicectl")
.arg("device")
.arg("install")
.arg("app")
.arg("--device")
.arg(device_identifier)
.arg(executable_dot_app_path)
.stdout(std::process::Stdio::inherit())
.stderr(std::process::Stdio::inherit());
#[cfg(unix)]
unsafe {
cmd.pre_exec(crate::pre_exec_hook);
}
let child = cmd.spawn().expect(ERR_SPAWN);
let output = wait_with_output(process_child_ids, child);
if !output.status.success() {
return Err(eyre!(
"Error installing app on physical iOS device. Aborting."
));
}
let mut cmd =
physical_device_launch_command(device_identifier, bundle_identifier, design_server_addr);
#[cfg(unix)]
unsafe {
cmd.pre_exec(crate::pre_exec_hook);
}
let child = cmd.spawn().expect(ERR_SPAWN);
let _app_ready_watcher = app_ready_marker.and_then(|marker| {
let lifecycle_notifier = lifecycle_notifier.clone();
watch_app_ready_marker(marker, MOBILE_APP_READY_TIMEOUT, move || {
lifecycle_notifier.notify()
})
});
let output = wait_with_output(process_child_ids, child);
if !output.status.success() {
return Err(eyre!(
"Error launching app on physical iOS device. Aborting."
));
}
println!("{} 🚀 App launched on device {}.", *PAX_BADGE, device_name);
Ok(())
}
enum MatchMode {
IdentifierExact,
NameExact,
NameContains,
}
impl MatchMode {
fn matches(&self, candidate: &ResolvedIosDevice, query: &str) -> bool {
let identifier = candidate.identifier.to_ascii_lowercase();
let name = candidate.name.to_ascii_lowercase();
match self {
MatchMode::IdentifierExact => identifier == query,
MatchMode::NameExact => name == query,
MatchMode::NameContains => name.contains(query),
}
}
}
fn resolve_dylib_file_name(project_path: &PathBuf) -> Result<String, eyre::Report> {
let manifest_path = project_path.join("Cargo.toml");
let metadata = MetadataCommand::new()
.manifest_path(&manifest_path)
.no_deps()
.exec()
.map_err(|err| {
eyre!(
"Failed to read cargo metadata for {:?}: {}",
manifest_path,
err
)
})?;
let root_package = metadata.root_package().ok_or_else(|| {
eyre!(
"Failed to determine root package for Apple build at {:?}",
manifest_path
)
})?;
let dylib_target = root_package
.targets
.iter()
.find(|target| {
target.kind.iter().any(|kind| kind == "cdylib")
|| target.crate_types.iter().any(|kind| kind == "cdylib")
})
.ok_or_else(|| {
eyre!(
"No cdylib target found for Apple build in {:?}",
manifest_path
)
})?;
Ok(format!("lib{}.dylib", dylib_target.name.replace('-', "_")))
}
fn configure_ios_local_signing(cmd: &mut Command, sdk: &str, selected_physical_device: bool) {
if sdk == "iphonesimulator" {
cmd.arg("CODE_SIGNING_ALLOWED=NO")
.arg("CODE_SIGNING_REQUIRED=NO")
.arg("CODE_SIGN_IDENTITY=");
} else {
cmd.arg("CODE_SIGN_IDENTITY=Apple Development")
.arg("CODE_SIGN_STYLE=Automatic")
.arg("-allowProvisioningUpdates");
if selected_physical_device {
cmd.arg("-allowProvisioningDeviceRegistration");
} else {
cmd.arg("-destination").arg("generic/platform=iOS");
}
}
}
fn resolve_ios_app_identity(
project_path: &Path,
project_metadata: &PaxProjectMetadata,
target: &RunTarget,
) -> Result<IosAppIdentity, eyre::Report> {
let package_name = read_cargo_package_name(project_path)?;
let project_identity_path = canonical_project_identity_path(project_path);
let project_hash = stable_hex_hash(&format!(
"pax-ios-app-v1\0{}\0{}",
package_name,
project_identity_path.display()
));
let package_segment = bundle_identifier_segment(&package_name);
Ok(IosAppIdentity {
bundle_identifier: project_metadata
.apple_bundle_identifier(target)
.unwrap_or_else(|| format!("dev.pax.{}.{}", package_segment, project_hash)),
display_name: ios_display_name(&package_name),
})
}
fn read_cargo_package_name(project_path: &Path) -> Result<String, eyre::Report> {
let manifest_path = project_path.join("Cargo.toml");
let document = fs::read_to_string(&manifest_path)
.map_err(|err| {
eyre!(
"Failed to read Cargo manifest at {:?}: {}",
manifest_path,
err
)
})?
.parse::<toml_edit::Document>()
.map_err(|err| {
eyre!(
"Failed to parse Cargo manifest at {:?}: {}",
manifest_path,
err
)
})?;
document
.get("package")
.and_then(|package| package.get("name"))
.and_then(|name| name.as_str())
.map(str::to_string)
.ok_or_else(|| {
eyre!(
"Cargo manifest at {:?} is missing package.name",
manifest_path
)
})
}
fn canonical_project_identity_path(project_path: &Path) -> PathBuf {
fs::canonicalize(project_path).unwrap_or_else(|_| {
if project_path.is_absolute() {
project_path.to_path_buf()
} else {
std::env::current_dir()
.map(|cwd| cwd.join(project_path))
.unwrap_or_else(|_| project_path.to_path_buf())
}
})
}
fn bundle_identifier_segment(value: &str) -> String {
let mut output = String::new();
let mut previous_was_separator = false;
for ch in value.chars() {
if ch.is_ascii_alphanumeric() {
output.push(ch.to_ascii_lowercase());
previous_was_separator = false;
} else if !previous_was_separator {
output.push('-');
previous_was_separator = true;
}
}
let trimmed = output.trim_matches('-');
if trimmed.is_empty() {
"project".to_string()
} else {
trimmed.to_string()
}
}
fn ios_display_name(package_name: &str) -> String {
let words = package_name
.split(|ch| ch == '-' || ch == '_')
.filter(|word| !word.is_empty())
.map(|word| {
let mut chars = word.chars();
match chars.next() {
Some(first) => {
let mut normalized = first.to_ascii_uppercase().to_string();
normalized.push_str(chars.as_str());
normalized
}
None => String::new(),
}
})
.collect::<Vec<_>>();
if words.is_empty() {
"Pax App".to_string()
} else {
words.join(" ")
}
}
fn stable_hex_hash(value: &str) -> String {
let mut hash = 0xcbf29ce484222325u64;
for byte in value.as_bytes() {
hash ^= u64::from(*byte);
hash = hash.wrapping_mul(0x100000001b3);
}
format!("{hash:016x}")
}
fn best_simulator_for_target<'a>(
simulators: &'a [SimulatorDevice],
apple_mobile_target: AppleMobileTarget,
) -> Option<&'a SimulatorDevice> {
match apple_mobile_target {
AppleMobileTarget::Phone => best_phone_simulator(simulators),
AppleMobileTarget::Tablet => best_tablet_simulator(simulators),
}
}
fn best_phone_simulator(simulators: &[SimulatorDevice]) -> Option<&SimulatorDevice> {
let mut best_choice: Option<(i32, SimulatorVariantRank, bool, &SimulatorDevice)> = None;
for simulator in simulators {
let Some((generation, variant_rank)) = parse_iphone_simulator_preference(&simulator.name)
else {
continue;
};
let candidate = (
generation,
variant_rank,
simulator.state == "Booted",
simulator,
);
if best_choice
.as_ref()
.map(|best| {
candidate.0 > best.0
|| (candidate.0 == best.0
&& (candidate.1 > best.1
|| (candidate.1 == best.1 && candidate.2 && !best.2)))
})
.unwrap_or(true)
{
best_choice = Some(candidate);
}
}
best_choice.map(|(_, _, _, simulator)| simulator)
}
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
enum TabletVariantRank {
Other = 0,
Mini = 1,
Base = 2,
Air = 3,
Pro = 4,
}
fn best_tablet_simulator(simulators: &[SimulatorDevice]) -> Option<&SimulatorDevice> {
let mut best_choice: Option<(TabletVariantRank, i32, bool, &SimulatorDevice)> = None;
for simulator in simulators {
let Some((variant_rank, revision)) = parse_ipad_simulator_preference(&simulator.name)
else {
continue;
};
let candidate = (
variant_rank,
revision,
simulator.state == "Booted",
simulator,
);
if best_choice
.as_ref()
.map(|best| {
candidate.0 > best.0
|| (candidate.0 == best.0
&& (candidate.1 > best.1
|| (candidate.1 == best.1 && candidate.2 && !best.2)))
})
.unwrap_or(true)
{
best_choice = Some(candidate);
}
}
best_choice.map(|(_, _, _, simulator)| simulator)
}
pub fn rebuild_staged_macos_logic_dylib(
project_root: &PathBuf,
session_dir: &Path,
) -> Result<MacosLogicReloadBuild, eyre::Report> {
let process_child_ids = Arc::new(Mutex::new(vec![]));
let ctx = RunContext {
target: RunTarget::macOS,
project_path: project_root.clone(),
verbose: false,
should_also_run: false,
is_libdev_mode: false,
process_child_ids: process_child_ids.clone(),
should_run_designtime: true,
hot_reload: None,
is_release: false,
profile_wasm_size: false,
ios_device: None,
ios_development_team: None,
lifecycle_observer: None,
};
let prepared = prepare_cartridge_sources(&ctx)?;
let target_mapping =
select_apple_target_mappings(&RunTarget::macOS, false, None, std::env::consts::ARCH)
.into_iter()
.next()
.ok_or_else(|| eyre!("no macOS target mapping available for staged logic reload"))?;
let dylib_file_name = resolve_dylib_file_name(project_root)?;
let requested_features = vec!["macos", "designtime"];
let cargo_features = pax_project_feature_args(project_root, &requested_features);
let mut cmd = Command::new("cargo");
cmd.current_dir(project_root)
.arg("build")
.arg("--color")
.arg("always")
.arg("--target")
.arg(target_mapping.rust_target)
.arg("--features")
.arg(cargo_features.join(","))
.env("PAX_DIR", &prepared.pax_dir)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped());
configure_pax_build_env(&mut cmd, "macos", true);
#[cfg(unix)]
unsafe {
cmd.pre_exec(crate::pre_exec_hook);
}
let child = cmd.spawn().expect(ERR_SPAWN);
let output = wait_with_output(&process_child_ids, child);
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
if !stdout.trim().is_empty() {
println!("{}", stdout);
}
if !stderr.trim().is_empty() {
eprintln!("{}", stderr);
}
if !output.status.success() {
return Err(eyre!(
"failed to rebuild the macOS logic dylib for hot reload"
));
}
let dylib_src = project_root
.join("target")
.join(target_mapping.rust_target)
.join("debug")
.join(&dylib_file_name);
if !dylib_src.exists() {
return Err(eyre!(
"logic reload build succeeded but dylib was missing at {:?}",
dylib_src
));
}
let staged_dir = session_dir.join("logic-modules");
fs::create_dir_all(&staged_dir)?;
let build_id = now_ms().to_string();
let staged_dylib_path = staged_dir.join(format!(
"{}-{}.dylib",
dylib_file_name.trim_end_matches(".dylib"),
build_id
));
fs::copy(&dylib_src, &staged_dylib_path)?;
let mut install_name_cmd = Command::new("install_name_tool");
install_name_cmd
.arg("-id")
.arg(&staged_dylib_path)
.arg(&staged_dylib_path)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped());
#[cfg(unix)]
unsafe {
install_name_cmd.pre_exec(crate::pre_exec_hook);
}
let child = install_name_cmd.spawn().expect(ERR_SPAWN);
let output = wait_with_output(&process_child_ids, child);
if !output.status.success() {
return Err(eyre!(
"failed to rewrite the staged macOS logic dylib install name"
));
}
Ok(MacosLogicReloadBuild {
manifest: prepared.userland_manifest,
build_id,
dylib_path: staged_dylib_path,
})
}
fn parse_iphone_simulator_preference(name: &str) -> Option<(i32, SimulatorVariantRank)> {
let rest = name.strip_prefix("iPhone ")?;
let generation_end = rest.find(|ch: char| !ch.is_ascii_digit())?;
let generation = rest[..generation_end].parse::<i32>().ok()?;
let variant = rest[generation_end..].trim();
let rank = match variant {
"Pro" => SimulatorVariantRank::Pro,
"Pro Max" => SimulatorVariantRank::ProMax,
"" => SimulatorVariantRank::Base,
_ => SimulatorVariantRank::Other,
};
Some((generation, rank))
}
fn parse_ipad_simulator_preference(name: &str) -> Option<(TabletVariantRank, i32)> {
if !name.starts_with("iPad") {
return None;
}
let rank = if name.starts_with("iPad Pro") {
TabletVariantRank::Pro
} else if name.starts_with("iPad Air") {
TabletVariantRank::Air
} else if name.starts_with("iPad mini") {
TabletVariantRank::Mini
} else if name.starts_with("iPad ") {
TabletVariantRank::Base
} else {
TabletVariantRank::Other
};
extract_last_numeric_token(name).map(|revision| (rank, revision))
}
fn extract_last_numeric_token(input: &str) -> Option<i32> {
let mut last_token = None;
let mut current_token = String::new();
for ch in input.chars() {
if ch.is_ascii_digit() {
current_token.push(ch);
} else if !current_token.is_empty() {
last_token = current_token.parse::<i32>().ok();
current_token.clear();
}
}
if !current_token.is_empty() {
last_token = current_token.parse::<i32>().ok();
}
last_token
}
fn normalize_apple_package_paths(xcodeproj_path: &PathBuf) -> Result<(), eyre::Report> {
let project_file = xcodeproj_path.join("project.pbxproj");
let contents = fs::read_to_string(&project_file)?;
let normalized = contents
.replace(
"../../interface/common/pax-swift-cartridge",
"../../common/pax-swift-cartridge",
)
.replace(
"../../interface/common/pax-swift-common",
"../../common/pax-swift-common",
);
if normalized != contents {
fs::write(project_file, normalized)?;
}
Ok(())
}
fn normalize_macos_framework_layout(framework_dir: &PathBuf) -> Result<(), eyre::Report> {
let versions_dir = framework_dir.join("Versions");
let current_link = versions_dir.join("Current");
if current_link.exists() {
return Ok(());
}
let version_dir = versions_dir.join("A");
let resources_dir = version_dir.join("Resources");
fs::create_dir_all(&resources_dir)?;
move_if_exists(&framework_dir.join("Headers"), &version_dir.join("Headers"))?;
move_if_exists(&framework_dir.join("Modules"), &version_dir.join("Modules"))?;
move_if_exists(
&framework_dir.join("Info.plist"),
&resources_dir.join("Info.plist"),
)?;
move_if_exists(
&framework_dir.join("PaxCartridge"),
&version_dir.join("PaxCartridge"),
)?;
create_symlink(&PathBuf::from("A"), ¤t_link)?;
create_symlink(
&PathBuf::from("Versions/Current/Headers"),
&framework_dir.join("Headers"),
)?;
create_symlink(
&PathBuf::from("Versions/Current/Modules"),
&framework_dir.join("Modules"),
)?;
create_symlink(
&PathBuf::from("Versions/Current/Resources"),
&framework_dir.join("Resources"),
)?;
create_symlink(
&PathBuf::from("Versions/Current/PaxCartridge"),
&framework_dir.join("PaxCartridge"),
)?;
Ok(())
}
fn move_if_exists(src: &PathBuf, dest: &PathBuf) -> Result<(), eyre::Report> {
if !src.exists() {
return Ok(());
}
if let Some(parent) = dest.parent() {
fs::create_dir_all(parent)?;
}
if dest.exists() {
if dest.is_dir() {
fs::remove_dir_all(dest)?;
} else {
fs::remove_file(dest)?;
}
}
fs::rename(src, dest)?;
Ok(())
}
fn create_symlink(target: &PathBuf, link: &PathBuf) -> Result<(), eyre::Report> {
if link.exists() || link.symlink_metadata().is_ok() {
if link.is_dir() && !link.is_symlink() {
fs::remove_dir_all(link)?;
} else {
fs::remove_file(link)?;
}
}
#[cfg(unix)]
unix_fs::symlink(target, link)?;
Ok(())
}
fn prepare_dev_session(
project_root: &PathBuf,
pax_dir: &PathBuf,
) -> Result<DevSession, eyre::Report> {
let started_at_ms = now_ms();
let session_id = format!("macos-{started_at_ms}-{}", std::process::id());
let session_dir = project_dev_dir(pax_dir).join("sessions").join(&session_id);
fs::create_dir_all(session_dir.join("requests"))?;
fs::create_dir_all(session_dir.join("responses"))?;
fs::create_dir_all(session_dir.join("captures"))?;
Ok(DevSession {
session_id,
platform: "macos".to_string(),
designtime: true,
project_root: Some(fs::canonicalize(project_root).unwrap_or_else(|_| project_root.clone())),
session_dir: Some(session_dir),
app_pid: None,
design_server_addr: None,
control_kind: "filesystem".to_string(),
location: Some("local-window".to_string()),
started_at_ms,
last_seen_ms: started_at_ms,
})
}
fn cleanup_dev_session(pax_dir: &PathBuf, session: &DevSession) -> Result<(), eyre::Report> {
dev_session::remove_project_active_session(pax_dir, &session.session_id)?;
dev_session::remove_registered_session(&session.session_id)?;
Ok(())
}
fn wait_for_registered_session_app_pid(
session_id: &str,
timeout: Duration,
) -> Result<Option<u32>, eyre::Report> {
let registry_file = dev_session::global_session_registry_file(session_id)?;
let start = std::time::Instant::now();
while start.elapsed() < timeout {
if let Ok(registry_data) = fs::read(®istry_file) {
if let Ok(session) = serde_json::from_slice::<DevSession>(®istry_data) {
if session.app_pid.is_some() {
return Ok(session.app_pid);
}
}
}
thread::sleep(Duration::from_millis(50));
}
Ok(None)
}
fn spawn_designtime_server_process(
pax_dir: &PathBuf,
project_root: &PathBuf,
manifest: &PaxManifest,
native_session_dir: Option<&Path>,
hot_reload: HotReloadMode,
ready_file: &PathBuf,
process_child_ids: Arc<Mutex<Vec<u64>>>,
) -> Result<Child, eyre::Report> {
let manifest_path = project_designtime_manifest_file(pax_dir);
fs::create_dir_all(manifest_path.parent().unwrap())?;
fs::write(&manifest_path, serde_json::to_vec(manifest)?)?;
let current_exe = std::env::current_exe()?;
let mut cmd = designtime_server_command(
¤t_exe,
pax_dir,
project_root,
&manifest_path,
native_session_dir,
hot_reload,
ready_file,
);
#[cfg(unix)]
unsafe {
cmd.pre_exec(crate::pre_exec_hook);
}
let child = cmd.spawn().expect(ERR_SPAWN);
process_child_ids.lock().unwrap().push(child.id() as u64);
Ok(child)
}
fn designtime_server_command(
executable: &Path,
pax_dir: &Path,
project_root: &Path,
manifest_path: &Path,
native_session_dir: Option<&Path>,
hot_reload: HotReloadMode,
ready_file: &Path,
) -> Command {
let mut cmd = Command::new(executable);
cmd.arg("designtime-server")
.arg("--serve-dir")
.arg(pax_dir)
.arg("--watch-dir")
.arg(project_root)
.arg("--manifest-path")
.arg(manifest_path)
.arg("--hot-reload")
.arg(hot_reload.as_str())
.arg("--port")
.arg("0")
.arg("--ready-file")
.arg(ready_file)
.arg("--suppress-address-log")
.stdout(std::process::Stdio::inherit())
.stderr(std::process::Stdio::inherit());
if let Some(native_session_dir) = native_session_dir {
cmd.arg("--macos-session-dir").arg(native_session_dir);
}
cmd
}
fn stop_designtime_server_process(server: &mut Child, process_child_ids: &Arc<Mutex<Vec<u64>>>) {
let server_pid = server.id() as u64;
let _ = server.kill();
let _ = server.wait();
process_child_ids
.lock()
.unwrap()
.retain(|&id| id != server_pid);
}
fn wait_for_designtime_ready(
ready_file: &PathBuf,
timeout: Duration,
) -> Result<String, eyre::Report> {
let start = std::time::Instant::now();
while start.elapsed() < timeout {
if let Ok(contents) = fs::read_to_string(ready_file) {
let trimmed = contents.trim();
if !trimmed.is_empty() {
return Ok(trimmed.to_string());
}
}
thread::sleep(Duration::from_millis(50));
}
Err(eyre!(
"timed out waiting for the local designtime server to become ready"
))
}
fn is_simulator_booted(device_udid: &str, process_child_ids: &Arc<Mutex<Vec<u64>>>) -> bool {
let mut cmd = Command::new("xcrun");
cmd.arg("simctl")
.arg("list")
.arg("devices")
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped());
#[cfg(unix)]
unsafe {
cmd.pre_exec(crate::pre_exec_hook);
}
let child = cmd.spawn().expect(ERR_SPAWN);
let output = wait_with_output(&process_child_ids, child);
if !output.status.success() {
panic!("Error checking simulator status. This is an unhandled error and may leave orphaned processes.");
}
let output_str = String::from_utf8(output.stdout).expect("Failed to convert to string");
output_str
.lines()
.any(|line| line.contains(device_udid) && line.contains("Booted"))
}
#[cfg(test)]
mod tests {
use super::*;
use std::ffi::OsStr;
const MACOS_HOST_SWIFT: &str =
include_str!("../../files/interfaces/macos/pax-app-macos/pax-app-macos/PaxViewMacos.swift");
fn resolved_ios_device(kind: IosDeviceKind) -> ResolvedIosDevice {
ResolvedIosDevice {
kind,
name: "Test Device".to_string(),
identifier: "TEST-DEVICE".to_string(),
}
}
fn rust_targets(target_mappings: &[AppleTargetMapping]) -> Vec<&'static str> {
target_mappings
.iter()
.map(|target_mapping| target_mapping.rust_target)
.collect()
}
fn simulator(name: &str, state: &str) -> SimulatorDevice {
SimulatorDevice {
name: name.to_string(),
udid: name.to_string(),
state: state.to_string(),
}
}
fn cargo_project(package_name: &str) -> tempfile::TempDir {
let dir = tempfile::tempdir().expect("tempdir should be created");
std::fs::write(
dir.path().join("Cargo.toml"),
format!(
r#"
[package]
name = "{package_name}"
version = "0.1.0"
edition = "2021"
"#
),
)
.expect("Cargo.toml should be written");
dir
}
fn command_args(command: &Command) -> Vec<String> {
command
.get_args()
.map(|arg| arg.to_string_lossy().into_owned())
.collect()
}
fn command_env(command: &Command, key: &str) -> Option<Option<String>> {
command
.get_envs()
.find(|(candidate, _)| *candidate == OsStr::new(key))
.map(|(_, value)| value.map(|value| value.to_string_lossy().into_owned()))
}
#[test]
fn app_ready_marker_watcher_notifies_after_marker_creation() {
let dir = tempfile::tempdir().unwrap();
let marker = dir.path().join("app-ready");
let (ready, observed) = mpsc::channel();
let watcher = watch_app_ready_marker(&marker, Duration::from_secs(1), move || {
let _ = ready.send(());
})
.unwrap();
fs::write(&marker, b"").unwrap();
observed.recv_timeout(Duration::from_secs(1)).unwrap();
drop(watcher);
}
#[test]
fn app_ready_marker_watcher_timeout_is_non_fatal() {
let dir = tempfile::tempdir().unwrap();
let marker = dir.path().join("app-ready");
let (ready, observed) = mpsc::channel();
let watcher = watch_app_ready_marker(&marker, Duration::from_millis(20), move || {
let _ = ready.send(());
})
.unwrap();
assert!(observed.recv_timeout(Duration::from_millis(100)).is_err());
drop(watcher);
}
#[test]
fn dropping_app_ready_marker_watcher_cancels_notification() {
let dir = tempfile::tempdir().unwrap();
let marker = dir.path().join("app-ready");
let (ready, observed) = mpsc::channel();
let watcher = watch_app_ready_marker(&marker, Duration::from_secs(1), move || {
let _ = ready.send(());
})
.unwrap();
drop(watcher);
fs::write(&marker, b"").unwrap();
assert!(observed.recv_timeout(Duration::from_millis(50)).is_err());
}
#[test]
fn ios_app_identity_is_stable_for_same_project() {
let project = cargo_project("scroll-garden");
let metadata = PaxProjectMetadata::default();
let first = resolve_ios_app_identity(project.path(), &metadata, &RunTarget::iOS)
.expect("identity should resolve");
let second = resolve_ios_app_identity(project.path(), &metadata, &RunTarget::iOS)
.expect("identity should resolve");
assert_eq!(first.bundle_identifier, second.bundle_identifier);
assert_eq!(first.display_name, "Scroll Garden");
assert!(first
.bundle_identifier
.starts_with("dev.pax.scroll-garden."));
assert_eq!(
first.bundle_identifier.rsplit('.').next().unwrap().len(),
16
);
}
#[test]
fn ios_app_identity_distinguishes_projects_with_same_package_name() {
let first_project = cargo_project("starter-project");
let second_project = cargo_project("starter-project");
let metadata = PaxProjectMetadata::default();
let first = resolve_ios_app_identity(first_project.path(), &metadata, &RunTarget::iOS)
.expect("identity should resolve");
let second = resolve_ios_app_identity(second_project.path(), &metadata, &RunTarget::iOS)
.expect("identity should resolve");
assert_ne!(first.bundle_identifier, second.bundle_identifier);
assert_eq!(first.display_name, second.display_name);
}
#[test]
fn ios_bundle_identifier_segment_sanitizes_for_xcode() {
assert_eq!(
bundle_identifier_segment("Fancy_Project!!"),
"fancy-project"
);
assert_eq!(bundle_identifier_segment("---"), "project");
}
#[test]
fn mobile_launch_identity_matches_metadata_xcode_identity() {
let project = cargo_project("custom-id");
let manifest = project.path().join("Cargo.toml");
let original = fs::read_to_string(&manifest).unwrap();
fs::write(
&manifest,
format!("{original}\n[package.metadata.pax.ios]\nbundle_identifier = 'dev.example.custom'\n"),
).unwrap();
let metadata = crate::project_metadata::load_project_metadata(project.path()).unwrap();
for target in [RunTarget::iOS, RunTarget::iPadOS] {
let identity = resolve_ios_app_identity(project.path(), &metadata, &target).unwrap();
assert_eq!(identity.bundle_identifier, "dev.example.custom");
assert!(metadata.apple_xcode_build_settings(&target).contains(&(
"PRODUCT_BUNDLE_IDENTIFIER".into(),
identity.bundle_identifier.clone(),
)));
let command =
physical_device_launch_command("TEST-DEVICE", &identity.bundle_identifier, None);
assert!(command_args(&command).contains(&identity.bundle_identifier));
assert_eq!(
command_env(&command, "DEVICECTL_CHILD_PAX_DESIGN_SERVER_ADDR"),
Some(None),
);
}
}
#[test]
fn mobile_release_architecture_follows_selected_destination() {
for target in [RunTarget::iOS, RunTarget::iPadOS] {
for (kind, expected) in [
(IosDeviceKind::Physical, "aarch64-apple-ios"),
(IosDeviceKind::Simulator, "aarch64-apple-ios-sim"),
] {
let device = resolved_ios_device(kind);
let mappings = select_apple_target_mappings(
&target, true, Some(&device), "aarch64",
);
assert_eq!(rust_targets(&mappings), vec![expected]);
}
}
}
#[test]
fn mobile_local_signing_separates_simulator_from_device_not_debug_from_release() {
let mut simulator = Command::new("xcodebuild");
configure_ios_local_signing(&mut simulator, "iphonesimulator", false);
assert_eq!(
command_args(&simulator),
vec!["CODE_SIGNING_ALLOWED=NO", "CODE_SIGNING_REQUIRED=NO", "CODE_SIGN_IDENTITY="],
);
for selected in [true, false] {
let mut device = Command::new("xcodebuild");
configure_ios_local_signing(&mut device, "iphoneos", selected);
let args = command_args(&device);
assert!(args.contains(&"CODE_SIGN_IDENTITY=Apple Development".into()));
assert!(args.contains(&"CODE_SIGN_STYLE=Automatic".into()));
assert!(args.contains(&"-allowProvisioningUpdates".into()));
assert_eq!(
args.contains(&"-allowProvisioningDeviceRegistration".into()), selected,
);
assert_eq!(args.contains(&"generic/platform=iOS".into()), !selected);
assert!(!args.iter().any(|arg| arg == "archive" || arg == "-exportArchive"));
}
}
#[test]
fn mobile_template_shared_scheme_supports_release_configuration() {
let template = &crate::helpers::PAX_IOS_INTERFACE_TEMPLATE;
let project = template
.get_file("pax-app-ios/pax-app-ios.xcodeproj/project.pbxproj")
.unwrap()
.contents_utf8()
.unwrap();
assert!(project.contains(XCODE_IOS_TARGET));
assert!(project.contains("name = Release;"));
assert!(!project.contains("Don't Code Sign"));
}
#[test]
fn ios_debug_without_destination_builds_host_simulator_arch() {
let target_mappings = select_apple_target_mappings(&RunTarget::iOS, false, None, "aarch64");
assert_eq!(
rust_targets(&target_mappings),
vec!["aarch64-apple-ios-sim"]
);
assert_eq!(xcode_archs_for_target_mappings(&target_mappings), "arm64");
}
#[test]
fn ios_debug_for_physical_device_builds_device_arch_only() {
let device = resolved_ios_device(IosDeviceKind::Physical);
let target_mappings =
select_apple_target_mappings(&RunTarget::iOS, false, Some(&device), "aarch64");
assert_eq!(rust_targets(&target_mappings), vec!["aarch64-apple-ios"]);
assert_eq!(xcode_archs_for_target_mappings(&target_mappings), "arm64");
}
#[test]
fn ios_release_without_destination_builds_device_arch_only() {
let target_mappings = select_apple_target_mappings(&RunTarget::iOS, true, None, "aarch64");
assert_eq!(rust_targets(&target_mappings), vec!["aarch64-apple-ios"]);
assert_eq!(xcode_archs_for_target_mappings(&target_mappings), "arm64");
}
#[test]
fn ios_simulator_on_unknown_host_keeps_multiarch_fallback() {
let target_mappings = select_apple_target_mappings(&RunTarget::iOS, false, None, "mystery");
assert_eq!(
rust_targets(&target_mappings),
vec!["aarch64-apple-ios-sim", "x86_64-apple-ios"]
);
assert_eq!(
xcode_archs_for_target_mappings(&target_mappings),
"arm64 x86_64"
);
}
#[test]
fn macos_release_keeps_multiarch_build() {
let target_mappings =
select_apple_target_mappings(&RunTarget::macOS, true, None, "aarch64");
assert_eq!(
rust_targets(&target_mappings),
vec!["aarch64-apple-darwin", "x86_64-apple-darwin"]
);
}
#[test]
fn ipados_target_builds_same_simulator_arch_as_ios() {
let target_mappings =
select_apple_target_mappings(&RunTarget::iPadOS, false, None, "aarch64");
assert_eq!(
rust_targets(&target_mappings),
vec!["aarch64-apple-ios-sim"]
);
}
#[test]
fn ipados_default_prefers_ipad_simulators_over_booted_iphones() {
let simulators = vec![
simulator("iPhone 17 Pro", "Booted"),
simulator("iPad Pro 13-inch (M5)", "Shutdown"),
simulator("iPad Air 11-inch (M4)", "Shutdown"),
];
let best = best_simulator_for_target(&simulators, AppleMobileTarget::Tablet)
.expect("expected an iPad simulator");
assert_eq!(best.name, "iPad Pro 13-inch (M5)");
}
#[test]
fn ipados_default_prefers_latest_base_ipad_over_older_models() {
let simulators = vec![
simulator("iPad (10th generation)", "Shutdown"),
simulator("iPad (A16)", "Shutdown"),
simulator("iPad (9th generation)", "Shutdown"),
];
let best = best_simulator_for_target(&simulators, AppleMobileTarget::Tablet)
.expect("expected an iPad simulator");
assert_eq!(best.name, "iPad (A16)");
}
#[test]
fn macos_launchservices_environment_is_forwarded_with_open_env_arguments() {
let mut command = Command::new("open");
add_launchservices_environment(&mut command, "PAX_DEV_SESSION_DIR", "/tmp/Pax Session");
let args = command_args(&command);
assert_eq!(
args,
vec![
"--env".to_string(),
"PAX_DEV_SESSION_DIR=/tmp/Pax Session".to_string()
]
);
assert_eq!(command_env(&command, "PAX_DEV_SESSION_DIR"), None);
}
#[test]
fn macos_debug_host_probes_revision_hooks_but_requires_them_for_reload() {
assert!(MACOS_HOST_SWIFT.contains("let activateAppRevision = resolveOptionalSymbol("));
assert!(MACOS_HOST_SWIFT.contains("let primeAppRevision = resolveOptionalSymbol("));
assert!(MACOS_HOST_SWIFT.contains(
"Reload candidate is not designtime-enabled; missing required revision hook(s):"
));
}
#[test]
fn simulator_designtime_launch_is_attached_and_injects_server_address() {
let command = simulator_launch_command(
"SIMULATOR-UDID",
"dev.pax.example",
Some("ws://127.0.0.1:43111"),
);
let args = command_args(&command);
assert!(args.windows(2).any(|args| args == ["simctl", "launch"]));
assert!(args.contains(&"--console".to_string()));
assert!(args.contains(&"--terminate-running-process".to_string()));
assert_eq!(
command_env(&command, "SIMCTL_CHILD_PAX_DESIGN_SERVER_ADDR"),
Some(Some("ws://127.0.0.1:43111".to_string()))
);
}
#[test]
fn physical_designtime_launch_injects_reachable_server_address() {
let command = physical_device_launch_command(
"PHYSICAL-UDID",
"dev.pax.example",
Some("ws://192.168.1.25:43111"),
);
let args = command_args(&command);
assert!(args.contains(&"--console".to_string()));
assert!(args.contains(&"--terminate-existing".to_string()));
assert_eq!(
command_env(&command, "DEVICECTL_CHILD_PAX_DESIGN_SERVER_ADDR"),
Some(Some("ws://192.168.1.25:43111".to_string()))
);
}
#[test]
fn launch_without_designtime_removes_inherited_server_address() {
let simulator = simulator_launch_command("SIM", "dev.pax.example", None);
let physical = physical_device_launch_command("PHONE", "dev.pax.example", None);
assert_eq!(
command_env(&simulator, "SIMCTL_CHILD_PAX_DESIGN_SERVER_ADDR"),
Some(None)
);
assert_eq!(
command_env(&physical, "DEVICECTL_CHILD_PAX_DESIGN_SERVER_ADDR"),
Some(None)
);
}
#[test]
fn mobile_template_server_omits_native_logic_reload_session() {
let command = designtime_server_command(
Path::new("/tmp/pax-cli"),
Path::new("/tmp/.pax"),
Path::new("/tmp/project"),
Path::new("/tmp/manifest.json"),
None,
HotReloadMode::Pax,
Path::new("/tmp/ready.txt"),
);
assert!(!command_args(&command).contains(&"--macos-session-dir".to_string()));
}
#[test]
fn macos_template_server_keeps_native_logic_reload_session() {
let command = designtime_server_command(
Path::new("/tmp/pax-cli"),
Path::new("/tmp/.pax"),
Path::new("/tmp/project"),
Path::new("/tmp/manifest.json"),
Some(Path::new("/tmp/session")),
HotReloadMode::Logic,
Path::new("/tmp/ready.txt"),
);
let args = command_args(&command);
let session_arg = args
.iter()
.position(|arg| arg == "--macos-session-dir")
.expect("macOS server command should include its native session");
assert_eq!(
args.get(session_arg + 1).map(String::as_str),
Some("/tmp/session")
);
let hot_reload_arg = args
.iter()
.position(|arg| arg == "--hot-reload")
.expect("server command should forward its hot-reload policy");
assert_eq!(
args.get(hot_reload_arg + 1).map(String::as_str),
Some("logic")
);
}
#[test]
fn physical_server_address_preserves_ready_port_and_formats_ipv6() {
assert_eq!(
design_server_address_with_host("ws://127.0.0.1:43111", "192.168.1.25").unwrap(),
"ws://192.168.1.25:43111"
);
assert_eq!(
normalize_design_server_host("fe80::1").unwrap(),
"[fe80::1]"
);
assert_eq!(
design_server_address_with_host(
"ws://127.0.0.1:43111",
&normalize_design_server_host("fe80::1").unwrap()
)
.unwrap(),
"ws://[fe80::1]:43111"
);
}
#[test]
fn designtime_local_network_description_is_inserted_once() {
let source = "<plist>\n<dict>\n\t<key>CFBundleName</key>\n\t<string>Pax</string>\n</dict>\n</plist>\n";
let updated = ios_info_plist_with_local_network_usage_description(source).unwrap();
let repeated = ios_info_plist_with_local_network_usage_description(&updated).unwrap();
assert!(updated.contains("<key>NSLocalNetworkUsageDescription</key>"));
assert!(updated.contains(IOS_LOCAL_NETWORK_USAGE_DESCRIPTION));
assert_eq!(updated, repeated);
}
}