1use crate::{FissionProject, MacosPackageConfig, NativeVariant};
2use anyhow::{bail, Context, Result};
3use serde::{Deserialize, Serialize};
4use std::ffi::OsString;
5use std::fs;
6use std::path::{Path, PathBuf};
7use std::process::Command;
8
9#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
10pub struct NativeMacosModuleConfig {
11 #[serde(default, skip_serializing_if = "Option::is_none")]
12 pub xcodegen_spec: Option<String>,
13 #[serde(default, skip_serializing_if = "Option::is_none")]
14 pub xcode_project: Option<String>,
15 #[serde(default, skip_serializing_if = "Option::is_none")]
16 pub derived_data: Option<String>,
17 #[serde(default, skip_serializing_if = "Vec::is_empty")]
18 pub test_schemes: Vec<String>,
19 #[serde(default, skip_serializing_if = "Vec::is_empty")]
20 pub products: Vec<NativeMacosProductConfig>,
21}
22
23impl NativeMacosModuleConfig {
24 pub fn is_empty(&self) -> bool {
25 self.xcodegen_spec.is_none()
26 && self.xcode_project.is_none()
27 && self.derived_data.is_none()
28 && self.test_schemes.is_empty()
29 && self.products.is_empty()
30 }
31}
32
33#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
34#[serde(rename_all = "kebab-case")]
35pub enum NativeMacosProductKind {
36 AppExtension,
37 SystemExtension,
38}
39
40#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
41pub struct NativeMacosProductSigningConfig {
42 #[serde(default, skip_serializing_if = "Option::is_none")]
43 pub entitlements: Option<String>,
44 #[serde(default, skip_serializing_if = "Option::is_none")]
45 pub provisioning_profile: Option<String>,
46 #[serde(default, skip_serializing_if = "Option::is_none")]
47 pub signing_identity: Option<String>,
48}
49
50impl NativeMacosProductSigningConfig {
51 fn is_empty(&self) -> bool {
52 self.entitlements.is_none()
53 && self.provisioning_profile.is_none()
54 && self.signing_identity.is_none()
55 }
56}
57
58#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
59pub struct NativeMacosProductConfig {
60 pub scheme: String,
61 pub bundle: String,
62 pub kind: NativeMacosProductKind,
63 #[serde(default = "enabled_by_default", skip_serializing_if = "is_enabled")]
66 pub package: bool,
67 #[serde(default, skip_serializing_if = "Option::is_none")]
68 pub entitlements: Option<String>,
69 #[serde(default, skip_serializing_if = "Option::is_none")]
70 pub provisioning_profile: Option<String>,
71 #[serde(default, skip_serializing_if = "Option::is_none")]
72 pub signing_identity: Option<String>,
73 #[serde(
74 default,
75 skip_serializing_if = "NativeMacosProductSigningConfig::is_empty"
76 )]
77 pub run: NativeMacosProductSigningConfig,
78}
79
80#[derive(Clone, Copy, Debug, Eq, PartialEq)]
81pub enum MacosNativeBundleMode {
82 Package,
83 Run,
84}
85
86#[derive(Clone, Debug)]
87struct BuiltProduct {
88 config: NativeMacosProductConfig,
89 path: PathBuf,
90}
91
92#[derive(Clone, Copy)]
93struct EffectiveSigning<'a> {
94 entitlements: Option<&'a str>,
95 provisioning_profile: Option<&'a str>,
96 signing_identity: Option<&'a str>,
97}
98
99pub fn build_macos_native_modules(
100 project_dir: &Path,
101 project: &FissionProject,
102 variant: Option<&NativeVariant>,
103 release: bool,
104) -> Result<()> {
105 let _ = build_products(project_dir, project, variant, None, release)?;
106 Ok(())
107}
108
109pub fn test_macos_native_modules(
110 project_dir: &Path,
111 project: &FissionProject,
112 variant: Option<&NativeVariant>,
113) -> Result<()> {
114 let project_dir = canonical_project_dir(project_dir)?;
115 for module in project.native_modules_for_variant(variant) {
116 if module.macos.is_empty() || module.macos.test_schemes.is_empty() {
117 continue;
118 }
119 let xcode_project = prepare_xcode_project(&project_dir, &module.name, &module.macos)?;
120 let derived_data = derived_data_path(&project_dir, &module.name, &module.macos, "test");
121 for scheme in &module.macos.test_schemes {
122 let scheme = required_value(scheme, "macOS native test scheme")?;
123 let mut command = Command::new("xcodebuild");
124 command
125 .arg("-quiet")
126 .arg("-project")
127 .arg(&xcode_project)
128 .arg("-scheme")
129 .arg(scheme)
130 .arg("-destination")
131 .arg("platform=macOS")
132 .arg("-derivedDataPath")
133 .arg(&derived_data)
134 .arg("CODE_SIGNING_ALLOWED=NO")
135 .arg("CODE_SIGNING_REQUIRED=NO")
136 .arg("test");
137 run_status(
138 &mut command,
139 &format!("macOS native test scheme `{scheme}`"),
140 )?;
141 }
142 }
143 Ok(())
144}
145
146pub fn embed_and_sign_macos_native_modules(
147 project_dir: &Path,
148 app_bundle: &Path,
149 project: &FissionProject,
150 variant: Option<&NativeVariant>,
151 host_signing: &MacosPackageConfig,
152 mode: MacosNativeBundleMode,
153 release: bool,
154) -> Result<()> {
155 let project_dir = canonical_project_dir(project_dir)?;
156 let app_bundle = fs::canonicalize(app_bundle).with_context(|| {
157 format!(
158 "failed to resolve macOS app bundle {}",
159 app_bundle.display()
160 )
161 })?;
162 for built in build_products(&project_dir, project, variant, Some(mode), release)? {
163 let destination = native_product_destination(&app_bundle, &built.config)?;
164 if destination.exists() {
165 fs::remove_dir_all(&destination).with_context(|| {
166 format!(
167 "failed to remove previous macOS native product {}",
168 destination.display()
169 )
170 })?;
171 }
172 let parent = destination
173 .parent()
174 .context("macOS native product destination has no parent")?;
175 fs::create_dir_all(parent)?;
176 copy_bundle(&built.path, &destination)?;
177 sign_native_product(
178 &project_dir,
179 &destination,
180 &built.config,
181 host_signing,
182 mode,
183 )?;
184 }
185 Ok(())
186}
187
188fn build_products(
189 project_dir: &Path,
190 project: &FissionProject,
191 variant: Option<&NativeVariant>,
192 mode: Option<MacosNativeBundleMode>,
193 release: bool,
194) -> Result<Vec<BuiltProduct>> {
195 let project_dir = canonical_project_dir(project_dir)?;
196 let profile = if release { "Release" } else { "Debug" };
197 let profile_dir = profile.to_ascii_lowercase();
198 let mut built = Vec::new();
199 for module in project.native_modules_for_variant(variant) {
200 if module.macos.is_empty() || module.macos.products.is_empty() {
201 continue;
202 }
203 let products = module
204 .macos
205 .products
206 .iter()
207 .filter(|product| product_enabled_for_mode(product, mode))
208 .collect::<Vec<_>>();
209 if products.is_empty() {
210 continue;
211 }
212 let xcode_project = prepare_xcode_project(&project_dir, &module.name, &module.macos)?;
213 let derived_data =
214 derived_data_path(&project_dir, &module.name, &module.macos, &profile_dir);
215 let product_dir = native_output_root(&project_dir, &module.name, &profile_dir);
216 if product_dir.exists() {
217 fs::remove_dir_all(&product_dir).with_context(|| {
218 format!(
219 "failed to clear macOS native product directory {}",
220 product_dir.display()
221 )
222 })?;
223 }
224 fs::create_dir_all(&product_dir)?;
225
226 for product in products {
227 validate_product(product)?;
228 let mut command = Command::new("xcodebuild");
229 command
230 .arg("-quiet")
231 .arg("-project")
232 .arg(&xcode_project)
233 .arg("-scheme")
234 .arg(product.scheme.trim())
235 .arg("-configuration")
236 .arg(profile)
237 .arg("-derivedDataPath")
238 .arg(&derived_data)
239 .arg(format!("CONFIGURATION_BUILD_DIR={}", product_dir.display()))
240 .arg("CODE_SIGNING_ALLOWED=NO")
241 .arg("CODE_SIGNING_REQUIRED=NO")
242 .arg("build");
243 run_status(
244 &mut command,
245 &format!("macOS native scheme `{}`", product.scheme.trim()),
246 )?;
247
248 let path = product_dir.join(product.bundle.trim());
249 if !path.is_dir() {
250 bail!(
251 "macOS native scheme `{}` completed but expected bundle is missing at {}",
252 product.scheme.trim(),
253 path.display()
254 );
255 }
256 built.push(BuiltProduct {
257 config: product.clone(),
258 path,
259 });
260 }
261 }
262 Ok(built)
263}
264
265fn product_enabled_for_mode(
266 product: &NativeMacosProductConfig,
267 mode: Option<MacosNativeBundleMode>,
268) -> bool {
269 mode != Some(MacosNativeBundleMode::Package) || product.package
270}
271
272const fn enabled_by_default() -> bool {
273 true
274}
275
276const fn is_enabled(value: &bool) -> bool {
277 *value
278}
279
280fn prepare_xcode_project(
281 project_dir: &Path,
282 module_name: &str,
283 config: &NativeMacosModuleConfig,
284) -> Result<PathBuf> {
285 let project = config
286 .xcode_project
287 .as_deref()
288 .filter(|value| !value.trim().is_empty())
289 .context("macOS native module requires `xcode_project`")?;
290 let project = resolve_project_path(project_dir, project);
291 if let Some(spec) = config
292 .xcodegen_spec
293 .as_deref()
294 .filter(|value| !value.trim().is_empty())
295 {
296 let spec = resolve_project_path(project_dir, spec);
297 if !spec.is_file() {
298 bail!(
299 "macOS native module `{module_name}` XcodeGen spec does not exist: {}",
300 spec.display()
301 );
302 }
303 let output_dir = project
304 .parent()
305 .context("macOS native Xcode project has no parent directory")?;
306 let status = Command::new("xcodegen")
307 .arg("--spec")
308 .arg(&spec)
309 .arg("--project")
310 .arg(output_dir)
311 .status()
312 .context("failed to run xcodegen; install XcodeGen or remove `xcodegen_spec`")?;
313 if !status.success() {
314 bail!("xcodegen failed for macOS native module `{module_name}` with {status}");
315 }
316 }
317 if !project.is_dir() {
318 bail!(
319 "macOS native module `{module_name}` Xcode project does not exist: {}",
320 project.display()
321 );
322 }
323 Ok(project)
324}
325
326fn native_product_destination(
327 app_bundle: &Path,
328 product: &NativeMacosProductConfig,
329) -> Result<PathBuf> {
330 validate_product(product)?;
331 let relative = match product.kind {
332 NativeMacosProductKind::AppExtension => "Contents/PlugIns",
333 NativeMacosProductKind::SystemExtension => "Contents/Library/SystemExtensions",
334 };
335 Ok(app_bundle.join(relative).join(product.bundle.trim()))
336}
337
338fn validate_product(product: &NativeMacosProductConfig) -> Result<()> {
339 required_value(&product.scheme, "macOS native product scheme")?;
340 let bundle = required_value(&product.bundle, "macOS native product bundle")?;
341 let expected_extension = match product.kind {
342 NativeMacosProductKind::AppExtension => "appex",
343 NativeMacosProductKind::SystemExtension => "systemextension",
344 };
345 if Path::new(bundle)
346 .extension()
347 .and_then(|value| value.to_str())
348 != Some(expected_extension)
349 {
350 bail!(
351 "macOS native product bundle `{bundle}` must use the .{expected_extension} extension"
352 );
353 }
354 if Path::new(bundle)
355 .file_name()
356 .and_then(|value| value.to_str())
357 != Some(bundle)
358 {
359 bail!("macOS native product bundle `{bundle}` must be a file name, not a path");
360 }
361 Ok(())
362}
363
364fn sign_native_product(
365 project_dir: &Path,
366 bundle: &Path,
367 product: &NativeMacosProductConfig,
368 host_signing: &MacosPackageConfig,
369 mode: MacosNativeBundleMode,
370) -> Result<()> {
371 let signing = effective_signing(product, host_signing, mode);
372 if signing.provisioning_profile.is_some()
373 && signing.signing_identity.is_none_or(|value| value == "-")
374 {
375 bail!(
376 "macOS native product `{}` has a provisioning profile but no real signing identity; ad-hoc signing with `-` cannot embed a provisioning profile",
377 product.bundle
378 );
379 }
380 if let Some(profile) = signing.provisioning_profile {
381 embed_provisioning_profile(project_dir, bundle, profile)?;
382 }
383 let Some(identity) = signing.signing_identity else {
384 return Ok(());
385 };
386
387 let status = Command::new("codesign")
388 .args(native_codesign_arguments(
389 project_dir,
390 identity,
391 signing.entitlements,
392 ))
393 .arg(bundle)
394 .status()
395 .with_context(|| format!("failed to sign macOS native product {}", bundle.display()))?;
396 if !status.success() {
397 bail!(
398 "codesign failed for macOS native product {} with {status}",
399 bundle.display()
400 );
401 }
402 let verify = Command::new("codesign")
403 .args(["--verify", "--strict", "--verbose=2"])
404 .arg(bundle)
405 .status()
406 .with_context(|| format!("failed to verify macOS native product {}", bundle.display()))?;
407 if !verify.success() {
408 bail!(
409 "codesign verification failed for macOS native product {} with {verify}",
410 bundle.display()
411 );
412 }
413 Ok(())
414}
415
416fn effective_signing<'a>(
417 product: &'a NativeMacosProductConfig,
418 host: &'a MacosPackageConfig,
419 mode: MacosNativeBundleMode,
420) -> EffectiveSigning<'a> {
421 let run = matches!(mode, MacosNativeBundleMode::Run);
422 EffectiveSigning {
423 entitlements: optional_value(if run {
424 product
425 .run
426 .entitlements
427 .as_deref()
428 .or(product.entitlements.as_deref())
429 } else {
430 product.entitlements.as_deref()
431 }),
432 provisioning_profile: optional_value(if run {
433 product
434 .run
435 .provisioning_profile
436 .as_deref()
437 .or(product.provisioning_profile.as_deref())
438 } else {
439 product.provisioning_profile.as_deref()
440 }),
441 signing_identity: optional_value(if run {
442 product
443 .run
444 .signing_identity
445 .as_deref()
446 .or(product.signing_identity.as_deref())
447 .or(host.signing_identity.as_deref())
448 } else {
449 product
450 .signing_identity
451 .as_deref()
452 .or(host.signing_identity.as_deref())
453 }),
454 }
455}
456
457fn native_codesign_arguments(
458 project_dir: &Path,
459 identity: &str,
460 entitlements: Option<&str>,
461) -> Vec<OsString> {
462 let mut args = vec![
463 "--force".into(),
464 "--timestamp".into(),
465 "--options".into(),
466 "runtime".into(),
467 "--sign".into(),
468 identity.into(),
469 ];
470 if let Some(entitlements) = entitlements {
471 args.push("--entitlements".into());
472 args.push(resolve_project_path(project_dir, entitlements).into_os_string());
473 }
474 args
475}
476
477fn embed_provisioning_profile(project_dir: &Path, bundle: &Path, profile: &str) -> Result<()> {
478 let source = resolve_project_path(project_dir, profile);
479 if !source.is_file() {
480 bail!(
481 "macOS native product provisioning profile does not exist: {}",
482 source.display()
483 );
484 }
485 let contents = bundle.join("Contents");
486 fs::create_dir_all(&contents)?;
487 let destination = contents.join("embedded.provisionprofile");
488 fs::copy(&source, &destination).with_context(|| {
489 format!(
490 "failed to embed macOS native product profile {} at {}",
491 source.display(),
492 destination.display()
493 )
494 })?;
495 Ok(())
496}
497
498fn copy_bundle(source: &Path, destination: &Path) -> Result<()> {
499 let status = Command::new("ditto")
500 .arg(source)
501 .arg(destination)
502 .status()
503 .context("failed to run ditto while embedding a macOS native product")?;
504 if !status.success() {
505 bail!(
506 "ditto failed while embedding {} at {} with {status}",
507 source.display(),
508 destination.display()
509 );
510 }
511 Ok(())
512}
513
514fn derived_data_path(
515 project_dir: &Path,
516 module_name: &str,
517 config: &NativeMacosModuleConfig,
518 profile: &str,
519) -> PathBuf {
520 config
521 .derived_data
522 .as_deref()
523 .filter(|value| !value.trim().is_empty())
524 .map(|value| resolve_project_path(project_dir, value))
525 .unwrap_or_else(|| {
526 project_dir
527 .join(".fission/native/macos")
528 .join(sanitize_component(module_name))
529 .join(profile)
530 .join("DerivedData")
531 })
532}
533
534fn native_output_root(project_dir: &Path, module_name: &str, profile: &str) -> PathBuf {
535 project_dir
536 .join(".fission/native/macos")
537 .join(sanitize_component(module_name))
538 .join(profile)
539 .join("Products")
540}
541
542fn resolve_project_path(project_dir: &Path, value: &str) -> PathBuf {
543 let path = Path::new(value);
544 if path.is_absolute() {
545 path.to_path_buf()
546 } else {
547 project_dir.join(path)
548 }
549}
550
551fn canonical_project_dir(project_dir: &Path) -> Result<PathBuf> {
552 fs::canonicalize(project_dir).with_context(|| {
553 format!(
554 "failed to resolve project directory {}",
555 project_dir.display()
556 )
557 })
558}
559
560fn required_value<'a>(value: &'a str, label: &str) -> Result<&'a str> {
561 let value = value.trim();
562 if value.is_empty() {
563 bail!("{label} cannot be empty");
564 }
565 Ok(value)
566}
567
568fn optional_value(value: Option<&str>) -> Option<&str> {
569 value.map(str::trim).filter(|value| !value.is_empty())
570}
571
572fn sanitize_component(value: &str) -> String {
573 let value = value
574 .chars()
575 .map(|ch| {
576 if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_') {
577 ch
578 } else {
579 '-'
580 }
581 })
582 .collect::<String>();
583 let value = value.trim_matches('-');
584 if value.is_empty() {
585 "module".to_string()
586 } else {
587 value.to_string()
588 }
589}
590
591fn run_status(command: &mut Command, label: &str) -> Result<()> {
592 let status = command
593 .status()
594 .with_context(|| format!("failed to run {label}"))?;
595 if !status.success() {
596 bail!("{label} failed with {status}");
597 }
598 Ok(())
599}
600
601#[cfg(test)]
602mod tests {
603 use super::*;
604 use crate::FissionProject;
605
606 #[test]
607 fn parses_macos_native_module_products() {
608 let project: FissionProject = toml::from_str(
609 r#"
610targets = ["macos"]
611
612[app]
613name = "demo"
614app_id = "com.example.demo"
615
616[[native.modules]]
617name = "demo-native"
618
619[native.modules.macos]
620xcodegen_spec = "platforms/macos/native/project.yml"
621xcode_project = "platforms/macos/native/Demo.xcodeproj"
622test_schemes = ["DemoNativeTests"]
623
624[[native.modules.macos.products]]
625scheme = "DemoFileProvider"
626bundle = "DemoFileProvider.appex"
627kind = "app-extension"
628package = false
629entitlements = "platforms/macos/native/FileProvider.entitlements"
630provisioning_profile = "profiles/FileProvider.provisionprofile"
631
632[native.modules.macos.products.run]
633provisioning_profile = "profiles/FileProviderDevelopment.provisionprofile"
634signing_identity = "Apple Development"
635"#,
636 )
637 .unwrap();
638
639 let module = &project.native.modules[0].macos;
640 assert_eq!(module.test_schemes, ["DemoNativeTests"]);
641 assert_eq!(
642 module.products[0].kind,
643 NativeMacosProductKind::AppExtension
644 );
645 assert!(!module.products[0].package);
646 assert!(!product_enabled_for_mode(
647 &module.products[0],
648 Some(MacosNativeBundleMode::Package)
649 ));
650 assert!(product_enabled_for_mode(
651 &module.products[0],
652 Some(MacosNativeBundleMode::Run)
653 ));
654 assert!(product_enabled_for_mode(&module.products[0], None));
655 assert_eq!(
656 module.products[0].run.provisioning_profile.as_deref(),
657 Some("profiles/FileProviderDevelopment.provisionprofile")
658 );
659 }
660
661 #[test]
662 fn macos_native_products_are_packaged_by_default() {
663 let project: FissionProject = toml::from_str(
664 r#"
665targets = ["macos"]
666
667[app]
668name = "demo"
669app_id = "com.example.demo"
670
671[[native.modules]]
672name = "demo-native"
673
674[[native.modules.macos.products]]
675scheme = "DemoEndpointSecurity"
676bundle = "DemoEndpointSecurity.systemextension"
677kind = "system-extension"
678"#,
679 )
680 .unwrap();
681
682 assert!(project.native.modules[0].macos.products[0].package);
683 }
684
685 #[test]
686 fn resolves_native_bundle_destinations() {
687 let app = Path::new("/tmp/Demo.app");
688 let app_extension = product(NativeMacosProductKind::AppExtension, "Provider.appex");
689 let system_extension = product(
690 NativeMacosProductKind::SystemExtension,
691 "Security.systemextension",
692 );
693
694 assert_eq!(
695 native_product_destination(app, &app_extension).unwrap(),
696 Path::new("/tmp/Demo.app/Contents/PlugIns/Provider.appex")
697 );
698 assert_eq!(
699 native_product_destination(app, &system_extension).unwrap(),
700 Path::new("/tmp/Demo.app/Contents/Library/SystemExtensions/Security.systemextension")
701 );
702 }
703
704 #[test]
705 fn run_signing_overrides_package_values_and_inherits_host_identity() {
706 let mut product = product(NativeMacosProductKind::AppExtension, "Provider.appex");
707 product.entitlements = Some("release.entitlements".into());
708 product.provisioning_profile = Some("release.provisionprofile".into());
709 product.run.entitlements = Some("development.entitlements".into());
710 product.run.provisioning_profile = Some("development.provisionprofile".into());
711 let host = MacosPackageConfig {
712 signing_identity: Some("Apple Development".into()),
713 ..Default::default()
714 };
715
716 let signing = effective_signing(&product, &host, MacosNativeBundleMode::Run);
717
718 assert_eq!(signing.entitlements, Some("development.entitlements"));
719 assert_eq!(
720 signing.provisioning_profile,
721 Some("development.provisionprofile")
722 );
723 assert_eq!(signing.signing_identity, Some("Apple Development"));
724 }
725
726 #[test]
727 fn rejects_bundle_suffix_mismatches() {
728 let product = product(NativeMacosProductKind::SystemExtension, "Provider.appex");
729
730 let error = validate_product(&product).unwrap_err();
731
732 assert!(error.to_string().contains(".systemextension"));
733 }
734
735 #[test]
736 fn native_product_rejects_profile_with_ad_hoc_signing_identity() {
737 let product = NativeMacosProductConfig {
738 scheme: "Share".into(),
739 bundle: "Share.appex".into(),
740 kind: NativeMacosProductKind::AppExtension,
741 package: true,
742 entitlements: None,
743 provisioning_profile: Some("profiles/Share.provisionprofile".into()),
744 signing_identity: Some("-".into()),
745 run: NativeMacosProductSigningConfig::default(),
746 };
747 let error = sign_native_product(
748 Path::new("/project"),
749 Path::new("/tmp/Share.appex"),
750 &product,
751 &MacosPackageConfig::default(),
752 MacosNativeBundleMode::Package,
753 )
754 .unwrap_err();
755
756 assert!(error.to_string().contains("ad-hoc signing"));
757 }
758
759 fn product(kind: NativeMacosProductKind, bundle: &str) -> NativeMacosProductConfig {
760 NativeMacosProductConfig {
761 scheme: "Demo".into(),
762 bundle: bundle.into(),
763 kind,
764 package: true,
765 entitlements: None,
766 provisioning_profile: None,
767 signing_identity: None,
768 run: NativeMacosProductSigningConfig::default(),
769 }
770 }
771}