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

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.
package/Cargo.lock CHANGED
@@ -953,7 +953,7 @@ checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e"
953
953
 
954
954
  [[package]]
955
955
  name = "electron-cli"
956
- version = "0.3.0-alpha.17"
956
+ version = "0.3.0-alpha.19"
957
957
  dependencies = [
958
958
  "anyhow",
959
959
  "apple-codesign",
package/Cargo.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "electron-cli"
3
- version = "0.3.0-alpha.17"
3
+ version = "0.3.0-alpha.19"
4
4
  edition = "2021"
5
5
  description = "Experimental Rust CLI for Electron project diagnostics and workflow automation"
6
6
  license = "MIT"
package/README.md CHANGED
@@ -41,9 +41,9 @@ The Rust-native flow currently owns:
41
41
 
42
42
  The GitHub publisher creates or reuses a release, uploads selected make artifacts, and can replace an existing asset with `--force`. It reads `GITHUB_TOKEN` or `GH_TOKEN` and can infer `OWNER/REPO` from package metadata, Forge GitHub publisher config, or `package.json` `repository`. You can also pass `--github-repo`.
43
43
 
44
- The package command recognizes macOS `packagerConfig.osxSign` and `packagerConfig.osxNotarize` options and reports the signing/notarization plan without serializing credential values. When `osxSign` is enabled on macOS and no certificate identity is configured, or the identity is `"-"`, `package` writes an experimental Rust-native ad-hoc signature for the generated `.app` bundle. Developer ID certificate/keychain signing and notarization execution are not implemented yet.
44
+ The package command recognizes macOS `packagerConfig.osxSign` and `packagerConfig.osxNotarize` options and reports the signing/notarization plan without serializing credential values. When `osxSign` is enabled on macOS and no certificate identity is configured, or the identity is `"-"`, `package` writes an experimental Rust-native ad-hoc signature for the generated `.app` bundle. When `osxSign.p12File` points at a `.p12`/PFX certificate export, `package` can sign the bundle with that certificate and can request a CMS timestamp token for notarization-compatible signatures. macOS keychain identity lookup and notarization submission/stapling are not implemented yet.
45
45
 
46
- The DMG maker is currently a pure-Rust FAT32 image with the app bundle and an Applications entry. The MSI maker writes a compressed embedded CAB, Windows Installer database tables, and a Start Menu shortcut when the packaged executable is present. HFS+/APFS DMG layout customization, installer UI customization, Windows/Linux icon embedding, Developer ID signing execution, and notarization execution are still TODO.
46
+ The DMG maker is currently a pure-Rust FAT32 image with the app bundle and an Applications entry. The MSI maker writes a compressed embedded CAB, Windows Installer database tables, and a Start Menu shortcut when the packaged executable is present. HFS+/APFS DMG layout customization, installer UI customization, Windows/Linux icon embedding, macOS keychain signing, and notarization execution are still TODO.
47
47
 
48
48
  Package metadata can be configured in `package.json`:
49
49
 
@@ -57,7 +57,9 @@ Package metadata can be configured in `package.json`:
57
57
  "icon": "assets/icon",
58
58
  "extraResource": "assets/config.json",
59
59
  "osxSign": {
60
- "identity": "-",
60
+ "p12File": "certs/developer-id.p12",
61
+ "p12PasswordEnv": "ELECTRON_CLI_P12_PASSWORD",
62
+ "timestamp": true,
61
63
  "entitlements": "assets/entitlements.plist",
62
64
  "hardenedRuntime": true
63
65
  },
@@ -97,7 +99,7 @@ Package metadata can be configured in `package.json`:
97
99
  }
98
100
  ```
99
101
 
100
- Set `identity` to a Developer ID certificate name when you want the plan to reflect Forge-style release signing, but this project will report it as not executable until Rust-native certificate/keychain signing exists. Use `identity: "-"` or omit `identity` for the current ad-hoc signing path.
102
+ Use `p12PasswordEnv`, `p12PasswordFile`, or `p12Password` for the `.p12` password; password values are not serialized in package reports. Set `osxSign.timestamp` to a timestamp server URL, `true` for Apple's default `http://timestamp.apple.com/ts01`, or `"none"` / `false` to disable timestamping. When `osxNotarize` is enabled with p12 signing, `electron-cli` automatically enables notarization-compatible signing and uses Apple's timestamp server unless timestamping is disabled explicitly. Set `identity` to a Developer ID certificate name when you want the plan to reflect Forge-style keychain release signing, but this project will report it as not executable until Rust-native keychain lookup exists. Use `identity: "-"` or omit `identity` for the current ad-hoc signing path.
101
103
 
102
104
  The package command also reads JSON-shaped `config.forge.packagerConfig` and `electronPackagerConfig` entries for the same fields. The make command maps JSON-shaped Forge maker names to the Rust-native targets it supports: zip, dmg, deb, rpm, and wix/msi. The publish command maps JSON-shaped publisher names to local and GitHub.
