diff --git a/app/Cargo.toml b/app/Cargo.toml index 0400ac0..ddfc4b4 100644 --- a/app/Cargo.toml +++ b/app/Cargo.toml @@ -22,6 +22,9 @@ clap = { version = "4", features = ["derive"] } # Web server axum = { version = "0.8", features = ["ws"] } tokio = { version = "1", features = ["full"] } +tokio-stream = "0.1" +futures = "0.3" +async-stream = "0.3" # Serialization serde = { version = "1", features = ["derive"] } diff --git a/app/src/routes/mod.rs b/app/src/routes/mod.rs index e9e3c20..461f781 100644 --- a/app/src/routes/mod.rs +++ b/app/src/routes/mod.rs @@ -17,7 +17,13 @@ pub fn create_routes(frontend_dir: Option<&str>) -> Router> { .route("/about", get(health::about)) .route("/projects/validate", post(projects::validate_project)) .route("/projects/recent", get(projects::get_recent_projects)) - .route("/xcode/discover", post(xcode::discover_project)); + .route("/xcode/discover", post(xcode::discover_project)) + .route("/xcode/build", post(xcode::build_scheme)) + .route("/xcode/build/stream", post(xcode::build_scheme_stream)) + .route( + "/xcode/launchable-products", + post(xcode::get_launchable_products), + ); let router = Router::new().nest("/api", api_routes); diff --git a/app/src/routes/xcode.rs b/app/src/routes/xcode.rs index 1bece27..a56b8d9 100644 --- a/app/src/routes/xcode.rs +++ b/app/src/routes/xcode.rs @@ -1,5 +1,13 @@ use crate::xcode; -use axum::{http::StatusCode, response::IntoResponse, Json}; +use axum::{ + http::StatusCode, + response::{ + sse::{Event, KeepAlive, Sse}, + IntoResponse, + }, + Json, +}; +use futures::stream::StreamExt; use serde::Deserialize; use serde_json::json; use std::path::Path; @@ -9,14 +17,51 @@ pub struct DiscoverProjectRequest { pub path: String, } +#[derive(Debug, Deserialize)] +pub struct BuildSchemeRequest { + pub path: String, + pub scheme: String, +} + +#[derive(Debug, Deserialize)] +pub struct GetLaunchableProductsRequest { + pub build_dir: String, +} + /// Discover Xcode project information (schemes, targets, configurations) pub async fn discover_project(Json(request): Json) -> impl IntoResponse { let path = Path::new(&request.path); match xcode::discover_project(path).await { - Ok(project) => { - (StatusCode::OK, Json(serde_json::to_value(project).unwrap())).into_response() - } + Ok(project) => match serde_json::to_value(project) { + Ok(json) => (StatusCode::OK, Json(json)).into_response(), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": format!("Failed to serialize response: {}", e) })), + ) + .into_response(), + }, + Err(error) => ( + StatusCode::BAD_REQUEST, + Json(json!({ "error": error.to_string() })), + ) + .into_response(), + } +} + +/// Build an Xcode scheme for iOS Simulator with code signing disabled +pub async fn build_scheme(Json(request): Json) -> impl IntoResponse { + let path = Path::new(&request.path); + + match xcode::build_scheme(path, &request.scheme).await { + Ok(result) => match serde_json::to_value(result) { + Ok(json) => (StatusCode::OK, Json(json)).into_response(), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": format!("Failed to serialize response: {}", e) })), + ) + .into_response(), + }, Err(error) => ( StatusCode::BAD_REQUEST, Json(json!({ "error": error.to_string() })), @@ -24,3 +69,61 @@ pub async fn discover_project(Json(request): Json) -> im .into_response(), } } + +/// Get launchable products from a build directory +pub async fn get_launchable_products( + Json(request): Json, +) -> impl IntoResponse { + match xcode::get_launchable_products_from_dir(&request.build_dir).await { + Ok(products) => match serde_json::to_value(products) { + Ok(json) => (StatusCode::OK, Json(json)).into_response(), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": format!("Failed to serialize response: {}", e) })), + ) + .into_response(), + }, + Err(error) => ( + StatusCode::BAD_REQUEST, + Json(json!({ "error": error.to_string() })), + ) + .into_response(), + } +} + +/// Stream build output via Server-Sent Events +pub async fn build_scheme_stream( + Json(request): Json, +) -> Result< + Sse>>, + (StatusCode, Json), +> { + let path = Path::new(&request.path); + + let event_stream = match xcode::build_scheme_stream(path, &request.scheme).await { + Ok(stream) => stream, + Err(error) => { + return Err(( + StatusCode::BAD_REQUEST, + Json(json!({ "error": error.to_string() })), + )); + } + }; + + let sse_stream = event_stream.map(|result| match result { + Ok(event) => { + let json_data = serde_json::to_string(&event).unwrap_or_else(|e| { + tracing::error!("Failed to serialize build event: {}", e); + json!({"type": "error", "message": "Failed to serialize event"}).to_string() + }); + Ok(Event::default().data(json_data)) + } + Err(e) => { + tracing::error!("Build stream error: {}", e); + let error_json = json!({"type": "error", "message": "Stream error"}).to_string(); + Ok(Event::default().data(error_json)) + } + }); + + Ok(Sse::new(sse_stream).keep_alive(KeepAlive::default())) +} diff --git a/app/src/xcode/build.rs b/app/src/xcode/build.rs new file mode 100644 index 0000000..c7f1bf4 --- /dev/null +++ b/app/src/xcode/build.rs @@ -0,0 +1,405 @@ +use crate::services::projects; +use futures::stream::Stream; +use serde::{Deserialize, Serialize}; +use std::path::{Path, PathBuf}; +use std::process::Stdio; +use tokio::io::{AsyncBufReadExt, BufReader}; +use tokio::process::Command; + +#[derive(Debug, thiserror::Error)] +pub enum BuildError { + #[error("No Xcode project found at path")] + ProjectNotFound, + + #[error("Not an Xcode project: {0:?}")] + NotXcodeProject(projects::ProjectType), + + #[error("Failed to execute xcodebuild: {0}")] + XcodebuildExecution(#[from] std::io::Error), + + #[error("xcodebuild failed: {0}")] + XcodebuildFailed(String), + + #[error("Failed to parse build output: {0}")] + ParseError(String), + + #[error("Scheme not found: {0}")] + SchemeNotFound(String), + + #[error("No build products found")] + NoBuildProducts, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct BuildResult { + pub success: bool, + pub build_dir: String, + pub products: Vec, + pub stdout: String, + pub stderr: String, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct BuildProduct { + pub name: String, + pub path: String, +} + +/// Build an Xcode scheme for iOS Simulator with code signing disabled +pub async fn build_scheme(project_path: &Path, scheme: &str) -> Result { + let project = projects::detect_project(project_path).ok_or(BuildError::ProjectNotFound)?; + + if !matches!(project.project_type, projects::ProjectType::Xcode) { + return Err(BuildError::NotXcodeProject(project.project_type)); + } + + let is_workspace = project.path.ends_with(".xcworkspace"); + + let mut cmd = Command::new("xcodebuild"); + + if is_workspace { + cmd.arg("-workspace").arg(&project.path); + } else { + cmd.arg("-project").arg(&project.path); + } + + cmd.arg("-scheme") + .arg(scheme) + .arg("-configuration") + .arg("Debug") + .arg("-sdk") + .arg("iphonesimulator") + .arg("-destination") + .arg("generic/platform=iOS Simulator") + .arg("CODE_SIGN_IDENTITY=") + .arg("CODE_SIGNING_REQUIRED=NO") + .arg("CODE_SIGNING_ALLOWED=NO") + .arg("-showBuildSettings"); + + let output = cmd.output().await?; + + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + + if !output.status.success() { + return Ok(BuildResult { + success: false, + build_dir: String::new(), + products: vec![], + stdout, + stderr, + }); + } + + // Extract build directory from build settings + let build_dir = extract_build_dir_from_settings(&stdout) + .ok_or_else(|| BuildError::ParseError("Could not find build directory".to_string()))?; + + // Now run the actual build + let mut build_cmd = Command::new("xcodebuild"); + + if is_workspace { + build_cmd.arg("-workspace").arg(&project.path); + } else { + build_cmd.arg("-project").arg(&project.path); + } + + build_cmd + .arg("-scheme") + .arg(scheme) + .arg("-configuration") + .arg("Debug") + .arg("-sdk") + .arg("iphonesimulator") + .arg("-destination") + .arg("generic/platform=iOS Simulator") + .arg("CODE_SIGN_IDENTITY=") + .arg("CODE_SIGNING_REQUIRED=NO") + .arg("CODE_SIGNING_ALLOWED=NO"); + + let build_output = build_cmd.output().await?; + let build_stdout = String::from_utf8_lossy(&build_output.stdout).to_string(); + let build_stderr = String::from_utf8_lossy(&build_output.stderr).to_string(); + + if !build_output.status.success() { + return Ok(BuildResult { + success: false, + build_dir: String::new(), + products: vec![], + stdout: build_stdout, + stderr: build_stderr, + }); + } + + let products = find_build_products(&build_dir).await?; + + Ok(BuildResult { + success: true, + build_dir, + products, + stdout: build_stdout, + stderr: build_stderr, + }) +} + +/// Extract the build directory from xcodebuild -showBuildSettings output +fn extract_build_dir_from_settings(output: &str) -> Option { + // Look for CONFIGURATION_BUILD_DIR or BUILD_DIR + for line in output.lines() { + let trimmed = line.trim(); + if let Some(value) = trimmed.strip_prefix("CONFIGURATION_BUILD_DIR = ") { + return Some(value.to_string()); + } + } + None +} + +async fn find_build_products(build_dir: &str) -> Result, BuildError> { + let path = PathBuf::from(build_dir); + + if !path.exists() { + return Ok(vec![]); + } + + let mut products = Vec::new(); + let mut entries = tokio::fs::read_dir(&path) + .await + .map_err(|e| BuildError::ParseError(e.to_string()))?; + + while let Some(entry) = entries + .next_entry() + .await + .map_err(|e| BuildError::ParseError(e.to_string()))? + { + let file_name = entry.file_name(); + let file_name_str = file_name.to_string_lossy(); + + // Only include .app files + if !file_name_str.ends_with(".app") { + continue; + } + + let path = entry.path(); + let path_str = path.to_string_lossy().to_string(); + + products.push(BuildProduct { + name: file_name_str.to_string(), + path: path_str, + }); + } + + Ok(products) +} + +/// Get launchable products from a list of build products +/// Since all detected products are .app files, this simply returns a clone of the input +pub fn get_launchable_products(products: &[BuildProduct]) -> Vec { + products.to_vec() +} + +/// Get launchable products from a build directory +pub async fn get_launchable_products_from_dir( + build_dir: &str, +) -> Result, BuildError> { + let all_products = find_build_products(build_dir).await?; + Ok(get_launchable_products(&all_products)) +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum BuildEvent { + Started { + scheme: String, + project_path: String, + }, + Output { + line: String, + }, + Completed { + success: bool, + build_dir: String, + products: Vec, + }, + Error { + message: String, + }, +} + +/// Stream build output line by line for live updates +pub async fn build_scheme_stream( + project_path: &Path, + scheme: &str, +) -> Result>, BuildError> { + let project = projects::detect_project(project_path).ok_or(BuildError::ProjectNotFound)?; + + if !matches!(project.project_type, projects::ProjectType::Xcode) { + return Err(BuildError::NotXcodeProject(project.project_type)); + } + + let is_workspace = project.path.ends_with(".xcworkspace"); + let scheme_owned = scheme.to_string(); + let project_path_owned = project_path.to_string_lossy().to_string(); + + // First, get build settings to find the build directory + let mut settings_cmd = Command::new("xcodebuild"); + + if is_workspace { + settings_cmd.arg("-workspace").arg(&project.path); + } else { + settings_cmd.arg("-project").arg(&project.path); + } + + settings_cmd + .arg("-scheme") + .arg(scheme) + .arg("-configuration") + .arg("Debug") + .arg("-sdk") + .arg("iphonesimulator") + .arg("-destination") + .arg("generic/platform=iOS Simulator") + .arg("CODE_SIGN_IDENTITY=") + .arg("CODE_SIGNING_REQUIRED=NO") + .arg("CODE_SIGNING_ALLOWED=NO") + .arg("-showBuildSettings"); + + let settings_output = settings_cmd.output().await?; + let settings_stdout = String::from_utf8_lossy(&settings_output.stdout).to_string(); + + let build_dir = extract_build_dir_from_settings(&settings_stdout) + .ok_or_else(|| BuildError::ParseError("Could not find build directory".to_string()))?; + + // Now start the actual build with streaming + let mut cmd = Command::new("xcodebuild"); + + if is_workspace { + cmd.arg("-workspace").arg(&project.path); + } else { + cmd.arg("-project").arg(&project.path); + } + + cmd.arg("-scheme") + .arg(scheme) + .arg("-configuration") + .arg("Debug") + .arg("-sdk") + .arg("iphonesimulator") + .arg("-destination") + .arg("generic/platform=iOS Simulator") + .arg("CODE_SIGN_IDENTITY=") + .arg("CODE_SIGNING_REQUIRED=NO") + .arg("CODE_SIGNING_ALLOWED=NO") + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + + let mut child = cmd.spawn()?; + + let stdout = child + .stdout + .take() + .ok_or_else(|| BuildError::ParseError("Failed to capture stdout".to_string()))?; + + let stderr = child + .stderr + .take() + .ok_or_else(|| BuildError::ParseError("Failed to capture stderr".to_string()))?; + + let stream = async_stream::stream! { + yield Ok(BuildEvent::Started { + scheme: scheme_owned.clone(), + project_path: project_path_owned.clone(), + }); + + let stdout_reader = BufReader::new(stdout); + let stderr_reader = BufReader::new(stderr); + + let mut stdout_lines = stdout_reader.lines(); + let mut stderr_lines = stderr_reader.lines(); + + loop { + tokio::select! { + line = stdout_lines.next_line() => { + match line { + Ok(Some(line)) => { + yield Ok(BuildEvent::Output { line }); + } + Ok(None) => break, + Err(e) => { + yield Err(BuildError::ParseError(e.to_string())); + break; + } + } + } + line = stderr_lines.next_line() => { + match line { + Ok(Some(line)) => { + yield Ok(BuildEvent::Output { line }); + } + Ok(None) => {}, + Err(e) => { + yield Err(BuildError::ParseError(e.to_string())); + break; + } + } + } + } + } + + let status = child.wait().await; + + match status { + Ok(exit_status) => { + let success = exit_status.success(); + let products = if success { + find_build_products(&build_dir).await.unwrap_or_default() + } else { + vec![] + }; + + yield Ok(BuildEvent::Completed { + success, + build_dir, + products, + }); + } + Err(e) => { + yield Ok(BuildEvent::Error { + message: e.to_string(), + }); + } + } + }; + + Ok(stream) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_get_launchable_products() { + let products = vec![ + BuildProduct { + name: "MyApp.app".to_string(), + path: "/path/to/MyApp.app".to_string(), + }, + BuildProduct { + name: "AnotherApp.app".to_string(), + path: "/path/to/AnotherApp.app".to_string(), + }, + ]; + + let launchable = get_launchable_products(&products); + assert_eq!(launchable.len(), 2); + assert_eq!(launchable[0].name, "MyApp.app"); + } + + #[test] + fn test_get_launchable_products_empty() { + let products = vec![]; + + let launchable = get_launchable_products(&products); + assert_eq!(launchable.len(), 0); + } +} diff --git a/app/src/xcode/mod.rs b/app/src/xcode/mod.rs index 65d3c58..df15a52 100644 --- a/app/src/xcode/mod.rs +++ b/app/src/xcode/mod.rs @@ -1,3 +1,5 @@ +pub mod build; pub mod discovery; +pub use build::*; pub use discovery::*; diff --git a/app/tests/xcode_integration_tests.rs b/app/tests/xcode_integration_tests.rs index 8be6072..5321792 100644 --- a/app/tests/xcode_integration_tests.rs +++ b/app/tests/xcode_integration_tests.rs @@ -365,3 +365,140 @@ async fn test_real_xcode_project_discovery_from_directory() { let schemes = json["schemes"].as_array().unwrap(); assert_eq!(schemes[0], "Plasma Project"); } + +// Build tests with real Xcode fixture + +#[tokio::test] +#[cfg(target_os = "macos")] +async fn test_build_scheme_with_fixture() { + use app_lib::xcode; + use std::path::Path; + + let project_path = fixture_path("Plasma/Plasma.xcodeproj"); + let path = Path::new(&project_path); + + // Build the fixture project + let result = xcode::build_scheme(path, "Plasma Project").await; + + // The build should complete (may succeed or fail depending on environment) + assert!(result.is_ok()); + + let build_result = result.unwrap(); + + // If build succeeded, verify the build directory and products + if build_result.success { + // Verify we got a build directory + assert!(!build_result.build_dir.is_empty()); + // Build dir should be in DerivedData + assert!(build_result.build_dir.contains("DerivedData")); + + // Should have at least one product + assert!(!build_result.products.is_empty()); + + // Find launchable products + let launchable = xcode::get_launchable_products(&build_result.products); + + // Should have at least one launchable app + assert!(!launchable.is_empty()); + assert!(launchable[0].name.ends_with(".app")); + } else { + // Build failed - this is ok in test environment + // Just verify that build_dir is empty and no products were found + assert!(build_result.build_dir.is_empty()); + assert!(build_result.products.is_empty()); + } +} + +#[tokio::test] +#[cfg(target_os = "macos")] +async fn test_build_scheme_stream_with_fixture() { + use app_lib::xcode; + use futures::stream::StreamExt; + use std::path::Path; + + let project_path = fixture_path("Plasma/Plasma.xcodeproj"); + let path = Path::new(&project_path); + + // Build the fixture project with streaming + let stream = xcode::build_scheme_stream(path, "Plasma Project").await; + assert!(stream.is_ok()); + + let event_stream = stream.unwrap(); + futures::pin_mut!(event_stream); + + let mut events = Vec::new(); + let mut has_started = false; + let mut has_completed = false; + + // Collect all events from the stream + while let Some(result) = event_stream.next().await { + assert!(result.is_ok()); + let event = result.unwrap(); + + match &event { + xcode::BuildEvent::Started { scheme, .. } => { + assert_eq!(scheme, "Plasma Project"); + has_started = true; + } + xcode::BuildEvent::Output { .. } => { + // Build output lines + } + xcode::BuildEvent::Completed { build_dir, .. } => { + assert!(build_dir.contains("DerivedData")); + has_completed = true; + } + xcode::BuildEvent::Error { .. } => { + // Build error + } + } + + events.push(event); + } + + // Verify we got the expected events + assert!(has_started, "Should have started event"); + assert!(has_completed, "Should have completed event"); + assert!( + events.len() > 2, + "Should have multiple events including output" + ); +} + +#[tokio::test] +#[cfg(target_os = "macos")] +async fn test_build_endpoint_with_fixture() { + let app = create_test_app().await; + let project_path = fixture_path("Plasma/Plasma.xcodeproj"); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/xcode/build") + .header("content-type", "application/json") + .body(Body::from( + json!({ + "path": project_path, + "scheme": "Plasma Project" + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + let json: Value = serde_json::from_slice(&body).unwrap(); + + // Verify response structure + assert!(json["success"].is_boolean()); + assert!(json["build_dir"].is_string()); + assert!(json["products"].is_array()); + assert!(json["stdout"].is_string()); + assert!(json["stderr"].is_string()); +}