1use 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::{
25 BuildOptions, BuildProgress, BuiltTarget, RustBuild, RustDynamicLibraries, RustLinkage,
26 },
27 device::Artifact,
28 platform::{PackageOptions, TargetBackend, TargetPlatform},
29 project::{BrowserRuntimePlan, Project, ResolvedWebViewBackend},
30 templates::FontRegistrationTemplateEntry,
31 toolchain::Host,
32 utils::{copy_file, run_command_os},
33};
34
35#[derive(Debug, Clone, Default, PartialEq, Eq)]
36struct AppleNativeLinkInputs {
37 archives: Vec<PathBuf>,
38 linker_flags: Vec<String>,
39}
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56enum AppleHostLibrary {
57 Archive,
59 Dynamic,
61}
62
63impl AppleHostLibrary {
64 const fn for_linkage(linkage: RustLinkage) -> Self {
65 match linkage {
66 RustLinkage::Static => Self::Archive,
67 RustLinkage::SharedRuntime => Self::Dynamic,
68 }
69 }
70
71 const fn crate_type(self) -> &'static str {
72 match self {
73 Self::Archive => "staticlib",
74 Self::Dynamic => "cdylib",
75 }
76 }
77
78 const fn linked_file_name(self) -> &'static str {
80 match self {
81 Self::Archive => "libwaterui_app.a",
82 Self::Dynamic => "libwaterui_app.dylib",
83 }
84 }
85
86 const fn superseded(self) -> Self {
89 match self {
90 Self::Archive => Self::Dynamic,
91 Self::Dynamic => Self::Archive,
92 }
93 }
94}
95
96async fn remove_superseded_host_library(
103 directory: &Path,
104 produced: AppleHostLibrary,
105) -> eyre::Result<()> {
106 let stale = directory.join(produced.superseded().linked_file_name());
107 match fs::remove_file(&stale).await {
108 Ok(()) => Ok(()),
109 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
110 Err(error) => Err(error).wrap_err_with(|| {
111 format!(
112 "Failed to remove superseded host library {}",
113 stale.display()
114 )
115 }),
116 }
117}
118
119pub(crate) async fn apple_ffi_dependency_features(
133 project: &Project,
134 browser_runtime: BrowserRuntimePlan,
135) -> eyre::Result<Vec<String>> {
136 let build_manifest = project.ffi_crate_path().join("Cargo.toml");
137 let mut features = vec!["waterui-ffi/c-api".to_string()];
138 features.extend(
139 crate::project_model::assets::capability_ffi_features(project, &build_manifest).await?,
140 );
141 if browser_runtime.chromium {
142 features.push("waterui-ffi/chromium".to_string());
143 }
144 if matches!(browser_runtime.webview, Some(ResolvedWebViewBackend::Cef)) {
145 features.push("waterui-ffi/webview-cef".to_string());
146 }
147 Ok(features)
148}
149
150async fn apple_ffi_build_features(
151 project: &Project,
152 browser_runtime: BrowserRuntimePlan,
153 linkage: RustLinkage,
154) -> eyre::Result<Vec<String>> {
155 let mut features = apple_ffi_dependency_features(project, browser_runtime).await?;
156 if linkage == RustLinkage::SharedRuntime {
157 features.push("dev".to_string());
158 }
159 Ok(features)
160}
161
162pub async fn build_rust_lib(
167 project: &Project,
168 platform: TargetPlatform,
169 options: BuildOptions,
170) -> eyre::Result<BuiltTarget> {
171 let font_declarations =
174 crate::assets::scan_fonts(project, &project.ffi_crate_path().join("Cargo.toml")).await?;
175 let _resolved_fonts = crate::assets::resolve_fonts(font_declarations).await?;
176 let browser_runtime_plan = project
177 .browser_runtime_plan(platform, TargetBackend::Apple)
178 .await?;
179
180 let triple = options
181 .target_triple()
182 .cloned()
183 .unwrap_or_else(|| platform.triple());
184 let target = triple.to_string();
185 let target_underscore = target.replace('-', "_");
186 let host_library = AppleHostLibrary::for_linkage(options.linkage());
187 let mut build = RustBuild::new(project.ffi_crate_path(), triple.clone())
188 .with_project(project)
189 .with_features(
190 apple_ffi_build_features(project, browser_runtime_plan, options.linkage()).await?,
191 )
192 .with_crate_type_override(host_library.crate_type())
193 .with_envs(options.cargo_envs().iter().cloned());
194 if let Some(sccache_path) = options.sccache_path() {
195 build = build.with_sccache(sccache_path.to_path_buf());
196 }
197 if let Some(progress) = options.progress() {
198 build = build.with_progress(progress.clone());
199 }
200 build = build
201 .with_env("PKG_CONFIG_ALLOW_CROSS", "1")
202 .with_env(format!("PKG_CONFIG_ALLOW_CROSS_{target_underscore}"), "1")
203 .with_env(format!("PKG_CONFIG_ALLOW_CROSS_{target}"), "1");
204 let (deployment_environment, deployment_target) =
205 apple_deployment_target(project, platform).await?;
206 build = build.with_env(deployment_environment, deployment_target);
207 if options.linkage() == RustLinkage::SharedRuntime {
208 build = build.with_preferred_dynamic_linking();
209 }
210 build = build.with_target_dir(project.water_target_dir(options.linkage()).await?);
211 let built_target = build.build_lib(options.is_release()).await?;
212 if project.declares_cef_helper().await? {
216 build
217 .clone()
218 .with_final_rustc_arg("-Clink-arg=-Wl,-rpath,@executable_path/../Frameworks")
219 .build_binary(
220 &crate::project_model::project_types::cef_helper_binary_name(
221 project.ffi_crate_name().as_str(),
222 ),
223 options.is_release(),
224 )
225 .await?;
226 }
227
228 if let Some(output_dir) = options.output_dir() {
230 fs::create_dir_all(output_dir).await?;
231 let dest_lib = output_dir.join(host_library.linked_file_name());
232 copy_file(&built_target.artifact, &dest_lib).await?;
233 remove_superseded_host_library(output_dir, host_library).await?;
234 if options.linkage() == RustLinkage::SharedRuntime {
235 let libraries = RustDynamicLibraries::resolve(&built_target, &triple, project).await?;
236 dynamic_runtime::prepare_host_runtime(libraries.waterui()).await?;
237 libraries.stage(output_dir).await?;
238 }
239 }
240
241 Ok(built_target)
242}
243
244pub async fn apple_deployment_target(
250 project: &Project,
251 platform: TargetPlatform,
252) -> eyre::Result<(&'static str, String)> {
253 let backend = project
254 .apple_backend()
255 .ok_or_else(|| eyre::eyre!("Apple backend must be configured"))?;
256 let (environment, build_setting) = match platform {
257 TargetPlatform::MacOS => ("MACOSX_DEPLOYMENT_TARGET", "MACOSX_DEPLOYMENT_TARGET"),
258 TargetPlatform::IOS | TargetPlatform::IOSSimulator => {
259 ("IPHONEOS_DEPLOYMENT_TARGET", "IPHONEOS_DEPLOYMENT_TARGET")
260 }
261 other => {
262 bail!("Platform {other:?} does not have an Apple deployment target");
263 }
264 };
265 let project_file = project
266 .backend_path::<AppleBackend>()
267 .join(format!("{}.xcodeproj", backend.scheme))
268 .join("project.pbxproj");
269 let contents = fs::read_to_string(&project_file)
270 .await
271 .wrap_err_with(|| format!("Failed to read {}", project_file.display()))?;
272 let target = unique_xcode_build_setting(&contents, build_setting)?;
273 Ok((environment, target))
274}
275
276fn unique_xcode_build_setting(contents: &str, key: &str) -> eyre::Result<String> {
277 let prefix = format!("{key} = ");
278 let values = contents
279 .lines()
280 .filter_map(|line| line.trim().strip_prefix(&prefix))
281 .filter_map(|value| value.strip_suffix(';'))
282 .map(|value| value.trim_matches('"').to_string())
283 .collect::<BTreeSet<_>>();
284 match values.len() {
285 1 => Ok(values.into_iter().next().expect("one build setting value")),
286 0 => {
287 bail!("Xcode project does not define {key}");
288 }
289 _ => {
290 bail!(
291 "Xcode project defines conflicting {key} values: {}",
292 values.into_iter().collect::<Vec<_>>().join(", ")
293 );
294 }
295 }
296}
297
298fn validate_local_apple_backend(project: &Project) -> eyre::Result<()> {
307 let Some(backend_path) = project
308 .manifest()
309 .backends
310 .apple()
311 .and_then(|backend| backend.backend_path.as_deref())
312 else {
313 return Ok(());
314 };
315
316 let backend_root = {
317 let candidate = PathBuf::from(backend_path);
318 if candidate.is_absolute() {
319 candidate
320 } else {
321 project.root().join(candidate)
322 }
323 };
324
325 let package_manifest = backend_root.join("Package.swift");
326 if package_manifest.exists() {
327 return Ok(());
328 }
329
330 bail!(
331 "`[backend.apple] backend_path` points at `{}`, which has no `Package.swift` — \
332 the Apple backend lives in its own repository now; point it at an \
333 `apple-backend` checkout, or remove `backend_path` to consume the pinned \
334 release from SwiftPM.",
335 backend_root.display()
336 );
337}
338
339async fn ensure_apple_linker_flags(
340 xcodeproj: &Path,
341 required_flags: &[String],
342) -> eyre::Result<()> {
343 let pbxproj_path = xcodeproj.join("project.pbxproj");
344 if !pbxproj_path.exists() {
345 return Ok(());
346 }
347
348 let content = fs::read_to_string(&pbxproj_path)
349 .await
350 .wrap_err_with(|| format!("Failed to read {}", pbxproj_path.display()))?;
351 let (updated, changed) = inject_other_ldflags(&content, required_flags);
352 if changed {
353 fs::write(&pbxproj_path, updated)
354 .await
355 .wrap_err_with(|| format!("Failed to write {}", pbxproj_path.display()))?;
356 info!(
357 "Updated {} with required Apple linker flags",
358 pbxproj_path.display()
359 );
360 }
361
362 Ok(())
363}
364
365fn inject_other_ldflags(content: &str, required_flags: &[String]) -> (String, bool) {
366 let mut changed = false;
367 let mut lines = Vec::new();
368 for line in content.lines() {
369 if line.contains("OTHER_LDFLAGS = \"")
370 && let Some((prefix, rest)) = line.split_once("OTHER_LDFLAGS = \"")
371 && let Some((flags, suffix)) = rest.split_once("\";")
372 {
373 let (mut merged, _) = sanitize_other_ldflags(flags);
374 for required in required_flags {
375 if !merged.contains(required) {
376 if !merged.is_empty() {
377 merged.push(' ');
378 }
379 merged.push_str(required);
380 }
381 }
382 let line_changed = merged != flags;
383 if line_changed {
384 changed = true;
385 }
386 lines.push(format!("{prefix}OTHER_LDFLAGS = \"{merged}\";{suffix}"));
387 continue;
388 }
389 lines.push(line.to_string());
390 }
391
392 let mut updated = lines.join("\n");
393 if content.ends_with('\n') {
394 updated.push('\n');
395 }
396 (updated, changed)
397}
398
399fn sanitize_other_ldflags(flags: &str) -> (String, bool) {
400 let normalized = flags
401 .split_whitespace()
402 .filter(|flag| !matches!(*flag, "-lwaterui_app" | "-lwaterui_dylib"))
403 .collect::<Vec<_>>()
404 .join(" ");
405 let changed = normalized != flags;
406 (normalized, changed)
407}
408
409async fn collect_apple_native_link_inputs(lib_dir: &Path) -> eyre::Result<AppleNativeLinkInputs> {
410 let lib_dir = lib_dir.to_path_buf();
411 smol::unblock(move || collect_apple_native_link_inputs_sync(&lib_dir)).await
412}
413
414fn collect_apple_native_link_inputs_sync(lib_dir: &Path) -> eyre::Result<AppleNativeLinkInputs> {
415 let build_root = lib_dir.join("build");
416 if !build_root.exists() {
417 return Ok(AppleNativeLinkInputs::default());
418 }
419
420 let mut archive_paths = BTreeSet::new();
421 let mut linker_flags = Vec::new();
422
423 for entry in std::fs::read_dir(&build_root)? {
424 let entry = entry?;
425 let crate_build_dir = entry.path();
426 if !crate_build_dir.is_dir() {
427 continue;
428 }
429
430 if is_build_script_unit_dir(&crate_build_dir) {
435 collect_link_inputs_from_unit_dir(
436 &crate_build_dir,
437 &mut archive_paths,
438 &mut linker_flags,
439 )?;
440 } else {
441 for sub_entry in std::fs::read_dir(&crate_build_dir)?.flatten() {
442 let sub_dir = sub_entry.path();
443 if sub_dir.is_dir() && is_build_script_unit_dir(&sub_dir) {
444 collect_link_inputs_from_unit_dir(
445 &sub_dir,
446 &mut archive_paths,
447 &mut linker_flags,
448 )?;
449 }
450 }
451 }
452 }
453
454 Ok(AppleNativeLinkInputs {
455 archives: archive_paths.into_iter().collect(),
456 linker_flags,
457 })
458}
459
460fn is_build_script_unit_dir(dir: &Path) -> bool {
465 dir.join("output").is_file() || dir.join("run").join("stdout").is_file()
466}
467
468fn collect_link_inputs_from_unit_dir(
469 unit_dir: &Path,
470 archive_paths: &mut BTreeSet<PathBuf>,
471 linker_flags: &mut Vec<String>,
472) -> eyre::Result<()> {
473 for output_path in [unit_dir.join("output"), unit_dir.join("run").join("stdout")] {
479 if output_path.is_file() {
480 let output = std::fs::read_to_string(&output_path)?;
481 for flag in apple_linker_flags_from_build_output(&output) {
482 push_unique_flag(linker_flags, flag);
483 }
484 }
485 }
486
487 let out_dir = unit_dir.join("out");
491 if !out_dir.is_dir() {
492 return Ok(());
493 }
494
495 for out_entry in std::fs::read_dir(&out_dir)? {
496 let out_entry = out_entry?;
497 let path = out_entry.path();
498 if path.extension().is_some_and(|ext| ext == "a")
499 && path
500 .file_name()
501 .and_then(|name| name.to_str())
502 .is_some_and(|name| name.starts_with("lib"))
503 {
504 archive_paths.insert(path.clone());
505 if let Some(flag) = static_archive_link_flag(&path) {
506 push_unique_flag(linker_flags, flag);
507 }
508 }
509 }
510 Ok(())
511}
512
513fn static_archive_link_flag(archive_path: &Path) -> Option<String> {
514 let file_name = archive_path.file_name()?.to_str()?;
515 let library_name = file_name
516 .strip_prefix("lib")?
517 .strip_suffix(".a")
518 .unwrap_or(file_name);
519 Some(format!("-l{library_name}"))
520}
521
522fn apple_linker_flags_from_build_output(output: &str) -> Vec<String> {
523 let mut flags = Vec::new();
524 for line in output.lines() {
525 if let Some(framework) = line.strip_prefix("cargo:rustc-link-lib=framework=") {
526 push_unique_flag(&mut flags, format!("-framework {framework}"));
527 } else if let Some(arg) = line.strip_prefix("cargo:rustc-link-arg=") {
528 push_unique_flag(&mut flags, arg.to_string());
529 } else if let Some(search) = line.strip_prefix("cargo:rustc-link-search=") {
530 let (kind, dir) = search
536 .split_once('=')
537 .map_or(("all", search), |(kind, dir)| (kind, dir));
538 match kind {
539 "framework" => push_unique_flag(&mut flags, format!("-F{dir}")),
540 "native" | "all" => push_unique_flag(&mut flags, format!("-L{dir}")),
541 _ => {}
544 }
545 }
546 }
547 flags
548}
549
550fn push_unique_flag(flags: &mut Vec<String>, flag: String) {
551 if !flags.iter().any(|existing| existing == &flag) {
552 flags.push(flag);
553 }
554}
555
556pub async fn clean_apple(project: &Project) -> eyre::Result<()> {
565 let Some(backend) = project.apple_backend() else {
566 return Ok(()); };
568
569 let project_path = project.backend_path::<AppleBackend>();
570 let xcodeproj = project_path.join(format!("{}.xcodeproj", backend.scheme));
571
572 if !xcodeproj.exists() {
573 return Ok(());
574 }
575
576 let args: Vec<OsString> = vec![
577 "-project".into(),
578 xcodeproj.as_os_str().to_owned(),
579 "-scheme".into(),
580 backend.scheme.as_str().into(),
581 "clean".into(),
582 ];
583 run_command_os("xcodebuild", args).await?;
584
585 let build_dir = project_path.join("build");
586 if build_dir.exists() {
587 fs::remove_dir_all(&build_dir).await?;
588 }
589
590 Ok(())
591}
592
593#[allow(clippy::too_many_lines)]
602pub async fn package_apple(
603 project: &Project,
604 platform: TargetPlatform,
605 options: PackageOptions,
606 built: &BuiltTarget,
607) -> eyre::Result<Artifact> {
608 let backend = project
609 .apple_backend()
610 .ok_or_else(|| eyre::eyre!("Apple backend must be configured"))?;
611 let browser_runtime_plan = project
612 .browser_runtime_plan(platform, TargetBackend::Apple)
613 .await?;
614
615 let project_path = project.backend_path::<AppleBackend>();
616 let xcodeproj = project_path.join(format!("{}.xcodeproj", backend.scheme));
617
618 if !xcodeproj.exists() {
619 bail!(
620 "Xcode project not found at {}. Did you run 'water create'?",
621 xcodeproj.display()
622 );
623 }
624
625 validate_local_apple_backend(project)?;
626
627 let app_resources_dir = project_path.join(&backend.scheme);
629 copy_assets_and_fonts(
630 project,
631 &app_resources_dir,
632 None,
633 options.uses_dev_server(),
634 options.progress(),
635 )
636 .await?;
637
638 let configuration = if options.is_debug() {
639 "Debug"
640 } else {
641 "Release"
642 };
643
644 let derived_data = project_path.join("DerivedData");
645 let triple = platform.triple();
646
647 let linkage = if options.uses_shared_rust_runtime() {
649 RustLinkage::SharedRuntime
650 } else {
651 RustLinkage::Static
652 };
653 let lib_dir = &built.profile_dir;
654 let host_library = AppleHostLibrary::for_linkage(linkage);
655 let source_lib = &built.artifact;
656
657 let sdk_name = platform
659 .sdk_name()
660 .ok_or_else(|| eyre::eyre!("Platform {:?} is not an Apple platform", platform))?;
661
662 let products_config = if sdk_name == "macosx" {
664 configuration.to_string()
665 } else {
666 format!("{configuration}-{sdk_name}")
667 };
668 let products_dir = derived_data.join("Build/Products").join(&products_config);
669 fs::create_dir_all(&products_dir).await?;
670 let product_name = crate::apple::backend::apple_product_name(project)?;
673 let app_path = products_dir.join(format!("{product_name}.app"));
674
675 #[cfg(target_os = "macos")]
676 if platform == TargetPlatform::MacOS {
677 browser_runtime::remove_macos_app(&app_path.join("Contents")).await?;
678 remove_cef_helper_apps(&app_path, product_name).await?;
679 }
680
681 let dest_lib = products_dir.join(host_library.linked_file_name());
682 copy_file(&source_lib, &dest_lib).await?;
683 remove_superseded_host_library(&products_dir, host_library).await?;
684 if host_library == AppleHostLibrary::Dynamic {
685 dynamic_runtime::set_rpath_install_name(&dest_lib, host_library.linked_file_name()).await?;
686 }
687
688 let shared_runtime = if options.uses_shared_rust_runtime() {
689 let libraries = RustDynamicLibraries::resolve(built, &triple, project).await?;
690 dynamic_runtime::prepare_host_runtime(libraries.waterui()).await?;
691 libraries.stage(&products_dir).await?;
692 Some(libraries)
693 } else {
694 RustDynamicLibraries::remove_staged(&products_dir, &triple).await?;
695 None
696 };
697
698 let native_link_inputs = collect_apple_native_link_inputs(lib_dir).await?;
699 for archive in &native_link_inputs.archives {
700 let file_name = archive.file_name().ok_or_else(|| {
701 eyre::eyre!(
702 "Bridge archive path had no file name: {}",
703 archive.display()
704 )
705 })?;
706 copy_file(archive, &products_dir.join(file_name)).await?;
707 }
708
709 let mut required_link_flags = vec![
710 "-framework VideoToolbox".to_string(),
711 "-lwaterui_app".to_string(),
716 ];
717 if shared_runtime.is_some() {
718 required_link_flags.push("-lwaterui_dylib".to_string());
719 }
720 for flag in native_link_inputs.linker_flags {
721 push_unique_flag(&mut required_link_flags, flag);
722 }
723 ensure_apple_linker_flags(&xcodeproj, &required_link_flags).await?;
724
725 let arch_name = match platform.arch() {
728 Architecture::Aarch64(_) => "arm64",
729 Architecture::X86_64 => "x86_64",
730 other => {
731 bail!("Unsupported Apple architecture for xcodebuild ARCHS: {other:?}");
732 }
733 };
734 let archs_arg = format!("ARCHS={arch_name}");
735
736 let mut args = vec![
737 OsString::from("-project"),
738 xcodeproj.as_os_str().to_owned(),
739 OsString::from("-scheme"),
740 backend.scheme.as_str().into(),
741 OsString::from("-configuration"),
742 configuration.into(),
743 OsString::from("-sdk"),
744 sdk_name.into(),
745 OsString::from("-derivedDataPath"),
746 derived_data.as_os_str().to_owned(),
747 archs_arg.into(),
748 OsString::from("ONLY_ACTIVE_ARCH=YES"),
749 OsString::from("build"),
750 ];
751
752 let device_platform = matches!(
757 platform,
758 TargetPlatform::IOS
759 | TargetPlatform::TvOS
760 | TargetPlatform::WatchOS
761 | TargetPlatform::VisionOS
762 );
763 if device_platform {
764 let team = crate::apple::toolchain::development_team_id(&Host::current()).await?;
765 args.extend([
769 OsString::from("-allowProvisioningUpdates"),
770 OsString::from("CODE_SIGN_STYLE=Automatic"),
771 OsString::from(format!("DEVELOPMENT_TEAM={team}")),
772 ]);
773 } else if platform.is_simulator() || options.is_debug() {
774 args.extend([
775 OsString::from("CODE_SIGNING_ALLOWED=NO"),
776 OsString::from("CODE_SIGNING_REQUIRED=NO"),
777 OsString::from("CODE_SIGN_IDENTITY=-"),
778 ]);
779 }
780
781 let swift_conditions = apple_swift_conditions(project).await?;
784 if !swift_conditions.is_empty() {
785 args.push(format!("OTHER_SWIFT_FLAGS={}", swift_conditions.join(" ")).into());
786 }
787
788 Host::current()
793 .with_env("WATERUI_SKIP_RUST_BUILD", "1")
794 .run("xcodebuild", args)
795 .await?;
796
797 if !app_path.exists() {
798 bail!(
799 "Built app not found at {}. Check xcodebuild output for errors.",
800 app_path.display()
801 );
802 }
803
804 let frameworks_dir = apple_frameworks_dir(&app_path, sdk_name);
805 if let Some(libraries) = shared_runtime {
806 fs::create_dir_all(&frameworks_dir).await?;
807 libraries.stage(&frameworks_dir).await?;
808 copy_file(
811 &dest_lib,
812 &frameworks_dir.join(host_library.linked_file_name()),
813 )
814 .await?;
815 } else {
816 RustDynamicLibraries::remove_staged(&frameworks_dir, &triple).await?;
817 }
818
819 #[cfg(target_os = "macos")]
823 if device_platform {
824 crate::macos_bundle::sign_staged_device_libraries(&app_path, &frameworks_dir).await?;
825 }
826
827 #[cfg(target_os = "macos")]
828 if platform == TargetPlatform::MacOS && browser_runtime_plan.requires_cef() {
829 browser_runtime::stage_macos_app(browser_runtime_plan, lib_dir, &app_path.join("Contents"))
830 .await?;
831 if project.declares_cef_helper().await? {
835 let main_binary = app_path.join("Contents/MacOS").join(product_name);
836 let helper_binary =
837 lib_dir.join(crate::project_model::project_types::cef_helper_binary_name(
838 project.ffi_crate_name().as_str(),
839 ));
840 package_cef_helper_app(
841 &app_path,
842 &main_binary,
843 &helper_binary,
844 project.bundle_identifier(),
845 )
846 .await?;
847 }
848 let requires_stable_identity = project.manifest().permissions.iter().any(|(key, entry)| {
849 entry.is_enabled() && !key.macos_usage_description_keys().is_empty()
850 });
851 sign_macos_app(
852 &app_path,
853 project.bundle_identifier(),
854 requires_stable_identity,
855 )
856 .await?;
857 }
858
859 #[cfg(not(target_os = "macos"))]
860 let _ = browser_runtime_plan;
861
862 Ok(Artifact::new(project.bundle_identifier(), app_path))
863}
864
865fn apple_frameworks_dir(app_path: &Path, sdk_name: &str) -> PathBuf {
866 if sdk_name == "macosx" {
867 app_path.join("Contents/Frameworks")
868 } else {
869 app_path.join("Frameworks")
870 }
871}
872
873async fn copy_assets_and_fonts(
879 project: &Project,
880 dest_dir: &Path,
881 sccache_path: Option<&Path>,
882 dev_server: bool,
883 progress: Option<&BuildProgress>,
884) -> eyre::Result<()> {
885 let manifest = assets::stage_project_assets_for_apple(
887 project,
888 dest_dir,
889 sccache_path,
890 dev_server,
891 progress,
892 )
893 .await?;
894
895 let font_declarations =
897 assets::scan_fonts(project, &project.ffi_crate_path().join("Cargo.toml")).await?;
898 let mut resolved_fonts = assets::resolve_fonts(font_declarations).await?;
899 resolved_fonts.extend(assets::scan_project_font_assets(&manifest)?);
900
901 if !resolved_fonts.is_empty() {
902 let fonts_dest = dest_dir.join("fonts");
904 assets::copy_fonts(&resolved_fonts, &fonts_dest).await?;
905
906 generate_font_registration_swift(&resolved_fonts, dest_dir).await?;
908
909 info!("Copied {} fonts to Apple app", resolved_fonts.len());
910 }
911
912 Ok(())
913}
914
915#[derive(Template)]
916#[template(
917 path = "src/templates/apple/AppName/WaterUIFonts.swift.tpl",
918 escape = "none"
919)]
920struct WaterUiFontsSwiftTemplate<'a> {
921 font_entries: &'a [FontRegistrationTemplateEntry],
922}
923
924async fn generate_font_registration_swift(
926 fonts: &[ResolvedFont],
927 dest_dir: &Path,
928) -> eyre::Result<()> {
929 let font_entries = fonts
930 .iter()
931 .map(|font| FontRegistrationTemplateEntry {
932 family_name: font.name.clone(),
933 file_name: font
934 .path
935 .file_name()
936 .and_then(|n| n.to_str())
937 .unwrap_or_default()
938 .to_string(),
939 })
940 .collect::<Vec<_>>();
941
942 let content = WaterUiFontsSwiftTemplate {
943 font_entries: &font_entries,
944 }
945 .render()
946 .map_err(|error| eyre::eyre!("Failed to render WaterUIFonts.swift template: {error}"))?;
947
948 let swift_path = dest_dir.join("WaterUIFonts.swift");
949 fs::write(&swift_path, content).await?;
950
951 debug!("Generated {}", swift_path.display());
952
953 Ok(())
954}
955
956#[must_use]
962pub const fn is_apple_platform(platform: TargetPlatform) -> bool {
963 matches!(
964 platform,
965 TargetPlatform::MacOS
966 | TargetPlatform::IOS
967 | TargetPlatform::IOSSimulator
968 | TargetPlatform::TvOS
969 | TargetPlatform::TvOSSimulator
970 | TargetPlatform::WatchOS
971 | TargetPlatform::WatchOSSimulator
972 | TargetPlatform::VisionOS
973 | TargetPlatform::VisionOSSimulator
974 )
975}
976
977async fn apple_swift_conditions(project: &Project) -> eyre::Result<Vec<String>> {
996 const OPTIONAL_COMPONENTS: &[(&str, &str)] =
998 &[("map", "WATERUI_MAP"), ("webview", "WATERUI_WEBVIEW")];
999 const DEFAULT_COMPONENTS: &[(&str, &str)] =
1001 &[("gpu", "WATERUI_NO_GPU"), ("media", "WATERUI_NO_MEDIA")];
1002
1003 let build_manifest = project.ffi_crate_path().join("Cargo.toml");
1004 let mut conditions = Vec::new();
1005 for (capability, condition) in OPTIONAL_COMPONENTS {
1006 if crate::project_model::assets::capability_enabled(project, &build_manifest, capability)
1007 .await?
1008 {
1009 conditions.push(format!("-D{condition}"));
1010 }
1011 }
1012 for (capability, condition) in DEFAULT_COMPONENTS {
1013 if !crate::project_model::assets::capability_enabled(project, &build_manifest, capability)
1014 .await?
1015 {
1016 conditions.push(format!("-D{condition}"));
1017 }
1018 }
1019 Ok(conditions)
1020}
1021
1022#[cfg(test)]
1023mod tests {
1024 use tempfile::tempdir;
1025
1026 use super::{
1027 apple_linker_flags_from_build_output, collect_apple_native_link_inputs_sync,
1028 inject_other_ldflags, unique_xcode_build_setting,
1029 };
1030
1031 #[test]
1032 fn derives_a_unique_xcode_deployment_target() {
1033 let settings = "MACOSX_DEPLOYMENT_TARGET = 15.0;\nMACOSX_DEPLOYMENT_TARGET = 15.0;\n";
1034 assert_eq!(
1035 unique_xcode_build_setting(settings, "MACOSX_DEPLOYMENT_TARGET")
1036 .expect("unique deployment target"),
1037 "15.0"
1038 );
1039 }
1040
1041 #[test]
1042 fn rejects_conflicting_xcode_deployment_targets() {
1043 let settings = "MACOSX_DEPLOYMENT_TARGET = 14.0;\nMACOSX_DEPLOYMENT_TARGET = 15.0;\n";
1044 let error = unique_xcode_build_setting(settings, "MACOSX_DEPLOYMENT_TARGET")
1045 .expect_err("conflicting targets must fail");
1046 assert!(error.to_string().contains("14.0, 15.0"));
1047 }
1048
1049 #[test]
1050 fn injects_required_apple_frameworks_into_other_ldflags() {
1051 let input =
1052 "OTHER_LDFLAGS = \"-lwaterui_app -lc++\";\nOTHER_LDFLAGS = \"-lwaterui_app -lc++\";\n";
1053 let required_flags = vec!["-framework VideoToolbox".to_string()];
1054 let (output, changed) = inject_other_ldflags(input, &required_flags);
1055 assert!(changed);
1056 assert_eq!(output.matches("-framework VideoToolbox").count(), 2);
1057 assert!(!output.contains("-lwaterui_app"));
1058 }
1059
1060 #[test]
1061 fn linker_flag_injection_is_idempotent() {
1062 let input = "OTHER_LDFLAGS = \"-lc++ -framework VideoToolbox\";\n";
1063 let required_flags = vec!["-framework VideoToolbox".to_string()];
1064 let (output, changed) = inject_other_ldflags(input, &required_flags);
1065 assert!(!changed);
1066 assert_eq!(output, input);
1067 }
1068
1069 #[test]
1070 fn removes_redundant_waterui_app_link_flag() {
1071 let input = "OTHER_LDFLAGS = \"-lwaterui_app -lc++ -framework VideoToolbox\";\n";
1072 let required_flags = vec!["-framework VideoToolbox".to_string()];
1073 let (output, changed) = inject_other_ldflags(input, &required_flags);
1074 assert!(changed);
1075 assert_eq!(
1076 output,
1077 "OTHER_LDFLAGS = \"-lc++ -framework VideoToolbox\";\n"
1078 );
1079 }
1080
1081 #[test]
1082 fn switches_between_shared_runtime_and_static_link_flags() {
1083 let input = "OTHER_LDFLAGS = \"-lc++ -framework VideoToolbox\";\n";
1084 let dynamic_flags = vec![
1085 "-framework VideoToolbox".to_string(),
1086 "-lwaterui_dylib".to_string(),
1087 ];
1088 let (dynamic, changed) = inject_other_ldflags(input, &dynamic_flags);
1089 assert!(changed);
1090 assert!(dynamic.contains("-lwaterui_dylib"));
1091
1092 let static_flags = vec!["-framework VideoToolbox".to_string()];
1093 let (static_linked, changed) = inject_other_ldflags(&dynamic, &static_flags);
1094 assert!(changed);
1095 assert_eq!(static_linked, input);
1096 }
1097
1098 #[test]
1099 fn parses_frameworks_and_link_args_from_build_output() {
1100 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";
1101 let flags = apple_linker_flags_from_build_output(output);
1102 assert_eq!(
1103 flags,
1104 vec![
1105 "-framework AppKit".to_string(),
1106 "-rpath".to_string(),
1107 "/usr/lib/swift".to_string(),
1108 "-framework Foundation".to_string()
1109 ]
1110 );
1111 }
1112
1113 #[test]
1114 fn forwards_link_search_dirs_that_link_args_depend_on() {
1115 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";
1119 let flags = apple_linker_flags_from_build_output(output);
1120 assert_eq!(
1121 flags,
1122 vec![
1123 "-L/Xcode/lib/clang/17/lib/darwin".to_string(),
1124 "-lclang_rt.osx".to_string(),
1125 "-F/Frameworks".to_string(),
1126 "-L/plain".to_string(),
1127 ]
1128 );
1129 }
1130
1131 #[test]
1132 fn collects_swift_bridge_archives_and_flags_from_target_build_dir() {
1133 let dir = tempdir().expect("tempdir");
1134 let lib_dir = dir.path().join("aarch64-apple-darwin/debug");
1135 let build_dir = lib_dir.join("build/waterkit-haptic-1234");
1136 let out_dir = build_dir.join("out");
1137 std::fs::create_dir_all(&out_dir).expect("create out dir");
1138 std::fs::write(out_dir.join("CombinedHelper.swift"), "// bridge").expect("write swift");
1139 std::fs::write(out_dir.join("libHelper.a"), "").expect("write archive");
1140 std::fs::write(
1141 build_dir.join("output"),
1142 "cargo:rustc-link-lib=framework=AppKit\ncargo:rustc-link-arg=-rpath\ncargo:rustc-link-arg=/usr/lib/swift\n",
1143 )
1144 .expect("write build output");
1145
1146 let link_inputs =
1147 collect_apple_native_link_inputs_sync(&lib_dir).expect("collect native link inputs");
1148
1149 assert_eq!(link_inputs.archives, vec![out_dir.join("libHelper.a")]);
1150 assert_eq!(
1151 link_inputs.linker_flags,
1152 vec![
1153 "-framework AppKit".to_string(),
1154 "-rpath".to_string(),
1155 "/usr/lib/swift".to_string(),
1156 "-lHelper".to_string()
1157 ]
1158 );
1159 }
1160
1161 #[test]
1162 fn collects_link_flags_from_crates_without_swift_or_archives() {
1163 let dir = tempdir().expect("tempdir");
1166 let lib_dir = dir.path().join("aarch64-apple-darwin/release");
1167 let sys_build_dir = lib_dir.join("build/system-configuration-sys-1234");
1168 std::fs::create_dir_all(sys_build_dir.join("out")).expect("create out dir");
1169 std::fs::write(
1170 sys_build_dir.join("output"),
1171 "cargo:rustc-link-lib=framework=SystemConfiguration\n",
1172 )
1173 .expect("write build output");
1174
1175 let plain_build_dir = lib_dir.join("build/some-native-5678");
1178 let plain_out_dir = plain_build_dir.join("out");
1179 std::fs::create_dir_all(&plain_out_dir).expect("create out dir");
1180 std::fs::write(plain_out_dir.join("libwrapper.a"), "").expect("write archive");
1181 std::fs::write(
1182 plain_build_dir.join("output"),
1183 "cargo:rustc-link-lib=static=wrapper\n",
1184 )
1185 .expect("write build output");
1186
1187 let link_inputs =
1188 collect_apple_native_link_inputs_sync(&lib_dir).expect("collect native link inputs");
1189
1190 assert_eq!(
1191 link_inputs.archives,
1192 vec![plain_out_dir.join("libwrapper.a")]
1193 );
1194 let mut flags = link_inputs.linker_flags;
1195 flags.sort_unstable();
1196 assert_eq!(
1197 flags,
1198 vec![
1199 "-framework SystemConfiguration".to_string(),
1200 "-lwrapper".to_string()
1201 ]
1202 );
1203 }
1204
1205 #[test]
1206 fn collects_framework_flags_from_crates_without_swift_archives() {
1207 let dir = tempdir().expect("tempdir");
1208 let lib_dir = dir.path().join("aarch64-apple-darwin/debug");
1209 let build_dir = lib_dir.join("build/system-configuration-sys-1234");
1210 std::fs::create_dir_all(build_dir.join("out")).expect("create out dir");
1211 std::fs::write(
1212 build_dir.join("output"),
1213 "cargo:rustc-link-lib=framework=SystemConfiguration\n",
1214 )
1215 .expect("write build output");
1216
1217 let link_inputs =
1218 collect_apple_native_link_inputs_sync(&lib_dir).expect("collect native link inputs");
1219
1220 assert!(link_inputs.archives.is_empty());
1221 assert_eq!(
1222 link_inputs.linker_flags,
1223 vec!["-framework SystemConfiguration".to_string()]
1224 );
1225 }
1226 #[test]
1227 fn collects_link_inputs_from_nightly_build_layout() {
1228 let dir = tempdir().expect("tempdir");
1231 let lib_dir = dir.path().join("aarch64-apple-darwin/debug");
1232 let sys_unit = lib_dir.join("build/system-configuration-sys/1234abcd");
1233 std::fs::create_dir_all(sys_unit.join("run")).expect("create run dir");
1234 std::fs::write(
1235 sys_unit.join("run/stdout"),
1236 "cargo:rustc-link-lib=framework=SystemConfiguration\n",
1237 )
1238 .expect("write build stdout");
1239
1240 let compile_unit = lib_dir.join("build/plain-crate/5678efgh");
1243 std::fs::create_dir_all(compile_unit.join("out")).expect("create out dir");
1244 std::fs::write(compile_unit.join("out/libplain_crate.a"), "").expect("write archive");
1245
1246 let link_inputs =
1247 collect_apple_native_link_inputs_sync(&lib_dir).expect("collect native link inputs");
1248
1249 assert!(link_inputs.archives.is_empty());
1250 assert_eq!(
1251 link_inputs.linker_flags,
1252 vec!["-framework SystemConfiguration".to_string()]
1253 );
1254 }
1255}