Skip to main content

waterui_cli/apple/
platform.rs

1//! Apple platform build and package utilities.
2//!
3//! This module provides utility functions for building and packaging Apple apps.
4//! These functions are used by `AppleBackend` to implement the `Backend` trait.
5
6use std::collections::BTreeSet;
7use std::ffi::OsString;
8use std::path::{Path, PathBuf};
9
10use askama::Template;
11use eyre::{Context, bail};
12use smol::fs;
13use target_lexicon::Architecture;
14use tracing::{debug, info};
15
16#[cfg(target_os = "macos")]
17use crate::browser_runtime;
18#[cfg(target_os = "macos")]
19use crate::macos_bundle::{package_cef_helper_app, remove_cef_helper_apps, sign_macos_app};
20use crate::{
21    apple::backend::AppleBackend,
22    apple::dynamic_runtime,
23    assets::{self, ResolvedFont},
24    build::{BuildOptions, RustBuild, RustDynamicLibraries, RustLinkage},
25    device::Artifact,
26    platform::{PackageOptions, TargetBackend, TargetPlatform},
27    project::{BrowserRuntimePlan, Project, ResolvedWebViewBackend},
28    templates::FontRegistrationTemplateEntry,
29    toolchain::Host,
30    utils::{copy_file, run_command_os},
31};
32
33#[derive(Debug, Clone, Default, PartialEq, Eq)]
34struct AppleNativeLinkInputs {
35    archives: Vec<PathBuf>,
36    linker_flags: Vec<String>,
37}
38
39// ============================================================================
40// Build Utilities
41// ============================================================================
42
43/// The library shape an Apple build hands to Xcode.
44///
45/// A packaged app links the runtime into itself and needs a self-contained archive. A
46/// development build resolves the runtime from `libwaterui_dylib.dylib` at load time, so
47/// the archive's contents are redundant there: `ld` satisfies the symbols from the dylib
48/// and pulls almost nothing out of the archive, which is why the shipped executable comes
49/// out around 19 MB from a 428 MB input. Emitting a `cdylib` instead expresses the same
50/// final link without materializing the archive at all — 9.8 MB instead of 428 MB, and
51/// proportionally less I/O on machines whose storage is slower than the one this was
52/// measured on.
53#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54enum AppleHostLibrary {
55    /// Self-contained archive linked into a packaged application.
56    Archive,
57    /// Shared library that resolves the `WaterUI` runtime at load time.
58    Dynamic,
59}
60
61impl AppleHostLibrary {
62    const fn for_linkage(linkage: RustLinkage) -> Self {
63        match linkage {
64            RustLinkage::Static => Self::Archive,
65            RustLinkage::SharedRuntime => Self::Dynamic,
66        }
67    }
68
69    const fn crate_type(self) -> &'static str {
70        match self {
71            Self::Archive => "staticlib",
72            Self::Dynamic => "cdylib",
73        }
74    }
75
76    /// Extension Cargo gives the built artifact.
77    const fn built_extension(self) -> &'static str {
78        match self {
79            Self::Archive => "a",
80            Self::Dynamic => "dylib",
81        }
82    }
83
84    /// Name Xcode links against, via `-lwaterui_app` in `OTHER_LDFLAGS`.
85    const fn linked_file_name(self) -> &'static str {
86        match self {
87            Self::Archive => "libwaterui_app.a",
88            Self::Dynamic => "libwaterui_app.dylib",
89        }
90    }
91
92    /// The shape this build must delete, so `-lwaterui_app` cannot resolve to a stale
93    /// artifact left by a build of the other kind.
94    const fn superseded(self) -> Self {
95        match self {
96            Self::Archive => Self::Dynamic,
97            Self::Dynamic => Self::Archive,
98        }
99    }
100}
101
102/// Remove the host library shape this build did not produce.
103///
104/// `-lwaterui_app` resolves against whatever sits in the products directory, and `ld`
105/// prefers a `.dylib` over a `.a` when both are present. Leaving the previous build's
106/// artifact behind would let a packaging build silently link the development shared
107/// library, or leave a stale archive shadowing nothing at all.
108async fn remove_superseded_host_library(
109    directory: &Path,
110    produced: AppleHostLibrary,
111) -> eyre::Result<()> {
112    let stale = directory.join(produced.superseded().linked_file_name());
113    match fs::remove_file(&stale).await {
114        Ok(()) => Ok(()),
115        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
116        Err(error) => Err(error).wrap_err_with(|| {
117            format!(
118                "Failed to remove superseded host library {}",
119                stale.display()
120            )
121        }),
122    }
123}
124
125/// Cargo features an Apple FFI build resolves its dependency graph with.
126/// The `waterui-ffi` features an Apple runtime is compiled with.
127///
128/// Anything loaded into that runtime has to be compiled with the same set. Cargo
129/// unifies features per build and folds the result into the `-C metadata` hash it
130/// mangles into every symbol, so a module that enables one feature more or fewer
131/// than its host links against a runtime whose symbols no longer match. Both
132/// callers derive the set here rather than each listing it, so the two cannot
133/// drift apart.
134///
135/// # Errors
136///
137/// Returns an error when the project's enabled capabilities cannot be resolved.
138pub(crate) async fn apple_ffi_dependency_features(
139    project: &Project,
140    browser_runtime: BrowserRuntimePlan,
141) -> eyre::Result<Vec<String>> {
142    let mut features = vec!["waterui-ffi/c-api".to_string()];
143    features.extend(crate::project_model::assets::capability_ffi_features(project).await?);
144    if browser_runtime.chromium {
145        features.push("waterui-ffi/chromium".to_string());
146    }
147    if matches!(browser_runtime.webview, Some(ResolvedWebViewBackend::Cef)) {
148        features.push("waterui-ffi/webview-cef".to_string());
149    }
150    Ok(features)
151}
152
153async fn apple_ffi_build_features(
154    project: &Project,
155    browser_runtime: BrowserRuntimePlan,
156    linkage: RustLinkage,
157) -> eyre::Result<Vec<String>> {
158    let mut features = apple_ffi_dependency_features(project, browser_runtime).await?;
159    if linkage == RustLinkage::SharedRuntime {
160        features.push("dev".to_string());
161    }
162    Ok(features)
163}
164
165/// Build Rust library for an Apple platform.
166///
167/// # Errors
168/// Returns an error if the Rust build fails or the expected Apple archive cannot be copied.
169pub async fn build_rust_lib(
170    project: &Project,
171    platform: TargetPlatform,
172    options: BuildOptions,
173) -> eyre::Result<PathBuf> {
174    // Resolve fonts BEFORE cargo build - this ensures icons.json is downloaded
175    // for crates like fontawesome7 that need it during build.rs
176    let font_declarations = crate::assets::scan_fonts(project).await?;
177    let _resolved_fonts = crate::assets::resolve_fonts(font_declarations).await?;
178    let browser_runtime_plan = project
179        .browser_runtime_plan(platform, TargetBackend::Apple)
180        .await?;
181
182    let triple = options
183        .target_triple()
184        .cloned()
185        .unwrap_or_else(|| platform.triple());
186    let target = triple.to_string();
187    let target_underscore = target.replace('-', "_");
188    let host_library = AppleHostLibrary::for_linkage(options.linkage());
189    let mut build = RustBuild::new(project.ffi_crate_path(), triple.clone())
190        .with_project(project)
191        .with_features(
192            apple_ffi_build_features(project, browser_runtime_plan, options.linkage()).await?,
193        )
194        .with_crate_type_override(host_library.crate_type())
195        .with_envs(options.cargo_envs().iter().cloned());
196    if let Some(sccache_path) = options.sccache_path() {
197        build = build.with_sccache(sccache_path.to_path_buf());
198    }
199    build = build
200        .with_env("PKG_CONFIG_ALLOW_CROSS", "1")
201        .with_env(format!("PKG_CONFIG_ALLOW_CROSS_{target_underscore}"), "1")
202        .with_env(format!("PKG_CONFIG_ALLOW_CROSS_{target}"), "1");
203    let (deployment_environment, deployment_target) =
204        apple_deployment_target(project, platform).await?;
205    build = build.with_env(deployment_environment, deployment_target);
206    if options.linkage() == RustLinkage::SharedRuntime {
207        build = build.with_preferred_dynamic_linking();
208    }
209    build = build.with_target_dir(project.water_target_dir(options.linkage()).await?);
210    let lib_dir = build.build_lib(options.is_release()).await?;
211    if browser_runtime_plan.requires_cef() {
212        build
213            .clone()
214            .with_final_rustc_arg("-Clink-arg=-Wl,-rpath,@executable_path/../Frameworks")
215            .build_binary("waterui-cef-helper", options.is_release())
216            .await?;
217    }
218
219    // If output_dir is specified, copy the library there
220    if let Some(output_dir) = options.output_dir() {
221        let lib_name = project.ffi_crate_name().replace('-', "_");
222        let source_lib = lib_dir.join(format!("lib{lib_name}.{}", host_library.built_extension()));
223
224        if !source_lib.exists() {
225            bail!(
226                "Built library not found at {} (expected {} for Apple target {})",
227                source_lib.display(),
228                host_library.crate_type(),
229                triple
230            );
231        }
232        fs::create_dir_all(output_dir).await?;
233        let dest_lib = output_dir.join(host_library.linked_file_name());
234        copy_file(&source_lib, &dest_lib).await?;
235        remove_superseded_host_library(output_dir, host_library).await?;
236        if options.linkage() == RustLinkage::SharedRuntime {
237            let libraries = RustDynamicLibraries::resolve(&lib_dir, &triple).await?;
238            dynamic_runtime::prepare_host_runtime(libraries.waterui()).await?;
239            libraries.stage(output_dir).await?;
240        }
241    }
242
243    Ok(lib_dir)
244}
245
246/// Resolve the deployment-target environment variable an Apple build must carry.
247///
248/// # Errors
249///
250/// Returns an error when the Xcode project does not define exactly one value.
251pub async fn apple_deployment_target(
252    project: &Project,
253    platform: TargetPlatform,
254) -> eyre::Result<(&'static str, String)> {
255    let backend = project
256        .apple_backend()
257        .ok_or_else(|| eyre::eyre!("Apple backend must be configured"))?;
258    let (environment, build_setting) = match platform {
259        TargetPlatform::MacOS => ("MACOSX_DEPLOYMENT_TARGET", "MACOSX_DEPLOYMENT_TARGET"),
260        TargetPlatform::IOS | TargetPlatform::IOSSimulator => {
261            ("IPHONEOS_DEPLOYMENT_TARGET", "IPHONEOS_DEPLOYMENT_TARGET")
262        }
263        other => {
264            bail!("Platform {other:?} does not have an Apple deployment target");
265        }
266    };
267    let project_file = project
268        .backend_path::<AppleBackend>()
269        .join(format!("{}.xcodeproj", backend.scheme))
270        .join("project.pbxproj");
271    let contents = fs::read_to_string(&project_file)
272        .await
273        .wrap_err_with(|| format!("Failed to read {}", project_file.display()))?;
274    let target = unique_xcode_build_setting(&contents, build_setting)?;
275    Ok((environment, target))
276}
277
278fn unique_xcode_build_setting(contents: &str, key: &str) -> eyre::Result<String> {
279    let prefix = format!("{key} = ");
280    let values = contents
281        .lines()
282        .filter_map(|line| line.trim().strip_prefix(&prefix))
283        .filter_map(|value| value.strip_suffix(';'))
284        .map(|value| value.trim_matches('"').to_string())
285        .collect::<BTreeSet<_>>();
286    match values.len() {
287        1 => Ok(values.into_iter().next().expect("one build setting value")),
288        0 => {
289            bail!("Xcode project does not define {key}");
290        }
291        _ => {
292            bail!(
293                "Xcode project defines conflicting {key} values: {}",
294                values.into_iter().collect::<Vec<_>>().join(", ")
295            );
296        }
297    }
298}
299
300// ============================================================================
301// Validation
302// ============================================================================
303
304/// The local Apple backend `[backend.apple] backend_path` names is the
305/// checkout the generated project references — validate it is a real Swift
306/// package. `waterui_path` alone no longer supplies one: the framework
307/// checkout carries no `backends/apple` tree since the submodule was dropped.
308fn validate_local_apple_backend(project: &Project) -> eyre::Result<()> {
309    let Some(backend_path) = project
310        .manifest()
311        .backends
312        .apple()
313        .and_then(|backend| backend.backend_path.as_deref())
314    else {
315        return Ok(());
316    };
317
318    let backend_root = {
319        let candidate = PathBuf::from(backend_path);
320        if candidate.is_absolute() {
321            candidate
322        } else {
323            project.root().join(candidate)
324        }
325    };
326
327    let package_manifest = backend_root.join("Package.swift");
328    if package_manifest.exists() {
329        return Ok(());
330    }
331
332    bail!(
333        "`[backend.apple] backend_path` points at `{}`, which has no `Package.swift` — \
334         the Apple backend lives in its own repository now; point it at an \
335         `apple-backend` checkout, or remove `backend_path` to consume the pinned \
336         release from SwiftPM.",
337        backend_root.display()
338    );
339}
340
341async fn ensure_apple_linker_flags(
342    xcodeproj: &Path,
343    required_flags: &[String],
344) -> eyre::Result<()> {
345    let pbxproj_path = xcodeproj.join("project.pbxproj");
346    if !pbxproj_path.exists() {
347        return Ok(());
348    }
349
350    let content = fs::read_to_string(&pbxproj_path)
351        .await
352        .wrap_err_with(|| format!("Failed to read {}", pbxproj_path.display()))?;
353    let (updated, changed) = inject_other_ldflags(&content, required_flags);
354    if changed {
355        fs::write(&pbxproj_path, updated)
356            .await
357            .wrap_err_with(|| format!("Failed to write {}", pbxproj_path.display()))?;
358        info!(
359            "Updated {} with required Apple linker flags",
360            pbxproj_path.display()
361        );
362    }
363
364    Ok(())
365}
366
367fn inject_other_ldflags(content: &str, required_flags: &[String]) -> (String, bool) {
368    let mut changed = false;
369    let mut lines = Vec::new();
370    for line in content.lines() {
371        if line.contains("OTHER_LDFLAGS = \"")
372            && let Some((prefix, rest)) = line.split_once("OTHER_LDFLAGS = \"")
373            && let Some((flags, suffix)) = rest.split_once("\";")
374        {
375            let (mut merged, _) = sanitize_other_ldflags(flags);
376            for required in required_flags {
377                if !merged.contains(required) {
378                    if !merged.is_empty() {
379                        merged.push(' ');
380                    }
381                    merged.push_str(required);
382                }
383            }
384            let line_changed = merged != flags;
385            if line_changed {
386                changed = true;
387            }
388            lines.push(format!("{prefix}OTHER_LDFLAGS = \"{merged}\";{suffix}"));
389            continue;
390        }
391        lines.push(line.to_string());
392    }
393
394    let mut updated = lines.join("\n");
395    if content.ends_with('\n') {
396        updated.push('\n');
397    }
398    (updated, changed)
399}
400
401fn sanitize_other_ldflags(flags: &str) -> (String, bool) {
402    let normalized = flags
403        .split_whitespace()
404        .filter(|flag| !matches!(*flag, "-lwaterui_app" | "-lwaterui_dylib"))
405        .collect::<Vec<_>>()
406        .join(" ");
407    let changed = normalized != flags;
408    (normalized, changed)
409}
410
411async fn collect_apple_native_link_inputs(lib_dir: &Path) -> eyre::Result<AppleNativeLinkInputs> {
412    let lib_dir = lib_dir.to_path_buf();
413    smol::unblock(move || collect_apple_native_link_inputs_sync(&lib_dir)).await
414}
415
416fn collect_apple_native_link_inputs_sync(lib_dir: &Path) -> eyre::Result<AppleNativeLinkInputs> {
417    let build_root = lib_dir.join("build");
418    if !build_root.exists() {
419        return Ok(AppleNativeLinkInputs::default());
420    }
421
422    let mut archive_paths = BTreeSet::new();
423    let mut linker_flags = Vec::new();
424
425    for entry in std::fs::read_dir(&build_root)? {
426        let entry = entry?;
427        let crate_build_dir = entry.path();
428        if !crate_build_dir.is_dir() {
429            continue;
430        }
431
432        // Stable names each unit dir `<pkg>-<hash>`; current nightly nests one
433        // level deeper under `<pkg>/<hash>` (#901). A top-level dir that is a
434        // build-script unit is processed directly, otherwise its hash subdirs
435        // are.
436        if is_build_script_unit_dir(&crate_build_dir) {
437            collect_link_inputs_from_unit_dir(
438                &crate_build_dir,
439                &mut archive_paths,
440                &mut linker_flags,
441            )?;
442        } else {
443            for sub_entry in std::fs::read_dir(&crate_build_dir)?.flatten() {
444                let sub_dir = sub_entry.path();
445                if sub_dir.is_dir() && is_build_script_unit_dir(&sub_dir) {
446                    collect_link_inputs_from_unit_dir(
447                        &sub_dir,
448                        &mut archive_paths,
449                        &mut linker_flags,
450                    )?;
451                }
452            }
453        }
454    }
455
456    Ok(AppleNativeLinkInputs {
457        archives: archive_paths.into_iter().collect(),
458        linker_flags,
459    })
460}
461
462/// A build-script unit dir carries the script's captured stdout — `output` on
463/// stable, `run/stdout` on current nightly. Compile units get an `out/` dir
464/// for their own artifacts too, so `out/` alone is not proof of a
465/// build-script unit under nightly.
466fn is_build_script_unit_dir(dir: &Path) -> bool {
467    dir.join("output").is_file() || dir.join("run").join("stdout").is_file()
468}
469
470fn collect_link_inputs_from_unit_dir(
471    unit_dir: &Path,
472    archive_paths: &mut BTreeSet<PathBuf>,
473    linker_flags: &mut Vec<String>,
474) -> eyre::Result<()> {
475    // Every crate that ran a build script may have emitted
476    // `cargo:rustc-link-*` directives; `-sys` crates like
477    // `system-configuration-sys` emit only those, with no archive or Swift
478    // bridge artifact to show for it, so the parse cannot be gated on
479    // outputs.
480    for output_path in [unit_dir.join("output"), unit_dir.join("run").join("stdout")] {
481        if output_path.is_file() {
482            let output = std::fs::read_to_string(&output_path)?;
483            for flag in apple_linker_flags_from_build_output(&output) {
484                push_unique_flag(linker_flags, flag);
485            }
486        }
487    }
488
489    // For a build-script unit, `out/` is OUT_DIR (nightly records it in
490    // `run/root-output`), so a `lib*.a` inside is an artifact the crate ships
491    // for linking, with or without a Swift bridge alongside it.
492    let out_dir = unit_dir.join("out");
493    if !out_dir.is_dir() {
494        return Ok(());
495    }
496
497    for out_entry in std::fs::read_dir(&out_dir)? {
498        let out_entry = out_entry?;
499        let path = out_entry.path();
500        if path.extension().is_some_and(|ext| ext == "a")
501            && path
502                .file_name()
503                .and_then(|name| name.to_str())
504                .is_some_and(|name| name.starts_with("lib"))
505        {
506            archive_paths.insert(path.clone());
507            if let Some(flag) = static_archive_link_flag(&path) {
508                push_unique_flag(linker_flags, flag);
509            }
510        }
511    }
512    Ok(())
513}
514
515fn static_archive_link_flag(archive_path: &Path) -> Option<String> {
516    let file_name = archive_path.file_name()?.to_str()?;
517    let library_name = file_name
518        .strip_prefix("lib")?
519        .strip_suffix(".a")
520        .unwrap_or(file_name);
521    Some(format!("-l{library_name}"))
522}
523
524fn apple_linker_flags_from_build_output(output: &str) -> Vec<String> {
525    let mut flags = Vec::new();
526    for line in output.lines() {
527        if let Some(framework) = line.strip_prefix("cargo:rustc-link-lib=framework=") {
528            push_unique_flag(&mut flags, format!("-framework {framework}"));
529        } else if let Some(arg) = line.strip_prefix("cargo:rustc-link-arg=") {
530            push_unique_flag(&mut flags, arg.to_string());
531        }
532    }
533    flags
534}
535
536fn push_unique_flag(flags: &mut Vec<String>, flag: String) {
537    if !flags.iter().any(|existing| existing == &flag) {
538        flags.push(flag);
539    }
540}
541
542// ============================================================================
543// Clean
544// ============================================================================
545
546/// Clean Xcode build artifacts for an Apple platform.
547///
548/// # Errors
549/// Returns an error if `xcodebuild clean` fails or generated build directories cannot be removed.
550pub async fn clean_apple(project: &Project) -> eyre::Result<()> {
551    let Some(backend) = project.apple_backend() else {
552        return Ok(()); // Nothing to clean if no backend configured
553    };
554
555    let project_path = project.backend_path::<AppleBackend>();
556    let xcodeproj = project_path.join(format!("{}.xcodeproj", backend.scheme));
557
558    if !xcodeproj.exists() {
559        return Ok(());
560    }
561
562    let args: Vec<OsString> = vec![
563        "-project".into(),
564        xcodeproj.as_os_str().to_owned(),
565        "-scheme".into(),
566        backend.scheme.as_str().into(),
567        "clean".into(),
568    ];
569    run_command_os("xcodebuild", args).await?;
570
571    let build_dir = project_path.join("build");
572    if build_dir.exists() {
573        fs::remove_dir_all(&build_dir).await?;
574    }
575
576    Ok(())
577}
578
579// ============================================================================
580// Package
581// ============================================================================
582
583/// Package an Apple app using xcodebuild.
584///
585/// # Errors
586/// Returns an error if the backend is missing, packaging prerequisites are invalid, or `xcodebuild` fails.
587#[allow(clippy::too_many_lines)]
588pub async fn package_apple(
589    project: &Project,
590    platform: TargetPlatform,
591    options: PackageOptions,
592) -> eyre::Result<Artifact> {
593    let backend = project
594        .apple_backend()
595        .ok_or_else(|| eyre::eyre!("Apple backend must be configured"))?;
596    let browser_runtime_plan = project
597        .browser_runtime_plan(platform, TargetBackend::Apple)
598        .await?;
599
600    let project_path = project.backend_path::<AppleBackend>();
601    let xcodeproj = project_path.join(format!("{}.xcodeproj", backend.scheme));
602
603    if !xcodeproj.exists() {
604        bail!(
605            "Xcode project not found at {}. Did you run 'water create'?",
606            xcodeproj.display()
607        );
608    }
609
610    validate_local_apple_backend(project)?;
611
612    // Copy project assets and fonts
613    let app_resources_dir = project_path.join(&backend.scheme);
614    copy_assets_and_fonts(project, &app_resources_dir, None, options.uses_dev_server()).await?;
615
616    let configuration = if options.is_debug() {
617        "Debug"
618    } else {
619        "Release"
620    };
621
622    let derived_data = project_path.join("DerivedData");
623    let triple = platform.triple();
624
625    // Copy the built Rust library to where Xcode expects it
626    let linkage = if options.uses_shared_rust_runtime() {
627        RustLinkage::SharedRuntime
628    } else {
629        RustLinkage::Static
630    };
631    let lib_dir = RustBuild::new(project.ffi_crate_path(), triple.clone())
632        .with_target_dir(project.water_target_dir(linkage).await?)
633        .lib_output_dir(!options.is_debug())
634        .await
635        .wrap_err("Failed to resolve native FFI crate target directory")?;
636    let host_library = AppleHostLibrary::for_linkage(linkage);
637    let lib_name = project.ffi_crate_name().replace('-', "_");
638    let source_lib = lib_dir.join(format!("lib{lib_name}.{}", host_library.built_extension()));
639
640    // Get SDK name - must be an Apple platform
641    let sdk_name = platform
642        .sdk_name()
643        .ok_or_else(|| eyre::eyre!("Platform {:?} is not an Apple platform", platform))?;
644
645    // Xcode uses "Debug-iphonesimulator" for simulators, "Debug" for macOS
646    let products_config = if sdk_name == "macosx" {
647        configuration.to_string()
648    } else {
649        format!("{configuration}-{sdk_name}")
650    };
651    let products_dir = derived_data.join("Build/Products").join(&products_config);
652    fs::create_dir_all(&products_dir).await?;
653    // The bundle is named after the project, not after the scheme, so that
654    // macOS shows the application's own name rather than the scaffold's.
655    let product_name = crate::apple::backend::apple_product_name(project)?;
656    let app_path = products_dir.join(format!("{product_name}.app"));
657
658    #[cfg(target_os = "macos")]
659    if platform == TargetPlatform::MacOS {
660        browser_runtime::remove_macos_app(&app_path.join("Contents")).await?;
661        remove_cef_helper_apps(&app_path, product_name).await?;
662    }
663
664    let dest_lib = products_dir.join(host_library.linked_file_name());
665    copy_file(&source_lib, &dest_lib).await?;
666    remove_superseded_host_library(&products_dir, host_library).await?;
667    if host_library == AppleHostLibrary::Dynamic {
668        dynamic_runtime::set_rpath_install_name(&dest_lib, host_library.linked_file_name()).await?;
669    }
670
671    let shared_runtime = if options.uses_shared_rust_runtime() {
672        let libraries = RustDynamicLibraries::resolve(&lib_dir, &triple).await?;
673        dynamic_runtime::prepare_host_runtime(libraries.waterui()).await?;
674        libraries.stage(&products_dir).await?;
675        Some(libraries)
676    } else {
677        RustDynamicLibraries::remove_staged(&products_dir, &triple).await?;
678        None
679    };
680
681    let native_link_inputs = collect_apple_native_link_inputs(&lib_dir).await?;
682    for archive in &native_link_inputs.archives {
683        let file_name = archive.file_name().ok_or_else(|| {
684            eyre::eyre!(
685                "Bridge archive path had no file name: {}",
686                archive.display()
687            )
688        })?;
689        copy_file(archive, &products_dir.join(file_name)).await?;
690    }
691
692    let mut required_link_flags = vec![
693        "-framework VideoToolbox".to_string(),
694        // The Xcode project no longer names the Rust library, because its shape depends
695        // on the linkage this build selected. `-lwaterui_app` resolves to whichever of
696        // `libwaterui_app.a` / `libwaterui_app.dylib` this build left in
697        // `BUILT_PRODUCTS_DIR`; the other is removed so the choice is unambiguous.
698        "-lwaterui_app".to_string(),
699    ];
700    if shared_runtime.is_some() {
701        required_link_flags.push("-lwaterui_dylib".to_string());
702    }
703    for flag in native_link_inputs.linker_flags {
704        push_unique_flag(&mut required_link_flags, flag);
705    }
706    ensure_apple_linker_flags(&xcodeproj, &required_link_flags).await?;
707
708    // Build with xcodebuild
709    // Determine the Xcode arch name from the platform architecture
710    let arch_name = match platform.arch() {
711        Architecture::Aarch64(_) => "arm64",
712        Architecture::X86_64 => "x86_64",
713        other => {
714            bail!("Unsupported Apple architecture for xcodebuild ARCHS: {other:?}");
715        }
716    };
717    let archs_arg = format!("ARCHS={arch_name}");
718
719    let mut args = vec![
720        OsString::from("-project"),
721        xcodeproj.as_os_str().to_owned(),
722        OsString::from("-scheme"),
723        backend.scheme.as_str().into(),
724        OsString::from("-configuration"),
725        configuration.into(),
726        OsString::from("-sdk"),
727        sdk_name.into(),
728        OsString::from("-derivedDataPath"),
729        derived_data.as_os_str().to_owned(),
730        archs_arg.into(),
731        OsString::from("ONLY_ACTIVE_ARCH=YES"),
732        OsString::from("build"),
733    ];
734
735    // Physical Apple OSes refuse unsigned code in every profile, so signing
736    // is disabled only for simulators and for local macOS debug builds. For a
737    // device target the build sets automatic signing with the developer team
738    // resolved from the keychain — the generated Xcode project cannot know it.
739    let device_platform = matches!(
740        platform,
741        TargetPlatform::IOS
742            | TargetPlatform::TvOS
743            | TargetPlatform::WatchOS
744            | TargetPlatform::VisionOS
745    );
746    if device_platform {
747        let team = crate::apple::toolchain::development_team_id(&Host::current()).await?;
748        // `-allowProvisioningUpdates` lets automatic signing mint the
749        // development provisioning profile a fresh bundle id does not have
750        // yet; without it xcodebuild fails GatherProvisioningInputs.
751        args.extend([
752            OsString::from("-allowProvisioningUpdates"),
753            OsString::from("CODE_SIGN_STYLE=Automatic"),
754            OsString::from(format!("DEVELOPMENT_TEAM={team}")),
755        ]);
756    } else if platform.is_simulator() || options.is_debug() {
757        args.extend([
758            OsString::from("CODE_SIGNING_ALLOWED=NO"),
759            OsString::from("CODE_SIGNING_REQUIRED=NO"),
760            OsString::from("CODE_SIGN_IDENTITY=-"),
761        ]);
762    }
763
764    // Optional capabilities are compiled out of the backend unless the app
765    // enabled the matching FFI feature, which is what exports their symbols.
766    let swift_conditions = apple_swift_conditions(project).await?;
767    if !swift_conditions.is_empty() {
768        args.push(format!("OTHER_SWIFT_FLAGS={}", swift_conditions.join(" ")).into());
769    }
770
771    // Tell Xcode's run-script phases not to call `water build` again (the
772    // Rust library is already built). The flag is scoped to the xcodebuild
773    // child and propagates to its script phases; it must never be set on
774    // this process.
775    Host::current()
776        .with_env("WATERUI_SKIP_RUST_BUILD", "1")
777        .run("xcodebuild", args)
778        .await?;
779
780    if !app_path.exists() {
781        bail!(
782            "Built app not found at {}. Check xcodebuild output for errors.",
783            app_path.display()
784        );
785    }
786
787    let frameworks_dir = apple_frameworks_dir(&app_path, sdk_name);
788    if let Some(libraries) = shared_runtime {
789        fs::create_dir_all(&frameworks_dir).await?;
790        libraries.stage(&frameworks_dir).await?;
791        // The executable resolves `@rpath/libwaterui_app.dylib` through the bundle's
792        // Frameworks directory, the same way it resolves the shared runtime.
793        copy_file(
794            &dest_lib,
795            &frameworks_dir.join(host_library.linked_file_name()),
796        )
797        .await?;
798    } else {
799        RustDynamicLibraries::remove_staged(&frameworks_dir, &triple).await?;
800    }
801
802    // The dylibs staged above land after `xcodebuild` signed the bundle, so
803    // they carry no signature. A device refuses them at `dyld`; sign them
804    // with the identity Xcode resolved for the app.
805    #[cfg(target_os = "macos")]
806    if device_platform {
807        crate::macos_bundle::sign_staged_device_libraries(&app_path, &frameworks_dir).await?;
808    }
809
810    #[cfg(target_os = "macos")]
811    if platform == TargetPlatform::MacOS && browser_runtime_plan.requires_cef() {
812        browser_runtime::stage_macos_app(
813            browser_runtime_plan,
814            &lib_dir,
815            &app_path.join("Contents"),
816        )
817        .await?;
818        let main_binary = app_path.join("Contents/MacOS").join(product_name);
819        let helper_binary = lib_dir.join("waterui-cef-helper");
820        package_cef_helper_app(
821            &app_path,
822            &main_binary,
823            &helper_binary,
824            project.bundle_identifier(),
825        )
826        .await?;
827        let requires_stable_identity = project.manifest().permissions.iter().any(|(key, entry)| {
828            entry.is_enabled() && !key.macos_usage_description_keys().is_empty()
829        });
830        sign_macos_app(
831            &app_path,
832            project.bundle_identifier(),
833            requires_stable_identity,
834        )
835        .await?;
836    }
837
838    #[cfg(not(target_os = "macos"))]
839    let _ = browser_runtime_plan;
840
841    Ok(Artifact::new(project.bundle_identifier(), app_path))
842}
843
844fn apple_frameworks_dir(app_path: &Path, sdk_name: &str) -> PathBuf {
845    if sdk_name == "macosx" {
846        app_path.join("Contents/Frameworks")
847    } else {
848        app_path.join("Frameworks")
849    }
850}
851
852// ============================================================================
853// Asset and Font Handling
854// ============================================================================
855
856/// Copy project assets and dependency fonts to the app resources directory.
857async fn copy_assets_and_fonts(
858    project: &Project,
859    dest_dir: &Path,
860    sccache_path: Option<&Path>,
861    dev_server: bool,
862) -> eyre::Result<()> {
863    // Stage project assets using platform-native conventions.
864    let manifest =
865        assets::stage_project_assets_for_apple(project, dest_dir, sccache_path, dev_server).await?;
866
867    // Scan and resolve dependency fonts
868    let font_declarations = assets::scan_fonts(project).await?;
869    let mut resolved_fonts = assets::resolve_fonts(font_declarations).await?;
870    resolved_fonts.extend(assets::scan_project_font_assets(&manifest)?);
871
872    if !resolved_fonts.is_empty() {
873        // Copy fonts to app resources
874        let fonts_dest = dest_dir.join("fonts");
875        assets::copy_fonts(&resolved_fonts, &fonts_dest).await?;
876
877        // Generate WaterUIFonts.swift for font registration
878        generate_font_registration_swift(&resolved_fonts, dest_dir).await?;
879
880        info!("Copied {} fonts to Apple app", resolved_fonts.len());
881    }
882
883    Ok(())
884}
885
886#[derive(Template)]
887#[template(
888    path = "src/templates/apple/AppName/WaterUIFonts.swift.tpl",
889    escape = "none"
890)]
891struct WaterUiFontsSwiftTemplate<'a> {
892    font_entries: &'a [FontRegistrationTemplateEntry],
893}
894
895/// Generate WaterUIFonts.swift file for registering custom fonts.
896async fn generate_font_registration_swift(
897    fonts: &[ResolvedFont],
898    dest_dir: &Path,
899) -> eyre::Result<()> {
900    let font_entries = fonts
901        .iter()
902        .map(|font| FontRegistrationTemplateEntry {
903            family_name: font.name.clone(),
904            file_name: font
905                .path
906                .file_name()
907                .and_then(|n| n.to_str())
908                .unwrap_or_default()
909                .to_string(),
910        })
911        .collect::<Vec<_>>();
912
913    let content = WaterUiFontsSwiftTemplate {
914        font_entries: &font_entries,
915    }
916    .render()
917    .map_err(|error| eyre::eyre!("Failed to render WaterUIFonts.swift template: {error}"))?;
918
919    let swift_path = dest_dir.join("WaterUIFonts.swift");
920    fs::write(&swift_path, content).await?;
921
922    debug!("Generated {}", swift_path.display());
923
924    Ok(())
925}
926
927// ============================================================================
928// Platform Support Check
929// ============================================================================
930
931/// Check if a platform is supported by the Apple backend.
932#[must_use]
933pub const fn is_apple_platform(platform: TargetPlatform) -> bool {
934    matches!(
935        platform,
936        TargetPlatform::MacOS
937            | TargetPlatform::IOS
938            | TargetPlatform::IOSSimulator
939            | TargetPlatform::TvOS
940            | TargetPlatform::TvOSSimulator
941            | TargetPlatform::WatchOS
942            | TargetPlatform::WatchOSSimulator
943            | TargetPlatform::VisionOS
944            | TargetPlatform::VisionOSSimulator
945    )
946}
947
948/// Swift compilation conditions matching the optional capabilities this app's
949/// graph carries, so the backend compiles exactly the components whose symbols
950/// exist.
951///
952/// The decision must come from [`assets::capability_enabled`] — the same
953/// predicate that forwards each capability's feature to the FFI build. The FFI
954/// features travel on the build command line, never into the generated
955/// manifest, so re-resolving `waterui-ffi`'s features from the manifest graph
956/// reads every capability as off and prunes components whose symbols the
957/// dylib does export.
958///
959/// The two lists mirror each capability's default polarity, so a bare
960/// `swift build` of the backend package — no conditions at all — still
961/// compiles what a default-featured app links. A default-off capability gets a
962/// positive condition when carried; a default-on capability gets a negative
963/// condition when dropped.
964///
965/// [`assets::capability_enabled`]: crate::project_model::assets::capability_enabled
966async fn apple_swift_conditions(project: &Project) -> eyre::Result<Vec<String>> {
967    /// Default-off capabilities, named when the app's graph carries them.
968    const OPTIONAL_COMPONENTS: &[(&str, &str)] =
969        &[("map", "WATERUI_MAP"), ("webview", "WATERUI_WEBVIEW")];
970    /// Default-on capabilities, named when the app's graph drops them.
971    const DEFAULT_COMPONENTS: &[(&str, &str)] =
972        &[("gpu", "WATERUI_NO_GPU"), ("media", "WATERUI_NO_MEDIA")];
973
974    let mut conditions = Vec::new();
975    for (capability, condition) in OPTIONAL_COMPONENTS {
976        if crate::project_model::assets::capability_enabled(project, capability).await? {
977            conditions.push(format!("-D{condition}"));
978        }
979    }
980    for (capability, condition) in DEFAULT_COMPONENTS {
981        if !crate::project_model::assets::capability_enabled(project, capability).await? {
982            conditions.push(format!("-D{condition}"));
983        }
984    }
985    Ok(conditions)
986}
987
988#[cfg(test)]
989mod tests {
990    use tempfile::tempdir;
991
992    use super::{
993        apple_linker_flags_from_build_output, collect_apple_native_link_inputs_sync,
994        inject_other_ldflags, unique_xcode_build_setting,
995    };
996
997    #[test]
998    fn derives_a_unique_xcode_deployment_target() {
999        let settings = "MACOSX_DEPLOYMENT_TARGET = 15.0;\nMACOSX_DEPLOYMENT_TARGET = 15.0;\n";
1000        assert_eq!(
1001            unique_xcode_build_setting(settings, "MACOSX_DEPLOYMENT_TARGET")
1002                .expect("unique deployment target"),
1003            "15.0"
1004        );
1005    }
1006
1007    #[test]
1008    fn rejects_conflicting_xcode_deployment_targets() {
1009        let settings = "MACOSX_DEPLOYMENT_TARGET = 14.0;\nMACOSX_DEPLOYMENT_TARGET = 15.0;\n";
1010        let error = unique_xcode_build_setting(settings, "MACOSX_DEPLOYMENT_TARGET")
1011            .expect_err("conflicting targets must fail");
1012        assert!(error.to_string().contains("14.0, 15.0"));
1013    }
1014
1015    #[test]
1016    fn injects_required_apple_frameworks_into_other_ldflags() {
1017        let input =
1018            "OTHER_LDFLAGS = \"-lwaterui_app -lc++\";\nOTHER_LDFLAGS = \"-lwaterui_app -lc++\";\n";
1019        let required_flags = vec!["-framework VideoToolbox".to_string()];
1020        let (output, changed) = inject_other_ldflags(input, &required_flags);
1021        assert!(changed);
1022        assert_eq!(output.matches("-framework VideoToolbox").count(), 2);
1023        assert!(!output.contains("-lwaterui_app"));
1024    }
1025
1026    #[test]
1027    fn linker_flag_injection_is_idempotent() {
1028        let input = "OTHER_LDFLAGS = \"-lc++ -framework VideoToolbox\";\n";
1029        let required_flags = vec!["-framework VideoToolbox".to_string()];
1030        let (output, changed) = inject_other_ldflags(input, &required_flags);
1031        assert!(!changed);
1032        assert_eq!(output, input);
1033    }
1034
1035    #[test]
1036    fn removes_redundant_waterui_app_link_flag() {
1037        let input = "OTHER_LDFLAGS = \"-lwaterui_app -lc++ -framework VideoToolbox\";\n";
1038        let required_flags = vec!["-framework VideoToolbox".to_string()];
1039        let (output, changed) = inject_other_ldflags(input, &required_flags);
1040        assert!(changed);
1041        assert_eq!(
1042            output,
1043            "OTHER_LDFLAGS = \"-lc++ -framework VideoToolbox\";\n"
1044        );
1045    }
1046
1047    #[test]
1048    fn switches_between_shared_runtime_and_static_link_flags() {
1049        let input = "OTHER_LDFLAGS = \"-lc++ -framework VideoToolbox\";\n";
1050        let dynamic_flags = vec![
1051            "-framework VideoToolbox".to_string(),
1052            "-lwaterui_dylib".to_string(),
1053        ];
1054        let (dynamic, changed) = inject_other_ldflags(input, &dynamic_flags);
1055        assert!(changed);
1056        assert!(dynamic.contains("-lwaterui_dylib"));
1057
1058        let static_flags = vec!["-framework VideoToolbox".to_string()];
1059        let (static_linked, changed) = inject_other_ldflags(&dynamic, &static_flags);
1060        assert!(changed);
1061        assert_eq!(static_linked, input);
1062    }
1063
1064    #[test]
1065    fn parses_frameworks_and_link_args_from_build_output() {
1066        let output = "cargo:rustc-link-lib=framework=AppKit\ncargo:rustc-link-arg=-rpath\ncargo:rustc-link-arg=/usr/lib/swift\ncargo:rustc-link-lib=framework=Foundation\n";
1067        let flags = apple_linker_flags_from_build_output(output);
1068        assert_eq!(
1069            flags,
1070            vec![
1071                "-framework AppKit".to_string(),
1072                "-rpath".to_string(),
1073                "/usr/lib/swift".to_string(),
1074                "-framework Foundation".to_string()
1075            ]
1076        );
1077    }
1078
1079    #[test]
1080    fn collects_swift_bridge_archives_and_flags_from_target_build_dir() {
1081        let dir = tempdir().expect("tempdir");
1082        let lib_dir = dir.path().join("aarch64-apple-darwin/debug");
1083        let build_dir = lib_dir.join("build/waterkit-haptic-1234");
1084        let out_dir = build_dir.join("out");
1085        std::fs::create_dir_all(&out_dir).expect("create out dir");
1086        std::fs::write(out_dir.join("CombinedHelper.swift"), "// bridge").expect("write swift");
1087        std::fs::write(out_dir.join("libHelper.a"), "").expect("write archive");
1088        std::fs::write(
1089            build_dir.join("output"),
1090            "cargo:rustc-link-lib=framework=AppKit\ncargo:rustc-link-arg=-rpath\ncargo:rustc-link-arg=/usr/lib/swift\n",
1091        )
1092        .expect("write build output");
1093
1094        let link_inputs =
1095            collect_apple_native_link_inputs_sync(&lib_dir).expect("collect native link inputs");
1096
1097        assert_eq!(link_inputs.archives, vec![out_dir.join("libHelper.a")]);
1098        assert_eq!(
1099            link_inputs.linker_flags,
1100            vec![
1101                "-framework AppKit".to_string(),
1102                "-rpath".to_string(),
1103                "/usr/lib/swift".to_string(),
1104                "-lHelper".to_string()
1105            ]
1106        );
1107    }
1108
1109    #[test]
1110    fn collects_link_flags_from_crates_without_swift_or_archives() {
1111        // A `-sys` crate that only emits `cargo:rustc-link-lib` directives has
1112        // nothing in `out/`; its flags must still reach the linker.
1113        let dir = tempdir().expect("tempdir");
1114        let lib_dir = dir.path().join("aarch64-apple-darwin/release");
1115        let sys_build_dir = lib_dir.join("build/system-configuration-sys-1234");
1116        std::fs::create_dir_all(sys_build_dir.join("out")).expect("create out dir");
1117        std::fs::write(
1118            sys_build_dir.join("output"),
1119            "cargo:rustc-link-lib=framework=SystemConfiguration\n",
1120        )
1121        .expect("write build output");
1122
1123        // A crate can also ship an archive with no Swift bridge at all; the
1124        // archive and its `-l` flag must still be collected.
1125        let plain_build_dir = lib_dir.join("build/some-native-5678");
1126        let plain_out_dir = plain_build_dir.join("out");
1127        std::fs::create_dir_all(&plain_out_dir).expect("create out dir");
1128        std::fs::write(plain_out_dir.join("libwrapper.a"), "").expect("write archive");
1129        std::fs::write(
1130            plain_build_dir.join("output"),
1131            "cargo:rustc-link-lib=static=wrapper\n",
1132        )
1133        .expect("write build output");
1134
1135        let link_inputs =
1136            collect_apple_native_link_inputs_sync(&lib_dir).expect("collect native link inputs");
1137
1138        assert_eq!(
1139            link_inputs.archives,
1140            vec![plain_out_dir.join("libwrapper.a")]
1141        );
1142        let mut flags = link_inputs.linker_flags;
1143        flags.sort_unstable();
1144        assert_eq!(
1145            flags,
1146            vec![
1147                "-framework SystemConfiguration".to_string(),
1148                "-lwrapper".to_string()
1149            ]
1150        );
1151    }
1152
1153    #[test]
1154    fn collects_framework_flags_from_crates_without_swift_archives() {
1155        let dir = tempdir().expect("tempdir");
1156        let lib_dir = dir.path().join("aarch64-apple-darwin/debug");
1157        let build_dir = lib_dir.join("build/system-configuration-sys-1234");
1158        std::fs::create_dir_all(build_dir.join("out")).expect("create out dir");
1159        std::fs::write(
1160            build_dir.join("output"),
1161            "cargo:rustc-link-lib=framework=SystemConfiguration\n",
1162        )
1163        .expect("write build output");
1164
1165        let link_inputs =
1166            collect_apple_native_link_inputs_sync(&lib_dir).expect("collect native link inputs");
1167
1168        assert!(link_inputs.archives.is_empty());
1169        assert_eq!(
1170            link_inputs.linker_flags,
1171            vec!["-framework SystemConfiguration".to_string()]
1172        );
1173    }
1174    #[test]
1175    fn collects_link_inputs_from_nightly_build_layout() {
1176        // Nightly cargo nests unit dirs as `build/<pkg>/<hash>` and records the
1177        // script's captured stdout at `run/stdout` instead of `output` (#901).
1178        let dir = tempdir().expect("tempdir");
1179        let lib_dir = dir.path().join("aarch64-apple-darwin/debug");
1180        let sys_unit = lib_dir.join("build/system-configuration-sys/1234abcd");
1181        std::fs::create_dir_all(sys_unit.join("run")).expect("create run dir");
1182        std::fs::write(
1183            sys_unit.join("run/stdout"),
1184            "cargo:rustc-link-lib=framework=SystemConfiguration\n",
1185        )
1186        .expect("write build stdout");
1187
1188        // A compile unit's `out/` holds its own artifacts, not OUT_DIR — it
1189        // must not be mined for archives.
1190        let compile_unit = lib_dir.join("build/plain-crate/5678efgh");
1191        std::fs::create_dir_all(compile_unit.join("out")).expect("create out dir");
1192        std::fs::write(compile_unit.join("out/libplain_crate.a"), "").expect("write archive");
1193
1194        let link_inputs =
1195            collect_apple_native_link_inputs_sync(&lib_dir).expect("collect native link inputs");
1196
1197        assert!(link_inputs.archives.is_empty());
1198        assert_eq!(
1199            link_inputs.linker_flags,
1200            vec!["-framework SystemConfiguration".to_string()]
1201        );
1202    }
1203}