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/// `[backends.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    /// Whether this entry configures backend-project scaffolding — anything
110    /// beyond `backend_path`, which only selects the runtime's source.
111    #[must_use]
112    pub fn configures_project(&self) -> bool {
113        self.project_path != default_apple_project_path()
114            || !self.scheme.is_empty()
115            || self.branch.is_some()
116            || self.revision.is_some()
117    }
118}
119
120fn default_apple_project_path() -> PathBuf {
121    PathBuf::from("apple")
122}
123
124fn is_default_apple_project_path(s: &Path) -> bool {
125    s == Path::new("apple")
126}
127
128impl Backend for AppleBackend {
129    const DEFAULT_PATH: &'static str = "apple";
130
131    // Preserve Xcode build caches during re-scaffolding.
132    const CACHE_PATHS: &'static [&'static str] = &["DerivedData"];
133
134    fn path(&self) -> &Path {
135        &self.project_path
136    }
137
138    async fn init(project: &Project) -> Result<Self, crate::backend::FailToInitBackend> {
139        let manifest = project.manifest();
140        // A `[backends.apple]` source override the manifest already carries is
141        // a user choice; init re-scaffolds the project without rewriting it.
142        let existing = manifest.backends.apple();
143
144        // For playground projects, use fixed scheme name "WaterUIApp"
145        // For regular projects, scheme name must match the Xcode target name (crate name)
146        let is_playground =
147            manifest.package.package_type == crate::project::PackageType::Playground;
148
149        // For playground projects, use fixed names
150        // For regular projects, derive from crate name
151        let (scheme, app_name, crate_name_for_template) = if is_playground {
152            (
153                "WaterUIApp".to_string(),
154                "WaterUIApp".to_string(),
155                CrateName::try_from("WaterUIApp").expect("playground crate name must be valid"),
156            )
157        } else {
158            let crate_name = project.crate_name().clone();
159            // App name for Swift code must be a valid Swift identifier (no hyphens)
160            // Convert "video-player-example" to "VideoPlayerExample"
161            let app_name = templates::apple_app_name(&crate_name);
162            (crate_name.to_string(), app_name, crate_name)
163        };
164
165        let project_path = default_apple_project_path();
166
167        let ios_permissions = manifest
168            .permissions
169            .iter()
170            .filter(|(_, entry)| entry.is_enabled())
171            .filter_map(|(key, entry)| {
172                key.ios_plist_key()
173                    .map(|plist_key| templates::IosPermissionTemplateEntry {
174                        plist_key,
175                        description: entry.description().to_string(),
176                    })
177            })
178            .collect();
179        let webview_enabled = project
180            .uses_standard_webview()
181            .await
182            .map_err(crate::backend::FailToInitBackend::Config)?;
183        let chromium_enabled = project
184            .links_runtime_package("waterui-chromium")
185            .await
186            .map_err(crate::backend::FailToInitBackend::Config)?;
187        let browser_engine = project
188            .linked_browser_engine()
189            .await
190            .map_err(crate::backend::FailToInitBackend::Config)?;
191        // The generated project names the launch assets the catalog will
192        // hold, so the two are decided from the same resolution.
193        let launch = crate::assets::project_launch_assets(project)
194            .map_err(crate::backend::FailToInitBackend::Config)?;
195        let launch_entry = templates::LaunchTemplateEntry {
196            has_background: launch.plan().background(ColorScheme::Light).is_some(),
197            has_image: launch.has_artwork(),
198        };
199        let ctx = TemplateContext::for_project_manifest(
200            manifest,
201            crate_name_for_template,
202            app_name,
203            &project
204                .resolved_framework()
205                .await
206                .map_err(crate::backend::FailToInitBackend::Config)?,
207        )
208        .with_backend_project_path(project.backend_path::<Self>())
209        .with_project_root_path(project.root().to_path_buf())
210        .with_ios_permissions(ios_permissions)
211        .with_webview_enabled(webview_enabled)
212        .with_chromium_enabled(chromium_enabled)
213        .with_browser_engine(browser_engine)
214        .with_launch(launch_entry);
215
216        templates::apple::scaffold(&project.backend_path::<Self>(), &ctx)
217            .await
218            .map_err(crate::backend::FailToInitBackend::Io)?;
219
220        Ok(Self {
221            project_path,
222            scheme,
223            branch: existing.and_then(|backend| backend.branch.clone()),
224            revision: existing.and_then(|backend| backend.revision.clone()),
225            backend_path: existing.and_then(|backend| backend.backend_path.clone()),
226        })
227    }
228
229    fn supports(&self, platform: TargetPlatform) -> bool {
230        is_apple_platform(platform)
231    }
232
233    async fn build(
234        &self,
235        project: &Project,
236        platform: TargetPlatform,
237        options: BuildOptions,
238    ) -> eyre::Result<PathBuf> {
239        project
240            .browser_runtime_plan(platform, TargetBackend::Apple)
241            .await?;
242        build_rust_lib(project, platform, options).await
243    }
244
245    async fn package(
246        &self,
247        project: &Project,
248        platform: TargetPlatform,
249        options: PackageOptions,
250    ) -> eyre::Result<Artifact> {
251        package_apple(project, platform, options).await
252    }
253
254    async fn clean(&self, project: &Project, _platform: TargetPlatform) -> eyre::Result<()> {
255        clean_apple(project).await
256    }
257}