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::{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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54enum AppleHostLibrary {
55 Archive,
57 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 const fn built_extension(self) -> &'static str {
78 match self {
79 Self::Archive => "a",
80 Self::Dynamic => "dylib",
81 }
82 }
83
84 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 const fn superseded(self) -> Self {
95 match self {
96 Self::Archive => Self::Dynamic,
97 Self::Dynamic => Self::Archive,
98 }
99 }
100}
101
102async 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
125pub(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
165pub async fn build_rust_lib(
170 project: &Project,
171 platform: TargetPlatform,
172 options: BuildOptions,
173) -> eyre::Result<PathBuf> {
174 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 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 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
247pub 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
301fn 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 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
463fn 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 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 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 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 _ => {}
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
559pub async fn clean_apple(project: &Project) -> eyre::Result<()> {
568 let Some(backend) = project.apple_backend() else {
569 return Ok(()); };
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#[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 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 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 let sdk_name = platform
666 .sdk_name()
667 .ok_or_else(|| eyre::eyre!("Platform {:?} is not an Apple platform", platform))?;
668
669 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 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 "-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 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 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 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 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 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 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 #[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 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
884async 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 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 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 let fonts_dest = dest_dir.join("fonts");
914 assets::copy_fonts(&resolved_fonts, &fonts_dest).await?;
915
916 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
934async 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#[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
987async fn apple_swift_conditions(project: &Project) -> eyre::Result<Vec<String>> {
1006 const OPTIONAL_COMPONENTS: &[(&str, &str)] =
1008 &[("map", "WATERUI_MAP"), ("webview", "WATERUI_WEBVIEW")];
1009 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 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 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 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 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 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}