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, 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 build = build
200 .with_env("PKG_CONFIG_ALLOW_CROSS", "1")
201 .with_env(format!("PKG_CONFIG_ALLOW_CROSS_{target_underscore}"), "1")
202 .with_env(format!("PKG_CONFIG_ALLOW_CROSS_{target}"), "1");
203 let (deployment_environment, deployment_target) =
204 apple_deployment_target(project, platform).await?;
205 build = build.with_env(deployment_environment, deployment_target);
206 if options.linkage() == RustLinkage::SharedRuntime {
207 build = build.with_preferred_dynamic_linking();
208 }
209 build = build.with_target_dir(project.water_target_dir(options.linkage()).await?);
210 let lib_dir = build.build_lib(options.is_release()).await?;
211 if browser_runtime_plan.requires_cef() {
212 build
213 .clone()
214 .with_final_rustc_arg("-Clink-arg=-Wl,-rpath,@executable_path/../Frameworks")
215 .build_binary("waterui-cef-helper", options.is_release())
216 .await?;
217 }
218
219 if let Some(output_dir) = options.output_dir() {
221 let lib_name = project.ffi_crate_name().replace('-', "_");
222 let source_lib = lib_dir.join(format!("lib{lib_name}.{}", host_library.built_extension()));
223
224 if !source_lib.exists() {
225 bail!(
226 "Built library not found at {} (expected {} for Apple target {})",
227 source_lib.display(),
228 host_library.crate_type(),
229 triple
230 );
231 }
232 fs::create_dir_all(output_dir).await?;
233 let dest_lib = output_dir.join(host_library.linked_file_name());
234 copy_file(&source_lib, &dest_lib).await?;
235 remove_superseded_host_library(output_dir, host_library).await?;
236 if options.linkage() == RustLinkage::SharedRuntime {
237 let libraries = RustDynamicLibraries::resolve(&lib_dir, &triple).await?;
238 dynamic_runtime::prepare_host_runtime(libraries.waterui()).await?;
239 libraries.stage(output_dir).await?;
240 }
241 }
242
243 Ok(lib_dir)
244}
245
246pub async fn apple_deployment_target(
252 project: &Project,
253 platform: TargetPlatform,
254) -> eyre::Result<(&'static str, String)> {
255 let backend = project
256 .apple_backend()
257 .ok_or_else(|| eyre::eyre!("Apple backend must be configured"))?;
258 let (environment, build_setting) = match platform {
259 TargetPlatform::MacOS => ("MACOSX_DEPLOYMENT_TARGET", "MACOSX_DEPLOYMENT_TARGET"),
260 TargetPlatform::IOS | TargetPlatform::IOSSimulator => {
261 ("IPHONEOS_DEPLOYMENT_TARGET", "IPHONEOS_DEPLOYMENT_TARGET")
262 }
263 other => {
264 bail!("Platform {other:?} does not have an Apple deployment target");
265 }
266 };
267 let project_file = project
268 .backend_path::<AppleBackend>()
269 .join(format!("{}.xcodeproj", backend.scheme))
270 .join("project.pbxproj");
271 let contents = fs::read_to_string(&project_file)
272 .await
273 .wrap_err_with(|| format!("Failed to read {}", project_file.display()))?;
274 let target = unique_xcode_build_setting(&contents, build_setting)?;
275 Ok((environment, target))
276}
277
278fn unique_xcode_build_setting(contents: &str, key: &str) -> eyre::Result<String> {
279 let prefix = format!("{key} = ");
280 let values = contents
281 .lines()
282 .filter_map(|line| line.trim().strip_prefix(&prefix))
283 .filter_map(|value| value.strip_suffix(';'))
284 .map(|value| value.trim_matches('"').to_string())
285 .collect::<BTreeSet<_>>();
286 match values.len() {
287 1 => Ok(values.into_iter().next().expect("one build setting value")),
288 0 => {
289 bail!("Xcode project does not define {key}");
290 }
291 _ => {
292 bail!(
293 "Xcode project defines conflicting {key} values: {}",
294 values.into_iter().collect::<Vec<_>>().join(", ")
295 );
296 }
297 }
298}
299
300fn validate_local_apple_backend(project: &Project) -> eyre::Result<()> {
309 let Some(backend_path) = project
310 .manifest()
311 .backends
312 .apple()
313 .and_then(|backend| backend.backend_path.as_deref())
314 else {
315 return Ok(());
316 };
317
318 let backend_root = {
319 let candidate = PathBuf::from(backend_path);
320 if candidate.is_absolute() {
321 candidate
322 } else {
323 project.root().join(candidate)
324 }
325 };
326
327 let package_manifest = backend_root.join("Package.swift");
328 if package_manifest.exists() {
329 return Ok(());
330 }
331
332 bail!(
333 "`[backend.apple] backend_path` points at `{}`, which has no `Package.swift` — \
334 the Apple backend lives in its own repository now; point it at an \
335 `apple-backend` checkout, or remove `backend_path` to consume the pinned \
336 release from SwiftPM.",
337 backend_root.display()
338 );
339}
340
341async fn ensure_apple_linker_flags(
342 xcodeproj: &Path,
343 required_flags: &[String],
344) -> eyre::Result<()> {
345 let pbxproj_path = xcodeproj.join("project.pbxproj");
346 if !pbxproj_path.exists() {
347 return Ok(());
348 }
349
350 let content = fs::read_to_string(&pbxproj_path)
351 .await
352 .wrap_err_with(|| format!("Failed to read {}", pbxproj_path.display()))?;
353 let (updated, changed) = inject_other_ldflags(&content, required_flags);
354 if changed {
355 fs::write(&pbxproj_path, updated)
356 .await
357 .wrap_err_with(|| format!("Failed to write {}", pbxproj_path.display()))?;
358 info!(
359 "Updated {} with required Apple linker flags",
360 pbxproj_path.display()
361 );
362 }
363
364 Ok(())
365}
366
367fn inject_other_ldflags(content: &str, required_flags: &[String]) -> (String, bool) {
368 let mut changed = false;
369 let mut lines = Vec::new();
370 for line in content.lines() {
371 if line.contains("OTHER_LDFLAGS = \"")
372 && let Some((prefix, rest)) = line.split_once("OTHER_LDFLAGS = \"")
373 && let Some((flags, suffix)) = rest.split_once("\";")
374 {
375 let (mut merged, _) = sanitize_other_ldflags(flags);
376 for required in required_flags {
377 if !merged.contains(required) {
378 if !merged.is_empty() {
379 merged.push(' ');
380 }
381 merged.push_str(required);
382 }
383 }
384 let line_changed = merged != flags;
385 if line_changed {
386 changed = true;
387 }
388 lines.push(format!("{prefix}OTHER_LDFLAGS = \"{merged}\";{suffix}"));
389 continue;
390 }
391 lines.push(line.to_string());
392 }
393
394 let mut updated = lines.join("\n");
395 if content.ends_with('\n') {
396 updated.push('\n');
397 }
398 (updated, changed)
399}
400
401fn sanitize_other_ldflags(flags: &str) -> (String, bool) {
402 let normalized = flags
403 .split_whitespace()
404 .filter(|flag| !matches!(*flag, "-lwaterui_app" | "-lwaterui_dylib"))
405 .collect::<Vec<_>>()
406 .join(" ");
407 let changed = normalized != flags;
408 (normalized, changed)
409}
410
411async fn collect_apple_native_link_inputs(lib_dir: &Path) -> eyre::Result<AppleNativeLinkInputs> {
412 let lib_dir = lib_dir.to_path_buf();
413 smol::unblock(move || collect_apple_native_link_inputs_sync(&lib_dir)).await
414}
415
416fn collect_apple_native_link_inputs_sync(lib_dir: &Path) -> eyre::Result<AppleNativeLinkInputs> {
417 let build_root = lib_dir.join("build");
418 if !build_root.exists() {
419 return Ok(AppleNativeLinkInputs::default());
420 }
421
422 let mut archive_paths = BTreeSet::new();
423 let mut linker_flags = Vec::new();
424
425 for entry in std::fs::read_dir(&build_root)? {
426 let entry = entry?;
427 let crate_build_dir = entry.path();
428 if !crate_build_dir.is_dir() {
429 continue;
430 }
431
432 if is_build_script_unit_dir(&crate_build_dir) {
437 collect_link_inputs_from_unit_dir(
438 &crate_build_dir,
439 &mut archive_paths,
440 &mut linker_flags,
441 )?;
442 } else {
443 for sub_entry in std::fs::read_dir(&crate_build_dir)?.flatten() {
444 let sub_dir = sub_entry.path();
445 if sub_dir.is_dir() && is_build_script_unit_dir(&sub_dir) {
446 collect_link_inputs_from_unit_dir(
447 &sub_dir,
448 &mut archive_paths,
449 &mut linker_flags,
450 )?;
451 }
452 }
453 }
454 }
455
456 Ok(AppleNativeLinkInputs {
457 archives: archive_paths.into_iter().collect(),
458 linker_flags,
459 })
460}
461
462fn is_build_script_unit_dir(dir: &Path) -> bool {
467 dir.join("output").is_file() || dir.join("run").join("stdout").is_file()
468}
469
470fn collect_link_inputs_from_unit_dir(
471 unit_dir: &Path,
472 archive_paths: &mut BTreeSet<PathBuf>,
473 linker_flags: &mut Vec<String>,
474) -> eyre::Result<()> {
475 for output_path in [unit_dir.join("output"), unit_dir.join("run").join("stdout")] {
481 if output_path.is_file() {
482 let output = std::fs::read_to_string(&output_path)?;
483 for flag in apple_linker_flags_from_build_output(&output) {
484 push_unique_flag(linker_flags, flag);
485 }
486 }
487 }
488
489 let out_dir = unit_dir.join("out");
493 if !out_dir.is_dir() {
494 return Ok(());
495 }
496
497 for out_entry in std::fs::read_dir(&out_dir)? {
498 let out_entry = out_entry?;
499 let path = out_entry.path();
500 if path.extension().is_some_and(|ext| ext == "a")
501 && path
502 .file_name()
503 .and_then(|name| name.to_str())
504 .is_some_and(|name| name.starts_with("lib"))
505 {
506 archive_paths.insert(path.clone());
507 if let Some(flag) = static_archive_link_flag(&path) {
508 push_unique_flag(linker_flags, flag);
509 }
510 }
511 }
512 Ok(())
513}
514
515fn static_archive_link_flag(archive_path: &Path) -> Option<String> {
516 let file_name = archive_path.file_name()?.to_str()?;
517 let library_name = file_name
518 .strip_prefix("lib")?
519 .strip_suffix(".a")
520 .unwrap_or(file_name);
521 Some(format!("-l{library_name}"))
522}
523
524fn apple_linker_flags_from_build_output(output: &str) -> Vec<String> {
525 let mut flags = Vec::new();
526 for line in output.lines() {
527 if let Some(framework) = line.strip_prefix("cargo:rustc-link-lib=framework=") {
528 push_unique_flag(&mut flags, format!("-framework {framework}"));
529 } else if let Some(arg) = line.strip_prefix("cargo:rustc-link-arg=") {
530 push_unique_flag(&mut flags, arg.to_string());
531 }
532 }
533 flags
534}
535
536fn push_unique_flag(flags: &mut Vec<String>, flag: String) {
537 if !flags.iter().any(|existing| existing == &flag) {
538 flags.push(flag);
539 }
540}
541
542pub async fn clean_apple(project: &Project) -> eyre::Result<()> {
551 let Some(backend) = project.apple_backend() else {
552 return Ok(()); };
554
555 let project_path = project.backend_path::<AppleBackend>();
556 let xcodeproj = project_path.join(format!("{}.xcodeproj", backend.scheme));
557
558 if !xcodeproj.exists() {
559 return Ok(());
560 }
561
562 let args: Vec<OsString> = vec![
563 "-project".into(),
564 xcodeproj.as_os_str().to_owned(),
565 "-scheme".into(),
566 backend.scheme.as_str().into(),
567 "clean".into(),
568 ];
569 run_command_os("xcodebuild", args).await?;
570
571 let build_dir = project_path.join("build");
572 if build_dir.exists() {
573 fs::remove_dir_all(&build_dir).await?;
574 }
575
576 Ok(())
577}
578
579#[allow(clippy::too_many_lines)]
588pub async fn package_apple(
589 project: &Project,
590 platform: TargetPlatform,
591 options: PackageOptions,
592) -> eyre::Result<Artifact> {
593 let backend = project
594 .apple_backend()
595 .ok_or_else(|| eyre::eyre!("Apple backend must be configured"))?;
596 let browser_runtime_plan = project
597 .browser_runtime_plan(platform, TargetBackend::Apple)
598 .await?;
599
600 let project_path = project.backend_path::<AppleBackend>();
601 let xcodeproj = project_path.join(format!("{}.xcodeproj", backend.scheme));
602
603 if !xcodeproj.exists() {
604 bail!(
605 "Xcode project not found at {}. Did you run 'water create'?",
606 xcodeproj.display()
607 );
608 }
609
610 validate_local_apple_backend(project)?;
611
612 let app_resources_dir = project_path.join(&backend.scheme);
614 copy_assets_and_fonts(project, &app_resources_dir, None, options.uses_dev_server()).await?;
615
616 let configuration = if options.is_debug() {
617 "Debug"
618 } else {
619 "Release"
620 };
621
622 let derived_data = project_path.join("DerivedData");
623 let triple = platform.triple();
624
625 let linkage = if options.uses_shared_rust_runtime() {
627 RustLinkage::SharedRuntime
628 } else {
629 RustLinkage::Static
630 };
631 let lib_dir = RustBuild::new(project.ffi_crate_path(), triple.clone())
632 .with_target_dir(project.water_target_dir(linkage).await?)
633 .lib_output_dir(!options.is_debug())
634 .await
635 .wrap_err("Failed to resolve native FFI crate target directory")?;
636 let host_library = AppleHostLibrary::for_linkage(linkage);
637 let lib_name = project.ffi_crate_name().replace('-', "_");
638 let source_lib = lib_dir.join(format!("lib{lib_name}.{}", host_library.built_extension()));
639
640 let sdk_name = platform
642 .sdk_name()
643 .ok_or_else(|| eyre::eyre!("Platform {:?} is not an Apple platform", platform))?;
644
645 let products_config = if sdk_name == "macosx" {
647 configuration.to_string()
648 } else {
649 format!("{configuration}-{sdk_name}")
650 };
651 let products_dir = derived_data.join("Build/Products").join(&products_config);
652 fs::create_dir_all(&products_dir).await?;
653 let product_name = crate::apple::backend::apple_product_name(project)?;
656 let app_path = products_dir.join(format!("{product_name}.app"));
657
658 #[cfg(target_os = "macos")]
659 if platform == TargetPlatform::MacOS {
660 browser_runtime::remove_macos_app(&app_path.join("Contents")).await?;
661 remove_cef_helper_apps(&app_path, product_name).await?;
662 }
663
664 let dest_lib = products_dir.join(host_library.linked_file_name());
665 copy_file(&source_lib, &dest_lib).await?;
666 remove_superseded_host_library(&products_dir, host_library).await?;
667 if host_library == AppleHostLibrary::Dynamic {
668 dynamic_runtime::set_rpath_install_name(&dest_lib, host_library.linked_file_name()).await?;
669 }
670
671 let shared_runtime = if options.uses_shared_rust_runtime() {
672 let libraries = RustDynamicLibraries::resolve(&lib_dir, &triple).await?;
673 dynamic_runtime::prepare_host_runtime(libraries.waterui()).await?;
674 libraries.stage(&products_dir).await?;
675 Some(libraries)
676 } else {
677 RustDynamicLibraries::remove_staged(&products_dir, &triple).await?;
678 None
679 };
680
681 let native_link_inputs = collect_apple_native_link_inputs(&lib_dir).await?;
682 for archive in &native_link_inputs.archives {
683 let file_name = archive.file_name().ok_or_else(|| {
684 eyre::eyre!(
685 "Bridge archive path had no file name: {}",
686 archive.display()
687 )
688 })?;
689 copy_file(archive, &products_dir.join(file_name)).await?;
690 }
691
692 let mut required_link_flags = vec![
693 "-framework VideoToolbox".to_string(),
694 "-lwaterui_app".to_string(),
699 ];
700 if shared_runtime.is_some() {
701 required_link_flags.push("-lwaterui_dylib".to_string());
702 }
703 for flag in native_link_inputs.linker_flags {
704 push_unique_flag(&mut required_link_flags, flag);
705 }
706 ensure_apple_linker_flags(&xcodeproj, &required_link_flags).await?;
707
708 let arch_name = match platform.arch() {
711 Architecture::Aarch64(_) => "arm64",
712 Architecture::X86_64 => "x86_64",
713 other => {
714 bail!("Unsupported Apple architecture for xcodebuild ARCHS: {other:?}");
715 }
716 };
717 let archs_arg = format!("ARCHS={arch_name}");
718
719 let mut args = vec![
720 OsString::from("-project"),
721 xcodeproj.as_os_str().to_owned(),
722 OsString::from("-scheme"),
723 backend.scheme.as_str().into(),
724 OsString::from("-configuration"),
725 configuration.into(),
726 OsString::from("-sdk"),
727 sdk_name.into(),
728 OsString::from("-derivedDataPath"),
729 derived_data.as_os_str().to_owned(),
730 archs_arg.into(),
731 OsString::from("ONLY_ACTIVE_ARCH=YES"),
732 OsString::from("build"),
733 ];
734
735 let device_platform = matches!(
740 platform,
741 TargetPlatform::IOS
742 | TargetPlatform::TvOS
743 | TargetPlatform::WatchOS
744 | TargetPlatform::VisionOS
745 );
746 if device_platform {
747 let team = crate::apple::toolchain::development_team_id(&Host::current()).await?;
748 args.extend([
752 OsString::from("-allowProvisioningUpdates"),
753 OsString::from("CODE_SIGN_STYLE=Automatic"),
754 OsString::from(format!("DEVELOPMENT_TEAM={team}")),
755 ]);
756 } else if platform.is_simulator() || options.is_debug() {
757 args.extend([
758 OsString::from("CODE_SIGNING_ALLOWED=NO"),
759 OsString::from("CODE_SIGNING_REQUIRED=NO"),
760 OsString::from("CODE_SIGN_IDENTITY=-"),
761 ]);
762 }
763
764 let swift_conditions = apple_swift_conditions(project).await?;
767 if !swift_conditions.is_empty() {
768 args.push(format!("OTHER_SWIFT_FLAGS={}", swift_conditions.join(" ")).into());
769 }
770
771 Host::current()
776 .with_env("WATERUI_SKIP_RUST_BUILD", "1")
777 .run("xcodebuild", args)
778 .await?;
779
780 if !app_path.exists() {
781 bail!(
782 "Built app not found at {}. Check xcodebuild output for errors.",
783 app_path.display()
784 );
785 }
786
787 let frameworks_dir = apple_frameworks_dir(&app_path, sdk_name);
788 if let Some(libraries) = shared_runtime {
789 fs::create_dir_all(&frameworks_dir).await?;
790 libraries.stage(&frameworks_dir).await?;
791 copy_file(
794 &dest_lib,
795 &frameworks_dir.join(host_library.linked_file_name()),
796 )
797 .await?;
798 } else {
799 RustDynamicLibraries::remove_staged(&frameworks_dir, &triple).await?;
800 }
801
802 #[cfg(target_os = "macos")]
806 if device_platform {
807 crate::macos_bundle::sign_staged_device_libraries(&app_path, &frameworks_dir).await?;
808 }
809
810 #[cfg(target_os = "macos")]
811 if platform == TargetPlatform::MacOS && browser_runtime_plan.requires_cef() {
812 browser_runtime::stage_macos_app(
813 browser_runtime_plan,
814 &lib_dir,
815 &app_path.join("Contents"),
816 )
817 .await?;
818 let main_binary = app_path.join("Contents/MacOS").join(product_name);
819 let helper_binary = lib_dir.join("waterui-cef-helper");
820 package_cef_helper_app(
821 &app_path,
822 &main_binary,
823 &helper_binary,
824 project.bundle_identifier(),
825 )
826 .await?;
827 let requires_stable_identity = project.manifest().permissions.iter().any(|(key, entry)| {
828 entry.is_enabled() && !key.macos_usage_description_keys().is_empty()
829 });
830 sign_macos_app(
831 &app_path,
832 project.bundle_identifier(),
833 requires_stable_identity,
834 )
835 .await?;
836 }
837
838 #[cfg(not(target_os = "macos"))]
839 let _ = browser_runtime_plan;
840
841 Ok(Artifact::new(project.bundle_identifier(), app_path))
842}
843
844fn apple_frameworks_dir(app_path: &Path, sdk_name: &str) -> PathBuf {
845 if sdk_name == "macosx" {
846 app_path.join("Contents/Frameworks")
847 } else {
848 app_path.join("Frameworks")
849 }
850}
851
852async fn copy_assets_and_fonts(
858 project: &Project,
859 dest_dir: &Path,
860 sccache_path: Option<&Path>,
861 dev_server: bool,
862) -> eyre::Result<()> {
863 let manifest =
865 assets::stage_project_assets_for_apple(project, dest_dir, sccache_path, dev_server).await?;
866
867 let font_declarations = assets::scan_fonts(project).await?;
869 let mut resolved_fonts = assets::resolve_fonts(font_declarations).await?;
870 resolved_fonts.extend(assets::scan_project_font_assets(&manifest)?);
871
872 if !resolved_fonts.is_empty() {
873 let fonts_dest = dest_dir.join("fonts");
875 assets::copy_fonts(&resolved_fonts, &fonts_dest).await?;
876
877 generate_font_registration_swift(&resolved_fonts, dest_dir).await?;
879
880 info!("Copied {} fonts to Apple app", resolved_fonts.len());
881 }
882
883 Ok(())
884}
885
886#[derive(Template)]
887#[template(
888 path = "src/templates/apple/AppName/WaterUIFonts.swift.tpl",
889 escape = "none"
890)]
891struct WaterUiFontsSwiftTemplate<'a> {
892 font_entries: &'a [FontRegistrationTemplateEntry],
893}
894
895async fn generate_font_registration_swift(
897 fonts: &[ResolvedFont],
898 dest_dir: &Path,
899) -> eyre::Result<()> {
900 let font_entries = fonts
901 .iter()
902 .map(|font| FontRegistrationTemplateEntry {
903 family_name: font.name.clone(),
904 file_name: font
905 .path
906 .file_name()
907 .and_then(|n| n.to_str())
908 .unwrap_or_default()
909 .to_string(),
910 })
911 .collect::<Vec<_>>();
912
913 let content = WaterUiFontsSwiftTemplate {
914 font_entries: &font_entries,
915 }
916 .render()
917 .map_err(|error| eyre::eyre!("Failed to render WaterUIFonts.swift template: {error}"))?;
918
919 let swift_path = dest_dir.join("WaterUIFonts.swift");
920 fs::write(&swift_path, content).await?;
921
922 debug!("Generated {}", swift_path.display());
923
924 Ok(())
925}
926
927#[must_use]
933pub const fn is_apple_platform(platform: TargetPlatform) -> bool {
934 matches!(
935 platform,
936 TargetPlatform::MacOS
937 | TargetPlatform::IOS
938 | TargetPlatform::IOSSimulator
939 | TargetPlatform::TvOS
940 | TargetPlatform::TvOSSimulator
941 | TargetPlatform::WatchOS
942 | TargetPlatform::WatchOSSimulator
943 | TargetPlatform::VisionOS
944 | TargetPlatform::VisionOSSimulator
945 )
946}
947
948async fn apple_swift_conditions(project: &Project) -> eyre::Result<Vec<String>> {
967 const OPTIONAL_COMPONENTS: &[(&str, &str)] =
969 &[("map", "WATERUI_MAP"), ("webview", "WATERUI_WEBVIEW")];
970 const DEFAULT_COMPONENTS: &[(&str, &str)] =
972 &[("gpu", "WATERUI_NO_GPU"), ("media", "WATERUI_NO_MEDIA")];
973
974 let mut conditions = Vec::new();
975 for (capability, condition) in OPTIONAL_COMPONENTS {
976 if crate::project_model::assets::capability_enabled(project, capability).await? {
977 conditions.push(format!("-D{condition}"));
978 }
979 }
980 for (capability, condition) in DEFAULT_COMPONENTS {
981 if !crate::project_model::assets::capability_enabled(project, capability).await? {
982 conditions.push(format!("-D{condition}"));
983 }
984 }
985 Ok(conditions)
986}
987
988#[cfg(test)]
989mod tests {
990 use tempfile::tempdir;
991
992 use super::{
993 apple_linker_flags_from_build_output, collect_apple_native_link_inputs_sync,
994 inject_other_ldflags, unique_xcode_build_setting,
995 };
996
997 #[test]
998 fn derives_a_unique_xcode_deployment_target() {
999 let settings = "MACOSX_DEPLOYMENT_TARGET = 15.0;\nMACOSX_DEPLOYMENT_TARGET = 15.0;\n";
1000 assert_eq!(
1001 unique_xcode_build_setting(settings, "MACOSX_DEPLOYMENT_TARGET")
1002 .expect("unique deployment target"),
1003 "15.0"
1004 );
1005 }
1006
1007 #[test]
1008 fn rejects_conflicting_xcode_deployment_targets() {
1009 let settings = "MACOSX_DEPLOYMENT_TARGET = 14.0;\nMACOSX_DEPLOYMENT_TARGET = 15.0;\n";
1010 let error = unique_xcode_build_setting(settings, "MACOSX_DEPLOYMENT_TARGET")
1011 .expect_err("conflicting targets must fail");
1012 assert!(error.to_string().contains("14.0, 15.0"));
1013 }
1014
1015 #[test]
1016 fn injects_required_apple_frameworks_into_other_ldflags() {
1017 let input =
1018 "OTHER_LDFLAGS = \"-lwaterui_app -lc++\";\nOTHER_LDFLAGS = \"-lwaterui_app -lc++\";\n";
1019 let required_flags = vec!["-framework VideoToolbox".to_string()];
1020 let (output, changed) = inject_other_ldflags(input, &required_flags);
1021 assert!(changed);
1022 assert_eq!(output.matches("-framework VideoToolbox").count(), 2);
1023 assert!(!output.contains("-lwaterui_app"));
1024 }
1025
1026 #[test]
1027 fn linker_flag_injection_is_idempotent() {
1028 let input = "OTHER_LDFLAGS = \"-lc++ -framework VideoToolbox\";\n";
1029 let required_flags = vec!["-framework VideoToolbox".to_string()];
1030 let (output, changed) = inject_other_ldflags(input, &required_flags);
1031 assert!(!changed);
1032 assert_eq!(output, input);
1033 }
1034
1035 #[test]
1036 fn removes_redundant_waterui_app_link_flag() {
1037 let input = "OTHER_LDFLAGS = \"-lwaterui_app -lc++ -framework VideoToolbox\";\n";
1038 let required_flags = vec!["-framework VideoToolbox".to_string()];
1039 let (output, changed) = inject_other_ldflags(input, &required_flags);
1040 assert!(changed);
1041 assert_eq!(
1042 output,
1043 "OTHER_LDFLAGS = \"-lc++ -framework VideoToolbox\";\n"
1044 );
1045 }
1046
1047 #[test]
1048 fn switches_between_shared_runtime_and_static_link_flags() {
1049 let input = "OTHER_LDFLAGS = \"-lc++ -framework VideoToolbox\";\n";
1050 let dynamic_flags = vec![
1051 "-framework VideoToolbox".to_string(),
1052 "-lwaterui_dylib".to_string(),
1053 ];
1054 let (dynamic, changed) = inject_other_ldflags(input, &dynamic_flags);
1055 assert!(changed);
1056 assert!(dynamic.contains("-lwaterui_dylib"));
1057
1058 let static_flags = vec!["-framework VideoToolbox".to_string()];
1059 let (static_linked, changed) = inject_other_ldflags(&dynamic, &static_flags);
1060 assert!(changed);
1061 assert_eq!(static_linked, input);
1062 }
1063
1064 #[test]
1065 fn parses_frameworks_and_link_args_from_build_output() {
1066 let output = "cargo:rustc-link-lib=framework=AppKit\ncargo:rustc-link-arg=-rpath\ncargo:rustc-link-arg=/usr/lib/swift\ncargo:rustc-link-lib=framework=Foundation\n";
1067 let flags = apple_linker_flags_from_build_output(output);
1068 assert_eq!(
1069 flags,
1070 vec![
1071 "-framework AppKit".to_string(),
1072 "-rpath".to_string(),
1073 "/usr/lib/swift".to_string(),
1074 "-framework Foundation".to_string()
1075 ]
1076 );
1077 }
1078
1079 #[test]
1080 fn collects_swift_bridge_archives_and_flags_from_target_build_dir() {
1081 let dir = tempdir().expect("tempdir");
1082 let lib_dir = dir.path().join("aarch64-apple-darwin/debug");
1083 let build_dir = lib_dir.join("build/waterkit-haptic-1234");
1084 let out_dir = build_dir.join("out");
1085 std::fs::create_dir_all(&out_dir).expect("create out dir");
1086 std::fs::write(out_dir.join("CombinedHelper.swift"), "// bridge").expect("write swift");
1087 std::fs::write(out_dir.join("libHelper.a"), "").expect("write archive");
1088 std::fs::write(
1089 build_dir.join("output"),
1090 "cargo:rustc-link-lib=framework=AppKit\ncargo:rustc-link-arg=-rpath\ncargo:rustc-link-arg=/usr/lib/swift\n",
1091 )
1092 .expect("write build output");
1093
1094 let link_inputs =
1095 collect_apple_native_link_inputs_sync(&lib_dir).expect("collect native link inputs");
1096
1097 assert_eq!(link_inputs.archives, vec![out_dir.join("libHelper.a")]);
1098 assert_eq!(
1099 link_inputs.linker_flags,
1100 vec![
1101 "-framework AppKit".to_string(),
1102 "-rpath".to_string(),
1103 "/usr/lib/swift".to_string(),
1104 "-lHelper".to_string()
1105 ]
1106 );
1107 }
1108
1109 #[test]
1110 fn collects_link_flags_from_crates_without_swift_or_archives() {
1111 let dir = tempdir().expect("tempdir");
1114 let lib_dir = dir.path().join("aarch64-apple-darwin/release");
1115 let sys_build_dir = lib_dir.join("build/system-configuration-sys-1234");
1116 std::fs::create_dir_all(sys_build_dir.join("out")).expect("create out dir");
1117 std::fs::write(
1118 sys_build_dir.join("output"),
1119 "cargo:rustc-link-lib=framework=SystemConfiguration\n",
1120 )
1121 .expect("write build output");
1122
1123 let plain_build_dir = lib_dir.join("build/some-native-5678");
1126 let plain_out_dir = plain_build_dir.join("out");
1127 std::fs::create_dir_all(&plain_out_dir).expect("create out dir");
1128 std::fs::write(plain_out_dir.join("libwrapper.a"), "").expect("write archive");
1129 std::fs::write(
1130 plain_build_dir.join("output"),
1131 "cargo:rustc-link-lib=static=wrapper\n",
1132 )
1133 .expect("write build output");
1134
1135 let link_inputs =
1136 collect_apple_native_link_inputs_sync(&lib_dir).expect("collect native link inputs");
1137
1138 assert_eq!(
1139 link_inputs.archives,
1140 vec![plain_out_dir.join("libwrapper.a")]
1141 );
1142 let mut flags = link_inputs.linker_flags;
1143 flags.sort_unstable();
1144 assert_eq!(
1145 flags,
1146 vec![
1147 "-framework SystemConfiguration".to_string(),
1148 "-lwrapper".to_string()
1149 ]
1150 );
1151 }
1152
1153 #[test]
1154 fn collects_framework_flags_from_crates_without_swift_archives() {
1155 let dir = tempdir().expect("tempdir");
1156 let lib_dir = dir.path().join("aarch64-apple-darwin/debug");
1157 let build_dir = lib_dir.join("build/system-configuration-sys-1234");
1158 std::fs::create_dir_all(build_dir.join("out")).expect("create out dir");
1159 std::fs::write(
1160 build_dir.join("output"),
1161 "cargo:rustc-link-lib=framework=SystemConfiguration\n",
1162 )
1163 .expect("write build output");
1164
1165 let link_inputs =
1166 collect_apple_native_link_inputs_sync(&lib_dir).expect("collect native link inputs");
1167
1168 assert!(link_inputs.archives.is_empty());
1169 assert_eq!(
1170 link_inputs.linker_flags,
1171 vec!["-framework SystemConfiguration".to_string()]
1172 );
1173 }
1174 #[test]
1175 fn collects_link_inputs_from_nightly_build_layout() {
1176 let dir = tempdir().expect("tempdir");
1179 let lib_dir = dir.path().join("aarch64-apple-darwin/debug");
1180 let sys_unit = lib_dir.join("build/system-configuration-sys/1234abcd");
1181 std::fs::create_dir_all(sys_unit.join("run")).expect("create run dir");
1182 std::fs::write(
1183 sys_unit.join("run/stdout"),
1184 "cargo:rustc-link-lib=framework=SystemConfiguration\n",
1185 )
1186 .expect("write build stdout");
1187
1188 let compile_unit = lib_dir.join("build/plain-crate/5678efgh");
1191 std::fs::create_dir_all(compile_unit.join("out")).expect("create out dir");
1192 std::fs::write(compile_unit.join("out/libplain_crate.a"), "").expect("write archive");
1193
1194 let link_inputs =
1195 collect_apple_native_link_inputs_sync(&lib_dir).expect("collect native link inputs");
1196
1197 assert!(link_inputs.archives.is_empty());
1198 assert_eq!(
1199 link_inputs.linker_flags,
1200 vec!["-framework SystemConfiguration".to_string()]
1201 );
1202 }
1203}