//=============================================================================// // File Path: Element/Maintain/Source/Build/Process.rs //=============================================================================// // Module: Process // // Brief Description: Main orchestration logic for preparing and executing the // build. // // RESPONSIBILITIES: // ================ // // Primary: // - Orchestrate the entire build process from start to finish // - Generate product names and bundle identifiers // - Modify configuration files for specific build flavors // - Stage and bundle Node.js sidecar binaries if needed // - Execute the final build command // // Secondary: // - Provide detailed logging of build orchestration steps // - Ensure cleanup of temporary files // // ARCHITECTURAL ROLE: // =================== // // Position: // - Core/Orchestration layer // - Build process coordination // // Dependencies (What this module requires): // - External crates: std (env, fs, path, process, os), log, toml // - Internal modules: Constant::*, Definition::*, Error::BuildError, // Function::* // - Traits implemented: None // // Dependents (What depends on this module): // - Main entry point // - Fn function // // IMPLEMENTATION DETAILS: // ======================= // // Design Patterns: // - Orchestration pattern // - Guard pattern (for file backup/restoration) // // Performance Considerations: // - Complexity: O(n) - file I/O operations dominate // - Memory usage patterns: Moderate (stores configuration data in memory) // - Hot path optimizations: None needed (build time is user-facing) // // Thread Safety: // - Thread-safe: No (not designed for concurrent execution) // - Synchronization mechanisms used: None // - Interior mutability considerations: None // // Error Handling: // - Error types returned: BuildError (various) // - Recovery strategies: Guard restores files on error // // EXAMPLES: // ========= // // Example 1: Basic build orchestration use std::{ collections::BTreeMap, env, fs, path::PathBuf, process::{Command as ProcessCommand, Stdio}, }; use log::info; use toml; /// ```rust /// use crate::Maintain::Source::Build::{Argument, Process}; /// let argument = Argument::parse(); /// Process(&argument)?; /// ``` // Example 2: Handling build errors /// ```rust /// use crate::Maintain::Source::Build::Process; /// match Process(&argument) { /// Ok(_) => println!("Build succeeded"), /// Err(e) => println!("Build failed: {}", e), /// } /// ``` // //=============================================================================// // IMPLEMENTATION //=============================================================================// use crate::Build::Error::Error as BuildError; use crate::Build::{ Constant::{ CargoFile, CocoonEsbuildDefineEnv, IdDelimiter, JsonFile, JsonfiveFile, LandDisableEnv, LandInspectEnv, LandRecordEnv, LandTraceEnv, NameDelimiter, PlistFile, }, Definition::{Argument, Guard, Manifest}, GetTauriTargetTriple::GetTauriTargetTriple, JsonEdit::JsonEdit, Pascalize::Pascalize, PlistEdit::PlistEdit, TomlEdit::TomlEdit, WordsFromPascal::WordsFromPascal, }; /// Main orchestration logic for preparing and executing the build. /// /// This function is the core of the build system, coordinating all aspects /// of preparing, building, and restoring project configurations. It: /// /// 1. Validates the project directory and configuration files /// 2. Creates guards to backup and restore configuration files /// 3. Generates a unique product name and bundle identifier based on build /// flags /// 4. Modifies Cargo.toml and Tauri configuration files /// 5. Optionally stages a Node.js sidecar binary /// 6. Executes the provided build command /// 7. Cleans up temporary files after successful build /// /// # Parameters /// /// * `Argument` - Parsed command-line arguments and environment variables /// /// # Returns /// /// Returns `Ok(())` on successful build completion or a `BuildError` if /// any step fails. /// /// # Errors /// /// * `BuildError::Missing` - If the project directory doesn't exist /// * `BuildError::Config` - If Tauri configuration file not found /// * `BuildError::Exists` - If a backup file already exists /// * `BuildError::Io` - For file operation failures /// * `BuildError::Edit` - For TOML editing failures /// * `BuildError::Json` / `BuildError::Jsonfive` - For JSON/JSON5 parsing /// failures /// * `BuildError::Parse` - For TOML parsing failures /// * `BuildError::Shell` - If the build command fails /// /// # Build Flavor Generation /// /// The product name and bundle identifier are generated by combining: /// /// - **Environment**: Node.js environment (development, production, etc.) /// - **Dependency**: Dependency information (org/repo or generic) /// - **Node Version**: Node.js version if bundling a sidecar /// - **Build Flags**: Bundle, Clean, Browser, Compile, Debug /// /// Example product name: /// `Development_GenDependency_22NodeVersion_Debug_Mountain` /// /// Example bundle identifier: /// `land.editor.binary.development.generic.node.22.debug.mountain` /// /// # Node.js Sidecar Bundling /// /// If `NodeVersion` is specified: /// - The Node.js binary is copied from /// `Element/SideCar/{triple}/NODE/{version}/` /// - The binary is staged in the project's `Binary/` directory /// - The Tauri configuration is updated to include the sidecar /// - The binary is given appropriate permissions on Unix-like systems /// - The temporary directory is cleaned up after successful build /// /// # File Safety /// /// All configuration file modifications are protected by the Guard pattern: /// - Files are backed up before modification /// - Files are automatically restored on error or when the guard drops /// - This ensures the original state is preserved regardless of build outcome /// /// # Example /// /// ```no_run /// use crate::Maintain::Source::Build::{Argument, Process}; /// let argument = Argument::parse(); /// Process(&argument)?; /// ``` pub fn Process(Argument:&Argument) -> Result<(), BuildError> { info!(target: "Build", "Starting build orchestration..."); log::debug!(target: "Build", "Argument: {:?}", Argument); // Tier fan-out observability. The shell helper // `Maintain/Script/TierEnvironment.sh` exports `CargoFeatures` and // `CocoonEsbuildDefine`; surface them here so a build transcript shows // which tier set shipped into the binary without having to replay the // shell environment. if let Some(Features) = Argument.CargoFeatures.as_deref().filter(|v| !v.is_empty()) { info!(target: "Build", "Cargo features: {}", Features); } if let Some(Defines) = Argument.CocoonEsbuildDefine.as_deref().filter(|v| !v.is_empty()) { info!(target: "Build", "Cocoon esbuild defines: {}", Defines); } let ProjectDir = PathBuf::from(&Argument.Directory); if !ProjectDir.is_dir() { return Err(BuildError::Missing(ProjectDir)); } let CargoPath = ProjectDir.join(CargoFile); let ConfigPath = { let Jsonfive = ProjectDir.join(JsonfiveFile); if Jsonfive.exists() { Jsonfive } else { ProjectDir.join(JsonFile) } }; if !ConfigPath.exists() { return Err(BuildError::Config); } // Create guards for file backup and restoration let mut CargoGuard = Guard::New(CargoPath.clone(), "Cargo.toml".to_string())?; let mut ConfigGuard = Guard::New(ConfigPath.clone(), "Tauri config".to_string())?; let mut NamePartsForProductName = Vec::new(); let mut NamePartsForId = Vec::new(); // Include Node.js environment in product name if let Some(NodeValue) = &Argument.Environment { if !NodeValue.is_empty() { let PascalEnv = Pascalize(NodeValue); if !PascalEnv.is_empty() { NamePartsForProductName.push(format!("{}NodeEnvironment", PascalEnv)); NamePartsForId.extend(WordsFromPascal(&PascalEnv)); NamePartsForId.push("node".to_string()); NamePartsForId.push("environment".to_string()); } } } // Include dependency information in product name if let Some(DependencyValue) = &Argument.Dependency { if !DependencyValue.is_empty() { let (PascalDepBase, IdDepWords) = if DependencyValue.eq_ignore_ascii_case("true") { ("Generic".to_string(), vec!["generic".to_string()]) } else if let Some((Org, Repo)) = DependencyValue.split_once('/') { (format!("{}{}", Pascalize(Org), Pascalize(Repo)), { let mut w = WordsFromPascal(&Pascalize(Org)); w.extend(WordsFromPascal(&Pascalize(Repo))); w }) } else { (Pascalize(DependencyValue), WordsFromPascal(&Pascalize(DependencyValue))) }; if !PascalDepBase.is_empty() { NamePartsForProductName.push(format!("{}Dependency", PascalDepBase)); NamePartsForId.extend(IdDepWords); NamePartsForId.push("dependency".to_string()); } } } // Include Node.js version in product name if let Some(Version) = &Argument.NodeVersion { if !Version.is_empty() { let PascalVersion = format!("{}NodeVersion", Version); NamePartsForProductName.push(PascalVersion.clone()); NamePartsForId.push("node".to_string()); NamePartsForId.push(Version.to_string()); } } // Include build flags in product name if Argument.Bundle.as_ref().map_or(false, |v| v == "true") { NamePartsForProductName.push("Bundle".to_string()); NamePartsForId.push("bundle".to_string()); } if Argument.Clean.as_ref().map_or(false, |v| v == "true") { NamePartsForProductName.push("Clean".to_string()); NamePartsForId.push("clean".to_string()); } if Argument.Browser.as_ref().map_or(false, |v| v == "true") { NamePartsForProductName.push("Browser".to_string()); NamePartsForId.push("browser".to_string()); } if Argument.Compile.as_ref().map_or(false, |v| v == "true") { NamePartsForProductName.push("Compile".to_string()); NamePartsForId.push("compile".to_string()); } if Argument.Debug.as_ref().map_or(false, |v| v == "true") || Argument.Command.iter().any(|arg| arg.contains("--debug")) { NamePartsForProductName.push("Debug".to_string()); NamePartsForId.push("debug".to_string()); } // Workbench-profile suffixes. These are what keep `debug-mountain` and // `debug-electron` binaries separated on disk. Without them, both // profiles would compile into the same `Target/debug/_Mountain` // binary (because the Cargo bin name is "Mountain"), so switching // profiles couldn't run side-by-side and the bundler would thrash the // same artefacts every rebuild. if Argument.Mountain.as_ref().map_or(false, |v| v == "true") { NamePartsForProductName.push("MountainProfile".to_string()); NamePartsForId.push("mountain".to_string()); NamePartsForId.push("profile".to_string()); } if Argument.Electron.as_ref().map_or(false, |v| v == "true") { NamePartsForProductName.push("ElectronProfile".to_string()); NamePartsForId.push("electron".to_string()); NamePartsForId.push("profile".to_string()); } // Compiler variant (e.g. "Rest") - distinguishes the OXC build path // from the default TypeScript compiler path so two binaries with the // same workbench flavour but different compilers don't collide. if let Some(Variant) = &Argument.Compiler { if !Variant.is_empty() { let PascalCompiler = Pascalize(Variant); if !PascalCompiler.is_empty() { NamePartsForProductName.push(format!("{}Compiler", PascalCompiler)); NamePartsForId.extend(WordsFromPascal(&PascalCompiler)); NamePartsForId.push("compiler".to_string()); } } } // Generate final product name let ProductNamePrefix = NamePartsForProductName.join(NameDelimiter); let FinalName = if !ProductNamePrefix.is_empty() { format!("{}{}{}", ProductNamePrefix, NameDelimiter, Argument.Name) } else { Argument.Name.clone() }; info!(target: "Build", "Final generated product name: '{}'", FinalName); // Generate final bundle identifier NamePartsForId.extend(WordsFromPascal(&Argument.Name)); let IdSuffix = NamePartsForId .into_iter() .filter(|s| !s.is_empty()) .collect::>() .join(IdDelimiter); let FinalId = format!("{}{}{}", Argument.Prefix, IdDelimiter, IdSuffix); info!(target: "Build", "Generated bundle identifier: '{}'", FinalId); // Update Cargo.toml if product name changed if FinalName != Argument.Name { TomlEdit(&CargoPath, &Argument.Name, &FinalName)?; } // Get version from Cargo.toml let AppVersion = toml::from_str::(&fs::read_to_string(&CargoPath)?)? .get_version() .to_string(); // Update Tauri configuration and optionally bundle Node.js sidecar JsonEdit( &ConfigPath, &FinalName, &FinalId, &AppVersion, (if let Some(version) = &Argument.NodeVersion { info!(target: "Build", "Selected Node.js version: {}", version); let Triple = GetTauriTargetTriple(); // Path to the pre-downloaded Node executable let Executable = if cfg!(target_os = "windows") { PathBuf::from(format!("./Element/SideCar/{}/NODE/{}/node.exe", Triple, version)) } else { PathBuf::from(format!("./Element/SideCar/{}/NODE/{}/bin/node", Triple, version)) }; // Define a consistent, temporary directory for the staged binary let DirectorySideCarTemporary = ProjectDir.join("Binary"); fs::create_dir_all(&DirectorySideCarTemporary)?; // Define the consistent name for the binary that Tauri will bundle let PathExecutableDestination = if cfg!(target_os = "windows") { DirectorySideCarTemporary.join(format!("node-{}.exe", Triple)) } else { DirectorySideCarTemporary.join(format!("node-{}", Triple)) }; info!( target: "Build", "Staging sidecar from {} to {}", Executable.display(), PathExecutableDestination.display() ); // Perform the copy fs::copy(&Executable, &PathExecutableDestination)?; // On non-windows, make sure the copied binary is executable #[cfg(not(target_os = "windows"))] { use std::os::unix::fs::PermissionsExt; let mut Permission = fs::metadata(&PathExecutableDestination)?.permissions(); // rwxr-xr-x Permission.set_mode(0o755); fs::set_permissions(&PathExecutableDestination, Permission)?; } Some("Binary/node".to_string()) } else { info!(target: "Build", "No Node.js flavour selected for bundling."); None }) .as_deref(), )?; // On macOS, inject dev-control environment variables into Info.plist. // Tauri uses the project Info.plist as a template; when the .app is // launched via LaunchServices (Finder double-click, open, Spotlight), // keys under LSEnvironment are injected into the process environment. // We only do this if an Info.plist exists in the project directory and // we're running on macOS. Skip on Linux/Windows. #[cfg(target_os = "macos")] { let PlistPath = ProjectDir.join(PlistFile); if PlistPath.exists() { let PlistEnvVars = BuildPlistEnvironment(); if !PlistEnvVars.is_empty() { let mut PlistGuard = Guard::New(PlistPath.clone(), "Info.plist".to_string())?; let _ = PlistEdit(&PlistPath, &PlistEnvVars); PlistGuard.disarm(); } } } // Execute the build command if Argument.Command.is_empty() { return Err(BuildError::NoCommand); } // Materialise the command into an owned Vec so we can append // `--features ` to `pnpm tauri build [--debug]` invocations // without mutating the parsed `Argument`. The guard below keeps the // append scoped to tauri builds - other commands (e.g. cargo, direct // tooling) pass through unchanged. let mut CommandArguments:Vec = Argument.Command.clone(); let IsTauriBuild = CommandArguments.len() >= 3 && CommandArguments[0] == "pnpm" && CommandArguments[1] == "tauri" && CommandArguments[2] == "build"; if IsTauriBuild { if let Some(Features) = Argument.CargoFeatures.as_deref().filter(|v| !v.is_empty()) { let AlreadyPresent = CommandArguments.iter().any(|a| a == "--features" || a == "-f"); if !AlreadyPresent { info!( target: "Build", "Forwarding Cargo features to `tauri build`: {}", Features ); CommandArguments.push("--features".to_string()); CommandArguments.push(Features.to_string()); } } } let mut ShellCommand = if cfg!(target_os = "windows") { let mut Command = ProcessCommand::new("cmd"); Command.arg("/C").args(&CommandArguments); Command } else { let mut Command = ProcessCommand::new(&CommandArguments[0]); Command.args(&CommandArguments[1..]); Command }; // Re-assert `CocoonEsbuildDefine` on the child environment so Cocoon's // esbuild step sees the tier `define` blob even if a wrapper ever calls // `.env_clear()` on our `ProcessCommand`. `ProcessCommand` inherits the // parent env by default, so without a clear this is belt-and-braces. if let Some(Defines) = Argument.CocoonEsbuildDefine.as_deref().filter(|v| !v.is_empty()) { ShellCommand.env(CocoonEsbuildDefineEnv, Defines); } info!(target: "Build::Exec", "Executing final build command: {:?}", ShellCommand); let Status = ShellCommand .current_dir(env::current_dir()?) .stdout(Stdio::inherit()) .stderr(Stdio::inherit()) .status()?; // Handle build failure if !Status.success() { let temp_sidecar_dir = ProjectDir.join("bin"); if temp_sidecar_dir.exists() { let _ = fs::remove_dir_all(&temp_sidecar_dir); } return Err(BuildError::Shell(Status)); } // Final cleanup of the temporary sidecar directory after a successful build let DirectorySideCarTemporary = ProjectDir.join("bin"); if DirectorySideCarTemporary.exists() { fs::remove_dir_all(&DirectorySideCarTemporary)?; info!(target: "Build", "Cleaned up temporary sidecar directory."); } // Guards drop here, restoring Cargo.toml and tauri.conf.json to their // original state and deleting the .Backup files. The binary has already // been compiled with the generated product name so restoring the source // files is safe and required for the next build to succeed. drop(CargoGuard); drop(ConfigGuard); info!(target: "Build", "Build orchestration completed successfully."); Ok(()) } /// Collects environment variables from `.env.Land` for injection into /// Info.plist LSEnvironment so the bundled .app works standalone. /// /// Sources from the `.env.Land` file in the repo root (where Maintain /// runs from). This ensures every runtime-relevant variable -- Product*, /// Tier*, Network*, Trace, Record, Inspect, Disable, etc. -- is /// available when the .app is launched via LaunchServices. /// /// Build-time-only flags (CargoFeatures, CocoonEsbuildDefine, NODE_ENV) /// are excluded because they have no meaning at runtime inside the .app. fn BuildPlistEnvironment() -> BTreeMap { let mut EnvVars = BTreeMap::new(); // Build-time / Maintain-control keys that should NOT leak into the // bundled .app's LSEnvironment. let SkipKeys = ["CargoFeatures", "CocoonEsbuildDefine", "NODE_ENV"]; // Primary source: the .env.Land file at the repo root (Maintain's // working directory). for Source in [".env.Land", ".env.Land.Sample"] { let Path = PathBuf::from(Source); if Path.exists() { if let Ok(Content) = fs::read_to_string(&Path) { info!(target: "Build::Plist", "Loading LSEnvironment vars from {}", Source); for Line in Content.lines() { let Trimmed = Line.trim(); if Trimmed.is_empty() || Trimmed.starts_with('#') { continue; } if let Some((Key, Value)) = Trimmed.split_once('=') { let CleanKey = Key.trim(); let CleanValue = Value.trim().trim_matches('"').trim_matches('\''); // Skip build-time-only keys. if SkipKeys.contains(&CleanKey) { continue; } EnvVars.insert(CleanKey.to_string(), CleanValue.to_string()); } } } break; } } // Supplement from the current process environment for dev-control // knobs that live outside .env.Land (Trace, Record, Inspect, Disable). // These may have been overridden by the user before invoking Maintain. // Only add if not already populated from .env.Land. for Key in [LandTraceEnv, LandRecordEnv, LandInspectEnv, LandDisableEnv] { if !EnvVars.contains_key(Key) { if let Ok(Value) = env::var(Key) { EnvVars.insert(Key.to_string(), Value); } } } EnvVars }