electron-cli 0.3.0-alpha.15 → 0.3.0-alpha.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,6 +5,7 @@ use std::{
5
5
  };
6
6
 
7
7
  use anyhow::{bail, Context, Result};
8
+ use apple_codesign::{BundleSigner, CodeSignatureFlags, SettingsScope, SigningSettings};
8
9
  use camino::Utf8PathBuf;
9
10
  use plist::{Dictionary as PlistDictionary, Value as PlistValue};
10
11
  use serde::Serialize;
@@ -18,6 +19,7 @@ pub(crate) struct PackageReport {
18
19
  app_name: String,
19
20
  executable_name: String,
20
21
  metadata: PackageMetadata,
22
+ signing: PackageSigningPlan,
21
23
  platform: String,
22
24
  arch: String,
23
25
  electron_dist: Utf8PathBuf,
@@ -55,6 +57,39 @@ struct IconResource {
55
57
  to: Utf8PathBuf,
56
58
  }
57
59
 
60
+ #[derive(Clone, Debug, Serialize)]
61
+ struct PackageSigningPlan {
62
+ macos: MacosSigningPlan,
63
+ }
64
+
65
+ #[derive(Clone, Debug, Serialize)]
66
+ struct MacosSigningPlan {
67
+ sign: MacosSignPlan,
68
+ notarize: MacosNotarizePlan,
69
+ }
70
+
71
+ #[derive(Clone, Debug, Serialize)]
72
+ struct MacosSignPlan {
73
+ configured: bool,
74
+ enabled: bool,
75
+ will_execute: bool,
76
+ method: Option<String>,
77
+ identity: Option<String>,
78
+ entitlements: Vec<Utf8PathBuf>,
79
+ entitlements_inherit: Option<Utf8PathBuf>,
80
+ hardened_runtime: Option<bool>,
81
+ gatekeeper_assess: Option<bool>,
82
+ }
83
+
84
+ #[derive(Clone, Debug, Serialize)]
85
+ struct MacosNotarizePlan {
86
+ configured: bool,
87
+ enabled: bool,
88
+ auth_method: Option<String>,
89
+ keychain_profile: Option<String>,
90
+ keychain: Option<String>,
91
+ }
92
+
58
93
  #[derive(Clone, Copy, Debug, Serialize)]
59
94
  #[serde(rename_all = "kebab-case")]
60
95
  enum PackageStatus {
@@ -82,6 +117,35 @@ struct PackagerConfig {
82
117
  icon: Vec<String>,
83
118
  extra_resource: Vec<String>,
84
119
  darwin_dark_mode_support: bool,
120
+ osx_sign: MacosSignConfig,
121
+ osx_notarize: MacosNotarizeConfig,
122
+ }
123
+
124
+ #[derive(Clone, Debug, Default)]
125
+ struct MacosSignConfig {
126
+ configured: bool,
127
+ enabled: bool,
128
+ invalid_type: bool,
129
+ identity: Option<String>,
130
+ entitlements: Vec<String>,
131
+ entitlements_inherit: Option<String>,
132
+ hardened_runtime: Option<bool>,
133
+ gatekeeper_assess: Option<bool>,
134
+ }
135
+
136
+ #[derive(Clone, Debug, Default)]
137
+ struct MacosNotarizeConfig {
138
+ configured: bool,
139
+ enabled: bool,
140
+ invalid_type: bool,
141
+ apple_id_set: bool,
142
+ apple_id_password_set: bool,
143
+ team_id_set: bool,
144
+ apple_api_key: Option<String>,
145
+ apple_api_key_id_set: bool,
146
+ apple_api_issuer_set: bool,
147
+ keychain_profile: Option<String>,
148
+ keychain: Option<String>,
85
149
  }
86
150
 
87
151
  pub fn run(args: PackageArgs) -> Result<()> {
@@ -133,6 +197,7 @@ pub(crate) fn build_report(snapshot: ProjectSnapshot, args: &PackageArgs) -> Res
133
197
  &app_resources_dir,
134
198
  &platform,
135
199
  )?;
200
+ let (signing, signing_warnings) = package_signing(root, &package_config, &platform)?;
136
201
 
137
202
  let mut warnings = package_config.warnings.clone();
138
203
  if snapshot.package_json.is_none() {
@@ -170,6 +235,7 @@ pub(crate) fn build_report(snapshot: ProjectSnapshot, args: &PackageArgs) -> Res
170
235
 
171
236
  warnings.extend(runtime_dependency_warnings(root, &snapshot));
172
237
  warnings.extend(metadata_warnings);
238
+ warnings.extend(signing_warnings);
173
239
 
174
240
  let create_dirs = vec![package_root.clone(), app_resources_dir.clone()];
175
241
  let mut copy_steps = vec![
@@ -200,6 +266,7 @@ pub(crate) fn build_report(snapshot: ProjectSnapshot, args: &PackageArgs) -> Res
200
266
  app_name,
201
267
  executable_name,
202
268
  metadata,
269
+ signing,
203
270
  platform,
204
271
  arch,
205
272
  electron_dist: utf8_path(electron_dist)?,
@@ -299,10 +366,111 @@ pub(crate) fn execute_package(report: &PackageReport, force: bool) -> Result<()>
299
366
  &app_dir,
300
367
  &report.project,
301
368
  )?;
369
+ execute_macos_signing(report)?;
302
370
 
303
371
  Ok(())
304
372
  }
305
373
 
374
+ fn execute_macos_signing(report: &PackageReport) -> Result<()> {
375
+ if report.platform != "darwin" || !report.signing.macos.sign.will_execute {
376
+ return Ok(());
377
+ }
378
+
379
+ let bundle_dir = Path::new(report.bundle_dir.as_str());
380
+ let bundle_parent = bundle_dir
381
+ .parent()
382
+ .context("macOS bundle output has no parent directory")?;
383
+ let bundle_name = bundle_dir
384
+ .file_name()
385
+ .context("macOS bundle output has no bundle directory name")?;
386
+ let unique_suffix = std::time::SystemTime::now()
387
+ .duration_since(std::time::UNIX_EPOCH)
388
+ .context("system clock is before the Unix epoch")?
389
+ .as_nanos();
390
+ let signing_parent = bundle_parent.join(format!(
391
+ ".electron-cli-signing-{}-{unique_suffix}",
392
+ std::process::id()
393
+ ));
394
+ let signed_bundle_dir = signing_parent.join(bundle_name);
395
+
396
+ if signing_parent.exists() {
397
+ fs::remove_dir_all(&signing_parent)
398
+ .with_context(|| format!("Could not remove {}", signing_parent.display()))?;
399
+ }
400
+
401
+ let signing_result = (|| -> Result<()> {
402
+ let mut signer = BundleSigner::new_from_path(bundle_dir).with_context(|| {
403
+ format!(
404
+ "Could not prepare macOS bundle signing for {}",
405
+ bundle_dir.display()
406
+ )
407
+ })?;
408
+ signer
409
+ .collect_nested_bundles()
410
+ .context("Could not discover nested macOS bundles for signing")?;
411
+
412
+ let settings = macos_signing_settings(report)?;
413
+ signer
414
+ .write_signed_bundle(&signed_bundle_dir, &settings)
415
+ .with_context(|| {
416
+ format!(
417
+ "Could not write signed macOS bundle to {}",
418
+ signed_bundle_dir.display()
419
+ )
420
+ })?;
421
+
422
+ Ok(())
423
+ })();
424
+
425
+ if let Err(error) = signing_result {
426
+ let _ = fs::remove_dir_all(&signing_parent);
427
+ return Err(error);
428
+ }
429
+
430
+ fs::remove_dir_all(bundle_dir)
431
+ .with_context(|| format!("Could not remove {}", bundle_dir.display()))?;
432
+ fs::rename(&signed_bundle_dir, bundle_dir).with_context(|| {
433
+ format!(
434
+ "Could not move signed macOS bundle from {} to {}",
435
+ signed_bundle_dir.display(),
436
+ bundle_dir.display()
437
+ )
438
+ })?;
439
+ let _ = fs::remove_dir_all(&signing_parent);
440
+
441
+ Ok(())
442
+ }
443
+
444
+ fn macos_signing_settings(report: &PackageReport) -> Result<SigningSettings<'static>> {
445
+ let sign = &report.signing.macos.sign;
446
+ let mut settings = SigningSettings::default();
447
+ settings.set_binary_identifier(SettingsScope::Main, &report.metadata.bundle_identifier);
448
+
449
+ if sign.hardened_runtime.unwrap_or(false) {
450
+ settings.add_code_signature_flags(SettingsScope::Main, CodeSignatureFlags::RUNTIME);
451
+ }
452
+
453
+ if let Some(entitlements) = sign.entitlements.first() {
454
+ let entitlements_path = Path::new(entitlements.as_str());
455
+ let entitlements_xml = fs::read_to_string(entitlements_path).with_context(|| {
456
+ format!(
457
+ "Could not read macOS entitlements file {}",
458
+ entitlements_path.display()
459
+ )
460
+ })?;
461
+ settings
462
+ .set_entitlements_xml(SettingsScope::Main, entitlements_xml)
463
+ .with_context(|| {
464
+ format!(
465
+ "Could not parse macOS entitlements file {}",
466
+ entitlements_path.display()
467
+ )
468
+ })?;
469
+ }
470
+
471
+ Ok(settings)
472
+ }
473
+
306
474
  fn print_report(report: &PackageReport, json: bool) -> Result<()> {
307
475
  if json {
308
476
  return output::json(report);
@@ -325,6 +493,44 @@ fn print_report(report: &PackageReport, json: bool) -> Result<()> {
325
493
  println!(" target: {} {}", report.platform, report.arch);
326
494
  println!(" status: {}", report.status.as_str());
327
495
 
496
+ if report.signing.macos.sign.configured || report.signing.macos.notarize.configured {
497
+ println!();
498
+ println!("Signing");
499
+ println!(
500
+ " macOS signing: {}",
501
+ if report.signing.macos.sign.enabled {
502
+ "configured"
503
+ } else {
504
+ "disabled"
505
+ }
506
+ );
507
+ if let Some(identity) = &report.signing.macos.sign.identity {
508
+ println!(" identity: {identity}");
509
+ }
510
+ if let Some(method) = &report.signing.macos.sign.method {
511
+ println!(" signing method: {method}");
512
+ }
513
+ println!(
514
+ " signing execution: {}",
515
+ if report.signing.macos.sign.will_execute {
516
+ "enabled"
517
+ } else {
518
+ "not available"
519
+ }
520
+ );
521
+ println!(
522
+ " macOS notarization: {}",
523
+ if report.signing.macos.notarize.enabled {
524
+ "configured"
525
+ } else {
526
+ "disabled"
527
+ }
528
+ );
529
+ if let Some(method) = &report.signing.macos.notarize.auth_method {
530
+ println!(" notarization auth: {method}");
531
+ }
532
+ }
533
+
328
534
  println!();
329
535
  println!("Output");
330
536
  println!(" {}", report.bundle_dir);
@@ -400,6 +606,114 @@ fn parse_packager_config(value: &JsonValue) -> PackagerConfig {
400
606
  .get("darwinDarkModeSupport")
401
607
  .and_then(JsonValue::as_bool)
402
608
  .unwrap_or(false),
609
+ osx_sign: parse_macos_sign_config(value.get("osxSign")),
610
+ osx_notarize: parse_macos_notarize_config(value.get("osxNotarize")),
611
+ }
612
+ }
613
+
614
+ fn parse_macos_sign_config(value: Option<&JsonValue>) -> MacosSignConfig {
615
+ match value {
616
+ None => MacosSignConfig::default(),
617
+ Some(JsonValue::Bool(false)) => MacosSignConfig {
618
+ configured: true,
619
+ enabled: false,
620
+ ..MacosSignConfig::default()
621
+ },
622
+ Some(JsonValue::Bool(true)) => MacosSignConfig {
623
+ configured: true,
624
+ enabled: true,
625
+ ..MacosSignConfig::default()
626
+ },
627
+ Some(JsonValue::Object(object)) => {
628
+ let entitlements = [
629
+ "entitlements",
630
+ "entitlementsInherit",
631
+ "entitlementsLoginHelper",
632
+ ]
633
+ .iter()
634
+ .filter_map(|key| {
635
+ object
636
+ .get(*key)
637
+ .and_then(JsonValue::as_str)
638
+ .map(ToOwned::to_owned)
639
+ })
640
+ .collect();
641
+
642
+ MacosSignConfig {
643
+ configured: true,
644
+ enabled: true,
645
+ invalid_type: false,
646
+ identity: object
647
+ .get("identity")
648
+ .or_else(|| object.get("identityName"))
649
+ .and_then(JsonValue::as_str)
650
+ .map(ToOwned::to_owned),
651
+ entitlements,
652
+ entitlements_inherit: object
653
+ .get("entitlementsInherit")
654
+ .and_then(JsonValue::as_str)
655
+ .map(ToOwned::to_owned),
656
+ hardened_runtime: object.get("hardenedRuntime").and_then(JsonValue::as_bool),
657
+ gatekeeper_assess: object.get("gatekeeperAssess").and_then(JsonValue::as_bool),
658
+ }
659
+ }
660
+ Some(_) => MacosSignConfig {
661
+ configured: true,
662
+ invalid_type: true,
663
+ ..MacosSignConfig::default()
664
+ },
665
+ }
666
+ }
667
+
668
+ fn parse_macos_notarize_config(value: Option<&JsonValue>) -> MacosNotarizeConfig {
669
+ match value {
670
+ None => MacosNotarizeConfig::default(),
671
+ Some(JsonValue::Bool(false)) => MacosNotarizeConfig {
672
+ configured: true,
673
+ enabled: false,
674
+ ..MacosNotarizeConfig::default()
675
+ },
676
+ Some(JsonValue::Bool(true)) => MacosNotarizeConfig {
677
+ configured: true,
678
+ enabled: true,
679
+ ..MacosNotarizeConfig::default()
680
+ },
681
+ Some(JsonValue::Object(object)) => MacosNotarizeConfig {
682
+ configured: true,
683
+ enabled: true,
684
+ invalid_type: false,
685
+ apple_id_set: object.get("appleId").and_then(JsonValue::as_str).is_some(),
686
+ apple_id_password_set: object
687
+ .get("appleIdPassword")
688
+ .and_then(JsonValue::as_str)
689
+ .is_some(),
690
+ team_id_set: object.get("teamId").and_then(JsonValue::as_str).is_some(),
691
+ apple_api_key: object
692
+ .get("appleApiKey")
693
+ .and_then(JsonValue::as_str)
694
+ .map(ToOwned::to_owned),
695
+ apple_api_key_id_set: object
696
+ .get("appleApiKeyId")
697
+ .and_then(JsonValue::as_str)
698
+ .is_some(),
699
+ apple_api_issuer_set: object
700
+ .get("appleApiIssuer")
701
+ .and_then(JsonValue::as_str)
702
+ .is_some(),
703
+ keychain_profile: object
704
+ .get("keychainProfile")
705
+ .and_then(JsonValue::as_str)
706
+ .map(ToOwned::to_owned),
707
+ keychain: object
708
+ .get("keychain")
709
+ .and_then(JsonValue::as_str)
710
+ .map(ToOwned::to_owned),
711
+ },
712
+ Some(_) => MacosNotarizeConfig {
713
+ configured: true,
714
+ invalid_type: true,
715
+ ..MacosNotarizeConfig::default()
716
+ },
403
717
  }
404
718
  }
405
719
 
@@ -473,6 +787,202 @@ fn package_metadata(
473
787
  ))
474
788
  }
475
789
 
790
+ fn package_signing(
791
+ root: &Path,
792
+ config: &PackageJsonConfig,
793
+ platform: &str,
794
+ ) -> Result<(PackageSigningPlan, Vec<String>)> {
795
+ let mut warnings = Vec::new();
796
+ let sign = macos_sign_plan(root, &config.packager.osx_sign, platform, &mut warnings)?;
797
+ let notarize = macos_notarize_plan(root, config, platform, &mut warnings);
798
+
799
+ Ok((
800
+ PackageSigningPlan {
801
+ macos: MacosSigningPlan { sign, notarize },
802
+ },
803
+ warnings,
804
+ ))
805
+ }
806
+
807
+ fn macos_sign_plan(
808
+ root: &Path,
809
+ config: &MacosSignConfig,
810
+ platform: &str,
811
+ warnings: &mut Vec<String>,
812
+ ) -> Result<MacosSignPlan> {
813
+ if config.invalid_type {
814
+ warnings.push("packagerConfig.osxSign must be false, true, or an object.".to_string());
815
+ }
816
+
817
+ let entitlements = config
818
+ .entitlements
819
+ .iter()
820
+ .filter(|path| !path.trim().is_empty())
821
+ .map(|path| {
822
+ let resolved = resolve_project_path(root, path);
823
+ if !resolved.exists() {
824
+ warnings.push(format!(
825
+ "Configured macOS entitlements file does not exist: {}.",
826
+ resolved.display()
827
+ ));
828
+ }
829
+ utf8_path(resolved)
830
+ })
831
+ .collect::<Result<Vec<_>>>()?;
832
+ let entitlements_inherit = config
833
+ .entitlements_inherit
834
+ .as_deref()
835
+ .filter(|path| !path.trim().is_empty())
836
+ .map(|path| utf8_path(resolve_project_path(root, path)))
837
+ .transpose()?;
838
+ if let Some(path) = &entitlements_inherit {
839
+ if !Path::new(path.as_str()).exists() {
840
+ warnings.push(format!(
841
+ "Configured macOS inherited entitlements file does not exist: {}.",
842
+ path
843
+ ));
844
+ }
845
+ }
846
+
847
+ let identity = config.identity.as_deref().map(str::trim);
848
+ let ad_hoc_identity = matches!(identity, None | Some("-"));
849
+ let will_execute = config.enabled && platform == "darwin" && ad_hoc_identity;
850
+ let method = if config.enabled && platform == "darwin" {
851
+ if ad_hoc_identity {
852
+ Some("ad-hoc".to_string())
853
+ } else {
854
+ Some("certificate-identity".to_string())
855
+ }
856
+ } else {
857
+ None
858
+ };
859
+
860
+ if config.configured && platform != "darwin" {
861
+ warnings.push(format!(
862
+ "macOS signing is configured but ignored for target platform {platform}."
863
+ ));
864
+ } else if config.enabled && !will_execute {
865
+ warnings.push(
866
+ "macOS signing identity is configured, but Rust-native certificate/keychain signing is not implemented yet; package output will be unsigned. Use identity '-' or omit identity for experimental ad-hoc signing.".to_string(),
867
+ );
868
+ } else if will_execute {
869
+ if config.entitlements.len() > 1 {
870
+ warnings.push(
871
+ "Rust-native ad-hoc signing applies the first macOS entitlements file only; inherited/login-helper entitlement scoping is not implemented yet.".to_string(),
872
+ );
873
+ }
874
+ if config.entitlements_inherit.is_some() {
875
+ warnings.push(
876
+ "packagerConfig.osxSign.entitlementsInherit is recognized but not applied to nested bundles by Rust-native ad-hoc signing yet.".to_string(),
877
+ );
878
+ }
879
+ if config.gatekeeper_assess.is_some() {
880
+ warnings.push(
881
+ "packagerConfig.osxSign.gatekeeperAssess is recognized but Gatekeeper assessment is not implemented yet.".to_string(),
882
+ );
883
+ }
884
+ }
885
+
886
+ Ok(MacosSignPlan {
887
+ configured: config.configured,
888
+ enabled: config.enabled,
889
+ will_execute,
890
+ method,
891
+ identity: config.identity.clone(),
892
+ entitlements,
893
+ entitlements_inherit,
894
+ hardened_runtime: config.hardened_runtime,
895
+ gatekeeper_assess: config.gatekeeper_assess,
896
+ })
897
+ }
898
+
899
+ fn macos_notarize_plan(
900
+ root: &Path,
901
+ package_config: &PackageJsonConfig,
902
+ platform: &str,
903
+ warnings: &mut Vec<String>,
904
+ ) -> MacosNotarizePlan {
905
+ let config = &package_config.packager.osx_notarize;
906
+ if config.invalid_type {
907
+ warnings.push("packagerConfig.osxNotarize must be false, true, or an object.".to_string());
908
+ }
909
+
910
+ let auth_method = macos_notarize_auth_method(config);
911
+ if config.configured && platform != "darwin" {
912
+ warnings.push(format!(
913
+ "macOS notarization is configured but ignored for target platform {platform}."
914
+ ));
915
+ } else if config.enabled {
916
+ warnings.push(
917
+ "macOS notarization is configured, but Rust-native notarization is not implemented yet.".to_string(),
918
+ );
919
+ }
920
+
921
+ if config.enabled && !package_config.packager.osx_sign.enabled {
922
+ warnings.push(
923
+ "macOS notarization requires packagerConfig.osxSign to be enabled first.".to_string(),
924
+ );
925
+ }
926
+ if config.enabled
927
+ && platform == "darwin"
928
+ && package_config.packager.osx_sign.enabled
929
+ && matches!(
930
+ package_config
931
+ .packager
932
+ .osx_sign
933
+ .identity
934
+ .as_deref()
935
+ .map(str::trim),
936
+ None | Some("-")
937
+ )
938
+ {
939
+ warnings.push(
940
+ "macOS notarization requires a Developer ID signature; Rust-native ad-hoc signing is not notarizable.".to_string(),
941
+ );
942
+ }
943
+ if config.enabled && auth_method.is_none() {
944
+ warnings.push(
945
+ "macOS notarization config is missing a complete notarytool authentication set: appleId/appleIdPassword/teamId, appleApiKey/appleApiKeyId/appleApiIssuer, or keychainProfile.".to_string(),
946
+ );
947
+ }
948
+ if let Some(api_key) = &config.apple_api_key {
949
+ let path = resolve_project_path(root, api_key);
950
+ if !path.exists() {
951
+ warnings.push(format!(
952
+ "Configured Apple API key file does not exist: {}.",
953
+ path.display()
954
+ ));
955
+ }
956
+ }
957
+
958
+ MacosNotarizePlan {
959
+ configured: config.configured,
960
+ enabled: config.enabled,
961
+ auth_method,
962
+ keychain_profile: config.keychain_profile.clone(),
963
+ keychain: config.keychain.clone(),
964
+ }
965
+ }
966
+
967
+ fn macos_notarize_auth_method(config: &MacosNotarizeConfig) -> Option<String> {
968
+ if config
969
+ .keychain_profile
970
+ .as_deref()
971
+ .is_some_and(|value| !value.trim().is_empty())
972
+ {
973
+ Some("keychain-profile".to_string())
974
+ } else if config.apple_api_key.is_some()
975
+ && config.apple_api_key_id_set
976
+ && config.apple_api_issuer_set
977
+ {
978
+ Some("app-store-connect-api-key".to_string())
979
+ } else if config.apple_id_set && config.apple_id_password_set && config.team_id_set {
980
+ Some("apple-id".to_string())
981
+ } else {
982
+ None
983
+ }
984
+ }
985
+
476
986
  fn resolve_icon_resource(
477
987
  root: &Path,
478
988
  configured_icons: &[String],
@@ -795,6 +1305,7 @@ fn apply_macos_metadata(report: &PackageReport) -> Result<()> {
795
1305
  "CFBundleIdentifier",
796
1306
  &report.metadata.bundle_identifier,
797
1307
  );
1308
+ set_plist_string(&mut dictionary, "CFBundlePackageType", "APPL");
798
1309
 
799
1310
  if let Some(version) = &report.metadata.app_version {
800
1311
  set_plist_string(&mut dictionary, "CFBundleShortVersionString", version);
@@ -1158,6 +1669,12 @@ impl PackagerConfig {
1158
1669
  }
1159
1670
  self.darwin_dark_mode_support =
1160
1671
  other.darwin_dark_mode_support || self.darwin_dark_mode_support;
1672
+ if other.osx_sign.configured {
1673
+ self.osx_sign = other.osx_sign;
1674
+ }
1675
+ if other.osx_notarize.configured {
1676
+ self.osx_notarize = other.osx_notarize;
1677
+ }
1161
1678
  }
1162
1679
  }
1163
1680
 
@@ -1423,6 +1940,183 @@ mod tests {
1423
1940
  let _ = fs::remove_dir_all(root);
1424
1941
  }
1425
1942
 
1943
+ #[test]
1944
+ fn plans_macos_signing_and_notarization_without_serializing_secrets() {
1945
+ let root = unique_temp_dir("macos-signing-plan");
1946
+ write_package_json(&root);
1947
+ fs::write(root.join("entitlements.plist"), "<plist></plist>")
1948
+ .expect("entitlements should be written");
1949
+ fs::write(root.join("AuthKey_TEST.p8"), "secret api key")
1950
+ .expect("api key should be written");
1951
+ fs::write(
1952
+ root.join("forge.config.js"),
1953
+ r#"
1954
+ module.exports = {
1955
+ packagerConfig: {
1956
+ osxSign: {
1957
+ identity: 'Developer ID Application: Example, Inc. (TEAMID1234)',
1958
+ entitlements: 'entitlements.plist',
1959
+ entitlementsInherit: 'entitlements.plist',
1960
+ hardenedRuntime: true,
1961
+ gatekeeperAssess: false,
1962
+ },
1963
+ osxNotarize: {
1964
+ appleApiKey: 'AuthKey_TEST.p8',
1965
+ appleApiKeyId: 'SECRET_KEY_ID',
1966
+ appleApiIssuer: 'SECRET_ISSUER_ID',
1967
+ },
1968
+ },
1969
+ };
1970
+ "#,
1971
+ )
1972
+ .expect("forge config should be written");
1973
+ write_app_file(&root);
1974
+ write_fake_electron_dist(&root);
1975
+
1976
+ let args = PackageArgs {
1977
+ cwd: root.clone(),
1978
+ out_dir: PathBuf::from("out"),
1979
+ name: None,
1980
+ platform: Some("darwin".to_string()),
1981
+ arch: Some("arm64".to_string()),
1982
+ force: false,
1983
+ dry_run: true,
1984
+ json: true,
1985
+ };
1986
+ let snapshot = crate::project::inspect(&root).expect("project should inspect");
1987
+ let report = build_report(snapshot, &args).expect("report should build");
1988
+
1989
+ assert!(report.signing.macos.sign.configured);
1990
+ assert!(report.signing.macos.sign.enabled);
1991
+ assert!(!report.signing.macos.sign.will_execute);
1992
+ assert_eq!(
1993
+ report.signing.macos.sign.method.as_deref(),
1994
+ Some("certificate-identity")
1995
+ );
1996
+ assert_eq!(
1997
+ report.signing.macos.sign.identity.as_deref(),
1998
+ Some("Developer ID Application: Example, Inc. (TEAMID1234)")
1999
+ );
2000
+ assert_eq!(report.signing.macos.sign.hardened_runtime, Some(true));
2001
+ assert_eq!(report.signing.macos.sign.gatekeeper_assess, Some(false));
2002
+ assert_eq!(report.signing.macos.sign.entitlements.len(), 2);
2003
+ assert!(report.signing.macos.notarize.configured);
2004
+ assert_eq!(
2005
+ report.signing.macos.notarize.auth_method.as_deref(),
2006
+ Some("app-store-connect-api-key")
2007
+ );
2008
+ assert!(report
2009
+ .warnings
2010
+ .iter()
2011
+ .any(|warning| warning.contains("Rust-native certificate/keychain signing")));
2012
+ assert!(report
2013
+ .warnings
2014
+ .iter()
2015
+ .any(|warning| warning.contains("Rust-native notarization is not implemented")));
2016
+
2017
+ let json = serde_json::to_string(&report).expect("report should serialize");
2018
+ assert!(!json.contains("SECRET_KEY_ID"));
2019
+ assert!(!json.contains("SECRET_ISSUER_ID"));
2020
+ assert!(!json.contains("secret api key"));
2021
+
2022
+ let _ = fs::remove_dir_all(root);
2023
+ }
2024
+
2025
+ #[test]
2026
+ fn plans_macos_ad_hoc_signing_execution() {
2027
+ let root = unique_temp_dir("macos-ad-hoc-signing-plan");
2028
+ write_package_json(&root);
2029
+ fs::write(
2030
+ root.join("forge.config.js"),
2031
+ r#"
2032
+ module.exports = {
2033
+ packagerConfig: {
2034
+ osxSign: {
2035
+ identity: '-',
2036
+ hardenedRuntime: true,
2037
+ },
2038
+ },
2039
+ };
2040
+ "#,
2041
+ )
2042
+ .expect("forge config should be written");
2043
+ write_app_file(&root);
2044
+ write_fake_electron_dist(&root);
2045
+
2046
+ let args = PackageArgs {
2047
+ cwd: root.clone(),
2048
+ out_dir: PathBuf::from("out"),
2049
+ name: None,
2050
+ platform: Some("darwin".to_string()),
2051
+ arch: Some("arm64".to_string()),
2052
+ force: false,
2053
+ dry_run: true,
2054
+ json: true,
2055
+ };
2056
+ let snapshot = crate::project::inspect(&root).expect("project should inspect");
2057
+ let report = build_report(snapshot, &args).expect("report should build");
2058
+
2059
+ assert!(report.signing.macos.sign.configured);
2060
+ assert!(report.signing.macos.sign.enabled);
2061
+ assert!(report.signing.macos.sign.will_execute);
2062
+ assert_eq!(report.signing.macos.sign.method.as_deref(), Some("ad-hoc"));
2063
+ assert_eq!(report.signing.macos.sign.identity.as_deref(), Some("-"));
2064
+ assert_eq!(report.signing.macos.sign.hardened_runtime, Some(true));
2065
+ assert!(!report.warnings.iter().any(|warning| {
2066
+ warning.contains("Rust-native certificate/keychain signing")
2067
+ || warning.contains("Rust-native signing is not implemented")
2068
+ }));
2069
+
2070
+ let _ = fs::remove_dir_all(root);
2071
+ }
2072
+
2073
+ #[test]
2074
+ fn warns_when_macos_notarization_is_configured_without_signing() {
2075
+ let root = unique_temp_dir("notarize-without-sign");
2076
+ write_package_json(&root);
2077
+ fs::write(
2078
+ root.join("forge.config.js"),
2079
+ r#"
2080
+ module.exports = {
2081
+ packagerConfig: {
2082
+ osxSign: false,
2083
+ osxNotarize: {
2084
+ keychainProfile: 'notary-profile',
2085
+ },
2086
+ },
2087
+ };
2088
+ "#,
2089
+ )
2090
+ .expect("forge config should be written");
2091
+ write_app_file(&root);
2092
+ write_fake_electron_dist(&root);
2093
+
2094
+ let args = PackageArgs {
2095
+ cwd: root.clone(),
2096
+ out_dir: PathBuf::from("out"),
2097
+ name: None,
2098
+ platform: Some("darwin".to_string()),
2099
+ arch: Some("arm64".to_string()),
2100
+ force: false,
2101
+ dry_run: true,
2102
+ json: true,
2103
+ };
2104
+ let snapshot = crate::project::inspect(&root).expect("project should inspect");
2105
+ let report = build_report(snapshot, &args).expect("report should build");
2106
+
2107
+ assert!(report.signing.macos.sign.configured);
2108
+ assert!(!report.signing.macos.sign.enabled);
2109
+ assert_eq!(
2110
+ report.signing.macos.notarize.auth_method.as_deref(),
2111
+ Some("keychain-profile")
2112
+ );
2113
+ assert!(report.warnings.iter().any(|warning| {
2114
+ warning.contains("macOS notarization requires packagerConfig.osxSign")
2115
+ }));
2116
+
2117
+ let _ = fs::remove_dir_all(root);
2118
+ }
2119
+
1426
2120
  #[test]
1427
2121
  fn packages_macos_info_plist_metadata() {
1428
2122
  if current_platform() != "darwin" {
@@ -1478,6 +2172,10 @@ mod tests {
1478
2172
  plist_string(dictionary, "CFBundleIdentifier"),
1479
2173
  Some("com.example.starter")
1480
2174
  );
2175
+ assert_eq!(
2176
+ plist_string(dictionary, "CFBundlePackageType"),
2177
+ Some("APPL")
2178
+ );
1481
2179
  assert_eq!(
1482
2180
  plist_string(dictionary, "CFBundleShortVersionString"),
1483
2181
  Some("2.3.4")
@@ -1501,6 +2199,59 @@ mod tests {
1501
2199
  let _ = fs::remove_dir_all(root);
1502
2200
  }
1503
2201
 
2202
+ #[test]
2203
+ fn packages_macos_bundle_with_ad_hoc_signature() {
2204
+ if current_platform() != "darwin" {
2205
+ return;
2206
+ }
2207
+
2208
+ let root = unique_temp_dir("macos-ad-hoc-signing-execute");
2209
+ fs::write(
2210
+ root.join("package.json"),
2211
+ r#"{"name":"starter-app","version":"0.1.0","main":"src/main.js","devDependencies":{"electron":"30.0.0"},"electronCli":{"packagerConfig":{"appBundleId":"com.example.signed","osxSign":true}}}"#,
2212
+ )
2213
+ .expect("package.json should be written");
2214
+ write_app_file(&root);
2215
+ write_macho_electron_dist(&root);
2216
+
2217
+ let args = PackageArgs {
2218
+ cwd: root.clone(),
2219
+ out_dir: PathBuf::from("out"),
2220
+ name: None,
2221
+ platform: None,
2222
+ arch: None,
2223
+ force: false,
2224
+ dry_run: false,
2225
+ json: false,
2226
+ };
2227
+ let snapshot = crate::project::inspect(&root).expect("project should inspect");
2228
+ let report = build_report(snapshot, &args).expect("report should build");
2229
+
2230
+ assert!(report.signing.macos.sign.will_execute);
2231
+ assert_eq!(report.signing.macos.sign.method.as_deref(), Some("ad-hoc"));
2232
+ assert!(report.warnings.is_empty());
2233
+
2234
+ execute_package(&report, false).expect("package should succeed");
2235
+
2236
+ let bundle_dir = Path::new(report.bundle_dir.as_str());
2237
+ assert!(bundle_dir
2238
+ .join("Contents/_CodeSignature/CodeResources")
2239
+ .exists());
2240
+
2241
+ let executable = bundle_dir
2242
+ .join("Contents/MacOS")
2243
+ .join(&report.executable_name);
2244
+ let executable_data = fs::read(executable).expect("signed executable should read");
2245
+ let macho = apple_codesign::MachFile::parse(&executable_data)
2246
+ .expect("signed executable should parse as Mach-O");
2247
+ assert!(macho.iter_macho().all(|binary| binary
2248
+ .code_signature()
2249
+ .expect("code signature should parse")
2250
+ .is_some()));
2251
+
2252
+ let _ = fs::remove_dir_all(root);
2253
+ }
2254
+
1504
2255
  #[test]
1505
2256
  fn missing_required_runtime_dependency_fails() {
1506
2257
  let root = unique_temp_dir("runtime-deps");
@@ -1654,6 +2405,16 @@ mod tests {
1654
2405
  }
1655
2406
  }
1656
2407
 
2408
+ fn write_macho_electron_dist(root: &Path) {
2409
+ let app = root.join("node_modules/electron/dist/Electron.app/Contents/MacOS");
2410
+ fs::create_dir_all(&app).expect("macOS Electron app should be created");
2411
+ fs::copy(
2412
+ std::env::current_exe().expect("current test executable should resolve"),
2413
+ app.join("Electron"),
2414
+ )
2415
+ .expect("Mach-O test executable should be copied");
2416
+ }
2417
+
1657
2418
  fn unique_temp_dir(label: &str) -> PathBuf {
1658
2419
  let nanos = std::time::SystemTime::now()
1659
2420
  .duration_since(std::time::UNIX_EPOCH)