103
105
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "electron-cli",
3
- "version": "0.3.0-alpha.17",
3
+ "version": "0.3.0-alpha.19",
4
4
  "description": "Experimental Rust CLI for Electron project diagnostics and workflow automation",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -5,7 +5,10 @@ use std::{
5
5
  };
6
6
 
7
7
  use anyhow::{bail, Context, Result};
8
- use apple_codesign::{BundleSigner, CodeSignatureFlags, SettingsScope, SigningSettings};
8
+ use apple_codesign::{
9
+ cryptography::{parse_pfx_data, PrivateKey},
10
+ BundleSigner, CodeSignatureFlags, SettingsScope, SigningSettings,
11
+ };
9
12
  use camino::Utf8PathBuf;
10
13
  use plist::{Dictionary as PlistDictionary, Value as PlistValue};
11
14
  use serde::Serialize;
@@ -13,6 +16,8 @@ use serde_json::Value as JsonValue;
13
16
 
14
17
  use crate::{cli::PackageArgs, output, project::ProjectSnapshot};
15
18
 
19
+ const APPLE_TIMESTAMP_URL: &str = "http://timestamp.apple.com/ts01";
20
+
16
21
  #[derive(Clone, Debug, Serialize)]
17
22
  pub(crate) struct PackageReport {
18
23
  project: ProjectSnapshot,
@@ -75,6 +80,14 @@ struct MacosSignPlan {
75
80
  will_execute: bool,
76
81
  method: Option<String>,
77
82
  identity: Option<String>,
83
+ p12_file: Option<Utf8PathBuf>,
84
+ p12_password_source: Option<String>,
85
+ p12_password_env: Option<String>,
86
+ p12_password_file: Option<Utf8PathBuf>,
87
+ #[serde(skip)]
88
+ p12_password: RedactedSecret,
89
+ timestamp_url: Option<String>,
90
+ for_notarization: bool,
78
91
  entitlements: Vec<Utf8PathBuf>,
79
92
  entitlements_inherit: Option<Utf8PathBuf>,
80
93
  hardened_runtime: Option<bool>,
@@ -90,6 +103,29 @@ struct MacosNotarizePlan {
90
103
  keychain: Option<String>,
91
104
  }
92
105
 
106
+ #[derive(Clone, Default)]
107
+ struct RedactedSecret(Option<String>);
108
+
109
+ impl std::fmt::Debug for RedactedSecret {
110
+ fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
111
+ if self.0.is_some() {
112
+ formatter.write_str("<redacted>")
113
+ } else {
114
+ formatter.write_str("<unset>")
115
+ }
116
+ }
117
+ }
118
+
119
+ impl RedactedSecret {
120
+ fn new(value: Option<String>) -> Self {
121
+ Self(value)
122
+ }
123
+
124
+ fn as_deref(&self) -> Option<&str> {
125
+ self.0.as_deref()
126
+ }
127
+ }
128
+
93
129
  #[derive(Clone, Copy, Debug, Serialize)]
94
130
  #[serde(rename_all = "kebab-case")]
95
131
  enum PackageStatus {
@@ -127,12 +163,24 @@ struct MacosSignConfig {
127
163
  enabled: bool,
128
164
  invalid_type: bool,
129
165
  identity: Option<String>,
166
+ p12_file: Option<String>,
167
+ p12_password: Option<String>,
168
+ p12_password_env: Option<String>,
169
+ p12_password_file: Option<String>,
170
+ timestamp: Option<MacosTimestampConfig>,
130
171
  entitlements: Vec<String>,
131
172
  entitlements_inherit: Option<String>,
132
173
  hardened_runtime: Option<bool>,
133
174
  gatekeeper_assess: Option<bool>,
134
175
  }
135
176
 
177
+ #[derive(Clone, Debug)]
178
+ enum MacosTimestampConfig {
179
+ Default,
180
+ Disabled,
181
+ Url(String),
182
+ }
183
+
136
184
  #[derive(Clone, Debug, Default)]
137
185
  struct MacosNotarizeConfig {
138
186
  configured: bool,
@@ -409,15 +457,39 @@ fn execute_macos_signing(report: &PackageReport) -> Result<()> {
409
457
  .collect_nested_bundles()
410
458
  .context("Could not discover nested macOS bundles for signing")?;
411
459
 
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
- })?;
460
+ let mut settings = macos_signing_settings(report)?;
461
+ if let Some(p12_file) = &report.signing.macos.sign.p12_file {
462
+ let p12_path = Path::new(p12_file.as_str());
463
+ let p12_data = fs::read(p12_path)
464
+ .with_context(|| format!("Could not read {}", p12_path.display()))?;
465
+ let password = macos_p12_password(&report.signing.macos.sign)?;
466
+ let (certificate, signing_key) = parse_pfx_data(&p12_data, &password)
467
+ .with_context(|| format!("Could not parse {}", p12_path.display()))?;
468
+
469
+ settings.set_signing_key(signing_key.as_key_info_signer(), certificate);
470
+ settings.chain_apple_certificates();
471
+ settings.set_team_id_from_signing_certificate();
472
+ settings
473
+ .ensure_for_notarization_settings()
474
+ .context("macOS signing settings are not compatible with notarization")?;
475
+ signer
476
+ .write_signed_bundle(&signed_bundle_dir, &settings)
477
+ .with_context(|| {
478
+ format!(
479
+ "Could not write signed macOS bundle to {}",
480
+ signed_bundle_dir.display()
481
+ )
482
+ })?;
483
+ } else {
484
+ signer
485
+ .write_signed_bundle(&signed_bundle_dir, &settings)
486
+ .with_context(|| {
487
+ format!(
488
+ "Could not write signed macOS bundle to {}",
489
+ signed_bundle_dir.display()
490
+ )
491
+ })?;
492
+ }
421
493
 
422
494
  Ok(())
423
495
  })();
