waterui-cli 0.4.0

Cross-platform tooling for WaterUI applications
Documentation
use std::path::{Path, PathBuf};

use serde::{Deserialize, Serialize};
use waterui_assets_planner::ColorScheme;

use crate::{
    apple::platform::{build_rust_lib, clean_apple, is_apple_platform, package_apple},
    backend::Backend,
    build::BuildOptions,
    device::Artifact,
    platform::{PackageOptions, TargetBackend, TargetPlatform},
    project::Project,
    project_types::CrateName,
    templates::{self, TemplateContext},
};

#[derive(Debug, Serialize, Deserialize, Clone)]
// Warn: You cannot use both revision and local_path at the same time.
/// Configuration for the Apple backend in a `WaterUI` project.
///
/// `[backends.apple]` in `Water.toml`
pub struct AppleBackend {
    #[serde(
        default = "default_apple_project_path",
        skip_serializing_if = "is_default_apple_project_path"
    )]
    /// Path to the Apple project within the `WaterUI` project.
    pub project_path: PathBuf,
    /// The scheme to use for building the Apple project.
    pub scheme: String,
    /// The branch of the Apple backend to use.
    ///
    /// You cannot use both branch and revision at the same time.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub branch: Option<String>,

    /// The revision (commit hash or tag) of the Apple backend to use.
    ///
    /// You cannot use both revision and branch at the same time.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub revision: Option<String>,
    /// Local path to the Apple backend for local dev.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub backend_path: Option<String>,
}

/// What this project's built application bundle is called.
///
/// Deliberately not the scheme. The scheme is a fixed handle the CLI drives the
/// Xcode project with — every playground shares one, which is what lets one set
/// of commands build any of them — while the product name is the one a person
/// reads. macOS takes `CFBundleName`, and with it the menu bar, the Dock and
/// Force Quit, from `PRODUCT_NAME`, so a project that leaves the two equal
/// announces itself as the scaffold's target rather than as itself.
///
/// The scaffold writes this same name into `PRODUCT_NAME`, so this is also
/// where the built bundle is found afterwards; the two must agree.
///
/// # Errors
///
/// Returns an error when the name cannot be a bundle's: empty, or containing a
/// path separator that would place the bundle somewhere else entirely.
pub fn apple_product_name(project: &Project) -> Result<&str, eyre::Report> {
    let name = project.manifest().package.name.as_str();
    if name.is_empty() {
        eyre::bail!("This project has no name; `package.name` in Water.toml names the application");
    }
    if name.contains(std::path::MAIN_SEPARATOR) || name.contains('/') {
        eyre::bail!(
            "The project name {name:?} contains a path separator, so it cannot name an application bundle"
        );
    }
    Ok(name)
}

impl AppleBackend {
    /// Create a new Apple backend configuration with the given scheme.
    #[must_use]
    pub fn new(scheme: impl Into<String>) -> Self {
        Self {
            project_path: default_apple_project_path(),
            scheme: scheme.into(),
            branch: None,
            revision: None,
            backend_path: None,
        }
    }

    /// Set a custom project path (defaults to "apple").
    #[must_use]
    pub fn with_project_path(mut self, path: impl Into<PathBuf>) -> Self {
        self.project_path = path.into();
        self
    }

    /// Set the local backend path for development.
    #[must_use]
    pub fn with_backend_path(mut self, path: impl Into<String>) -> Self {
        self.backend_path = Some(path.into());
        self
    }

    /// Get the path to the Apple project within the `WaterUI` project.
    #[must_use]
    pub fn project_path(&self) -> &Path {
        &self.project_path
    }

    /// Whether this entry configures backend-project scaffolding — anything
    /// beyond `backend_path`, which only selects the runtime's source.
    #[must_use]
    pub fn configures_project(&self) -> bool {
        self.project_path != default_apple_project_path()
            || !self.scheme.is_empty()
            || self.branch.is_some()
            || self.revision.is_some()
    }
}

fn default_apple_project_path() -> PathBuf {
    PathBuf::from("apple")
}

fn is_default_apple_project_path(s: &Path) -> bool {
    s == Path::new("apple")
}

impl Backend for AppleBackend {
    const DEFAULT_PATH: &'static str = "apple";

