Skip to main content

waterui_cli/apple/
backend.rs

1use std::path::{Path, PathBuf};
2
3use serde::{Deserialize, Serialize};
4use waterui_assets_planner::ColorScheme;
5
6use crate::{
7    apple::platform::{build_rust_lib, clean_apple, is_apple_platform, package_apple},
8    backend::Backend,
9    build::BuildOptions,
10    device::Artifact,
11    platform::{PackageOptions, TargetBackend, TargetPlatform},
12    project::Project,
13    project_types::CrateName,
14    templates::{self, TemplateContext},
15};
16
17#[derive(Debug, Serialize, Deserialize, Clone)]
18// Warn: You cannot use both revision and local_path at the same time.
19/// Configuration for the Apple backend in a `WaterUI` project.
20///
21/// `[backend.apple]` in `Water.toml`
22pub struct AppleBackend {
23    #[serde(
24        default = "default_apple_project_path",
25        skip_serializing_if = "is_default_apple_project_path"
26    )]
27    /// Path to the Apple project within the `WaterUI` project.
28    pub project_path: PathBuf,
29    /// The scheme to use for building the Apple project.
30    pub scheme: String,
31    /// The branch of the Apple backend to use.
32    ///
33    /// You cannot use both branch and revision at the same time.
34    #[serde(skip_serializing_if = "Option::is_none")]
35    pub branch: Option<String>,
36
37    /// The revision (commit hash or tag) of the Apple backend to use.
38    ///
39    /// You cannot use both revision and branch at the same time.
40    #[serde(skip_serializing_if = "Option::is_none")]
41    pub revision: Option<String>,
42    /// Local path to the Apple backend for local dev.
43    #[serde(skip_serializing_if = "Option::is_none")]
44    pub backend_path: Option<String>,
45}
46
47/// What this project's built application bundle is called.
48///
49/// Deliberately not the scheme. The scheme is a fixed handle the CLI drives the
50/// Xcode project with — every playground shares one, which is what lets one set
51/// of commands build any of them — while the product name is the one a person
52/// reads. macOS takes `CFBundleName`, and with it the menu bar, the Dock and
53/// Force Quit, from `PRODUCT_NAME`, so a project that leaves the two equal
54/// announces itself as the scaffold's target rather than as itself.
55///
56/// The scaffold writes this same name into `PRODUCT_NAME`, so this is also
57/// where the built bundle is found afterwards; the two must agree.
58///
59/// # Errors
60///
61/// Returns an error when the name cannot be a bundle's: empty, or containing a
62/// path separator that would place the bundle somewhere else entirely.
63pub fn apple_product_name(project: &Project) -> Result<&str, eyre::Report> {
64    let name = project.manifest().package.name.as_str();
65    if name.is_empty() {
66        eyre::bail!("This project has no name; `package.name` in Water.toml names the application");
67    }
68    if name.contains(std::path::MAIN_SEPARATOR) || name.contains('/') {
69        eyre::bail!(
70            "The project name {name:?} contains a path separator, so it cannot name an application bundle"
71        );
72    }
73    Ok(name)
74}
75
76impl AppleBackend {
77    /// Create a new Apple backend configuration with the given scheme.
78    #[must_use]
79    pub fn new(scheme: impl Into<String>) -> Self {
80        Self {
81            project_path: default_apple_project_path(),
82            scheme: scheme.into(),
83            branch: None,
84            revision: None,
85            backend_path: None,
86        }
87    }
88
89    /// Set a custom project path (defaults to "apple").
90    #[must_use]
91    pub fn with_project_path(mut self, path: impl Into<PathBuf>) -> Self {
92        self.project_path = path.into();
93        self
94    }
95
96    /// Set the local backend path for development.
97    #[must_use]
98    pub fn with_backend_path(mut self, path: impl Into<String>) -> Self {
99        self.backend_path = Some(path.into());
100        self
101    }
102
103    /// Get the path to the Apple project within the `WaterUI` project.
104    #[must_use]
105    pub fn project_path(&self) -> &Path {
106        &self.project_path
107    }
108}
109
110fn default_apple_project_path() -> PathBuf {
111    PathBuf::from("apple")
112}
113
114fn is_default_apple_project_path(s: &Path) -> bool {
115    s == Path::new("apple")
116}
117
118impl Backend for AppleBackend {
119    const DEFAULT_PATH: &'static str = "apple";
120
121    // Preserve Xcode build caches during re-scaffolding.
122    const CACHE_PATHS: &'static [&'static str] = &["DerivedData"];
123
124    fn path(&self) -> &Path {
125        &self.project_path
126    }
127
128    async fn init(project: &Project) -> Result<Self, crate::backend::FailToInitBackend> {
129        let manifest = project.manifest();
130        // A `[backend.apple]` source override the manifest already carries is
131        // a user choice; init re-scaffolds the project without rewriting it.
132        let existing = manifest.backends.apple();
133
134        // For playground projects, use fixed scheme name "WaterUIApp"
135        // For regular projects, scheme name must match the Xcode target name (crate name)
136        let is_playground =
137            manifest.package.package_type == crate::project::PackageType::Playground;
138
139        // For playground projects, use fixed names
140        // For regular projects, derive from crate name
141        let (scheme, app_name, crate_name_for_template) = if is_playground {
142            (
143                "WaterUIApp".to_string(),
144                "WaterUIApp".to_string(),
145                CrateName::try_from("WaterUIApp").expect("playground crate name must be valid"),
146            )
147        } else {
148            let crate_name = project.crate_name().clone();
149            // App name for Swift code must be a valid Swift identifier (no hyphens)
150            // Convert "video-player-example" to "VideoPlayerExample"
151            let app_name = templates::apple_app_name(&crate_name);
152            (crate_name.to_string(), app_name, crate_name)
153        };
154
155        let project_path = default_apple_project_path();
156
157        let ios_permissions = manifest
158            .permissions
159            .iter()
160            .filter(|(_, entry)| entry.is_enabled())
161            .filter_map(|(key, entry)| {
162                key.ios_plist_key()
163                    .map(|plist_key| templates::IosPermissionTemplateEntry {
164                        plist_key,
165                        description: entry.description().to_string(),
166                    })
167            })
168            .collect();
169        let webview_enabled = project
170            .uses_standard_webview()
171            .await
172            .map_err(crate::backend::FailToInitBackend::Config)?;
173        let chromium_enabled = project
174            .links_runtime_package("waterui-chromium")
175            .await
176            .map_err(crate::backend::FailToInitBackend::Config)?;
177        let browser_engine = project
178            .linked_browser_engine()
179            .await
180            .map_err(crate::backend::FailToInitBackend::Config)?;
181        // The generated project names the launch assets the catalog will
182        // hold, so the two are decided from the same resolution.
183        let launch = crate::assets::project_launch_assets(project)
184            .map_err(crate::backend::FailToInitBackend::Config)?;
185        let launch_entry = templates::LaunchTemplateEntry {
186            has_background: launch.plan().background(ColorScheme::Light).is_some(),
187            has_image: launch.has_artwork(),
188        };
189        let ctx = TemplateContext::for_project_manifest(
190            manifest,
191            crate_name_for_template,
192            app_name,
193            &project
194                .resolved_framework()
195                .await
196                .map_err(crate::backend::FailToInitBackend::Config)?,
197        )
198        .with_backend_project_path(project.backend_path::<Self>())
199        .with_project_root_path(project.root().to_path_buf())
200        .with_ios_permissions(ios_permissions)
201        .with_webview_enabled(webview_enabled)
202        .with_chromium_enabled(chromium_enabled)
203        .with_browser_engine(browser_engine)
204        .with_launch(launch_entry);
205
206        templates::apple::scaffold(&project.backend_path::<Self>(), &ctx)
207            .await
208            .map_err(crate::backend::FailToInitBackend::Io)?;
209
210        Ok(Self {
211            project_path,
212            scheme,
213            branch: existing.and_then(|backend| backend.branch.clone()),
214            revision: existing.and_then(|backend| backend.revision.clone()),
215            backend_path: existing.and_then(|backend| backend.backend_path.clone()),
216        })
217    }
218
219    fn supports(&self, platform: TargetPlatform) -> bool {
220        is_apple_platform(platform)
221    }
222
223    async fn build(
224        &self,
225        project: &Project,
226        platform: TargetPlatform,
227        options: BuildOptions,
228    ) -> eyre::Result<PathBuf> {
229        project
230            .browser_runtime_plan(platform, TargetBackend::Apple)
231            .await?;
232        build_rust_lib(project, platform, options).await
233    }
234
235    async fn package(
236        &self,
237        project: &Project,
238        platform: TargetPlatform,
239        options: PackageOptions,
240    ) -> eyre::Result<Artifact> {
241        package_apple(project, platform, options).await
242    }
243
244    async fn clean(&self, project: &Project, _platform: TargetPlatform) -> eyre::Result<()> {
245        clean_apple(project).await
246    }
247}