@@ -441,10 +513,17 @@ fn execute_macos_signing(report: &PackageReport) -> Result<()> {
441
513
  Ok(())
442
514
  }
443
515
 
444
- fn macos_signing_settings(report: &PackageReport) -> Result<SigningSettings<'static>> {
516
+ fn macos_signing_settings<'key>(report: &PackageReport) -> Result<SigningSettings<'key>> {
445
517
  let sign = &report.signing.macos.sign;
446
518
  let mut settings = SigningSettings::default();
447
519
  settings.set_binary_identifier(SettingsScope::Main, &report.metadata.bundle_identifier);
520
+ settings.set_for_notarization(sign.for_notarization);
521
+
522
+ if let Some(timestamp_url) = &sign.timestamp_url {
523
+ settings
524
+ .set_time_stamp_url(timestamp_url)
525
+ .with_context(|| format!("Invalid macOS signing timestamp URL: {timestamp_url}"))?;
526
+ }
448
527
 
449
528
  if sign.hardened_runtime.unwrap_or(false) {
450
529
  settings.add_code_signature_flags(SettingsScope::Main, CodeSignatureFlags::RUNTIME);
@@ -471,6 +550,37 @@ fn macos_signing_settings(report: &PackageReport) -> Result<SigningSettings<'sta
471
550
  Ok(settings)
472
551
  }
473
552
 
553
+ fn macos_p12_password(sign: &MacosSignPlan) -> Result<String> {
554
+ if let Some(password) = sign.p12_password.as_deref() {
555
+ return Ok(password.to_string());
556
+ }
557
+
558
+ if let Some(env_name) = &sign.p12_password_env {
559
+ return std::env::var(env_name)
560
+ .with_context(|| format!("Could not read macOS signing p12 password env {env_name}"));
561
+ }
562
+
563
+ if let Some(path) = &sign.p12_password_file {
564
+ let password_path = Path::new(path.as_str());
565
+ return fs::read_to_string(password_path)
566
+ .with_context(|| {
567
+ format!(
568
+ "Could not read macOS signing p12 password file {}",
569
+ password_path.display()
570
+ )
571
+ })
572
+ .and_then(|contents| {
573
+ contents
574
+ .lines()
575
+ .next()
576
+ .map(str::to_string)
577
+ .context("macOS signing p12 password file is empty")
578
+ });
579
+ }
580
+
581
+ Ok(String::new())
582
+ }
583
+
474
584
  fn print_report(report: &PackageReport, json: bool) -> Result<()> {
475
585
  if json {
476
586
  return output::json(report);
@@ -507,6 +617,18 @@ fn print_report(report: &PackageReport, json: bool) -> Result<()> {
507
617
  if let Some(identity) = &report.signing.macos.sign.identity {
508
618
  println!(" identity: {identity}");
509
619
  }
620
+ if let Some(path) = &report.signing.macos.sign.p12_file {
621
+ println!(" p12 file: {path}");
622
+ }
623
+ if let Some(source) = &report.signing.macos.sign.p12_password_source {
624
+ println!(" p12 password: {source}");
625
+ }
626
+ if let Some(timestamp_url) = &report.signing.macos.sign.timestamp_url {
627
+ println!(" timestamp server: {timestamp_url}");
628
+ }
629
+ if report.signing.macos.sign.for_notarization {
630
+ println!(" signing mode: notarization-compatible");
631
+ }
510
632
  if let Some(method) = &report.signing.macos.sign.method {
511
633
  println!(" signing method: {method}");
512
634
  }
@@ -648,6 +770,32 @@ fn parse_macos_sign_config(value: Option<&JsonValue>) -> MacosSignConfig {
648
770
  .or_else(|| object.get("identityName"))
649
771
  .and_then(JsonValue::as_str)
650
772
  .map(ToOwned::to_owned),
773
+ p12_file: object
774
+ .get("p12File")
775
+ .or_else(|| object.get("pfxFile"))
776
+ .and_then(JsonValue::as_str)
777
+ .map(ToOwned::to_owned),
778
+ p12_password: object
779
+ .get("p12Password")
780
+ .or_else(|| object.get("pfxPassword"))
781
+ .and_then(JsonValue::as_str)
782
+ .map(ToOwned::to_owned),
783
+ p12_password_env: object
784
+ .get("p12PasswordEnv")
785
+ .or_else(|| object.get("pfxPasswordEnv"))
786
+ .and_then(JsonValue::as_str)
787
+ .map(ToOwned::to_owned),
788
+ p12_password_file: object
789
+ .get("p12PasswordFile")
790
+ .or_else(|| object.get("pfxPasswordFile"))
791
+ .and_then(JsonValue::as_str)
792
+ .map(ToOwned::to_owned),
793
+ timestamp: parse_macos_timestamp_config(
794
+ object
795
+ .get("timestamp")
796
+ .or_else(|| object.get("timestampUrl"))
797
+ .or_else(|| object.get("timestampURL")),
798
+ ),
651
799
  entitlements,
652
800
  entitlements_inherit: object
653
801
  .get("entitlementsInherit")
@@ -665,6 +813,22 @@ fn parse_macos_sign_config(value: Option<&JsonValue>) -> MacosSignConfig {
665
813
  }
666
814
  }
667
815
 
816
+ fn parse_macos_timestamp_config(value: Option<&JsonValue>) -> Option<MacosTimestampConfig> {
817
+ match value {
818
+ Some(JsonValue::String(value)) => {
819
+ let value = value.trim();
820
+ if value.is_empty() || value.eq_ignore_ascii_case("none") {
821
+ Some(MacosTimestampConfig::Disabled)
822
+ } else {
823
+ Some(MacosTimestampConfig::Url(value.to_string()))
824
+ }
825
+ }
826
+ Some(JsonValue::Bool(true)) => Some(MacosTimestampConfig::Default),
827
+ Some(JsonValue::Bool(false)) => Some(MacosTimestampConfig::Disabled),
828
+ _ => None,
829
+ }
830
+ }
831
+
668
832
  fn parse_macos_notarize_config(value: Option<&JsonValue>) -> MacosNotarizeConfig {
669
833
  match value {
670
834
  None => MacosNotarizeConfig::default(),
@@ -793,7 +957,13 @@ fn package_signing(
793
957
  platform: &str,
794
958
  ) -> Result<(PackageSigningPlan, Vec<String>)> {
795
959
  let mut warnings = Vec::new();
796
- let sign = macos_sign_plan(root, &config.packager.osx_sign, platform, &mut warnings)?;
960
+ let sign = macos_sign_plan(
961
+ root,
962
+ &config.packager.osx_sign,
963
+ &config.packager.osx_notarize,
964
+ platform,
965
+ &mut warnings,
966
+ )?;
797
967
  let notarize = macos_notarize_plan(root, config, platform, &mut warnings);
798
968
 
799
969
  Ok((
@@ -807,6 +977,7 @@ fn package_signing(
807
977
  fn macos_sign_plan(
808
978
  root: &Path,
809
979
  config: &MacosSignConfig,
980
+ notarize_config: &MacosNotarizeConfig,
810
981
  platform: &str,
811
982
  warnings: &mut Vec<String>,
812
983
  ) -> Result<MacosSignPlan> {
@@ -844,11 +1015,63 @@ fn macos_sign_plan(
844
1015
  }
845
1016
  }
846
1017
 
1018
+ let p12_file = config
1019
+ .p12_file
1020
+ .as_deref()
1021
+ .filter(|path| !path.trim().is_empty())
1022
+ .map(|path| utf8_path(resolve_project_path(root, path)))
1023
+ .transpose()?;
1024
+ if let Some(path) = &p12_file {
1025
+ if !Path::new(path.as_str()).exists() {
1026
+ warnings.push(format!(
1027
+ "Configured macOS signing p12 file does not exist: {}.",
1028
+ path
1029
+ ));
1030
+ }
1031
+ }
1032
+ let p12_password_file = config
1033
+ .p12_password_file
1034
+ .as_deref()
1035
+ .filter(|path| !path.trim().is_empty())
1036
+ .map(|path| utf8_path(resolve_project_path(root, path)))
1037
+ .transpose()?;
1038
+ if let Some(path) = &p12_password_file {
1039
+ if !Path::new(path.as_str()).exists() {
1040
+ warnings.push(format!(
1041
+ "Configured macOS signing p12 password file does not exist: {}.",
1042
+ path
1043
+ ));
1044
+ }
1045
+ }
1046
+ let p12_password_source = if p12_file.is_some() {
1047
+ if config.p12_password.is_some() {
1048
+ Some("config".to_string())
1049
+ } else if let Some(env_name) = config
1050
+ .p12_password_env
1051
+ .as_deref()
1052
+ .filter(|name| !name.trim().is_empty())
1053
+ {
1054
+ Some(format!("env:{env_name}"))
1055
+ } else if let Some(path) = &p12_password_file {
1056
+ Some(format!("file:{path}"))
1057
+ } else {
1058
+ Some("empty".to_string())
1059
+ }
1060
+ } else {
1061
+ None
1062
+ };
1063
+
847
1064
  let identity = config.identity.as_deref().map(str::trim);
848
1065
  let ad_hoc_identity = matches!(identity, None | Some("-"));
849
- let will_execute = config.enabled && platform == "darwin" && ad_hoc_identity;
1066
+ let p12_identity = p12_file.is_some();
1067
+ let will_execute = config.enabled && platform == "darwin" && (ad_hoc_identity || p12_identity);
1068
+ let timestamp_url = macos_timestamp_url(config, p12_identity, notarize_config.enabled);
1069
+ let for_notarization =
1070
+ will_execute && p12_identity && notarize_config.enabled && timestamp_url.is_some();
850
1071
  let method = if config.enabled && platform == "darwin" {
851
- if ad_hoc_identity {
1072
+ if p12_identity {
1073
+ Some("certificate-p12".to_string())
1074
+ } else if ad_hoc_identity {
852
1075
  Some("ad-hoc".to_string())
853
1076
  } else {
854
1077
  Some("certificate-identity".to_string())
@@ -863,17 +1086,22 @@ fn macos_sign_plan(
863
1086
  ));
864
1087
  } else if config.enabled && !will_execute {
865
1088
  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(),
1089
+ "macOS signing identity is configured, but Rust-native keychain identity signing is not implemented yet; package output will be unsigned. Use p12File for certificate signing, or identity '-' / omit identity for experimental ad-hoc signing.".to_string(),
867
1090
  );
868
1091
  } else if will_execute {
1092
+ if p12_identity && identity.is_some() {
1093
+ warnings.push(
1094
+ "packagerConfig.osxSign.p12File supplies the signing certificate; identity is reported but not used for keychain lookup.".to_string(),
1095
+ );
1096
+ }
869
1097
  if config.entitlements.len() > 1 {
870
1098
  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(),
1099
+ "Rust-native macOS signing applies the first macOS entitlements file only; inherited/login-helper entitlement scoping is not implemented yet.".to_string(),
872
1100
  );
873
1101
  }
874
1102
  if config.entitlements_inherit.is_some() {
875
1103
  warnings.push(
876
- "packagerConfig.osxSign.entitlementsInherit is recognized but not applied to nested bundles by Rust-native ad-hoc signing yet.".to_string(),
1104
+ "packagerConfig.osxSign.entitlementsInherit is recognized but not applied to nested bundles by Rust-native signing yet.".to_string(),
877
1105
  );
878
1106
  }
879
1107
  if config.gatekeeper_assess.is_some() {
@@ -881,6 +1109,16 @@ fn macos_sign_plan(
881
1109
  "packagerConfig.osxSign.gatekeeperAssess is recognized but Gatekeeper assessment is not implemented yet.".to_string(),
882
1110
  );
883
1111
  }
1112
+ if config.timestamp.is_some() && !p12_identity {
1113
+ warnings.push(
1114
+ "packagerConfig.osxSign.timestamp is recognized but ignored without p12File certificate signing.".to_string(),
1115
+ );
1116
+ }
1117
+ if notarize_config.enabled && p12_identity && timestamp_url.is_none() {
1118
+ warnings.push(
1119
+ "macOS notarization requires a secure timestamp; packagerConfig.osxSign.timestamp disabled timestamping.".to_string(),
1120
+ );
1121
+ }
884
1122
  }
885
1123
 
886
1124
  Ok(MacosSignPlan {
@@ -889,6 +1127,13 @@ fn macos_sign_plan(
889
1127
  will_execute,
890
1128
  method,
891
1129
  identity: config.identity.clone(),
1130
+ p12_file,
1131
+ p12_password_source,
1132
+ p12_password_env: config.p12_password_env.clone(),
1133
+ p12_password_file,
1134
+ p12_password: RedactedSecret::new(config.p12_password.clone()),
1135
+ timestamp_url,
1136
+ for_notarization,
892
1137
  entitlements,
893
1138
  entitlements_inherit,
894
1139
  hardened_runtime: config.hardened_runtime,
@@ -896,6 +1141,24 @@ fn macos_sign_plan(
896
1141
  })
897
1142
  }
898
1143
 
1144
+ fn macos_timestamp_url(
1145
+ config: &MacosSignConfig,
1146
+ p12_identity: bool,
1147
+ notarize_enabled: bool,
1148
+ ) -> Option<String> {
1149
+ if !p12_identity {
1150
+ return None;
1151
+ }
1152
+
1153
+ match &config.timestamp {
1154
+ Some(MacosTimestampConfig::Default) => Some(APPLE_TIMESTAMP_URL.to_string()),
1155
+ Some(MacosTimestampConfig::Disabled) => None,
1156
+ Some(MacosTimestampConfig::Url(url)) => Some(url.clone()),
1157
+ None if notarize_enabled => Some(APPLE_TIMESTAMP_URL.to_string()),
1158
+ None => None,
1159
+ }
1160
+ }
1161
+
899
1162
  fn macos_notarize_plan(
900
1163
  root: &Path,
901
1164
  package_config: &PackageJsonConfig,
@@ -926,6 +1189,7 @@ fn macos_notarize_plan(
926
1189
  if config.enabled
927
1190
  && platform == "darwin"
928
1191
  && package_config.packager.osx_sign.enabled
1192
+ && package_config.packager.osx_sign.p12_file.is_none()
929
1193
  && matches!(
930
1194
  package_config
931
1195
  .packager
@@ -2008,7 +2272,7 @@ mod tests {
2008
2272
  assert!(report
2009
2273
  .warnings
2010
2274
  .iter()
2011
- .any(|warning| warning.contains("Rust-native certificate/keychain signing")));
2275
+ .any(|warning| warning.contains("Rust-native keychain identity signing")));
2012
2276
  assert!(report
2013
2277
  .warnings
2014
2278
  .iter()
@@ -2063,13 +2327,198 @@ mod tests {
2063
2327
  assert_eq!(report.signing.macos.sign.identity.as_deref(), Some("-"));
2064
2328
  assert_eq!(report.signing.macos.sign.hardened_runtime, Some(true));
2065
2329
  assert!(!report.warnings.iter().any(|warning| {
2066
- warning.contains("Rust-native certificate/keychain signing")
2330
+ warning.contains("Rust-native keychain identity signing")
2067
2331
  || warning.contains("Rust-native signing is not implemented")
2068
2332
  }));
2069
2333
 
2070
2334
  let _ = fs::remove_dir_all(root);
2071
2335
  }
2072
2336
 
2337
+ #[test]
2338
+ fn plans_macos_p12_signing_without_serializing_password() {
2339
+ let root = unique_temp_dir("macos-p12-signing-plan");
2340
+ write_package_json(&root);
2341
+ fs::write(root.join("developer-id.p12"), "not a real p12")
2342
+ .expect("p12 placeholder should be written");
2343
+ fs::write(
2344
+ root.join("forge.config.js"),
2345
+ r#"
2346
+ module.exports = {
2347
+ packagerConfig: {
2348
+ osxSign: {
2349
+ identity: 'Developer ID Application: Example, Inc. (TEAMID1234)',
2350
+ p12File: 'developer-id.p12',
2351
+ p12Password: 'p12-secret',
2352
+ timestamp: 'http://timestamp.example.test/tsa',
2353
+ hardenedRuntime: true,
2354
+ },
2355
+ },
2356
+ };
2357
+ "#,
2358
+ )
2359
+ .expect("forge config should be written");
2360
+ write_app_file(&root);
2361
+ write_fake_electron_dist(&root);
2362
+
2363
+ let args = PackageArgs {
2364
+ cwd: root.clone(),
2365
+ out_dir: PathBuf::from("out"),
2366
+ name: None,
2367
+ platform: Some("darwin".to_string()),
2368
+ arch: Some("arm64".to_string()),
2369
+ force: false,
2370
+ dry_run: true,
2371
+ json: true,
2372
+ };
2373
+ let snapshot = crate::project::inspect(&root).expect("project should inspect");
2374
+ let report = build_report(snapshot, &args).expect("report should build");
2375
+
2376
+ assert!(report.signing.macos.sign.configured);
2377
+ assert!(report.signing.macos.sign.enabled);
2378
+ assert!(report.signing.macos.sign.will_execute);
2379
+ assert_eq!(
2380
+ report.signing.macos.sign.method.as_deref(),
2381
+ Some("certificate-p12")
2382
+ );
2383
+ assert_eq!(
2384
+ report.signing.macos.sign.p12_password_source.as_deref(),
2385
+ Some("config")
2386
+ );
2387
+ assert_eq!(
2388
+ report.signing.macos.sign.timestamp_url.as_deref(),
2389
+ Some("http://timestamp.example.test/tsa")
2390
+ );
2391
+ assert!(!report.signing.macos.sign.for_notarization);
2392
+ assert!(report.signing.macos.sign.p12_file.is_some());
2393
+ assert!(report
2394
+ .warnings
2395
+ .iter()
2396
+ .any(|warning| { warning.contains("p12File supplies the signing certificate") }));
2397
+
2398
+ let json = serde_json::to_string(&report).expect("report should serialize");
2399
+ assert!(!json.contains("p12-secret"));
2400
+
2401
+ let _ = fs::remove_dir_all(root);
2402
+ }
2403
+
2404
+ #[test]
2405
+ fn plans_macos_p12_signing_for_notarization_with_default_timestamp() {
2406
+ let root = unique_temp_dir("macos-p12-notarization-signing-plan");
2407
+ write_package_json(&root);
2408
+ fs::write(root.join("developer-id.p12"), "not a real p12")
2409
+ .expect("p12 placeholder should be written");
2410
+ fs::write(
2411
+ root.join("forge.config.js"),
2412
+ r#"
2413
+ module.exports = {
2414
+ packagerConfig: {
2415
+ appBundleId: 'com.example.notarized',
2416
+ osxSign: {
2417
+ p12File: 'developer-id.p12',
2418
+ p12PasswordEnv: 'P12_PASSWORD',
2419
+ },
2420
+ osxNotarize: {
2421
+ keychainProfile: 'notary-profile',
2422
+ },
2423
+ },
2424
+ };
2425
+ "#,
2426
+ )
2427
+ .expect("forge config should be written");
2428
+ write_app_file(&root);
2429
+ write_fake_electron_dist(&root);
2430
+
2431
+ let args = PackageArgs {
2432
+ cwd: root.clone(),
2433
+ out_dir: PathBuf::from("out"),
2434
+ name: None,
2435
+ platform: Some("darwin".to_string()),
2436
+ arch: Some("arm64".to_string()),
2437
+ force: false,
2438
+ dry_run: true,
2439
+ json: true,
2440
+ };
2441
+ let snapshot = crate::project::inspect(&root).expect("project should inspect");
2442
+ let report = build_report(snapshot, &args).expect("report should build");
2443
+
2444
+ assert!(report.signing.macos.sign.will_execute);
2445
+ assert_eq!(
2446
+ report.signing.macos.sign.timestamp_url.as_deref(),
2447
+ Some(APPLE_TIMESTAMP_URL)
2448
+ );
2449
+ assert!(report.signing.macos.sign.for_notarization);
2450
+ assert_eq!(
2451
+ report.signing.macos.notarize.auth_method.as_deref(),
2452
+ Some("keychain-profile")
2453
+ );
2454
+ assert!(!report
2455
+ .warnings
2456
+ .iter()
2457
+ .any(|warning| warning.contains("ad-hoc signing is not notarizable")));
2458
+
2459
+ let settings = macos_signing_settings(&report).expect("signing settings should build");
2460
+ assert!(settings.for_notarization());
2461
+ assert_eq!(
2462
+ settings.time_stamp_url().map(|url| url.as_str()),
2463
+ Some(APPLE_TIMESTAMP_URL)
2464
+ );
2465
+
2466
+ let json = serde_json::to_string(&report).expect("report should serialize");
2467
+ assert!(!json.contains("P12_PASSWORD="));
2468
+
2469
+ let _ = fs::remove_dir_all(root);
2470
+ }
2471
+
2472
+ #[test]
2473
+ fn warns_when_macos_notarization_timestamp_is_disabled() {
2474
+ let root = unique_temp_dir("macos-p12-notarization-no-timestamp");
2475
+ write_package_json(&root);
2476
+ fs::write(root.join("developer-id.p12"), "not a real p12")
2477
+ .expect("p12 placeholder should be written");
2478
+ fs::write(
2479
+ root.join("forge.config.js"),
2480
+ r#"
2481
+ module.exports = {
2482
+ packagerConfig: {
2483
+ osxSign: {
2484
+ p12File: 'developer-id.p12',
2485
+ timestamp: 'none',
2486
+ },
2487
+ osxNotarize: {
2488
+ keychainProfile: 'notary-profile',
2489
+ },
2490
+ },
2491
+ };
2492
+ "#,
2493
+ )
2494
+ .expect("forge config should be written");
2495
+ write_app_file(&root);
2496
+ write_fake_electron_dist(&root);
2497
+
2498
+ let args = PackageArgs {
2499
+ cwd: root.clone(),
2500
+ out_dir: PathBuf::from("out"),
2501
+ name: None,
2502
+ platform: Some("darwin".to_string()),
2503
+ arch: Some("arm64".to_string()),
2504
+ force: false,
2505
+ dry_run: true,
2506
+ json: true,
2507
+ };
2508
+ let snapshot = crate::project::inspect(&root).expect("project should inspect");
2509
+ let report = build_report(snapshot, &args).expect("report should build");
2510
+
2511
+ assert!(report.signing.macos.sign.will_execute);
2512
+ assert!(report.signing.macos.sign.timestamp_url.is_none());
2513
+ assert!(!report.signing.macos.sign.for_notarization);
2514
+ assert!(report
2515
+ .warnings
2516
+ .iter()
2517
+ .any(|warning| warning.contains("requires a secure timestamp")));
2518
+
2519
+ let _ = fs::remove_dir_all(root);
2520
+ }
2521
+
2073
2522
  #[test]
2074
2523
  fn warns_when_macos_notarization_is_configured_without_signing() {
2075
2524
  let root = unique_temp_dir("notarize-without-sign");
@@ -2252,6 +2701,69 @@ mod tests {
2252
2701
  let _ = fs::remove_dir_all(root);
2253
2702
  }
2254
2703
 
2704
+ #[test]
2705
+ fn packages_macos_bundle_with_p12_certificate_signature() {
2706
+ if current_platform() != "darwin" {
2707
+ return;
2708
+ }
2709
+
2710
+ let Some(p12_fixture) = apple_codesign_test_fixture("apple-codesign-testuser.p12") else {
2711
+ return;
2712
+ };
2713
+
2714
+ let root = unique_temp_dir("macos-p12-signing-execute");
2715
+ fs::copy(&p12_fixture, root.join("developer-id.p12"))
2716
+ .expect("p12 fixture should be copied");
2717
+ fs::write(
2718
+ root.join("package.json"),
2719
+ r#"{"name":"starter-app","version":"0.1.0","main":"src/main.js","devDependencies":{"electron":"30.0.0"},"electronCli":{"packagerConfig":{"appBundleId":"com.example.p12-signed","osxSign":{"p12File":"developer-id.p12","p12Password":"password123","hardenedRuntime":true}}}}"#,
2720
+ )
2721
+ .expect("package.json should be written");
2722
+ write_app_file(&root);
2723
+ write_macho_electron_dist(&root);
2724
+
2725
+ let args = PackageArgs {
2726
+ cwd: root.clone(),
2727
+ out_dir: PathBuf::from("out"),
2728
+ name: None,
2729
+ platform: None,
2730
+ arch: None,
2731
+ force: false,
2732
+ dry_run: false,
2733
+ json: false,
2734
+ };
2735
+ let snapshot = crate::project::inspect(&root).expect("project should inspect");
2736
+ let report = build_report(snapshot, &args).expect("report should build");
2737
+
2738
+ assert!(report.signing.macos.sign.will_execute);
2739
+ assert_eq!(
2740
+ report.signing.macos.sign.method.as_deref(),
2741
+ Some("certificate-p12")
2742
+ );
2743
+ assert!(report.warnings.is_empty());
2744
+
2745
+ execute_package(&report, false).expect("package should succeed");
2746
+
2747
+ let executable = Path::new(report.bundle_dir.as_str())
2748
+ .join("Contents/MacOS")
2749
+ .join(&report.executable_name);
2750
+ let executable_data = fs::read(executable).expect("signed executable should read");
2751
+ let macho = apple_codesign::MachFile::parse(&executable_data)
2752
+ .expect("signed executable should parse as Mach-O");
2753
+ assert!(macho.iter_macho().all(|binary| {
2754
+ let signature = binary
2755
+ .code_signature()
2756
+ .expect("code signature should parse")
2757
+ .expect("code signature should exist");
2758
+ signature
2759
+ .signature_data()
2760
+ .expect("CMS signature should parse")
2761
+ .is_some_and(|data| !data.is_empty())
2762
+ }));
2763
+
2764
+ let _ = fs::remove_dir_all(root);
2765
+ }
2766
+
2255
2767
  #[test]
2256
2768
  fn missing_required_runtime_dependency_fails() {
2257
2769
  let root = unique_temp_dir("runtime-deps");
@@ -2415,6 +2927,31 @@ mod tests {
2415
2927
  .expect("Mach-O test executable should be copied");
2416
2928
  }
2417
2929
 
2930
+ fn apple_codesign_test_fixture(file_name: &str) -> Option<PathBuf> {
2931
+ let cargo_home = std::env::var_os("CARGO_HOME")
2932
+ .map(PathBuf::from)
2933
+ .or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".cargo")))?;
2934
+ let registry_src = cargo_home.join("registry/src");
2935
+ for index_dir in fs::read_dir(registry_src).ok()? {
2936
+ let index_dir = index_dir.ok()?;
2937
+ for crate_dir in fs::read_dir(index_dir.path()).ok()? {
2938
+ let crate_dir = crate_dir.ok()?;
2939
+ let file_name_matches = crate_dir
2940
+ .file_name()
2941
+ .to_str()
2942
+ .is_some_and(|name| name.starts_with("apple-codesign-"));
2943
+ if file_name_matches {
2944
+ let candidate = crate_dir.path().join("src").join(file_name);
2945
+ if candidate.exists() {
2946
+ return Some(candidate);
2947
+ }
2948
+ }
2949
+ }
2950
+ }
2951
+
2952
+ None
2953
+ }
2954
+
2418
2955
  fn unique_temp_dir(label: &str) -> PathBuf {
2419
2956
  let nanos = std::time::SystemTime::now()
2420
2957
  .duration_since(std::time::UNIX_EPOCH)