    // Preserve Xcode build caches during re-scaffolding.
    const CACHE_PATHS: &'static [&'static str] = &["DerivedData"];

    fn path(&self) -> &Path {
        &self.project_path
    }

    async fn init(project: &Project) -> Result<Self, crate::backend::FailToInitBackend> {
        let manifest = project.manifest();
        // A `[backends.apple]` source override the manifest already carries is
        // a user choice; init re-scaffolds the project without rewriting it.
        let existing = manifest.backends.apple();

        // For playground projects, use fixed scheme name "WaterUIApp"
        // For regular projects, scheme name must match the Xcode target name (crate name)
        let is_playground =
            manifest.package.package_type == crate::project::PackageType::Playground;

        // For playground projects, use fixed names
        // For regular projects, derive from crate name
        let (scheme, app_name, crate_name_for_template) = if is_playground {
            (
                "WaterUIApp".to_string(),
                "WaterUIApp".to_string(),
                CrateName::try_from("WaterUIApp").expect("playground crate name must be valid"),
            )
        } else {
            let crate_name = project.crate_name().clone();
            // App name for Swift code must be a valid Swift identifier (no hyphens)
            // Convert "video-player-example" to "VideoPlayerExample"
            let app_name = templates::apple_app_name(&crate_name);
            (crate_name.to_string(), app_name, crate_name)
        };

        let project_path = default_apple_project_path();

        let ios_permissions = manifest
            .permissions
            .iter()
            .filter(|(_, entry)| entry.is_enabled())
            .filter_map(|(key, entry)| {
                key.ios_plist_key()
                    .map(|plist_key| templates::IosPermissionTemplateEntry {
                        plist_key,
                        description: entry.description().to_string(),
                    })
            })
            .collect();
        let webview_enabled = project
            .uses_standard_webview()
            .await
            .map_err(crate::backend::FailToInitBackend::Config)?;
        let chromium_enabled = project
            .links_runtime_package("waterui-chromium")
            .await
            .map_err(crate::backend::FailToInitBackend::Config)?;
        let browser_engine = project
            .linked_browser_engine()
            .await
            .map_err(crate::backend::FailToInitBackend::Config)?;
        // The generated project names the launch assets the catalog will
        // hold, so the two are decided from the same resolution.
        let launch = crate::assets::project_launch_assets(project)
            .map_err(crate::backend::FailToInitBackend::Config)?;
        let launch_entry = templates::LaunchTemplateEntry {
            has_background: launch.plan().background(ColorScheme::Light).is_some(),
            has_image: launch.has_artwork(),
        };
        let ctx = TemplateContext::for_project_manifest(
            manifest,
            crate_name_for_template,
            app_name,
            &project
                .resolved_framework()
                .await
                .map_err(crate::backend::FailToInitBackend::Config)?,
        )
        .with_backend_project_path(project.backend_path::<Self>())
        .with_project_root_path(project.root().to_path_buf())
        .with_ios_permissions(ios_permissions)
        .with_webview_enabled(webview_enabled)
        .with_chromium_enabled(chromium_enabled)
        .with_browser_engine(browser_engine)
        .with_launch(launch_entry);

        templates::apple::scaffold(&project.backend_path::<Self>(), &ctx)
            .await
            .map_err(crate::backend::FailToInitBackend::Io)?;

        Ok(Self {
            project_path,
            scheme,
            branch: existing.and_then(|backend| backend.branch.clone()),
            revision: existing.and_then(|backend| backend.revision.clone()),
            backend_path: existing.and_then(|backend| backend.backend_path.clone()),
        })
    }

    fn supports(&self, platform: TargetPlatform) -> bool {
        is_apple_platform(platform)
    }

    async fn build(
        &self,
        project: &Project,
        platform: TargetPlatform,
        options: BuildOptions,
    ) -> eyre::Result<PathBuf> {
        project
            .browser_runtime_plan(platform, TargetBackend::Apple)
            .await?;
        build_rust_lib(project, platform, options).await
    }

    async fn package(
        &self,
        project: &Project,
        platform: TargetPlatform,
        options: PackageOptions,
    ) -> eyre::Result<Artifact> {
        package_apple(project, platform, options).await
    }

    async fn clean(&self, project: &Project, _platform: TargetPlatform) -> eyre::Result<()> {
        clean_apple(project).await
    }
}