Skip to main content

fission_command_core/
macos_native.rs

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    /// Controls whether `fission package` builds and embeds this product.
64    /// `fission build` and `fission run` always include configured products.
65    #[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}