electron-cli 0.3.0-alpha.2 → 0.3.0-alpha.20

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.
@@ -0,0 +1,3238 @@
1
+ use std::{
2
+ collections::{BTreeMap, BTreeSet, VecDeque},
3
+ fs,
4
+ path::{Path, PathBuf},
5
+ };
6
+
7
+ use anyhow::{bail, Context, Result};
8
+ use app_store_connect::UnifiedApiKey;
9
+ use apple_codesign::{
10
+ cryptography::{parse_pfx_data, PrivateKey},
11
+ stapling::Stapler,
12
+ BundleSigner, CodeSignatureFlags, NotarizationUpload, Notarizer, SettingsScope,
13
+ SigningSettings,
14
+ };
15
+ use camino::Utf8PathBuf;
16
+ use plist::{Dictionary as PlistDictionary, Value as PlistValue};
17
+ use serde::Serialize;
18
+ use serde_json::Value as JsonValue;
19
+
20
+ use crate::{cli::PackageArgs, output, project::ProjectSnapshot};
21
+
22
+ const APPLE_TIMESTAMP_URL: &str = "http://timestamp.apple.com/ts01";
23
+ const MACOS_NOTARIZATION_WAIT_TIMEOUT_SECONDS: u64 = 600;
24
+
25
+ #[derive(Clone, Debug, Serialize)]
26
+ pub(crate) struct PackageReport {
27
+ project: ProjectSnapshot,
28
+ app_name: String,
29
+ executable_name: String,
30
+ metadata: PackageMetadata,
31
+ signing: PackageSigningPlan,
32
+ platform: String,
33
+ arch: String,
34
+ electron_dist: Utf8PathBuf,
35
+ output_dir: Utf8PathBuf,
36
+ bundle_dir: Utf8PathBuf,
37
+ app_resources_dir: Utf8PathBuf,
38
+ dry_run: bool,
39
+ status: PackageStatus,
40
+ create_dirs: Vec<Utf8PathBuf>,
41
+ copy_steps: Vec<CopyStep>,
42
+ warnings: Vec<String>,
43
+ }
44
+
45
+ #[derive(Clone, Debug, Serialize)]
46
+ struct CopyStep {
47
+ from: Utf8PathBuf,
48
+ to: Utf8PathBuf,
49
+ }
50
+
51
+ #[derive(Clone, Debug, Serialize)]
52
+ struct PackageMetadata {
53
+ bundle_identifier: String,
54
+ app_version: Option<String>,
55
+ build_version: Option<String>,
56
+ app_category_type: Option<String>,
57
+ app_copyright: Option<String>,
58
+ icon: Option<IconResource>,
59
+ extra_resources: Vec<CopyStep>,
60
+ darwin_dark_mode_support: bool,
61
+ }
62
+
63
+ #[derive(Clone, Debug, Serialize)]
64
+ struct IconResource {
65
+ from: Utf8PathBuf,
66
+ to: Utf8PathBuf,
67
+ }
68
+
69
+ #[derive(Clone, Debug, Serialize)]
70
+ struct PackageSigningPlan {
71
+ macos: MacosSigningPlan,
72
+ }
73
+
74
+ #[derive(Clone, Debug, Serialize)]
75
+ struct MacosSigningPlan {
76
+ sign: MacosSignPlan,
77
+ notarize: MacosNotarizePlan,
78
+ }
79
+
80
+ #[derive(Clone, Debug, Serialize)]
81
+ struct MacosSignPlan {
82
+ configured: bool,
83
+ enabled: bool,
84
+ will_execute: bool,
85
+ method: Option<String>,
86
+ identity: Option<String>,
87
+ p12_file: Option<Utf8PathBuf>,
88
+ p12_password_source: Option<String>,
89
+ p12_password_env: Option<String>,
90
+ p12_password_file: Option<Utf8PathBuf>,
91
+ #[serde(skip)]
92
+ p12_password: RedactedSecret,
93
+ timestamp_url: Option<String>,
94
+ for_notarization: bool,
95
+ entitlements: Vec<Utf8PathBuf>,
96
+ entitlements_inherit: Option<Utf8PathBuf>,
97
+ hardened_runtime: Option<bool>,
98
+ gatekeeper_assess: Option<bool>,
99
+ }
100
+
101
+ #[derive(Clone, Debug, Serialize)]
102
+ struct MacosNotarizePlan {
103
+ configured: bool,
104
+ enabled: bool,
105
+ will_execute: bool,
106
+ auth_method: Option<String>,
107
+ apple_api_key: Option<Utf8PathBuf>,
108
+ #[serde(skip)]
109
+ apple_api_key_id: RedactedSecret,
110
+ #[serde(skip)]
111
+ apple_api_issuer: RedactedSecret,
112
+ keychain_profile: Option<String>,
113
+ keychain: Option<String>,
114
+ wait: bool,
115
+ wait_timeout_seconds: u64,
116
+ staple: bool,
117
+ }
118
+
119
+ #[derive(Clone, Default)]
120
+ struct RedactedSecret(Option<String>);
121
+
122
+ impl std::fmt::Debug for RedactedSecret {
123
+ fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
124
+ if self.0.is_some() {
125
+ formatter.write_str("<redacted>")
126
+ } else {
127
+ formatter.write_str("<unset>")
128
+ }
129
+ }
130
+ }
131
+
132
+ impl RedactedSecret {
133
+ fn new(value: Option<String>) -> Self {
134
+ Self(value)
135
+ }
136
+
137
+ fn as_deref(&self) -> Option<&str> {
138
+ self.0.as_deref()
139
+ }
140
+ }
141
+
142
+ #[derive(Clone, Copy, Debug, Serialize)]
143
+ #[serde(rename_all = "kebab-case")]
144
+ enum PackageStatus {
145
+ Planned,
146
+ Packaged,
147
+ }
148
+
149
+ #[derive(Debug, Default)]
150
+ struct PackageJsonConfig {
151
+ product_name: Option<String>,
152
+ app_version: Option<String>,
153
+ packager: PackagerConfig,
154
+ warnings: Vec<String>,
155
+ }
156
+
157
+ #[derive(Debug, Default)]
158
+ struct PackagerConfig {
159
+ name: Option<String>,
160
+ executable_name: Option<String>,
161
+ app_bundle_id: Option<String>,
162
+ app_category_type: Option<String>,
163
+ app_version: Option<String>,
164
+ build_version: Option<String>,
165
+ app_copyright: Option<String>,
166
+ icon: Vec<String>,
167
+ extra_resource: Vec<String>,
168
+ darwin_dark_mode_support: bool,
169
+ osx_sign: MacosSignConfig,
170
+ osx_notarize: MacosNotarizeConfig,
171
+ }
172
+
173
+ #[derive(Clone, Debug, Default)]
174
+ struct MacosSignConfig {
175
+ configured: bool,
176
+ enabled: bool,
177
+ invalid_type: bool,
178
+ identity: Option<String>,
179
+ p12_file: Option<String>,
180
+ p12_password: Option<String>,
181
+ p12_password_env: Option<String>,
182
+ p12_password_file: Option<String>,
183
+ timestamp: Option<MacosTimestampConfig>,
184
+ entitlements: Vec<String>,
185
+ entitlements_inherit: Option<String>,
186
+ hardened_runtime: Option<bool>,
187
+ gatekeeper_assess: Option<bool>,
188
+ }
189
+
190
+ #[derive(Clone, Debug)]
191
+ enum MacosTimestampConfig {
192
+ Default,
193
+ Disabled,
194
+ Url(String),
195
+ }
196
+
197
+ #[derive(Clone, Debug, Default)]
198
+ struct MacosNotarizeConfig {
199
+ configured: bool,
200
+ enabled: bool,
201
+ invalid_type: bool,
202
+ apple_id_set: bool,
203
+ apple_id_password_set: bool,
204
+ team_id_set: bool,
205
+ apple_api_key: Option<String>,
206
+ apple_api_key_id: Option<String>,
207
+ apple_api_issuer: Option<String>,
208
+ keychain_profile: Option<String>,
209
+ keychain: Option<String>,
210
+ wait: Option<bool>,
211
+ wait_timeout_seconds: Option<u64>,
212
+ staple: Option<bool>,
213
+ }
214
+
215
+ pub fn run(args: PackageArgs) -> Result<()> {
216
+ let snapshot = crate::project::inspect(&args.cwd)?;
217
+ let mut report = build_report(snapshot, &args)?;
218
+
219
+ if args.dry_run {
220
+ return print_report(&report, args.json);
221
+ }
222
+
223
+ execute_package(&report, args.force)?;
224
+ report.status = PackageStatus::Packaged;
225
+
226
+ print_report(&report, args.json)
227
+ }
228
+
229
+ pub(crate) fn build_report(snapshot: ProjectSnapshot, args: &PackageArgs) -> Result<PackageReport> {
230
+ let root = Path::new(snapshot.root.as_str());
231
+ let package_config = read_package_json_config(&snapshot)?;
232
+ let platform = args.platform.clone().unwrap_or_else(current_platform);
233
+ let arch = args.arch.clone().unwrap_or_else(current_arch);
234
+ let app_name = clean_app_name(
235
+ &args
236
+ .name
237
+ .clone()
238
+ .or_else(|| package_config.packager.name.clone())
239
+ .or_else(|| package_config.product_name.clone())
240
+ .or_else(|| snapshot.name.clone())
241
+ .unwrap_or_else(|| "electron-app".to_string()),
242
+ );
243
+ let executable_base = package_config
244
+ .packager
245
+ .executable_name
246
+ .clone()
247
+ .map(|name| clean_app_name(&name))
248
+ .unwrap_or_else(|| app_name.clone());
249
+ let executable_name = executable_name(&executable_base, &platform);
250
+ let artifact_name = sanitize_artifact_name(&app_name);
251
+ let output_dir = resolve_output_dir(root, &args.out_dir);
252
+ let package_root = output_dir.join(format!("{artifact_name}-{platform}-{arch}"));
253
+ let bundle_dir = bundle_dir(&package_root, &app_name, &platform);
254
+ let app_resources_dir = app_resources_dir(&bundle_dir, &platform);
255
+ let electron_dist = root.join("node_modules/electron/dist");
256
+ let electron_source = electron_source(&electron_dist, &platform);
257
+ let (metadata, metadata_warnings) = package_metadata(
258
+ root,
259
+ &package_config,
260
+ &artifact_name,
261
+ &app_resources_dir,
262
+ &platform,
263
+ )?;
264
+ let (signing, signing_warnings) = package_signing(root, &package_config, &platform)?;
265
+
266
+ let mut warnings = package_config.warnings.clone();
267
+ if snapshot.package_json.is_none() {
268
+ warnings.push("No package.json found.".to_string());
269
+ }
270
+
271
+ if snapshot.electron_dependency.is_none() {
272
+ warnings.push("No electron dependency is declared in package.json.".to_string());
273
+ }
274
+
275
+ if snapshot.main.is_none() {
276
+ warnings.push("No package.json main field found.".to_string());
277
+ }
278
+
279
+ if !electron_source.exists() {
280
+ warnings.push(format!(
281
+ "Electron runtime was not found at {}.",
282
+ electron_source.display()
283
+ ));
284
+ }
285
+
286
+ if platform != current_platform() {
287
+ warnings.push(format!(
288
+ "Cross-platform packaging is not implemented yet; this host can package {}.",
289
+ current_platform()
290
+ ));
291
+ }
292
+
293
+ if arch != current_arch() {
294
+ warnings.push(format!(
295
+ "Cross-architecture packaging is not implemented yet; this host can package {}.",
296
+ current_arch()
297
+ ));
298
+ }
299
+
300
+ warnings.extend(runtime_dependency_warnings(root, &snapshot));
301
+ warnings.extend(metadata_warnings);
302
+ warnings.extend(signing_warnings);
303
+
304
+ let create_dirs = vec![package_root.clone(), app_resources_dir.clone()];
305
+ let mut copy_steps = vec![
306
+ (electron_source, bundle_dir.clone()),
307
+ (root.to_path_buf(), app_resources_dir.join("app")),
308
+ ];
309
+ if has_runtime_dependencies(&snapshot) {
310
+ copy_steps.push((
311
+ root.join("node_modules"),
312
+ app_resources_dir.join("app/node_modules"),
313
+ ));
314
+ }
315
+ if let Some(icon) = &metadata.icon {
316
+ copy_steps.push((
317
+ Path::new(icon.from.as_str()).to_path_buf(),
318
+ Path::new(icon.to.as_str()).to_path_buf(),
319
+ ));
320
+ }
321
+ for resource in &metadata.extra_resources {
322
+ copy_steps.push((
323
+ Path::new(resource.from.as_str()).to_path_buf(),
324
+ Path::new(resource.to.as_str()).to_path_buf(),
325
+ ));
326
+ }
327
+
328
+ Ok(PackageReport {
329
+ project: snapshot,
330
+ app_name,
331
+ executable_name,
332
+ metadata,
333
+ signing,
334
+ platform,
335
+ arch,
336
+ electron_dist: utf8_path(electron_dist)?,
337
+ output_dir: utf8_path(output_dir)?,
338
+ bundle_dir: utf8_path(bundle_dir)?,
339
+ app_resources_dir: utf8_path(app_resources_dir)?,
340
+ dry_run: args.dry_run,
341
+ status: PackageStatus::Planned,
342
+ create_dirs: create_dirs
343
+ .into_iter()
344
+ .map(utf8_path)
345
+ .collect::<Result<Vec<_>>>()?,
346
+ copy_steps: copy_steps
347
+ .into_iter()
348
+ .map(|(from, to)| {
349
+ Ok(CopyStep {
350
+ from: utf8_path(from)?,
351
+ to: utf8_path(to)?,
352
+ })
353
+ })
354
+ .collect::<Result<Vec<_>>>()?,
355
+ warnings,
356
+ })
357
+ }
358
+
359
+ pub(crate) fn execute_package(report: &PackageReport, force: bool) -> Result<()> {
360
+ if report.project.package_json.is_none() {
361
+ bail!("No package.json found. Run electron-cli package inside an Electron project.");
362
+ }
363
+
364
+ if report.project.electron_dependency.is_none() {
365
+ bail!("No electron dependency found. Install Electron before packaging the app.");
366
+ }
367
+
368
+ if report.platform != current_platform() {
369
+ bail!(
370
+ "Cross-platform packaging is not implemented yet. Requested {}, host is {}.",
371
+ report.platform,
372
+ current_platform()
373
+ );
374
+ }
375
+
376
+ if report.arch != current_arch() {
377
+ bail!(
378
+ "Cross-architecture packaging is not implemented yet. Requested {}, host is {}.",
379
+ report.arch,
380
+ current_arch()
381
+ );
382
+ }
383
+
384
+ let bundle_dir = Path::new(report.bundle_dir.as_str());
385
+ let package_root = package_root(bundle_dir, &report.platform);
386
+ let app_resources_dir = Path::new(report.app_resources_dir.as_str());
387
+ let app_dir = app_resources_dir.join("app");
388
+
389
+ if package_root.exists() {
390
+ if force {
391
+ fs::remove_dir_all(&package_root)
392
+ .with_context(|| format!("Could not remove {}", package_root.display()))?;
393
+ } else {
394
+ bail!(
395
+ "Package output already exists: {}. Use --force to overwrite it.",
396
+ package_root.display()
397
+ );
398
+ }
399
+ }
400
+
401
+ let electron_source = Path::new(report.copy_steps[0].from.as_str());
402
+ if !electron_source.exists() {
403
+ bail!(
404
+ "Electron runtime was not found at {}. Run your package manager install first.",
405
+ electron_source.display()
406
+ );
407
+ }
408
+
409
+ fs::create_dir_all(&package_root)
410
+ .with_context(|| format!("Could not create {}", package_root.display()))?;
411
+ copy_recursively(electron_source, bundle_dir).with_context(|| {
412
+ format!(
413
+ "Could not copy Electron runtime to {}",
414
+ bundle_dir.display()
415
+ )
416
+ })?;
417
+ rename_runtime_executable(bundle_dir, &report.executable_name, &report.platform)?;
418
+ apply_package_metadata(report)?;
419
+ copy_package_resources(report)?;
420
+
421
+ fs::create_dir_all(&app_dir)
422
+ .with_context(|| format!("Could not create {}", app_dir.display()))?;
423
+ copy_project_files(
424
+ Path::new(report.project.root.as_str()),
425
+ &app_dir,
426
+ Path::new(report.output_dir.as_str()),
427
+ )?;
428
+ copy_runtime_dependencies(
429
+ Path::new(report.project.root.as_str()),
430
+ &app_dir,
431
+ &report.project,
432
+ )?;
433
+ execute_macos_signing(report)?;
434
+ execute_macos_notarization(report)?;
435
+
436
+ Ok(())
437
+ }
438
+
439
+ fn execute_macos_signing(report: &PackageReport) -> Result<()> {
440
+ if report.platform != "darwin" || !report.signing.macos.sign.will_execute {
441
+ return Ok(());
442
+ }
443
+
444
+ let bundle_dir = Path::new(report.bundle_dir.as_str());
445
+ let bundle_parent = bundle_dir
446
+ .parent()
447
+ .context("macOS bundle output has no parent directory")?;
448
+ let bundle_name = bundle_dir
449
+ .file_name()
450
+ .context("macOS bundle output has no bundle directory name")?;
451
+ let unique_suffix = std::time::SystemTime::now()
452
+ .duration_since(std::time::UNIX_EPOCH)
453
+ .context("system clock is before the Unix epoch")?
454
+ .as_nanos();
455
+ let signing_parent = bundle_parent.join(format!(
456
+ ".electron-cli-signing-{}-{unique_suffix}",
457
+ std::process::id()
458
+ ));
459
+ let signed_bundle_dir = signing_parent.join(bundle_name);
460
+
461
+ if signing_parent.exists() {
462
+ fs::remove_dir_all(&signing_parent)
463
+ .with_context(|| format!("Could not remove {}", signing_parent.display()))?;
464
+ }
465
+
466
+ let signing_result = (|| -> Result<()> {
467
+ let mut signer = BundleSigner::new_from_path(bundle_dir).with_context(|| {
468
+ format!(
469
+ "Could not prepare macOS bundle signing for {}",
470
+ bundle_dir.display()
471
+ )
472
+ })?;
473
+ signer
474
+ .collect_nested_bundles()
475
+ .context("Could not discover nested macOS bundles for signing")?;
476
+
477
+ let mut settings = macos_signing_settings(report)?;
478
+ if let Some(p12_file) = &report.signing.macos.sign.p12_file {
479
+ let p12_path = Path::new(p12_file.as_str());
480
+ let p12_data = fs::read(p12_path)
481
+ .with_context(|| format!("Could not read {}", p12_path.display()))?;
482
+ let password = macos_p12_password(&report.signing.macos.sign)?;
483
+ let (certificate, signing_key) = parse_pfx_data(&p12_data, &password)
484
+ .with_context(|| format!("Could not parse {}", p12_path.display()))?;
485
+
486
+ settings.set_signing_key(signing_key.as_key_info_signer(), certificate);
487
+ settings.chain_apple_certificates();
488
+ settings.set_team_id_from_signing_certificate();
489
+ settings
490
+ .ensure_for_notarization_settings()
491
+ .context("macOS signing settings are not compatible with notarization")?;
492
+ signer
493
+ .write_signed_bundle(&signed_bundle_dir, &settings)
494
+ .with_context(|| {
495
+ format!(
496
+ "Could not write signed macOS bundle to {}",
497
+ signed_bundle_dir.display()
498
+ )
499
+ })?;
500
+ } else {
501
+ signer
502
+ .write_signed_bundle(&signed_bundle_dir, &settings)
503
+ .with_context(|| {
504
+ format!(
505
+ "Could not write signed macOS bundle to {}",
506
+ signed_bundle_dir.display()
507
+ )
508
+ })?;
509
+ }
510
+
511
+ Ok(())
512
+ })();
513
+
514
+ if let Err(error) = signing_result {
515
+ let _ = fs::remove_dir_all(&signing_parent);
516
+ return Err(error);
517
+ }
518
+
519
+ fs::remove_dir_all(bundle_dir)
520
+ .with_context(|| format!("Could not remove {}", bundle_dir.display()))?;
521
+ fs::rename(&signed_bundle_dir, bundle_dir).with_context(|| {
522
+ format!(
523
+ "Could not move signed macOS bundle from {} to {}",
524
+ signed_bundle_dir.display(),
525
+ bundle_dir.display()
526
+ )
527
+ })?;
528
+ let _ = fs::remove_dir_all(&signing_parent);
529
+
530
+ Ok(())
531
+ }
532
+
533
+ fn execute_macos_notarization(report: &PackageReport) -> Result<()> {
534
+ if report.platform != "darwin" || !report.signing.macos.notarize.will_execute {
535
+ return Ok(());
536
+ }
537
+
538
+ let notarize = &report.signing.macos.notarize;
539
+ let bundle_dir = Path::new(report.bundle_dir.as_str());
540
+ let wait_limit = notarize.wait.then_some(std::time::Duration::from_secs(
541
+ notarize.wait_timeout_seconds,
542
+ ));
543
+ let notarizer = macos_notarizer(notarize)?;
544
+ let upload = notarizer
545
+ .notarize_path(bundle_dir, wait_limit)
546
+ .with_context(|| format!("Could not notarize macOS bundle {}", bundle_dir.display()))?;
547
+
548
+ if notarize.staple {
549
+ match upload {
550
+ NotarizationUpload::NotaryResponse(_) => {
551
+ let stapler =
552
+ Stapler::new().context("Could not prepare macOS notarization stapler")?;
553
+ stapler.staple_path(bundle_dir).with_context(|| {
554
+ format!(
555
+ "Could not staple notarization ticket to {}",
556
+ bundle_dir.display()
557
+ )
558
+ })?;
559
+ }
560
+ NotarizationUpload::UploadId(upload_id) => {
561
+ bail!(
562
+ "macOS notarization upload {upload_id} was submitted without waiting; stapling requires a completed notarization result."
563
+ );
564
+ }
565
+ }
566
+ }
567
+
568
+ Ok(())
569
+ }
570
+
571
+ fn macos_notarizer(notarize: &MacosNotarizePlan) -> Result<Notarizer> {
572
+ let api_key_path = notarize
573
+ .apple_api_key
574
+ .as_ref()
575
+ .context("macOS notarization requires appleApiKey")?;
576
+ let api_key_path = Path::new(api_key_path.as_str());
577
+ let key_id = notarize
578
+ .apple_api_key_id
579
+ .as_deref()
580
+ .context("macOS notarization requires appleApiKeyId")?;
581
+ let issuer = notarize
582
+ .apple_api_issuer
583
+ .as_deref()
584
+ .context("macOS notarization requires appleApiIssuer")?;
585
+
586
+ if path_extension(api_key_path) == Some("json") {
587
+ return Notarizer::from_api_key(api_key_path)
588
+ .with_context(|| format!("Could not load Apple API key {}", api_key_path.display()));
589
+ }
590
+
591
+ let temp_api_key = temporary_unified_api_key(issuer, key_id, api_key_path)?;
592
+ Notarizer::from_api_key(&temp_api_key.path)
593
+ .with_context(|| format!("Could not load Apple API key {}", api_key_path.display()))
594
+ }
595
+
596
+ struct TemporaryFile {
597
+ path: PathBuf,
598
+ }
599
+
600
+ impl Drop for TemporaryFile {
601
+ fn drop(&mut self) {
602
+ let _ = fs::remove_file(&self.path);
603
+ }
604
+ }
605
+
606
+ fn temporary_unified_api_key(
607
+ issuer: &str,
608
+ key_id: &str,
609
+ private_key_path: &Path,
610
+ ) -> Result<TemporaryFile> {
611
+ let unique_suffix = std::time::SystemTime::now()
612
+ .duration_since(std::time::UNIX_EPOCH)
613
+ .context("system clock is before the Unix epoch")?
614
+ .as_nanos();
615
+ let path = std::env::temp_dir().join(format!(
616
+ "electron-cli-notary-key-{}-{unique_suffix}.json",
617
+ std::process::id()
618
+ ));
619
+ let unified = UnifiedApiKey::from_ecdsa_pem_path(issuer, key_id, private_key_path)
620
+ .with_context(|| {
621
+ format!(
622
+ "Could not read Apple API private key {}",
623
+ private_key_path.display()
624
+ )
625
+ })?;
626
+ unified
627
+ .write_json_file(&path)
628
+ .with_context(|| format!("Could not write temporary Apple API key {}", path.display()))?;
629
+
630
+ Ok(TemporaryFile { path })
631
+ }
632
+
633
+ fn macos_signing_settings<'key>(report: &PackageReport) -> Result<SigningSettings<'key>> {
634
+ let sign = &report.signing.macos.sign;
635
+ let mut settings = SigningSettings::default();
636
+ settings.set_binary_identifier(SettingsScope::Main, &report.metadata.bundle_identifier);
637
+ settings.set_for_notarization(sign.for_notarization);
638
+
639
+ if let Some(timestamp_url) = &sign.timestamp_url {
640
+ settings
641
+ .set_time_stamp_url(timestamp_url)
642
+ .with_context(|| format!("Invalid macOS signing timestamp URL: {timestamp_url}"))?;
643
+ }
644
+
645
+ if sign.hardened_runtime.unwrap_or(false) {
646
+ settings.add_code_signature_flags(SettingsScope::Main, CodeSignatureFlags::RUNTIME);
647
+ }
648
+
649
+ if let Some(entitlements) = sign.entitlements.first() {
650
+ let entitlements_path = Path::new(entitlements.as_str());
651
+ let entitlements_xml = fs::read_to_string(entitlements_path).with_context(|| {
652
+ format!(
653
+ "Could not read macOS entitlements file {}",
654
+ entitlements_path.display()
655
+ )
656
+ })?;
657
+ settings
658
+ .set_entitlements_xml(SettingsScope::Main, entitlements_xml)
659
+ .with_context(|| {
660
+ format!(
661
+ "Could not parse macOS entitlements file {}",
662
+ entitlements_path.display()
663
+ )
664
+ })?;
665
+ }
666
+
667
+ Ok(settings)
668
+ }
669
+
670
+ fn macos_p12_password(sign: &MacosSignPlan) -> Result<String> {
671
+ if let Some(password) = sign.p12_password.as_deref() {
672
+ return Ok(password.to_string());
673
+ }
674
+
675
+ if let Some(env_name) = &sign.p12_password_env {
676
+ return std::env::var(env_name)
677
+ .with_context(|| format!("Could not read macOS signing p12 password env {env_name}"));
678
+ }
679
+
680
+ if let Some(path) = &sign.p12_password_file {
681
+ let password_path = Path::new(path.as_str());
682
+ return fs::read_to_string(password_path)
683
+ .with_context(|| {
684
+ format!(
685
+ "Could not read macOS signing p12 password file {}",
686
+ password_path.display()
687
+ )
688
+ })
689
+ .and_then(|contents| {
690
+ contents
691
+ .lines()
692
+ .next()
693
+ .map(str::to_string)
694
+ .context("macOS signing p12 password file is empty")
695
+ });
696
+ }
697
+
698
+ Ok(String::new())
699
+ }
700
+
701
+ fn print_report(report: &PackageReport, json: bool) -> Result<()> {
702
+ if json {
703
+ return output::json(report);
704
+ }
705
+
706
+ println!("electron-cli package");
707
+ println!();
708
+ println!("Project");
709
+ println!(" root: {}", report.project.root);
710
+ match report.project.package_label() {
711
+ Some(label) => println!(" package: {label}"),
712
+ None => println!(" package: not found"),
713
+ }
714
+ println!(" app name: {}", report.app_name);
715
+ println!(" executable: {}", report.executable_name);
716
+ println!(" bundle id: {}", report.metadata.bundle_identifier);
717
+ if let Some(version) = &report.metadata.app_version {
718
+ println!(" app version: {version}");
719
+ }
720
+ println!(" target: {} {}", report.platform, report.arch);
721
+ println!(" status: {}", report.status.as_str());
722
+
723
+ if report.signing.macos.sign.configured || report.signing.macos.notarize.configured {
724
+ println!();
725
+ println!("Signing");
726
+ println!(
727
+ " macOS signing: {}",
728
+ if report.signing.macos.sign.enabled {
729
+ "configured"
730
+ } else {
731
+ "disabled"
732
+ }
733
+ );
734
+ if let Some(identity) = &report.signing.macos.sign.identity {
735
+ println!(" identity: {identity}");
736
+ }
737
+ if let Some(path) = &report.signing.macos.sign.p12_file {
738
+ println!(" p12 file: {path}");
739
+ }
740
+ if let Some(source) = &report.signing.macos.sign.p12_password_source {
741
+ println!(" p12 password: {source}");
742
+ }
743
+ if let Some(timestamp_url) = &report.signing.macos.sign.timestamp_url {
744
+ println!(" timestamp server: {timestamp_url}");
745
+ }
746
+ if report.signing.macos.sign.for_notarization {
747
+ println!(" signing mode: notarization-compatible");
748
+ }
749
+ if let Some(method) = &report.signing.macos.sign.method {
750
+ println!(" signing method: {method}");
751
+ }
752
+ println!(
753
+ " signing execution: {}",
754
+ if report.signing.macos.sign.will_execute {
755
+ "enabled"
756
+ } else {
757
+ "not available"
758
+ }
759
+ );
760
+ println!(
761
+ " macOS notarization: {}",
762
+ if report.signing.macos.notarize.enabled {
763
+ "configured"
764
+ } else {
765
+ "disabled"
766
+ }
767
+ );
768
+ if let Some(method) = &report.signing.macos.notarize.auth_method {
769
+ println!(" notarization auth: {method}");
770
+ }
771
+ if let Some(path) = &report.signing.macos.notarize.apple_api_key {
772
+ println!(" Apple API key: {path}");
773
+ }
774
+ println!(
775
+ " notarization execution: {}",
776
+ if report.signing.macos.notarize.will_execute {
777
+ "enabled"
778
+ } else {
779
+ "not available"
780
+ }
781
+ );
782
+ if report.signing.macos.notarize.will_execute {
783
+ println!(
784
+ " notarization wait: {}",
785
+ if report.signing.macos.notarize.wait {
786
+ format!("{}s", report.signing.macos.notarize.wait_timeout_seconds)
787
+ } else {
788
+ "disabled".to_string()
789
+ }
790
+ );
791
+ println!(
792
+ " notarization stapling: {}",
793
+ if report.signing.macos.notarize.staple {
794
+ "enabled"
795
+ } else {
796
+ "disabled"
797
+ }
798
+ );
799
+ }
800
+ }
801
+
802
+ println!();
803
+ println!("Output");
804
+ println!(" {}", report.bundle_dir);
805
+
806
+ println!();
807
+ println!("Copy");
808
+ for step in &report.copy_steps {
809
+ println!(" {} -> {}", step.from, step.to);
810
+ }
811
+
812
+ if !report.warnings.is_empty() {
813
+ println!();
814
+ println!("Warnings");
815
+ for warning in &report.warnings {
816
+ println!(" {warning}");
817
+ }
818
+ }
819
+
820
+ Ok(())
821
+ }
822
+
823
+ fn read_package_json_config(snapshot: &ProjectSnapshot) -> Result<PackageJsonConfig> {
824
+ let project_config = crate::forge_config::read(snapshot)?;
825
+
826
+ let mut packager = PackagerConfig::default();
827
+ if let Some(config) = project_config
828
+ .forge()
829
+ .and_then(|forge| forge.get("packagerConfig"))
830
+ {
831
+ packager.merge(parse_packager_config(config));
832
+ }
833
+ if let Some(config) = project_config
834
+ .package()
835
+ .and_then(|package| package.get("electronPackagerConfig"))
836
+ {
837
+ packager.merge(parse_packager_config(config));
838
+ }
839
+ if let Some(config) = project_config
840
+ .electron_cli()
841
+ .and_then(|config| config.get("packagerConfig"))
842
+ {
843
+ packager.merge(parse_packager_config(config));
844
+ }
845
+
846
+ Ok(PackageJsonConfig {
847
+ product_name: project_config
848
+ .package()
849
+ .and_then(|package| package.get("productName"))
850
+ .and_then(JsonValue::as_str)
851
+ .map(ToOwned::to_owned),
852
+ app_version: project_config
853
+ .package()
854
+ .and_then(|package| package.get("version"))
855
+ .and_then(JsonValue::as_str)
856
+ .map(ToOwned::to_owned),
857
+ warnings: project_config.warnings().to_vec(),
858
+ packager,
859
+ })
860
+ }
861
+
862
+ fn parse_packager_config(value: &JsonValue) -> PackagerConfig {
863
+ PackagerConfig {
864
+ name: string_value(value, "name"),
865
+ executable_name: string_value(value, "executableName"),
866
+ app_bundle_id: string_value(value, "appBundleId"),
867
+ app_category_type: string_value(value, "appCategoryType"),
868
+ app_version: string_value(value, "appVersion"),
869
+ build_version: string_value(value, "buildVersion"),
870
+ app_copyright: string_value(value, "appCopyright"),
871
+ icon: string_list(value.get("icon")),
872
+ extra_resource: string_list(value.get("extraResource")),
873
+ darwin_dark_mode_support: value
874
+ .get("darwinDarkModeSupport")
875
+ .and_then(JsonValue::as_bool)
876
+ .unwrap_or(false),
877
+ osx_sign: parse_macos_sign_config(value.get("osxSign")),
878
+ osx_notarize: parse_macos_notarize_config(value.get("osxNotarize")),
879
+ }
880
+ }
881
+
882
+ fn parse_macos_sign_config(value: Option<&JsonValue>) -> MacosSignConfig {
883
+ match value {
884
+ None => MacosSignConfig::default(),
885
+ Some(JsonValue::Bool(false)) => MacosSignConfig {
886
+ configured: true,
887
+ enabled: false,
888
+ ..MacosSignConfig::default()
889
+ },
890
+ Some(JsonValue::Bool(true)) => MacosSignConfig {
891
+ configured: true,
892
+ enabled: true,
893
+ ..MacosSignConfig::default()
894
+ },
895
+ Some(JsonValue::Object(object)) => {
896
+ let entitlements = [
897
+ "entitlements",
898
+ "entitlementsInherit",
899
+ "entitlementsLoginHelper",
900
+ ]
901
+ .iter()
902
+ .filter_map(|key| {
903
+ object
904
+ .get(*key)
905
+ .and_then(JsonValue::as_str)
906
+ .map(ToOwned::to_owned)
907
+ })
908
+ .collect();
909
+
910
+ MacosSignConfig {
911
+ configured: true,
912
+ enabled: true,
913
+ invalid_type: false,
914
+ identity: object
915
+ .get("identity")
916
+ .or_else(|| object.get("identityName"))
917
+ .and_then(JsonValue::as_str)
918
+ .map(ToOwned::to_owned),
919
+ p12_file: object
920
+ .get("p12File")
921
+ .or_else(|| object.get("pfxFile"))
922
+ .and_then(JsonValue::as_str)
923
+ .map(ToOwned::to_owned),
924
+ p12_password: object
925
+ .get("p12Password")
926
+ .or_else(|| object.get("pfxPassword"))
927
+ .and_then(JsonValue::as_str)
928
+ .map(ToOwned::to_owned),
929
+ p12_password_env: object
930
+ .get("p12PasswordEnv")
931
+ .or_else(|| object.get("pfxPasswordEnv"))
932
+ .and_then(JsonValue::as_str)
933
+ .map(ToOwned::to_owned),
934
+ p12_password_file: object
935
+ .get("p12PasswordFile")
936
+ .or_else(|| object.get("pfxPasswordFile"))
937
+ .and_then(JsonValue::as_str)
938
+ .map(ToOwned::to_owned),
939
+ timestamp: parse_macos_timestamp_config(
940
+ object
941
+ .get("timestamp")
942
+ .or_else(|| object.get("timestampUrl"))
943
+ .or_else(|| object.get("timestampURL")),
944
+ ),
945
+ entitlements,
946
+ entitlements_inherit: object
947
+ .get("entitlementsInherit")
948
+ .and_then(JsonValue::as_str)
949
+ .map(ToOwned::to_owned),
950
+ hardened_runtime: object.get("hardenedRuntime").and_then(JsonValue::as_bool),
951
+ gatekeeper_assess: object.get("gatekeeperAssess").and_then(JsonValue::as_bool),
952
+ }
953
+ }
954
+ Some(_) => MacosSignConfig {
955
+ configured: true,
956
+ invalid_type: true,
957
+ ..MacosSignConfig::default()
958
+ },
959
+ }
960
+ }
961
+
962
+ fn parse_macos_timestamp_config(value: Option<&JsonValue>) -> Option<MacosTimestampConfig> {
963
+ match value {
964
+ Some(JsonValue::String(value)) => {
965
+ let value = value.trim();
966
+ if value.is_empty() || value.eq_ignore_ascii_case("none") {
967
+ Some(MacosTimestampConfig::Disabled)
968
+ } else {
969
+ Some(MacosTimestampConfig::Url(value.to_string()))
970
+ }
971
+ }
972
+ Some(JsonValue::Bool(true)) => Some(MacosTimestampConfig::Default),
973
+ Some(JsonValue::Bool(false)) => Some(MacosTimestampConfig::Disabled),
974
+ _ => None,
975
+ }
976
+ }
977
+
978
+ fn parse_macos_notarize_config(value: Option<&JsonValue>) -> MacosNotarizeConfig {
979
+ match value {
980
+ None => MacosNotarizeConfig::default(),
981
+ Some(JsonValue::Bool(false)) => MacosNotarizeConfig {
982
+ configured: true,
983
+ enabled: false,
984
+ ..MacosNotarizeConfig::default()
985
+ },
986
+ Some(JsonValue::Bool(true)) => MacosNotarizeConfig {
987
+ configured: true,
988
+ enabled: true,
989
+ ..MacosNotarizeConfig::default()
990
+ },
991
+ Some(JsonValue::Object(object)) => MacosNotarizeConfig {
992
+ configured: true,
993
+ enabled: true,
994
+ invalid_type: false,
995
+ apple_id_set: object.get("appleId").and_then(JsonValue::as_str).is_some(),
996
+ apple_id_password_set: object
997
+ .get("appleIdPassword")
998
+ .and_then(JsonValue::as_str)
999
+ .is_some(),
1000
+ team_id_set: object.get("teamId").and_then(JsonValue::as_str).is_some(),
1001
+ apple_api_key: object
1002
+ .get("appleApiKey")
1003
+ .and_then(JsonValue::as_str)
1004
+ .map(ToOwned::to_owned),
1005
+ apple_api_key_id: object
1006
+ .get("appleApiKeyId")
1007
+ .and_then(JsonValue::as_str)
1008
+ .map(ToOwned::to_owned),
1009
+ apple_api_issuer: object
1010
+ .get("appleApiIssuer")
1011
+ .and_then(JsonValue::as_str)
1012
+ .map(ToOwned::to_owned),
1013
+ keychain_profile: object
1014
+ .get("keychainProfile")
1015
+ .and_then(JsonValue::as_str)
1016
+ .map(ToOwned::to_owned),
1017
+ keychain: object
1018
+ .get("keychain")
1019
+ .and_then(JsonValue::as_str)
1020
+ .map(ToOwned::to_owned),
1021
+ wait: object.get("wait").and_then(JsonValue::as_bool),
1022
+ wait_timeout_seconds: object
1023
+ .get("maxWaitSeconds")
1024
+ .or_else(|| object.get("waitTimeoutSeconds"))
1025
+ .and_then(JsonValue::as_u64),
1026
+ staple: object.get("staple").and_then(JsonValue::as_bool),
1027
+ },
1028
+ Some(_) => MacosNotarizeConfig {
1029
+ configured: true,
1030
+ invalid_type: true,
1031
+ ..MacosNotarizeConfig::default()
1032
+ },
1033
+ }
1034
+ }
1035
+
1036
+ fn string_value(value: &JsonValue, key: &str) -> Option<String> {
1037
+ value
1038
+ .get(key)
1039
+ .and_then(JsonValue::as_str)
1040
+ .map(ToOwned::to_owned)
1041
+ }
1042
+
1043
+ fn string_list(value: Option<&JsonValue>) -> Vec<String> {
1044
+ match value {
1045
+ Some(JsonValue::String(value)) => vec![value.clone()],
1046
+ Some(JsonValue::Array(values)) => values
1047
+ .iter()
1048
+ .filter_map(JsonValue::as_str)
1049
+ .map(ToOwned::to_owned)
1050
+ .collect(),
1051
+ _ => Vec::new(),
1052
+ }
1053
+ }
1054
+
1055
+ fn package_metadata(
1056
+ root: &Path,
1057
+ config: &PackageJsonConfig,
1058
+ artifact_name: &str,
1059
+ app_resources_dir: &Path,
1060
+ platform: &str,
1061
+ ) -> Result<(PackageMetadata, Vec<String>)> {
1062
+ let mut warnings = Vec::new();
1063
+ let icon = resolve_icon_resource(
1064
+ root,
1065
+ &config.packager.icon,
1066
+ artifact_name,
1067
+ app_resources_dir,
1068
+ platform,
1069
+ &mut warnings,
1070
+ )?;
1071
+ let extra_resources = resolve_extra_resources(
1072
+ root,
1073
+ &config.packager.extra_resource,
1074
+ app_resources_dir,
1075
+ &mut warnings,
1076
+ )?;
1077
+ let app_version = config
1078
+ .packager
1079
+ .app_version
1080
+ .clone()
1081
+ .or_else(|| config.app_version.clone());
1082
+
1083
+ Ok((
1084
+ PackageMetadata {
1085
+ bundle_identifier: config
1086
+ .packager
1087
+ .app_bundle_id
1088
+ .clone()
1089
+ .unwrap_or_else(|| default_bundle_identifier(artifact_name)),
1090
+ app_version: app_version.clone(),
1091
+ build_version: config
1092
+ .packager
1093
+ .build_version
1094
+ .clone()
1095
+ .or_else(|| app_version.clone()),
1096
+ app_category_type: config.packager.app_category_type.clone(),
1097
+ app_copyright: config.packager.app_copyright.clone(),
1098
+ icon,
1099
+ extra_resources,
1100
+ darwin_dark_mode_support: config.packager.darwin_dark_mode_support,
1101
+ },
1102
+ warnings,
1103
+ ))
1104
+ }
1105
+
1106
+ fn package_signing(
1107
+ root: &Path,
1108
+ config: &PackageJsonConfig,
1109
+ platform: &str,
1110
+ ) -> Result<(PackageSigningPlan, Vec<String>)> {
1111
+ let mut warnings = Vec::new();
1112
+ let sign = macos_sign_plan(
1113
+ root,
1114
+ &config.packager.osx_sign,
1115
+ &config.packager.osx_notarize,
1116
+ platform,
1117
+ &mut warnings,
1118
+ )?;
1119
+ let notarize = macos_notarize_plan(root, config, platform, &sign, &mut warnings)?;
1120
+
1121
+ Ok((
1122
+ PackageSigningPlan {
1123
+ macos: MacosSigningPlan { sign, notarize },
1124
+ },
1125
+ warnings,
1126
+ ))
1127
+ }
1128
+
1129
+ fn macos_sign_plan(
1130
+ root: &Path,
1131
+ config: &MacosSignConfig,
1132
+ notarize_config: &MacosNotarizeConfig,
1133
+ platform: &str,
1134
+ warnings: &mut Vec<String>,
1135
+ ) -> Result<MacosSignPlan> {
1136
+ if config.invalid_type {
1137
+ warnings.push("packagerConfig.osxSign must be false, true, or an object.".to_string());
1138
+ }
1139
+
1140
+ let entitlements = config
1141
+ .entitlements
1142
+ .iter()
1143
+ .filter(|path| !path.trim().is_empty())
1144
+ .map(|path| {
1145
+ let resolved = resolve_project_path(root, path);
1146
+ if !resolved.exists() {
1147
+ warnings.push(format!(
1148
+ "Configured macOS entitlements file does not exist: {}.",
1149
+ resolved.display()
1150
+ ));
1151
+ }
1152
+ utf8_path(resolved)
1153
+ })
1154
+ .collect::<Result<Vec<_>>>()?;
1155
+ let entitlements_inherit = config
1156
+ .entitlements_inherit
1157
+ .as_deref()
1158
+ .filter(|path| !path.trim().is_empty())
1159
+ .map(|path| utf8_path(resolve_project_path(root, path)))
1160
+ .transpose()?;
1161
+ if let Some(path) = &entitlements_inherit {
1162
+ if !Path::new(path.as_str()).exists() {
1163
+ warnings.push(format!(
1164
+ "Configured macOS inherited entitlements file does not exist: {}.",
1165
+ path
1166
+ ));
1167
+ }
1168
+ }
1169
+
1170
+ let p12_file = config
1171
+ .p12_file
1172
+ .as_deref()
1173
+ .filter(|path| !path.trim().is_empty())
1174
+ .map(|path| utf8_path(resolve_project_path(root, path)))
1175
+ .transpose()?;
1176
+ if let Some(path) = &p12_file {
1177
+ if !Path::new(path.as_str()).exists() {
1178
+ warnings.push(format!(
1179
+ "Configured macOS signing p12 file does not exist: {}.",
1180
+ path
1181
+ ));
1182
+ }
1183
+ }
1184
+ let p12_password_file = config
1185
+ .p12_password_file
1186
+ .as_deref()
1187
+ .filter(|path| !path.trim().is_empty())
1188
+ .map(|path| utf8_path(resolve_project_path(root, path)))
1189
+ .transpose()?;
1190
+ if let Some(path) = &p12_password_file {
1191
+ if !Path::new(path.as_str()).exists() {
1192
+ warnings.push(format!(
1193
+ "Configured macOS signing p12 password file does not exist: {}.",
1194
+ path
1195
+ ));
1196
+ }
1197
+ }
1198
+ let p12_password_source = if p12_file.is_some() {
1199
+ if config.p12_password.is_some() {
1200
+ Some("config".to_string())
1201
+ } else if let Some(env_name) = config
1202
+ .p12_password_env
1203
+ .as_deref()
1204
+ .filter(|name| !name.trim().is_empty())
1205
+ {
1206
+ Some(format!("env:{env_name}"))
1207
+ } else if let Some(path) = &p12_password_file {
1208
+ Some(format!("file:{path}"))
1209
+ } else {
1210
+ Some("empty".to_string())
1211
+ }
1212
+ } else {
1213
+ None
1214
+ };
1215
+
1216
+ let identity = config.identity.as_deref().map(str::trim);
1217
+ let ad_hoc_identity = matches!(identity, None | Some("-"));
1218
+ let p12_identity = p12_file.is_some();
1219
+ let will_execute = config.enabled && platform == "darwin" && (ad_hoc_identity || p12_identity);
1220
+ let timestamp_url = macos_timestamp_url(config, p12_identity, notarize_config.enabled);
1221
+ let for_notarization =
1222
+ will_execute && p12_identity && notarize_config.enabled && timestamp_url.is_some();
1223
+ let method = if config.enabled && platform == "darwin" {
1224
+ if p12_identity {
1225
+ Some("certificate-p12".to_string())
1226
+ } else if ad_hoc_identity {
1227
+ Some("ad-hoc".to_string())
1228
+ } else {
1229
+ Some("certificate-identity".to_string())
1230
+ }
1231
+ } else {
1232
+ None
1233
+ };
1234
+
1235
+ if config.configured && platform != "darwin" {
1236
+ warnings.push(format!(
1237
+ "macOS signing is configured but ignored for target platform {platform}."
1238
+ ));
1239
+ } else if config.enabled && !will_execute {
1240
+ warnings.push(
1241
+ "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(),
1242
+ );
1243
+ } else if will_execute {
1244
+ if p12_identity && identity.is_some() {
1245
+ warnings.push(
1246
+ "packagerConfig.osxSign.p12File supplies the signing certificate; identity is reported but not used for keychain lookup.".to_string(),
1247
+ );
1248
+ }
1249
+ if config.entitlements.len() > 1 {
1250
+ warnings.push(
1251
+ "Rust-native macOS signing applies the first macOS entitlements file only; inherited/login-helper entitlement scoping is not implemented yet.".to_string(),
1252
+ );
1253
+ }
1254
+ if config.entitlements_inherit.is_some() {
1255
+ warnings.push(
1256
+ "packagerConfig.osxSign.entitlementsInherit is recognized but not applied to nested bundles by Rust-native signing yet.".to_string(),
1257
+ );
1258
+ }
1259
+ if config.gatekeeper_assess.is_some() {
1260
+ warnings.push(
1261
+ "packagerConfig.osxSign.gatekeeperAssess is recognized but Gatekeeper assessment is not implemented yet.".to_string(),
1262
+ );
1263
+ }
1264
+ if config.timestamp.is_some() && !p12_identity {
1265
+ warnings.push(
1266
+ "packagerConfig.osxSign.timestamp is recognized but ignored without p12File certificate signing.".to_string(),
1267
+ );
1268
+ }
1269
+ if notarize_config.enabled && p12_identity && timestamp_url.is_none() {
1270
+ warnings.push(
1271
+ "macOS notarization requires a secure timestamp; packagerConfig.osxSign.timestamp disabled timestamping.".to_string(),
1272
+ );
1273
+ }
1274
+ }
1275
+
1276
+ Ok(MacosSignPlan {
1277
+ configured: config.configured,
1278
+ enabled: config.enabled,
1279
+ will_execute,
1280
+ method,
1281
+ identity: config.identity.clone(),
1282
+ p12_file,
1283
+ p12_password_source,
1284
+ p12_password_env: config.p12_password_env.clone(),
1285
+ p12_password_file,
1286
+ p12_password: RedactedSecret::new(config.p12_password.clone()),
1287
+ timestamp_url,
1288
+ for_notarization,
1289
+ entitlements,
1290
+ entitlements_inherit,
1291
+ hardened_runtime: config.hardened_runtime,
1292
+ gatekeeper_assess: config.gatekeeper_assess,
1293
+ })
1294
+ }
1295
+
1296
+ fn macos_timestamp_url(
1297
+ config: &MacosSignConfig,
1298
+ p12_identity: bool,
1299
+ notarize_enabled: bool,
1300
+ ) -> Option<String> {
1301
+ if !p12_identity {
1302
+ return None;
1303
+ }
1304
+
1305
+ match &config.timestamp {
1306
+ Some(MacosTimestampConfig::Default) => Some(APPLE_TIMESTAMP_URL.to_string()),
1307
+ Some(MacosTimestampConfig::Disabled) => None,
1308
+ Some(MacosTimestampConfig::Url(url)) => Some(url.clone()),
1309
+ None if notarize_enabled => Some(APPLE_TIMESTAMP_URL.to_string()),
1310
+ None => None,
1311
+ }
1312
+ }
1313
+
1314
+ fn macos_notarize_plan(
1315
+ root: &Path,
1316
+ package_config: &PackageJsonConfig,
1317
+ platform: &str,
1318
+ sign: &MacosSignPlan,
1319
+ warnings: &mut Vec<String>,
1320
+ ) -> Result<MacosNotarizePlan> {
1321
+ let config = &package_config.packager.osx_notarize;
1322
+ if config.invalid_type {
1323
+ warnings.push("packagerConfig.osxNotarize must be false, true, or an object.".to_string());
1324
+ }
1325
+
1326
+ let auth_method = macos_notarize_auth_method(config);
1327
+ let apple_api_key = config
1328
+ .apple_api_key
1329
+ .as_deref()
1330
+ .filter(|path| !path.trim().is_empty())
1331
+ .map(|path| utf8_path(resolve_project_path(root, path)))
1332
+ .transpose()?;
1333
+ let api_key_auth = auth_method.as_deref() == Some("app-store-connect-api-key");
1334
+ let staple = config.staple.unwrap_or(true);
1335
+ let wait = staple || config.wait.unwrap_or(true);
1336
+ let wait_timeout_seconds = config
1337
+ .wait_timeout_seconds
1338
+ .unwrap_or(MACOS_NOTARIZATION_WAIT_TIMEOUT_SECONDS);
1339
+ let will_execute =
1340
+ config.enabled && platform == "darwin" && sign.for_notarization && api_key_auth;
1341
+
1342
+ if config.configured && platform != "darwin" {
1343
+ warnings.push(format!(
1344
+ "macOS notarization is configured but ignored for target platform {platform}."
1345
+ ));
1346
+ }
1347
+
1348
+ if config.enabled && !package_config.packager.osx_sign.enabled {
1349
+ warnings.push(
1350
+ "macOS notarization requires packagerConfig.osxSign to be enabled first.".to_string(),
1351
+ );
1352
+ }
1353
+ if config.enabled
1354
+ && platform == "darwin"
1355
+ && package_config.packager.osx_sign.enabled
1356
+ && package_config.packager.osx_sign.p12_file.is_none()
1357
+ && matches!(
1358
+ package_config
1359
+ .packager
1360
+ .osx_sign
1361
+ .identity
1362
+ .as_deref()
1363
+ .map(str::trim),
1364
+ None | Some("-")
1365
+ )
1366
+ {
1367
+ warnings.push(
1368
+ "macOS notarization requires a Developer ID signature; Rust-native ad-hoc signing is not notarizable.".to_string(),
1369
+ );
1370
+ }
1371
+ if config.enabled
1372
+ && platform == "darwin"
1373
+ && package_config.packager.osx_sign.enabled
1374
+ && !sign.for_notarization
1375
+ {
1376
+ warnings.push(
1377
+ "macOS notarization execution requires Rust-native p12File Developer ID signing with a secure timestamp.".to_string(),
1378
+ );
1379
+ }
1380
+ if config.enabled && auth_method.is_none() {
1381
+ warnings.push(
1382
+ "macOS notarization config is missing a complete notarytool authentication set: appleId/appleIdPassword/teamId, appleApiKey/appleApiKeyId/appleApiIssuer, or keychainProfile.".to_string(),
1383
+ );
1384
+ }
1385
+ if config.enabled
1386
+ && platform == "darwin"
1387
+ && matches!(
1388
+ auth_method.as_deref(),
1389
+ Some("keychain-profile") | Some("apple-id")
1390
+ )
1391
+ {
1392
+ warnings.push(
1393
+ "Rust-native macOS notarization execution currently requires appleApiKey, appleApiKeyId, and appleApiIssuer; keychain profile and Apple ID auth are recognized for planning only.".to_string(),
1394
+ );
1395
+ }
1396
+ if let Some(path) = &apple_api_key {
1397
+ if !Path::new(path.as_str()).exists() {
1398
+ warnings.push(format!(
1399
+ "Configured Apple API key file does not exist: {}.",
1400
+ path
1401
+ ));
1402
+ }
1403
+ }
1404
+ Ok(MacosNotarizePlan {
1405
+ configured: config.configured,
1406
+ enabled: config.enabled,
1407
+ will_execute,
1408
+ auth_method,
1409
+ apple_api_key,
1410
+ apple_api_key_id: RedactedSecret::new(config.apple_api_key_id.clone()),
1411
+ apple_api_issuer: RedactedSecret::new(config.apple_api_issuer.clone()),
1412
+ keychain_profile: config.keychain_profile.clone(),
1413
+ keychain: config.keychain.clone(),
1414
+ wait,
1415
+ wait_timeout_seconds,
1416
+ staple,
1417
+ })
1418
+ }
1419
+
1420
+ fn macos_notarize_auth_method(config: &MacosNotarizeConfig) -> Option<String> {
1421
+ if config
1422
+ .keychain_profile
1423
+ .as_deref()
1424
+ .is_some_and(|value| !value.trim().is_empty())
1425
+ {
1426
+ Some("keychain-profile".to_string())
1427
+ } else if config.apple_api_key.is_some()
1428
+ && config
1429
+ .apple_api_key_id
1430
+ .as_deref()
1431
+ .is_some_and(|value| !value.trim().is_empty())
1432
+ && config
1433
+ .apple_api_issuer
1434
+ .as_deref()
1435
+ .is_some_and(|value| !value.trim().is_empty())
1436
+ {
1437
+ Some("app-store-connect-api-key".to_string())
1438
+ } else if config.apple_id_set && config.apple_id_password_set && config.team_id_set {
1439
+ Some("apple-id".to_string())
1440
+ } else {
1441
+ None
1442
+ }
1443
+ }
1444
+
1445
+ fn resolve_icon_resource(
1446
+ root: &Path,
1447
+ configured_icons: &[String],
1448
+ artifact_name: &str,
1449
+ app_resources_dir: &Path,
1450
+ platform: &str,
1451
+ warnings: &mut Vec<String>,
1452
+ ) -> Result<Option<IconResource>> {
1453
+ let candidates = configured_icons
1454
+ .iter()
1455
+ .filter_map(|icon| icon_candidate(root, icon, platform))
1456
+ .collect::<Vec<_>>();
1457
+ let source = if platform == "darwin" {
1458
+ candidates
1459
+ .iter()
1460
+ .find(|candidate| candidate.exists() && path_extension(candidate) == Some("icns"))
1461
+ .cloned()
1462
+ .or_else(|| {
1463
+ candidates
1464
+ .iter()
1465
+ .find(|candidate| candidate.exists())
1466
+ .cloned()
1467
+ })
1468
+ } else {
1469
+ candidates
1470
+ .iter()
1471
+ .find(|candidate| candidate.exists())
1472
+ .cloned()
1473
+ };
1474
+ let Some(source) = source else {
1475
+ if let Some(first) = configured_icons.first() {
1476
+ let expected = icon_candidate(root, first, platform)
1477
+ .unwrap_or_else(|| resolve_project_path(root, first));
1478
+ warnings.push(format!(
1479
+ "Configured icon was not found for {platform}: {}.",
1480
+ expected.display()
1481
+ ));
1482
+ }
1483
+ return Ok(None);
1484
+ };
1485
+
1486
+ if platform == "darwin" && path_extension(&source) == Some("icon") {
1487
+ warnings.push(
1488
+ "macOS .icon files are not applied yet; provide an .icns icon for now.".to_string(),
1489
+ );
1490
+ return Ok(None);
1491
+ }
1492
+
1493
+ if platform == "win32" {
1494
+ warnings.push("Windows executable icon embedding is not implemented yet.".to_string());
1495
+ return Ok(None);
1496
+ }
1497
+
1498
+ if platform == "linux" {
1499
+ warnings.push(
1500
+ "Linux executable icons are not embedded; set the BrowserWindow icon in app code."
1501
+ .to_string(),
1502
+ );
1503
+ return Ok(None);
1504
+ }
1505
+
1506
+ let extension = path_extension(&source).unwrap_or("icns");
1507
+ let destination = app_resources_dir.join(format!("{artifact_name}.{extension}"));
1508
+
1509
+ Ok(Some(IconResource {
1510
+ from: utf8_path(source)?,
1511
+ to: utf8_path(destination)?,
1512
+ }))
1513
+ }
1514
+
1515
+ fn path_extension(path: &Path) -> Option<&str> {
1516
+ path.extension().and_then(|extension| extension.to_str())
1517
+ }
1518
+
1519
+ fn icon_candidate(root: &Path, configured_icon: &str, platform: &str) -> Option<PathBuf> {
1520
+ if configured_icon.trim().is_empty() {
1521
+ return None;
1522
+ }
1523
+
1524
+ let path = resolve_project_path(root, configured_icon);
1525
+ if path.extension().is_some() {
1526
+ return Some(path);
1527
+ }
1528
+
1529
+ let extension = match platform {
1530
+ "darwin" => "icns",
1531
+ "win32" => "ico",
1532
+ "linux" => "png",
1533
+ _ => return Some(path),
1534
+ };
1535
+ Some(path.with_extension(extension))
1536
+ }
1537
+
1538
+ fn resolve_extra_resources(
1539
+ root: &Path,
1540
+ extra_resources: &[String],
1541
+ app_resources_dir: &Path,
1542
+ warnings: &mut Vec<String>,
1543
+ ) -> Result<Vec<CopyStep>> {
1544
+ extra_resources
1545
+ .iter()
1546
+ .filter(|resource| !resource.trim().is_empty())
1547
+ .map(|resource| {
1548
+ let source = resolve_project_path(root, resource);
1549
+ if !source.exists() {
1550
+ warnings.push(format!(
1551
+ "Configured extra resource does not exist and packaging will fail: {}.",
1552
+ source.display()
1553
+ ));
1554
+ }
1555
+
1556
+ let file_name = source
1557
+ .file_name()
1558
+ .with_context(|| format!("Extra resource has no file name: {}", source.display()))?
1559
+ .to_owned();
1560
+ Ok(CopyStep {
1561
+ from: utf8_path(source)?,
1562
+ to: utf8_path(app_resources_dir.join(file_name))?,
1563
+ })
1564
+ })
1565
+ .collect()
1566
+ }
1567
+
1568
+ fn resolve_project_path(root: &Path, path: &str) -> PathBuf {
1569
+ let path = Path::new(path);
1570
+ if path.is_absolute() {
1571
+ path.to_path_buf()
1572
+ } else {
1573
+ root.join(path)
1574
+ }
1575
+ }
1576
+
1577
+ fn default_bundle_identifier(artifact_name: &str) -> String {
1578
+ let component = artifact_name
1579
+ .chars()
1580
+ .map(|char| {
1581
+ if char.is_ascii_alphanumeric() || char == '-' {
1582
+ char
1583
+ } else {
1584
+ '.'
1585
+ }
1586
+ })
1587
+ .collect::<String>()
1588
+ .trim_matches(['.', '-'])
1589
+ .to_string();
1590
+ let component = if component.is_empty() {
1591
+ "electron-app".to_string()
1592
+ } else {
1593
+ component
1594
+ };
1595
+ format!("com.electron.{component}")
1596
+ }
1597
+
1598
+ fn copy_project_files(source: &Path, destination: &Path, output_dir: &Path) -> Result<()> {
1599
+ for entry in
1600
+ fs::read_dir(source).with_context(|| format!("Could not read {}", source.display()))?
1601
+ {
1602
+ let entry = entry?;
1603
+ let source_path = entry.path();
1604
+ let file_name = entry.file_name();
1605
+ let file_name = file_name.to_string_lossy();
1606
+
1607
+ if should_skip_project_entry(&source_path, &file_name, output_dir) {
1608
+ continue;
1609
+ }
1610
+
1611
+ let destination_path = destination.join(file_name.as_ref());
1612
+ if source_path.is_dir() {
1613
+ copy_project_files(&source_path, &destination_path, output_dir)?;
1614
+ } else {
1615
+ if let Some(parent) = destination_path.parent() {
1616
+ fs::create_dir_all(parent)
1617
+ .with_context(|| format!("Could not create {}", parent.display()))?;
1618
+ }
1619
+ fs::copy(&source_path, &destination_path).with_context(|| {
1620
+ format!(
1621
+ "Could not copy {} to {}",
1622
+ source_path.display(),
1623
+ destination_path.display()
1624
+ )
1625
+ })?;
1626
+ }
1627
+ }
1628
+
1629
+ Ok(())
1630
+ }
1631
+
1632
+ #[derive(Debug)]
1633
+ struct DependencyRequest {
1634
+ name: String,
1635
+ requested_by: Option<PathBuf>,
1636
+ optional: bool,
1637
+ }
1638
+
1639
+ fn copy_runtime_dependencies(
1640
+ root: &Path,
1641
+ app_dir: &Path,
1642
+ snapshot: &ProjectSnapshot,
1643
+ ) -> Result<()> {
1644
+ if !has_runtime_dependencies(snapshot) {
1645
+ return Ok(());
1646
+ }
1647
+
1648
+ let root_node_modules = root.join("node_modules");
1649
+ let app_node_modules = app_dir.join("node_modules");
1650
+ let mut queue = VecDeque::new();
1651
+ let mut copied_paths = BTreeSet::new();
1652
+
1653
+ for name in snapshot.dependencies.keys() {
1654
+ queue.push_back(DependencyRequest {
1655
+ name: name.clone(),
1656
+ requested_by: None,
1657
+ optional: false,
1658
+ });
1659
+ }
1660
+
1661
+ for name in snapshot.optional_dependencies.keys() {
1662
+ queue.push_back(DependencyRequest {
1663
+ name: name.clone(),
1664
+ requested_by: None,
1665
+ optional: true,
1666
+ });
1667
+ }
1668
+
1669
+ while let Some(request) = queue.pop_front() {
1670
+ let Some(package_dir) = resolve_dependency_dir(
1671
+ &root_node_modules,
1672
+ request.requested_by.as_deref(),
1673
+ &request.name,
1674
+ ) else {
1675
+ if request.optional {
1676
+ continue;
1677
+ }
1678
+
1679
+ bail!(
1680
+ "Runtime dependency '{}' is not installed. Run your package manager install first.",
1681
+ request.name
1682
+ );
1683
+ };
1684
+
1685
+ let canonical_package_dir = package_dir
1686
+ .canonicalize()
1687
+ .with_context(|| format!("Could not resolve {}", package_dir.display()))?;
1688
+ let canonical_root_node_modules = root_node_modules
1689
+ .canonicalize()
1690
+ .with_context(|| format!("Could not resolve {}", root_node_modules.display()))?;
1691
+ if !copied_paths.insert(canonical_package_dir.clone()) {
1692
+ continue;
1693
+ }
1694
+
1695
+ let relative_path = canonical_package_dir
1696
+ .strip_prefix(&canonical_root_node_modules)
1697
+ .with_context(|| {
1698
+ format!(
1699
+ "Could not make dependency {} relative to {}",
1700
+ canonical_package_dir.display(),
1701
+ canonical_root_node_modules.display()
1702
+ )
1703
+ })?;
1704
+ let destination = app_node_modules.join(relative_path);
1705
+ copy_recursively(&canonical_package_dir, &destination).with_context(|| {
1706
+ format!(
1707
+ "Could not copy runtime dependency {} to {}",
1708
+ canonical_package_dir.display(),
1709
+ destination.display()
1710
+ )
1711
+ })?;
1712
+
1713
+ let package_json = read_dependency_package_json(&canonical_package_dir)?;
1714
+ for name in string_map(package_json.get("dependencies")).keys() {
1715
+ queue.push_back(DependencyRequest {
1716
+ name: name.clone(),
1717
+ requested_by: Some(canonical_package_dir.clone()),
1718
+ optional: false,
1719
+ });
1720
+ }
1721
+ for name in string_map(package_json.get("optionalDependencies")).keys() {
1722
+ queue.push_back(DependencyRequest {
1723
+ name: name.clone(),
1724
+ requested_by: Some(canonical_package_dir.clone()),
1725
+ optional: true,
1726
+ });
1727
+ }
1728
+ }
1729
+
1730
+ Ok(())
1731
+ }
1732
+
1733
+ fn apply_package_metadata(report: &PackageReport) -> Result<()> {
1734
+ if report.platform == "darwin" {
1735
+ apply_macos_metadata(report)?;
1736
+ }
1737
+
1738
+ Ok(())
1739
+ }
1740
+
1741
+ fn apply_macos_metadata(report: &PackageReport) -> Result<()> {
1742
+ let bundle_dir = Path::new(report.bundle_dir.as_str());
1743
+ let info_plist_path = bundle_dir.join("Contents/Info.plist");
1744
+ let mut dictionary = if info_plist_path.exists() {
1745
+ match PlistValue::from_file(&info_plist_path)
1746
+ .with_context(|| format!("Could not read {}", info_plist_path.display()))?
1747
+ {
1748
+ PlistValue::Dictionary(dictionary) => dictionary,
1749
+ _ => bail!("{} is not a plist dictionary", info_plist_path.display()),
1750
+ }
1751
+ } else {
1752
+ PlistDictionary::new()
1753
+ };
1754
+
1755
+ set_plist_string(&mut dictionary, "CFBundleName", &report.app_name);
1756
+ set_plist_string(&mut dictionary, "CFBundleDisplayName", &report.app_name);
1757
+ set_plist_string(
1758
+ &mut dictionary,
1759
+ "CFBundleExecutable",
1760
+ &report.executable_name,
1761
+ );
1762
+ set_plist_string(
1763
+ &mut dictionary,
1764
+ "CFBundleIdentifier",
1765
+ &report.metadata.bundle_identifier,
1766
+ );
1767
+ set_plist_string(&mut dictionary, "CFBundlePackageType", "APPL");
1768
+
1769
+ if let Some(version) = &report.metadata.app_version {
1770
+ set_plist_string(&mut dictionary, "CFBundleShortVersionString", version);
1771
+ }
1772
+ if let Some(version) = &report.metadata.build_version {
1773
+ set_plist_string(&mut dictionary, "CFBundleVersion", version);
1774
+ }
1775
+ if let Some(category) = &report.metadata.app_category_type {
1776
+ set_plist_string(&mut dictionary, "LSApplicationCategoryType", category);
1777
+ }
1778
+ if let Some(copyright) = &report.metadata.app_copyright {
1779
+ set_plist_string(&mut dictionary, "NSHumanReadableCopyright", copyright);
1780
+ }
1781
+ if let Some(icon) = &report.metadata.icon {
1782
+ let icon_name = Path::new(icon.to.as_str())
1783
+ .file_name()
1784
+ .and_then(|file_name| file_name.to_str())
1785
+ .context("Icon destination has no file name")?;
1786
+ set_plist_string(&mut dictionary, "CFBundleIconFile", icon_name);
1787
+ }
1788
+ if report.metadata.darwin_dark_mode_support {
1789
+ dictionary.insert(
1790
+ "NSRequiresAquaSystemAppearance".to_string(),
1791
+ PlistValue::Boolean(false),
1792
+ );
1793
+ }
1794
+
1795
+ if let Some(parent) = info_plist_path.parent() {
1796
+ fs::create_dir_all(parent)
1797
+ .with_context(|| format!("Could not create {}", parent.display()))?;
1798
+ }
1799
+ PlistValue::Dictionary(dictionary)
1800
+ .to_file_xml(&info_plist_path)
1801
+ .with_context(|| format!("Could not write {}", info_plist_path.display()))?;
1802
+
1803
+ Ok(())
1804
+ }
1805
+
1806
+ fn set_plist_string(dictionary: &mut PlistDictionary, key: &str, value: &str) {
1807
+ dictionary.insert(key.to_string(), PlistValue::String(value.to_string()));
1808
+ }
1809
+
1810
+ fn copy_package_resources(report: &PackageReport) -> Result<()> {
1811
+ if let Some(icon) = &report.metadata.icon {
1812
+ copy_recursively(Path::new(icon.from.as_str()), Path::new(icon.to.as_str()))
1813
+ .with_context(|| format!("Could not copy icon to {}", icon.to))?;
1814
+ }
1815
+
1816
+ for resource in &report.metadata.extra_resources {
1817
+ copy_recursively(
1818
+ Path::new(resource.from.as_str()),
1819
+ Path::new(resource.to.as_str()),
1820
+ )
1821
+ .with_context(|| format!("Could not copy extra resource to {}", resource.to))?;
1822
+ }
1823
+
1824
+ Ok(())
1825
+ }
1826
+
1827
+ fn runtime_dependency_warnings(root: &Path, snapshot: &ProjectSnapshot) -> Vec<String> {
1828
+ let mut warnings = Vec::new();
1829
+ let root_node_modules = root.join("node_modules");
1830
+
1831
+ for name in snapshot.dependencies.keys() {
1832
+ if resolve_dependency_dir(&root_node_modules, None, name).is_none() {
1833
+ warnings.push(format!(
1834
+ "Runtime dependency is not installed and packaging will fail: {name}."
1835
+ ));
1836
+ }
1837
+ }
1838
+
1839
+ for name in snapshot.optional_dependencies.keys() {
1840
+ if resolve_dependency_dir(&root_node_modules, None, name).is_none() {
1841
+ warnings.push(format!(
1842
+ "Optional runtime dependency is not installed and will be skipped: {name}."
1843
+ ));
1844
+ }
1845
+ }
1846
+
1847
+ warnings
1848
+ }
1849
+
1850
+ fn resolve_dependency_dir(
1851
+ root_node_modules: &Path,
1852
+ requested_by: Option<&Path>,
1853
+ name: &str,
1854
+ ) -> Option<PathBuf> {
1855
+ let relative_path = dependency_relative_path(name);
1856
+
1857
+ if let Some(requested_by) = requested_by {
1858
+ let nested = requested_by.join("node_modules").join(&relative_path);
1859
+ if nested.exists() {
1860
+ return Some(nested);
1861
+ }
1862
+ }
1863
+
1864
+ let hoisted = root_node_modules.join(relative_path);
1865
+ hoisted.exists().then_some(hoisted)
1866
+ }
1867
+
1868
+ fn dependency_relative_path(name: &str) -> PathBuf {
1869
+ let mut path = PathBuf::new();
1870
+ for part in name.split('/') {
1871
+ if !part.is_empty() {
1872
+ path.push(part);
1873
+ }
1874
+ }
1875
+ path
1876
+ }
1877
+
1878
+ fn read_dependency_package_json(package_dir: &Path) -> Result<JsonValue> {
1879
+ let package_json_path = package_dir.join("package.json");
1880
+ let raw = fs::read_to_string(&package_json_path)
1881
+ .with_context(|| format!("Could not read {}", package_json_path.display()))?;
1882
+ serde_json::from_str::<JsonValue>(&raw)
1883
+ .with_context(|| format!("Could not parse {}", package_json_path.display()))
1884
+ }
1885
+
1886
+ fn string_map(value: Option<&JsonValue>) -> BTreeMap<String, String> {
1887
+ value
1888
+ .and_then(JsonValue::as_object)
1889
+ .map(|object| {
1890
+ object
1891
+ .iter()
1892
+ .filter_map(|(key, value)| {
1893
+ value.as_str().map(|value| (key.clone(), value.to_string()))
1894
+ })
1895
+ .collect()
1896
+ })
1897
+ .unwrap_or_default()
1898
+ }
1899
+
1900
+ fn copy_recursively(source: &Path, destination: &Path) -> Result<()> {
1901
+ if source.is_dir() {
1902
+ fs::create_dir_all(destination)
1903
+ .with_context(|| format!("Could not create {}", destination.display()))?;
1904
+
1905
+ for entry in
1906
+ fs::read_dir(source).with_context(|| format!("Could not read {}", source.display()))?
1907
+ {
1908
+ let entry = entry?;
1909
+ let source_path = entry.path();
1910
+ let destination_path = destination.join(entry.file_name());
1911
+ copy_recursively(&source_path, &destination_path)?;
1912
+ }
1913
+ } else {
1914
+ if let Some(parent) = destination.parent() {
1915
+ fs::create_dir_all(parent)
1916
+ .with_context(|| format!("Could not create {}", parent.display()))?;
1917
+ }
1918
+ fs::copy(source, destination).with_context(|| {
1919
+ format!(
1920
+ "Could not copy {} to {}",
1921
+ source.display(),
1922
+ destination.display()
1923
+ )
1924
+ })?;
1925
+ }
1926
+
1927
+ Ok(())
1928
+ }
1929
+
1930
+ fn should_skip_project_entry(source_path: &Path, file_name: &str, output_dir: &Path) -> bool {
1931
+ if matches!(file_name, ".git" | "node_modules" | "target") {
1932
+ return true;
1933
+ }
1934
+
1935
+ same_path_or_inside(source_path, output_dir)
1936
+ }
1937
+
1938
+ fn same_path_or_inside(path: &Path, parent: &Path) -> bool {
1939
+ match (path.canonicalize(), parent.canonicalize()) {
1940
+ (Ok(path), Ok(parent)) => path == parent || path.starts_with(parent),
1941
+ _ => false,
1942
+ }
1943
+ }
1944
+
1945
+ fn rename_runtime_executable(
1946
+ bundle_dir: &Path,
1947
+ executable_name: &str,
1948
+ platform: &str,
1949
+ ) -> Result<()> {
1950
+ let current = if platform == "darwin" {
1951
+ bundle_dir.join("Contents/MacOS/Electron")
1952
+ } else if platform == "win32" {
1953
+ bundle_dir.join("electron.exe")
1954
+ } else {
1955
+ bundle_dir.join("electron")
1956
+ };
1957
+ let target = if platform == "darwin" {
1958
+ bundle_dir.join("Contents/MacOS").join(executable_name)
1959
+ } else {
1960
+ bundle_dir.join(executable_name)
1961
+ };
1962
+
1963
+ if current.exists() && current != target {
1964
+ fs::rename(&current, &target).with_context(|| {
1965
+ format!(
1966
+ "Could not rename {} to {}",
1967
+ current.display(),
1968
+ target.display()
1969
+ )
1970
+ })?;
1971
+ }
1972
+
1973
+ Ok(())
1974
+ }
1975
+
1976
+ fn resolve_output_dir(root: &Path, out_dir: &Path) -> PathBuf {
1977
+ if out_dir.is_absolute() {
1978
+ out_dir.to_path_buf()
1979
+ } else {
1980
+ root.join(out_dir)
1981
+ }
1982
+ }
1983
+
1984
+ fn electron_source(electron_dist: &Path, platform: &str) -> PathBuf {
1985
+ if platform == "darwin" {
1986
+ electron_dist.join("Electron.app")
1987
+ } else {
1988
+ electron_dist.to_path_buf()
1989
+ }
1990
+ }
1991
+
1992
+ fn bundle_dir(package_root: &Path, app_name: &str, platform: &str) -> PathBuf {
1993
+ if platform == "darwin" {
1994
+ package_root.join(format!("{app_name}.app"))
1995
+ } else {
1996
+ package_root.to_path_buf()
1997
+ }
1998
+ }
1999
+
2000
+ fn package_root(bundle_dir: &Path, platform: &str) -> PathBuf {
2001
+ if platform == "darwin" {
2002
+ bundle_dir
2003
+ .parent()
2004
+ .expect("macOS bundle should have package parent")
2005
+ .to_path_buf()
2006
+ } else {
2007
+ bundle_dir.to_path_buf()
2008
+ }
2009
+ }
2010
+
2011
+ fn app_resources_dir(bundle_dir: &Path, platform: &str) -> PathBuf {
2012
+ if platform == "darwin" {
2013
+ bundle_dir.join("Contents/Resources")
2014
+ } else {
2015
+ bundle_dir.join("resources")
2016
+ }
2017
+ }
2018
+
2019
+ fn executable_name(app_name: &str, platform: &str) -> String {
2020
+ let mut name = sanitize_artifact_name(app_name);
2021
+ if platform == "win32" {
2022
+ name.push_str(".exe");
2023
+ }
2024
+ name
2025
+ }
2026
+
2027
+ fn clean_app_name(name: &str) -> String {
2028
+ let cleaned = name
2029
+ .chars()
2030
+ .map(|char| {
2031
+ if char.is_ascii_alphanumeric() || matches!(char, ' ' | '-' | '_' | '.') {
2032
+ char
2033
+ } else {
2034
+ '-'
2035
+ }
2036
+ })
2037
+ .collect::<String>()
2038
+ .trim_matches([' ', '-', '.', '_'])
2039
+ .to_string();
2040
+
2041
+ if cleaned.is_empty() {
2042
+ "electron-app".to_string()
2043
+ } else {
2044
+ cleaned
2045
+ }
2046
+ }
2047
+
2048
+ fn sanitize_artifact_name(name: &str) -> String {
2049
+ let sanitized = name
2050
+ .to_ascii_lowercase()
2051
+ .chars()
2052
+ .map(|char| {
2053
+ if char.is_ascii_alphanumeric() || matches!(char, '-' | '_' | '.') {
2054
+ char
2055
+ } else {
2056
+ '-'
2057
+ }
2058
+ })
2059
+ .collect::<String>()
2060
+ .trim_matches(['-', '.', '_'])
2061
+ .to_string();
2062
+
2063
+ if sanitized.is_empty() {
2064
+ "electron-app".to_string()
2065
+ } else {
2066
+ sanitized
2067
+ }
2068
+ }
2069
+
2070
+ fn has_runtime_dependencies(snapshot: &ProjectSnapshot) -> bool {
2071
+ !snapshot.dependencies.is_empty() || !snapshot.optional_dependencies.is_empty()
2072
+ }
2073
+
2074
+ fn current_platform() -> String {
2075
+ if cfg!(target_os = "macos") {
2076
+ "darwin".to_string()
2077
+ } else if cfg!(target_os = "windows") {
2078
+ "win32".to_string()
2079
+ } else {
2080
+ "linux".to_string()
2081
+ }
2082
+ }
2083
+
2084
+ fn current_arch() -> String {
2085
+ match std::env::consts::ARCH {
2086
+ "aarch64" => "arm64".to_string(),
2087
+ "x86_64" => "x64".to_string(),
2088
+ arch => arch.to_string(),
2089
+ }
2090
+ }
2091
+
2092
+ fn utf8_path(path: PathBuf) -> Result<Utf8PathBuf> {
2093
+ Utf8PathBuf::from_path_buf(path).map_err(|path| {
2094
+ anyhow::anyhow!(
2095
+ "Path contains invalid UTF-8 and cannot be represented in JSON: {}",
2096
+ path.display()
2097
+ )
2098
+ })
2099
+ }
2100
+
2101
+ impl PackageStatus {
2102
+ fn as_str(&self) -> &'static str {
2103
+ match self {
2104
+ PackageStatus::Planned => "planned",
2105
+ PackageStatus::Packaged => "packaged",
2106
+ }
2107
+ }
2108
+ }
2109
+
2110
+ impl PackagerConfig {
2111
+ fn merge(&mut self, other: PackagerConfig) {
2112
+ self.name = other.name.or_else(|| self.name.take());
2113
+ self.executable_name = other
2114
+ .executable_name
2115
+ .or_else(|| self.executable_name.take());
2116
+ self.app_bundle_id = other.app_bundle_id.or_else(|| self.app_bundle_id.take());
2117
+ self.app_category_type = other
2118
+ .app_category_type
2119
+ .or_else(|| self.app_category_type.take());
2120
+ self.app_version = other.app_version.or_else(|| self.app_version.take());
2121
+ self.build_version = other.build_version.or_else(|| self.build_version.take());
2122
+ self.app_copyright = other.app_copyright.or_else(|| self.app_copyright.take());
2123
+ if !other.icon.is_empty() {
2124
+ self.icon = other.icon;
2125
+ }
2126
+ if !other.extra_resource.is_empty() {
2127
+ self.extra_resource = other.extra_resource;
2128
+ }
2129
+ self.darwin_dark_mode_support =
2130
+ other.darwin_dark_mode_support || self.darwin_dark_mode_support;
2131
+ if other.osx_sign.configured {
2132
+ self.osx_sign = other.osx_sign;
2133
+ }
2134
+ if other.osx_notarize.configured {
2135
+ self.osx_notarize = other.osx_notarize;
2136
+ }
2137
+ }
2138
+ }
2139
+
2140
+ impl PackageReport {
2141
+ pub(crate) fn project(&self) -> &ProjectSnapshot {
2142
+ &self.project
2143
+ }
2144
+
2145
+ pub(crate) fn mark_packaged(&mut self) {
2146
+ self.status = PackageStatus::Packaged;
2147
+ }
2148
+
2149
+ pub(crate) fn app_name(&self) -> &str {
2150
+ &self.app_name
2151
+ }
2152
+
2153
+ pub(crate) fn executable_name(&self) -> &str {
2154
+ &self.executable_name
2155
+ }
2156
+
2157
+ pub(crate) fn artifact_stem(&self) -> String {
2158
+ sanitize_artifact_name(&self.app_name)
2159
+ }
2160
+
2161
+ pub(crate) fn platform(&self) -> &str {
2162
+ &self.platform
2163
+ }
2164
+
2165
+ pub(crate) fn arch(&self) -> &str {
2166
+ &self.arch
2167
+ }
2168
+
2169
+ pub(crate) fn output_dir(&self) -> &Utf8PathBuf {
2170
+ &self.output_dir
2171
+ }
2172
+
2173
+ pub(crate) fn bundle_dir(&self) -> &Utf8PathBuf {
2174
+ &self.bundle_dir
2175
+ }
2176
+
2177
+ pub(crate) fn warnings(&self) -> &[String] {
2178
+ &self.warnings
2179
+ }
2180
+ }
2181
+
2182
+ #[cfg(test)]
2183
+ mod tests {
2184
+ use super::*;
2185
+
2186
+ #[test]
2187
+ fn plans_package_output_for_current_platform() {
2188
+ let root = unique_temp_dir("plan");
2189
+ write_package_json(&root);
2190
+ write_fake_electron_dist(&root);
2191
+
2192
+ let args = PackageArgs {
2193
+ cwd: root.clone(),
2194
+ out_dir: PathBuf::from("out"),
2195
+ name: None,
2196
+ platform: None,
2197
+ arch: None,
2198
+ force: false,
2199
+ dry_run: true,
2200
+ json: true,
2201
+ };
2202
+ let snapshot = crate::project::inspect(&root).expect("project should inspect");
2203
+ let report = build_report(snapshot, &args).expect("report should build");
2204
+
2205
+ assert_eq!(report.app_name, "starter-app");
2206
+ assert_eq!(report.platform, current_platform());
2207
+ assert_eq!(report.arch, current_arch());
2208
+ assert!(report.warnings.is_empty());
2209
+
2210
+ let _ = fs::remove_dir_all(root);
2211
+ }
2212
+
2213
+ #[test]
2214
+ fn packages_fake_electron_runtime_and_app_files() {
2215
+ let root = unique_temp_dir("execute");
2216
+ write_package_json(&root);
2217
+ write_app_file(&root);
2218
+ write_fake_electron_dist(&root);
2219
+ fs::create_dir_all(root.join("node_modules/ignored"))
2220
+ .expect("node_modules should be created");
2221
+ fs::write(root.join("node_modules/ignored/file.js"), "")
2222
+ .expect("ignored node module should be written");
2223
+
2224
+ let args = PackageArgs {
2225
+ cwd: root.clone(),
2226
+ out_dir: PathBuf::from("out"),
2227
+ name: Some("Starter App".to_string()),
2228
+ platform: None,
2229
+ arch: None,
2230
+ force: false,
2231
+ dry_run: false,
2232
+ json: false,
2233
+ };
2234
+ let snapshot = crate::project::inspect(&root).expect("project should inspect");
2235
+ let report = build_report(snapshot, &args).expect("report should build");
2236
+
2237
+ execute_package(&report, false).expect("package should succeed");
2238
+
2239
+ let app_dir = Path::new(report.app_resources_dir.as_str()).join("app");
2240
+ assert!(app_dir.join("package.json").exists());
2241
+ assert!(app_dir.join("src/main.js").exists());
2242
+ assert!(!app_dir.join("node_modules").exists());
2243
+
2244
+ if current_platform() == "darwin" {
2245
+ assert!(Path::new(report.bundle_dir.as_str())
2246
+ .join("Contents")
2247
+ .exists());
2248
+ } else {
2249
+ assert!(Path::new(report.bundle_dir.as_str())
2250
+ .join(report.executable_name)
2251
+ .exists());
2252
+ }
2253
+
2254
+ let _ = fs::remove_dir_all(root);
2255
+ }
2256
+
2257
+ #[test]
2258
+ fn packages_runtime_dependency_closure_from_node_modules() {
2259
+ let root = unique_temp_dir("runtime-deps");
2260
+ fs::write(
2261
+ root.join("package.json"),
2262
+ r#"{"name":"starter-app","version":"0.1.0","main":"src/main.js","dependencies":{"dep-a":"1.0.0"},"devDependencies":{"electron":"30.0.0","dev-only":"1.0.0"}}"#,
2263
+ )
2264
+ .expect("package.json should be written");
2265
+ write_app_file(&root);
2266
+ write_fake_electron_dist(&root);
2267
+ write_dependency_package(
2268
+ &root,
2269
+ "dep-a",
2270
+ r#"{"name":"dep-a","version":"1.0.0","dependencies":{"dep-b":"1.0.0"}}"#,
2271
+ );
2272
+ write_dependency_package(&root, "dep-b", r#"{"name":"dep-b","version":"1.0.0"}"#);
2273
+ write_dependency_package(
2274
+ &root,
2275
+ "dev-only",
2276
+ r#"{"name":"dev-only","version":"1.0.0"}"#,
2277
+ );
2278
+
2279
+ let args = PackageArgs {
2280
+ cwd: root.clone(),
2281
+ out_dir: PathBuf::from("out"),
2282
+ name: None,
2283
+ platform: None,
2284
+ arch: None,
2285
+ force: false,
2286
+ dry_run: false,
2287
+ json: false,
2288
+ };
2289
+ let snapshot = crate::project::inspect(&root).expect("project should inspect");
2290
+ let report = build_report(snapshot, &args).expect("report should build");
2291
+
2292
+ assert!(report.warnings.is_empty());
2293
+ execute_package(&report, false).expect("package should succeed");
2294
+
2295
+ let app_node_modules = Path::new(report.app_resources_dir.as_str())
2296
+ .join("app")
2297
+ .join("node_modules");
2298
+ assert!(app_node_modules.join("dep-a/package.json").exists());
2299
+ assert!(app_node_modules.join("dep-b/package.json").exists());
2300
+ assert!(!app_node_modules.join("dev-only").exists());
2301
+
2302
+ let _ = fs::remove_dir_all(root);
2303
+ }
2304
+
2305
+ #[test]
2306
+ fn plans_packager_metadata_from_package_json() {
2307
+ let root = unique_temp_dir("metadata-plan");
2308
+ write_metadata_package_json(&root);
2309
+ write_app_file(&root);
2310
+ write_fake_electron_dist(&root);
2311
+ write_icon_and_resource_files(&root);
2312
+
2313
+ let args = PackageArgs {
2314
+ cwd: root.clone(),
2315
+ out_dir: PathBuf::from("out"),
2316
+ name: None,
2317
+ platform: None,
2318
+ arch: None,
2319
+ force: false,
2320
+ dry_run: true,
2321
+ json: true,
2322
+ };
2323
+ let snapshot = crate::project::inspect(&root).expect("project should inspect");
2324
+ let report = build_report(snapshot, &args).expect("report should build");
2325
+
2326
+ assert_eq!(report.app_name, "Starter Pro");
2327
+ assert_eq!(
2328
+ report.executable_name,
2329
+ executable_name("StarterExec", &report.platform)
2330
+ );
2331
+ assert_eq!(report.metadata.bundle_identifier, "com.example.starter");
2332
+ assert_eq!(report.metadata.app_version.as_deref(), Some("2.3.4"));
2333
+ assert_eq!(report.metadata.build_version.as_deref(), Some("234"));
2334
+ assert_eq!(
2335
+ report.metadata.app_category_type.as_deref(),
2336
+ Some("public.app-category.developer-tools")
2337
+ );
2338
+ assert_eq!(
2339
+ report.metadata.app_copyright.as_deref(),
2340
+ Some("Copyright 2026 Example")
2341
+ );
2342
+ assert_eq!(report.metadata.extra_resources.len(), 1);
2343
+ assert!(report
2344
+ .copy_steps
2345
+ .iter()
2346
+ .any(|step| step.to.as_str().ends_with("config.json")));
2347
+
2348
+ if current_platform() == "darwin" {
2349
+ assert!(report.metadata.icon.is_some());
2350
+ assert!(report.warnings.is_empty());
2351
+ }
2352
+
2353
+ let _ = fs::remove_dir_all(root);
2354
+ }
2355
+
2356
+ #[test]
2357
+ fn plans_packager_metadata_from_forge_config_js() {
2358
+ let root = unique_temp_dir("forge-config-metadata");
2359
+ write_package_json(&root);
2360
+ fs::write(
2361
+ root.join("forge.config.js"),
2362
+ r#"
2363
+ module.exports = {
2364
+ packagerConfig: {
2365
+ name: 'Forge Config App',
2366
+ executableName: 'ForgeExec',
2367
+ appBundleId: 'com.example.forge-config',
2368
+ },
2369
+ };
2370
+ "#,
2371
+ )
2372
+ .expect("forge config should be written");
2373
+ write_app_file(&root);
2374
+ write_fake_electron_dist(&root);
2375
+
2376
+ let args = PackageArgs {
2377
+ cwd: root.clone(),
2378
+ out_dir: PathBuf::from("out"),
2379
+ name: None,
2380
+ platform: None,
2381
+ arch: None,
2382
+ force: false,
2383
+ dry_run: true,
2384
+ json: true,
2385
+ };
2386
+ let snapshot = crate::project::inspect(&root).expect("project should inspect");
2387
+ let report = build_report(snapshot, &args).expect("report should build");
2388
+
2389
+ assert_eq!(report.app_name, "Forge Config App");
2390
+ assert_eq!(
2391
+ report.executable_name,
2392
+ executable_name("ForgeExec", &report.platform)
2393
+ );
2394
+ assert_eq!(
2395
+ report.metadata.bundle_identifier,
2396
+ "com.example.forge-config"
2397
+ );
2398
+
2399
+ let _ = fs::remove_dir_all(root);
2400
+ }
2401
+
2402
+ #[test]
2403
+ fn plans_macos_signing_and_notarization_without_serializing_secrets() {
2404
+ let root = unique_temp_dir("macos-signing-plan");
2405
+ write_package_json(&root);
2406
+ fs::write(root.join("entitlements.plist"), "<plist></plist>")
2407
+ .expect("entitlements should be written");
2408
+ fs::write(root.join("AuthKey_TEST.p8"), "secret api key")
2409
+ .expect("api key should be written");
2410
+ fs::write(
2411
+ root.join("forge.config.js"),
2412
+ r#"
2413
+ module.exports = {
2414
+ packagerConfig: {
2415
+ osxSign: {
2416
+ identity: 'Developer ID Application: Example, Inc. (TEAMID1234)',
2417
+ entitlements: 'entitlements.plist',
2418
+ entitlementsInherit: 'entitlements.plist',
2419
+ hardenedRuntime: true,
2420
+ gatekeeperAssess: false,
2421
+ },
2422
+ osxNotarize: {
2423
+ appleApiKey: 'AuthKey_TEST.p8',
2424
+ appleApiKeyId: 'SECRET_KEY_ID',
2425
+ appleApiIssuer: 'SECRET_ISSUER_ID',
2426
+ },
2427
+ },
2428
+ };
2429
+ "#,
2430
+ )
2431
+ .expect("forge config should be written");
2432
+ write_app_file(&root);
2433
+ write_fake_electron_dist(&root);
2434
+
2435
+ let args = PackageArgs {
2436
+ cwd: root.clone(),
2437
+ out_dir: PathBuf::from("out"),
2438
+ name: None,
2439
+ platform: Some("darwin".to_string()),
2440
+ arch: Some("arm64".to_string()),
2441
+ force: false,
2442
+ dry_run: true,
2443
+ json: true,
2444
+ };
2445
+ let snapshot = crate::project::inspect(&root).expect("project should inspect");
2446
+ let report = build_report(snapshot, &args).expect("report should build");
2447
+
2448
+ assert!(report.signing.macos.sign.configured);
2449
+ assert!(report.signing.macos.sign.enabled);
2450
+ assert!(!report.signing.macos.sign.will_execute);
2451
+ assert_eq!(
2452
+ report.signing.macos.sign.method.as_deref(),
2453
+ Some("certificate-identity")
2454
+ );
2455
+ assert_eq!(
2456
+ report.signing.macos.sign.identity.as_deref(),
2457
+ Some("Developer ID Application: Example, Inc. (TEAMID1234)")
2458
+ );
2459
+ assert_eq!(report.signing.macos.sign.hardened_runtime, Some(true));
2460
+ assert_eq!(report.signing.macos.sign.gatekeeper_assess, Some(false));
2461
+ assert_eq!(report.signing.macos.sign.entitlements.len(), 2);
2462
+ assert!(report.signing.macos.notarize.configured);
2463
+ assert!(!report.signing.macos.notarize.will_execute);
2464
+ assert_eq!(
2465
+ report.signing.macos.notarize.auth_method.as_deref(),
2466
+ Some("app-store-connect-api-key")
2467
+ );
2468
+ assert!(report.signing.macos.notarize.apple_api_key.is_some());
2469
+ assert!(report
2470
+ .warnings
2471
+ .iter()
2472
+ .any(|warning| warning.contains("Rust-native keychain identity signing")));
2473
+ assert!(report
2474
+ .warnings
2475
+ .iter()
2476
+ .any(|warning| warning.contains("p12File Developer ID signing")));
2477
+
2478
+ let json = serde_json::to_string(&report).expect("report should serialize");
2479
+ assert!(!json.contains("SECRET_KEY_ID"));
2480
+ assert!(!json.contains("SECRET_ISSUER_ID"));
2481
+ assert!(!json.contains("secret api key"));
2482
+
2483
+ let _ = fs::remove_dir_all(root);
2484
+ }
2485
+
2486
+ #[test]
2487
+ fn plans_macos_ad_hoc_signing_execution() {
2488
+ let root = unique_temp_dir("macos-ad-hoc-signing-plan");
2489
+ write_package_json(&root);
2490
+ fs::write(
2491
+ root.join("forge.config.js"),
2492
+ r#"
2493
+ module.exports = {
2494
+ packagerConfig: {
2495
+ osxSign: {
2496
+ identity: '-',
2497
+ hardenedRuntime: true,
2498
+ },
2499
+ },
2500
+ };
2501
+ "#,
2502
+ )
2503
+ .expect("forge config should be written");
2504
+ write_app_file(&root);
2505
+ write_fake_electron_dist(&root);
2506
+
2507
+ let args = PackageArgs {
2508
+ cwd: root.clone(),
2509
+ out_dir: PathBuf::from("out"),
2510
+ name: None,
2511
+ platform: Some("darwin".to_string()),
2512
+ arch: Some("arm64".to_string()),
2513
+ force: false,
2514
+ dry_run: true,
2515
+ json: true,
2516
+ };
2517
+ let snapshot = crate::project::inspect(&root).expect("project should inspect");
2518
+ let report = build_report(snapshot, &args).expect("report should build");
2519
+
2520
+ assert!(report.signing.macos.sign.configured);
2521
+ assert!(report.signing.macos.sign.enabled);
2522
+ assert!(report.signing.macos.sign.will_execute);
2523
+ assert_eq!(report.signing.macos.sign.method.as_deref(), Some("ad-hoc"));
2524
+ assert_eq!(report.signing.macos.sign.identity.as_deref(), Some("-"));
2525
+ assert_eq!(report.signing.macos.sign.hardened_runtime, Some(true));
2526
+ assert!(!report.warnings.iter().any(|warning| {
2527
+ warning.contains("Rust-native keychain identity signing")
2528
+ || warning.contains("Rust-native signing is not implemented")
2529
+ }));
2530
+
2531
+ let _ = fs::remove_dir_all(root);
2532
+ }
2533
+
2534
+ #[test]
2535
+ fn plans_macos_p12_signing_without_serializing_password() {
2536
+ let root = unique_temp_dir("macos-p12-signing-plan");
2537
+ write_package_json(&root);
2538
+ fs::write(root.join("developer-id.p12"), "not a real p12")
2539
+ .expect("p12 placeholder should be written");
2540
+ fs::write(
2541
+ root.join("forge.config.js"),
2542
+ r#"
2543
+ module.exports = {
2544
+ packagerConfig: {
2545
+ osxSign: {
2546
+ identity: 'Developer ID Application: Example, Inc. (TEAMID1234)',
2547
+ p12File: 'developer-id.p12',
2548
+ p12Password: 'p12-secret',
2549
+ timestamp: 'http://timestamp.example.test/tsa',
2550
+ hardenedRuntime: true,
2551
+ },
2552
+ },
2553
+ };
2554
+ "#,
2555
+ )
2556
+ .expect("forge config should be written");
2557
+ write_app_file(&root);
2558
+ write_fake_electron_dist(&root);
2559
+
2560
+ let args = PackageArgs {
2561
+ cwd: root.clone(),
2562
+ out_dir: PathBuf::from("out"),
2563
+ name: None,
2564
+ platform: Some("darwin".to_string()),
2565
+ arch: Some("arm64".to_string()),
2566
+ force: false,
2567
+ dry_run: true,
2568
+ json: true,
2569
+ };
2570
+ let snapshot = crate::project::inspect(&root).expect("project should inspect");
2571
+ let report = build_report(snapshot, &args).expect("report should build");
2572
+
2573
+ assert!(report.signing.macos.sign.configured);
2574
+ assert!(report.signing.macos.sign.enabled);
2575
+ assert!(report.signing.macos.sign.will_execute);
2576
+ assert_eq!(
2577
+ report.signing.macos.sign.method.as_deref(),
2578
+ Some("certificate-p12")
2579
+ );
2580
+ assert_eq!(
2581
+ report.signing.macos.sign.p12_password_source.as_deref(),
2582
+ Some("config")
2583
+ );
2584
+ assert_eq!(
2585
+ report.signing.macos.sign.timestamp_url.as_deref(),
2586
+ Some("http://timestamp.example.test/tsa")
2587
+ );
2588
+ assert!(!report.signing.macos.sign.for_notarization);
2589
+ assert!(report.signing.macos.sign.p12_file.is_some());
2590
+ assert!(report
2591
+ .warnings
2592
+ .iter()
2593
+ .any(|warning| { warning.contains("p12File supplies the signing certificate") }));
2594
+
2595
+ let json = serde_json::to_string(&report).expect("report should serialize");
2596
+ assert!(!json.contains("p12-secret"));
2597
+
2598
+ let _ = fs::remove_dir_all(root);
2599
+ }
2600
+
2601
+ #[test]
2602
+ fn plans_macos_p12_signing_for_notarization_with_default_timestamp() {
2603
+ let root = unique_temp_dir("macos-p12-notarization-signing-plan");
2604
+ write_package_json(&root);
2605
+ fs::write(root.join("developer-id.p12"), "not a real p12")
2606
+ .expect("p12 placeholder should be written");
2607
+ fs::write(
2608
+ root.join("forge.config.js"),
2609
+ r#"
2610
+ module.exports = {
2611
+ packagerConfig: {
2612
+ appBundleId: 'com.example.notarized',
2613
+ osxSign: {
2614
+ p12File: 'developer-id.p12',
2615
+ p12PasswordEnv: 'P12_PASSWORD',
2616
+ },
2617
+ osxNotarize: {
2618
+ keychainProfile: 'notary-profile',
2619
+ },
2620
+ },
2621
+ };
2622
+ "#,
2623
+ )
2624
+ .expect("forge config should be written");
2625
+ write_app_file(&root);
2626
+ write_fake_electron_dist(&root);
2627
+
2628
+ let args = PackageArgs {
2629
+ cwd: root.clone(),
2630
+ out_dir: PathBuf::from("out"),
2631
+ name: None,
2632
+ platform: Some("darwin".to_string()),
2633
+ arch: Some("arm64".to_string()),
2634
+ force: false,
2635
+ dry_run: true,
2636
+ json: true,
2637
+ };
2638
+ let snapshot = crate::project::inspect(&root).expect("project should inspect");
2639
+ let report = build_report(snapshot, &args).expect("report should build");
2640
+
2641
+ assert!(report.signing.macos.sign.will_execute);
2642
+ assert_eq!(
2643
+ report.signing.macos.sign.timestamp_url.as_deref(),
2644
+ Some(APPLE_TIMESTAMP_URL)
2645
+ );
2646
+ assert!(report.signing.macos.sign.for_notarization);
2647
+ assert_eq!(
2648
+ report.signing.macos.notarize.auth_method.as_deref(),
2649
+ Some("keychain-profile")
2650
+ );
2651
+ assert!(!report.signing.macos.notarize.will_execute);
2652
+ assert!(!report
2653
+ .warnings
2654
+ .iter()
2655
+ .any(|warning| warning.contains("ad-hoc signing is not notarizable")));
2656
+ assert!(report
2657
+ .warnings
2658
+ .iter()
2659
+ .any(|warning| warning.contains("requires appleApiKey")));
2660
+
2661
+ let settings = macos_signing_settings(&report).expect("signing settings should build");
2662
+ assert!(settings.for_notarization());
2663
+ assert_eq!(
2664
+ settings.time_stamp_url().map(|url| url.as_str()),
2665
+ Some(APPLE_TIMESTAMP_URL)
2666
+ );
2667
+
2668
+ let json = serde_json::to_string(&report).expect("report should serialize");
2669
+ assert!(!json.contains("P12_PASSWORD="));
2670
+
2671
+ let _ = fs::remove_dir_all(root);
2672
+ }
2673
+
2674
+ #[test]
2675
+ fn plans_macos_native_notarization_execution_with_api_key_auth() {
2676
+ let root = unique_temp_dir("macos-native-notarization-plan");
2677
+ write_package_json(&root);
2678
+ fs::write(root.join("developer-id.p12"), "not a real p12")
2679
+ .expect("p12 placeholder should be written");
2680
+ fs::write(root.join("AuthKey_TEST.p8"), "not a real api key")
2681
+ .expect("api key placeholder should be written");
2682
+ fs::write(
2683
+ root.join("forge.config.js"),
2684
+ r#"
2685
+ module.exports = {
2686
+ packagerConfig: {
2687
+ appBundleId: 'com.example.native-notarized',
2688
+ osxSign: {
2689
+ p12File: 'developer-id.p12',
2690
+ p12Password: 'p12-secret',
2691
+ hardenedRuntime: true,
2692
+ },
2693
+ osxNotarize: {
2694
+ appleApiKey: 'AuthKey_TEST.p8',
2695
+ appleApiKeyId: 'SECRET_KEY_ID',
2696
+ appleApiIssuer: 'SECRET_ISSUER_ID',
2697
+ maxWaitSeconds: 120,
2698
+ },
2699
+ },
2700
+ };
2701
+ "#,
2702
+ )
2703
+ .expect("forge config should be written");
2704
+ write_app_file(&root);
2705
+ write_fake_electron_dist(&root);
2706
+
2707
+ let args = PackageArgs {
2708
+ cwd: root.clone(),
2709
+ out_dir: PathBuf::from("out"),
2710
+ name: None,
2711
+ platform: Some("darwin".to_string()),
2712
+ arch: Some("arm64".to_string()),
2713
+ force: false,
2714
+ dry_run: true,
2715
+ json: true,
2716
+ };
2717
+ let snapshot = crate::project::inspect(&root).expect("project should inspect");
2718
+ let report = build_report(snapshot, &args).expect("report should build");
2719
+
2720
+ assert!(report.signing.macos.sign.for_notarization);
2721
+ assert!(report.signing.macos.notarize.will_execute);
2722
+ assert_eq!(
2723
+ report.signing.macos.notarize.auth_method.as_deref(),
2724
+ Some("app-store-connect-api-key")
2725
+ );
2726
+ assert!(report.signing.macos.notarize.wait);
2727
+ assert_eq!(report.signing.macos.notarize.wait_timeout_seconds, 120);
2728
+ assert!(report.signing.macos.notarize.staple);
2729
+ assert!(!report
2730
+ .warnings
2731
+ .iter()
2732
+ .any(|warning| warning.contains("not implemented")));
2733
+
2734
+ let json = serde_json::to_string(&report).expect("report should serialize");
2735
+ assert!(!json.contains("SECRET_KEY_ID"));
2736
+ assert!(!json.contains("SECRET_ISSUER_ID"));
2737
+ assert!(!json.contains("p12-secret"));
2738
+ assert!(!json.contains("not a real api key"));
2739
+
2740
+ let _ = fs::remove_dir_all(root);
2741
+ }
2742
+
2743
+ #[test]
2744
+ fn warns_when_macos_notarization_timestamp_is_disabled() {
2745
+ let root = unique_temp_dir("macos-p12-notarization-no-timestamp");
2746
+ write_package_json(&root);
2747
+ fs::write(root.join("developer-id.p12"), "not a real p12")
2748
+ .expect("p12 placeholder should be written");
2749
+ fs::write(
2750
+ root.join("forge.config.js"),
2751
+ r#"
2752
+ module.exports = {
2753
+ packagerConfig: {
2754
+ osxSign: {
2755
+ p12File: 'developer-id.p12',
2756
+ timestamp: 'none',
2757
+ },
2758
+ osxNotarize: {
2759
+ keychainProfile: 'notary-profile',
2760
+ },
2761
+ },
2762
+ };
2763
+ "#,
2764
+ )
2765
+ .expect("forge config should be written");
2766
+ write_app_file(&root);
2767
+ write_fake_electron_dist(&root);
2768
+
2769
+ let args = PackageArgs {
2770
+ cwd: root.clone(),
2771
+ out_dir: PathBuf::from("out"),
2772
+ name: None,
2773
+ platform: Some("darwin".to_string()),
2774
+ arch: Some("arm64".to_string()),
2775
+ force: false,
2776
+ dry_run: true,
2777
+ json: true,
2778
+ };
2779
+ let snapshot = crate::project::inspect(&root).expect("project should inspect");
2780
+ let report = build_report(snapshot, &args).expect("report should build");
2781
+
2782
+ assert!(report.signing.macos.sign.will_execute);
2783
+ assert!(report.signing.macos.sign.timestamp_url.is_none());
2784
+ assert!(!report.signing.macos.sign.for_notarization);
2785
+ assert!(report
2786
+ .warnings
2787
+ .iter()
2788
+ .any(|warning| warning.contains("requires a secure timestamp")));
2789
+
2790
+ let _ = fs::remove_dir_all(root);
2791
+ }
2792
+
2793
+ #[test]
2794
+ fn warns_when_macos_notarization_is_configured_without_signing() {
2795
+ let root = unique_temp_dir("notarize-without-sign");
2796
+ write_package_json(&root);
2797
+ fs::write(
2798
+ root.join("forge.config.js"),
2799
+ r#"
2800
+ module.exports = {
2801
+ packagerConfig: {
2802
+ osxSign: false,
2803
+ osxNotarize: {
2804
+ keychainProfile: 'notary-profile',
2805
+ },
2806
+ },
2807
+ };
2808
+ "#,
2809
+ )
2810
+ .expect("forge config should be written");
2811
+ write_app_file(&root);
2812
+ write_fake_electron_dist(&root);
2813
+
2814
+ let args = PackageArgs {
2815
+ cwd: root.clone(),
2816
+ out_dir: PathBuf::from("out"),
2817
+ name: None,
2818
+ platform: Some("darwin".to_string()),
2819
+ arch: Some("arm64".to_string()),
2820
+ force: false,
2821
+ dry_run: true,
2822
+ json: true,
2823
+ };
2824
+ let snapshot = crate::project::inspect(&root).expect("project should inspect");
2825
+ let report = build_report(snapshot, &args).expect("report should build");
2826
+
2827
+ assert!(report.signing.macos.sign.configured);
2828
+ assert!(!report.signing.macos.sign.enabled);
2829
+ assert_eq!(
2830
+ report.signing.macos.notarize.auth_method.as_deref(),
2831
+ Some("keychain-profile")
2832
+ );
2833
+ assert!(report.warnings.iter().any(|warning| {
2834
+ warning.contains("macOS notarization requires packagerConfig.osxSign")
2835
+ }));
2836
+
2837
+ let _ = fs::remove_dir_all(root);
2838
+ }
2839
+
2840
+ #[test]
2841
+ fn packages_macos_info_plist_metadata() {
2842
+ if current_platform() != "darwin" {
2843
+ return;
2844
+ }
2845
+
2846
+ let root = unique_temp_dir("metadata-execute");
2847
+ write_metadata_package_json(&root);
2848
+ write_app_file(&root);
2849
+ write_fake_electron_dist(&root);
2850
+ write_icon_and_resource_files(&root);
2851
+
2852
+ let args = PackageArgs {
2853
+ cwd: root.clone(),
2854
+ out_dir: PathBuf::from("out"),
2855
+ name: None,
2856
+ platform: None,
2857
+ arch: None,
2858
+ force: false,
2859
+ dry_run: false,
2860
+ json: false,
2861
+ };
2862
+ let snapshot = crate::project::inspect(&root).expect("project should inspect");
2863
+ let report = build_report(snapshot, &args).expect("report should build");
2864
+
2865
+ execute_package(&report, false).expect("package should succeed");
2866
+
2867
+ let bundle_dir = Path::new(report.bundle_dir.as_str());
2868
+ assert!(bundle_dir
2869
+ .join("Contents/MacOS")
2870
+ .join(&report.executable_name)
2871
+ .exists());
2872
+ assert!(bundle_dir
2873
+ .join("Contents/Resources/starter-pro.icns")
2874
+ .exists());
2875
+ assert!(bundle_dir.join("Contents/Resources/config.json").exists());
2876
+
2877
+ let plist = PlistValue::from_file(bundle_dir.join("Contents/Info.plist"))
2878
+ .expect("Info.plist should parse");
2879
+ let dictionary = plist
2880
+ .as_dictionary()
2881
+ .expect("Info.plist should be a dictionary");
2882
+
2883
+ assert_eq!(
2884
+ plist_string(dictionary, "CFBundleDisplayName"),
2885
+ Some("Starter Pro")
2886
+ );
2887
+ assert_eq!(
2888
+ plist_string(dictionary, "CFBundleExecutable"),
2889
+ Some(report.executable_name.as_str())
2890
+ );
2891
+ assert_eq!(
2892
+ plist_string(dictionary, "CFBundleIdentifier"),
2893
+ Some("com.example.starter")
2894
+ );
2895
+ assert_eq!(
2896
+ plist_string(dictionary, "CFBundlePackageType"),
2897
+ Some("APPL")
2898
+ );
2899
+ assert_eq!(
2900
+ plist_string(dictionary, "CFBundleShortVersionString"),
2901
+ Some("2.3.4")
2902
+ );
2903
+ assert_eq!(plist_string(dictionary, "CFBundleVersion"), Some("234"));
2904
+ assert_eq!(
2905
+ plist_string(dictionary, "LSApplicationCategoryType"),
2906
+ Some("public.app-category.developer-tools")
2907
+ );
2908
+ assert_eq!(
2909
+ plist_string(dictionary, "CFBundleIconFile"),
2910
+ Some("starter-pro.icns")
2911
+ );
2912
+ assert_eq!(
2913
+ dictionary
2914
+ .get("NSRequiresAquaSystemAppearance")
2915
+ .and_then(PlistValue::as_boolean),
2916
+ Some(false)
2917
+ );
2918
+
2919
+ let _ = fs::remove_dir_all(root);
2920
+ }
2921
+
2922
+ #[test]
2923
+ fn packages_macos_bundle_with_ad_hoc_signature() {
2924
+ if current_platform() != "darwin" {
2925
+ return;
2926
+ }
2927
+
2928
+ let root = unique_temp_dir("macos-ad-hoc-signing-execute");
2929
+ fs::write(
2930
+ root.join("package.json"),
2931
+ 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}}}"#,
2932
+ )
2933
+ .expect("package.json should be written");
2934
+ write_app_file(&root);
2935
+ write_macho_electron_dist(&root);
2936
+
2937
+ let args = PackageArgs {
2938
+ cwd: root.clone(),
2939
+ out_dir: PathBuf::from("out"),
2940
+ name: None,
2941
+ platform: None,
2942
+ arch: None,
2943
+ force: false,
2944
+ dry_run: false,
2945
+ json: false,
2946
+ };
2947
+ let snapshot = crate::project::inspect(&root).expect("project should inspect");
2948
+ let report = build_report(snapshot, &args).expect("report should build");
2949
+
2950
+ assert!(report.signing.macos.sign.will_execute);
2951
+ assert_eq!(report.signing.macos.sign.method.as_deref(), Some("ad-hoc"));
2952
+ assert!(report.warnings.is_empty());
2953
+
2954
+ execute_package(&report, false).expect("package should succeed");
2955
+
2956
+ let bundle_dir = Path::new(report.bundle_dir.as_str());
2957
+ assert!(bundle_dir
2958
+ .join("Contents/_CodeSignature/CodeResources")
2959
+ .exists());
2960
+
2961
+ let executable = bundle_dir
2962
+ .join("Contents/MacOS")
2963
+ .join(&report.executable_name);
2964
+ let executable_data = fs::read(executable).expect("signed executable should read");
2965
+ let macho = apple_codesign::MachFile::parse(&executable_data)
2966
+ .expect("signed executable should parse as Mach-O");
2967
+ assert!(macho.iter_macho().all(|binary| binary
2968
+ .code_signature()
2969
+ .expect("code signature should parse")
2970
+ .is_some()));
2971
+
2972
+ let _ = fs::remove_dir_all(root);
2973
+ }
2974
+
2975
+ #[test]
2976
+ fn packages_macos_bundle_with_p12_certificate_signature() {
2977
+ if current_platform() != "darwin" {
2978
+ return;
2979
+ }
2980
+
2981
+ let Some(p12_fixture) = apple_codesign_test_fixture("apple-codesign-testuser.p12") else {
2982
+ return;
2983
+ };
2984
+
2985
+ let root = unique_temp_dir("macos-p12-signing-execute");
2986
+ fs::copy(&p12_fixture, root.join("developer-id.p12"))
2987
+ .expect("p12 fixture should be copied");
2988
+ fs::write(
2989
+ root.join("package.json"),
2990
+ 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}}}}"#,
2991
+ )
2992
+ .expect("package.json should be written");
2993
+ write_app_file(&root);
2994
+ write_macho_electron_dist(&root);
2995
+
2996
+ let args = PackageArgs {
2997
+ cwd: root.clone(),
2998
+ out_dir: PathBuf::from("out"),
2999
+ name: None,
3000
+ platform: None,
3001
+ arch: None,
3002
+ force: false,
3003
+ dry_run: false,
3004
+ json: false,
3005
+ };
3006
+ let snapshot = crate::project::inspect(&root).expect("project should inspect");
3007
+ let report = build_report(snapshot, &args).expect("report should build");
3008
+
3009
+ assert!(report.signing.macos.sign.will_execute);
3010
+ assert_eq!(
3011
+ report.signing.macos.sign.method.as_deref(),
3012
+ Some("certificate-p12")
3013
+ );
3014
+ assert!(report.warnings.is_empty());
3015
+
3016
+ execute_package(&report, false).expect("package should succeed");
3017
+
3018
+ let executable = Path::new(report.bundle_dir.as_str())
3019
+ .join("Contents/MacOS")
3020
+ .join(&report.executable_name);
3021
+ let executable_data = fs::read(executable).expect("signed executable should read");
3022
+ let macho = apple_codesign::MachFile::parse(&executable_data)
3023
+ .expect("signed executable should parse as Mach-O");
3024
+ assert!(macho.iter_macho().all(|binary| {
3025
+ let signature = binary
3026
+ .code_signature()
3027
+ .expect("code signature should parse")
3028
+ .expect("code signature should exist");
3029
+ signature
3030
+ .signature_data()
3031
+ .expect("CMS signature should parse")
3032
+ .is_some_and(|data| !data.is_empty())
3033
+ }));
3034
+
3035
+ let _ = fs::remove_dir_all(root);
3036
+ }
3037
+
3038
+ #[test]
3039
+ fn missing_required_runtime_dependency_fails() {
3040
+ let root = unique_temp_dir("runtime-deps");
3041
+ fs::write(
3042
+ root.join("package.json"),
3043
+ r#"{"name":"starter-app","version":"0.1.0","main":"src/main.js","dependencies":{"left-pad":"1.3.0"},"devDependencies":{"electron":"30.0.0"}}"#,
3044
+ )
3045
+ .expect("package.json should be written");
3046
+ write_app_file(&root);
3047
+ write_fake_electron_dist(&root);
3048
+
3049
+ let args = PackageArgs {
3050
+ cwd: root.clone(),
3051
+ out_dir: PathBuf::from("out"),
3052
+ name: None,
3053
+ platform: None,
3054
+ arch: None,
3055
+ force: false,
3056
+ dry_run: false,
3057
+ json: false,
3058
+ };
3059
+ let snapshot = crate::project::inspect(&root).expect("project should inspect");
3060
+ let report = build_report(snapshot, &args).expect("report should build");
3061
+
3062
+ assert!(report.warnings.contains(
3063
+ &"Runtime dependency is not installed and packaging will fail: left-pad.".to_string()
3064
+ ));
3065
+ assert!(execute_package(&report, false).is_err());
3066
+
3067
+ let _ = fs::remove_dir_all(root);
3068
+ }
3069
+
3070
+ #[test]
3071
+ fn missing_optional_runtime_dependency_is_skipped() {
3072
+ let root = unique_temp_dir("optional-runtime-deps");
3073
+ fs::write(
3074
+ root.join("package.json"),
3075
+ r#"{"name":"starter-app","version":"0.1.0","main":"src/main.js","optionalDependencies":{"optional-native":"1.0.0"},"devDependencies":{"electron":"30.0.0"}}"#,
3076
+ )
3077
+ .expect("package.json should be written");
3078
+ write_app_file(&root);
3079
+ write_fake_electron_dist(&root);
3080
+
3081
+ let args = PackageArgs {
3082
+ cwd: root.clone(),
3083
+ out_dir: PathBuf::from("out"),
3084
+ name: None,
3085
+ platform: None,
3086
+ arch: None,
3087
+ force: false,
3088
+ dry_run: false,
3089
+ json: false,
3090
+ };
3091
+ let snapshot = crate::project::inspect(&root).expect("project should inspect");
3092
+ let report = build_report(snapshot, &args).expect("report should build");
3093
+
3094
+ assert!(report.warnings.contains(
3095
+ &"Optional runtime dependency is not installed and will be skipped: optional-native."
3096
+ .to_string()
3097
+ ));
3098
+ execute_package(&report, false).expect("optional dependency should be skipped");
3099
+
3100
+ let _ = fs::remove_dir_all(root);
3101
+ }
3102
+
3103
+ #[test]
3104
+ fn cleans_scoped_package_names_for_bundle_paths() {
3105
+ assert_eq!(clean_app_name("@scope/app"), "scope-app");
3106
+ assert_eq!(sanitize_artifact_name("Starter App"), "starter-app");
3107
+ assert_eq!(
3108
+ dependency_relative_path("@scope/app"),
3109
+ PathBuf::from("@scope/app")
3110
+ );
3111
+ }
3112
+
3113
+ fn write_package_json(root: &Path) {
3114
+ fs::write(
3115
+ root.join("package.json"),
3116
+ r#"{"name":"starter-app","version":"0.1.0","main":"src/main.js","devDependencies":{"electron":"30.0.0"}}"#,
3117
+ )
3118
+ .expect("package.json should be written");
3119
+ }
3120
+
3121
+ fn write_metadata_package_json(root: &Path) {
3122
+ fs::write(
3123
+ root.join("package.json"),
3124
+ r#"{
3125
+ "name": "starter-app",
3126
+ "productName": "Starter Pro",
3127
+ "version": "2.3.4",
3128
+ "main": "src/main.js",
3129
+ "devDependencies": {
3130
+ "electron": "30.0.0"
3131
+ },
3132
+ "electronCli": {
3133
+ "packagerConfig": {
3134
+ "executableName": "StarterExec",
3135
+ "appBundleId": "com.example.starter",
3136
+ "appCategoryType": "public.app-category.developer-tools",
3137
+ "buildVersion": "234",
3138
+ "appCopyright": "Copyright 2026 Example",
3139
+ "icon": "assets/starter",
3140
+ "extraResource": "assets/config.json",
3141
+ "darwinDarkModeSupport": true
3142
+ }
3143
+ }
3144
+ }"#,
3145
+ )
3146
+ .expect("package.json should be written");
3147
+ }
3148
+
3149
+ fn write_app_file(root: &Path) {
3150
+ fs::create_dir_all(root.join("src")).expect("src should be created");
3151
+ fs::write(root.join("src/main.js"), "console.log('hello');")
3152
+ .expect("main file should be written");
3153
+ }
3154
+
3155
+ fn write_dependency_package(root: &Path, name: &str, package_json: &str) {
3156
+ let package_dir = root
3157
+ .join("node_modules")
3158
+ .join(dependency_relative_path(name));
3159
+ fs::create_dir_all(&package_dir).expect("dependency package dir should be created");
3160
+ fs::write(package_dir.join("package.json"), package_json)
3161
+ .expect("dependency package.json should be written");
3162
+ fs::write(package_dir.join("index.js"), "module.exports = true;")
3163
+ .expect("dependency index should be written");
3164
+ }
3165
+
3166
+ fn write_icon_and_resource_files(root: &Path) {
3167
+ fs::create_dir_all(root.join("assets")).expect("assets should be created");
3168
+ fs::write(root.join("assets/starter.icns"), b"icns").expect("icon should be written");
3169
+ fs::write(root.join("assets/config.json"), "{}").expect("resource should be written");
3170
+ }
3171
+
3172
+ fn plist_string<'a>(dictionary: &'a PlistDictionary, key: &str) -> Option<&'a str> {
3173
+ dictionary.get(key).and_then(PlistValue::as_string)
3174
+ }
3175
+
3176
+ fn write_fake_electron_dist(root: &Path) {
3177
+ let dist = root.join("node_modules/electron/dist");
3178
+ if current_platform() == "darwin" {
3179
+ let app = dist.join("Electron.app/Contents/MacOS");
3180
+ fs::create_dir_all(&app).expect("fake macOS electron app should be created");
3181
+ fs::write(app.join("Electron"), "").expect("fake macOS binary should be written");
3182
+ } else if current_platform() == "win32" {
3183
+ fs::create_dir_all(&dist).expect("fake electron dist should be created");
3184
+ fs::write(dist.join("electron.exe"), "").expect("fake exe should be written");
3185
+ } else {
3186
+ fs::create_dir_all(&dist).expect("fake electron dist should be created");
3187
+ fs::write(dist.join("electron"), "").expect("fake binary should be written");
3188
+ }
3189
+ }
3190
+
3191
+ fn write_macho_electron_dist(root: &Path) {
3192
+ let app = root.join("node_modules/electron/dist/Electron.app/Contents/MacOS");
3193
+ fs::create_dir_all(&app).expect("macOS Electron app should be created");
3194
+ fs::copy(
3195
+ std::env::current_exe().expect("current test executable should resolve"),
3196
+ app.join("Electron"),
3197
+ )
3198
+ .expect("Mach-O test executable should be copied");
3199
+ }
3200
+
3201
+ fn apple_codesign_test_fixture(file_name: &str) -> Option<PathBuf> {
3202
+ let cargo_home = std::env::var_os("CARGO_HOME")
3203
+ .map(PathBuf::from)
3204
+ .or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".cargo")))?;
3205
+ let registry_src = cargo_home.join("registry/src");
3206
+ for index_dir in fs::read_dir(registry_src).ok()? {
3207
+ let index_dir = index_dir.ok()?;
3208
+ for crate_dir in fs::read_dir(index_dir.path()).ok()? {
3209
+ let crate_dir = crate_dir.ok()?;
3210
+ let file_name_matches = crate_dir
3211
+ .file_name()
3212
+ .to_str()
3213
+ .is_some_and(|name| name.starts_with("apple-codesign-"));
3214
+ if file_name_matches {
3215
+ let candidate = crate_dir.path().join("src").join(file_name);
3216
+ if candidate.exists() {
3217
+ return Some(candidate);
3218
+ }
3219
+ }
3220
+ }
3221
+ }
3222
+
3223
+ None
3224
+ }
3225
+
3226
+ fn unique_temp_dir(label: &str) -> PathBuf {
3227
+ let nanos = std::time::SystemTime::now()
3228
+ .duration_since(std::time::UNIX_EPOCH)
3229
+ .expect("clock should be after epoch")
3230
+ .as_nanos();
3231
+ let path = std::env::temp_dir().join(format!(
3232
+ "electron-cli-package-{label}-{}-{nanos}",
3233
+ std::process::id()
3234
+ ));
3235
+ fs::create_dir_all(&path).expect("temp dir should be created");
3236
+ path
3237
+ }
3238
+ }