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