electron-cli 0.3.0-alpha.5 → 0.3.0-alpha.7

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.
@@ -1,11 +1,14 @@
1
1
  use std::{
2
+ collections::{BTreeMap, BTreeSet, VecDeque},
2
3
  fs,
3
4
  path::{Path, PathBuf},
4
5
  };
5
6
 
6
7
  use anyhow::{bail, Context, Result};
7
8
  use camino::Utf8PathBuf;
9
+ use plist::{Dictionary as PlistDictionary, Value as PlistValue};
8
10
  use serde::Serialize;
11
+ use serde_json::Value as JsonValue;
9
12
 
10
13
  use crate::{cli::PackageArgs, output, project::ProjectSnapshot};
11
14
 
@@ -14,6 +17,7 @@ pub(crate) struct PackageReport {
14
17
  project: ProjectSnapshot,
15
18
  app_name: String,
16
19
  executable_name: String,
20
+ metadata: PackageMetadata,
17
21
  platform: String,
18
22
  arch: String,
19
23
  electron_dist: Utf8PathBuf,
@@ -33,6 +37,24 @@ struct CopyStep {
33
37
  to: Utf8PathBuf,
34
38
  }
35
39
 
40
+ #[derive(Debug, Serialize)]
41
+ struct PackageMetadata {
42
+ bundle_identifier: String,
43
+ app_version: Option<String>,
44
+ build_version: Option<String>,
45
+ app_category_type: Option<String>,
46
+ app_copyright: Option<String>,
47
+ icon: Option<IconResource>,
48
+ extra_resources: Vec<CopyStep>,
49
+ darwin_dark_mode_support: bool,
50
+ }
51
+
52
+ #[derive(Debug, Serialize)]
53
+ struct IconResource {
54
+ from: Utf8PathBuf,
55
+ to: Utf8PathBuf,
56
+ }
57
+
36
58
  #[derive(Debug, Serialize)]
37
59
  #[serde(rename_all = "kebab-case")]
38
60
  enum PackageStatus {
@@ -40,6 +62,27 @@ enum PackageStatus {
40
62
  Packaged,
41
63
  }
42
64
 
65
+ #[derive(Debug, Default)]
66
+ struct PackageJsonConfig {
67
+ product_name: Option<String>,
68
+ app_version: Option<String>,
69
+ packager: PackagerConfig,
70
+ }
71
+
72
+ #[derive(Debug, Default)]
73
+ struct PackagerConfig {
74
+ name: Option<String>,
75
+ executable_name: Option<String>,
76
+ app_bundle_id: Option<String>,
77
+ app_category_type: Option<String>,
78
+ app_version: Option<String>,
79
+ build_version: Option<String>,
80
+ app_copyright: Option<String>,
81
+ icon: Vec<String>,
82
+ extra_resource: Vec<String>,
83
+ darwin_dark_mode_support: bool,
84
+ }
85
+
43
86
  pub fn run(args: PackageArgs) -> Result<()> {
44
87
  let snapshot = crate::project::inspect(&args.cwd)?;
45
88
  let mut report = build_report(snapshot, &args)?;
@@ -56,16 +99,25 @@ pub fn run(args: PackageArgs) -> Result<()> {
56
99
 
57
100
  pub(crate) fn build_report(snapshot: ProjectSnapshot, args: &PackageArgs) -> Result<PackageReport> {
58
101
  let root = Path::new(snapshot.root.as_str());
102
+ let package_config = read_package_json_config(&snapshot)?;
59
103
  let platform = args.platform.clone().unwrap_or_else(current_platform);
60
104
  let arch = args.arch.clone().unwrap_or_else(current_arch);
61
105
  let app_name = clean_app_name(
62
106
  &args
63
107
  .name
64
108
  .clone()
109
+ .or_else(|| package_config.packager.name.clone())
110
+ .or_else(|| package_config.product_name.clone())
65
111
  .or_else(|| snapshot.name.clone())
66
112
  .unwrap_or_else(|| "electron-app".to_string()),
67
113
  );
68
- let executable_name = executable_name(&app_name, &platform);
114
+ let executable_base = package_config
115
+ .packager
116
+ .executable_name
117
+ .clone()
118
+ .map(|name| clean_app_name(&name))
119
+ .unwrap_or_else(|| app_name.clone());
120
+ let executable_name = executable_name(&executable_base, &platform);
69
121
  let artifact_name = sanitize_artifact_name(&app_name);
70
122
  let output_dir = resolve_output_dir(root, &args.out_dir);
71
123
  let package_root = output_dir.join(format!("{artifact_name}-{platform}-{arch}"));
@@ -73,6 +125,13 @@ pub(crate) fn build_report(snapshot: ProjectSnapshot, args: &PackageArgs) -> Res
73
125
  let app_resources_dir = app_resources_dir(&bundle_dir, &platform);
74
126
  let electron_dist = root.join("node_modules/electron/dist");
75
127
  let electron_source = electron_source(&electron_dist, &platform);
128
+ let (metadata, metadata_warnings) = package_metadata(
129
+ root,
130
+ &package_config,
131
+ &artifact_name,
132
+ &app_resources_dir,
133
+ &platform,
134
+ )?;
76
135
 
77
136
  let mut warnings = Vec::new();
78
137
  if snapshot.package_json.is_none() {
@@ -87,13 +146,6 @@ pub(crate) fn build_report(snapshot: ProjectSnapshot, args: &PackageArgs) -> Res
87
146
  warnings.push("No package.json main field found.".to_string());
88
147
  }
89
148
 
90
- if has_runtime_dependencies(&snapshot) {
91
- warnings.push(
92
- "Packaging production node_modules is not implemented yet; this project declares runtime dependencies."
93
- .to_string(),
94
- );
95
- }
96
-
97
149
  if !electron_source.exists() {
98
150
  warnings.push(format!(
99
151
  "Electron runtime was not found at {}.",
@@ -115,16 +167,38 @@ pub(crate) fn build_report(snapshot: ProjectSnapshot, args: &PackageArgs) -> Res
115
167
  ));
116
168
  }
117
169
 
170
+ warnings.extend(runtime_dependency_warnings(root, &snapshot));
171
+ warnings.extend(metadata_warnings);
172
+
118
173
  let create_dirs = vec![package_root.clone(), app_resources_dir.clone()];
119
- let copy_steps = [
174
+ let mut copy_steps = vec![
120
175
  (electron_source, bundle_dir.clone()),
121
176
  (root.to_path_buf(), app_resources_dir.join("app")),
122
177
  ];
178
+ if has_runtime_dependencies(&snapshot) {
179
+ copy_steps.push((
180
+ root.join("node_modules"),
181
+ app_resources_dir.join("app/node_modules"),
182
+ ));
183
+ }
184
+ if let Some(icon) = &metadata.icon {
185
+ copy_steps.push((
186
+ Path::new(icon.from.as_str()).to_path_buf(),
187
+ Path::new(icon.to.as_str()).to_path_buf(),
188
+ ));
189
+ }
190
+ for resource in &metadata.extra_resources {
191
+ copy_steps.push((
192
+ Path::new(resource.from.as_str()).to_path_buf(),
193
+ Path::new(resource.to.as_str()).to_path_buf(),
194
+ ));
195
+ }
123
196
 
124
197
  Ok(PackageReport {
125
198
  project: snapshot,
126
199
  app_name,
127
200
  executable_name,
201
+ metadata,
128
202
  platform,
129
203
  arch,
130
204
  electron_dist: utf8_path(electron_dist)?,
@@ -159,12 +233,6 @@ pub(crate) fn execute_package(report: &PackageReport, force: bool) -> Result<()>
159
233
  bail!("No electron dependency found. Install Electron before packaging the app.");
160
234
  }
161
235
 
162
- if has_runtime_dependencies(&report.project) {
163
- bail!(
164
- "Packaging production node_modules is not implemented yet. Remove runtime dependencies or wait for dependency pruning support."
165
- );
166
- }
167
-
168
236
  if report.platform != current_platform() {
169
237
  bail!(
170
238
  "Cross-platform packaging is not implemented yet. Requested {}, host is {}.",
@@ -215,6 +283,8 @@ pub(crate) fn execute_package(report: &PackageReport, force: bool) -> Result<()>
215
283
  )
216
284
  })?;
217
285
  rename_runtime_executable(bundle_dir, &report.executable_name, &report.platform)?;
286
+ apply_package_metadata(report)?;
287
+ copy_package_resources(report)?;
218
288
 
219
289
  fs::create_dir_all(&app_dir)
220
290
  .with_context(|| format!("Could not create {}", app_dir.display()))?;
@@ -223,6 +293,11 @@ pub(crate) fn execute_package(report: &PackageReport, force: bool) -> Result<()>
223
293
  &app_dir,
224
294
  Path::new(report.output_dir.as_str()),
225
295
  )?;
296
+ copy_runtime_dependencies(
297
+ Path::new(report.project.root.as_str()),
298
+ &app_dir,
299
+ &report.project,
300
+ )?;
226
301
 
227
302
  Ok(())
228
303
  }
@@ -242,6 +317,10 @@ fn print_report(report: &PackageReport, json: bool) -> Result<()> {
242
317
  }
243
318
  println!(" app name: {}", report.app_name);
244
319
  println!(" executable: {}", report.executable_name);
320
+ println!(" bundle id: {}", report.metadata.bundle_identifier);
321
+ if let Some(version) = &report.metadata.app_version {
322
+ println!(" app version: {version}");
323
+ }
245
324
  println!(" target: {} {}", report.platform, report.arch);
246
325
  println!(" status: {}", report.status.as_str());
247
326
 
@@ -266,6 +345,290 @@ fn print_report(report: &PackageReport, json: bool) -> Result<()> {
266
345
  Ok(())
267
346
  }
268
347
 
348
+ fn read_package_json_config(snapshot: &ProjectSnapshot) -> Result<PackageJsonConfig> {
349
+ let Some(package_json_path) = &snapshot.package_json else {
350
+ return Ok(PackageJsonConfig::default());
351
+ };
352
+
353
+ let package_json_path = Path::new(package_json_path.as_str());
354
+ let raw = fs::read_to_string(package_json_path)
355
+ .with_context(|| format!("Could not read {}", package_json_path.display()))?;
356
+ let package = serde_json::from_str::<JsonValue>(&raw)
357
+ .with_context(|| format!("Could not parse {}", package_json_path.display()))?;
358
+
359
+ let mut packager = PackagerConfig::default();
360
+ if let Some(config) = package
361
+ .get("config")
362
+ .and_then(|config| config.get("forge"))
363
+ .and_then(|forge| forge.get("packagerConfig"))
364
+ {
365
+ packager.merge(parse_packager_config(config));
366
+ }
367
+ if let Some(config) = package.get("electronPackagerConfig") {
368
+ packager.merge(parse_packager_config(config));
369
+ }
370
+ if let Some(config) = package
371
+ .get("electronCli")
372
+ .or_else(|| package.get("electron-cli"))
373
+ .and_then(|config| config.get("packagerConfig"))
374
+ {
375
+ packager.merge(parse_packager_config(config));
376
+ }
377
+
378
+ Ok(PackageJsonConfig {
379
+ product_name: package
380
+ .get("productName")
381
+ .and_then(JsonValue::as_str)
382
+ .map(ToOwned::to_owned),
383
+ app_version: package
384
+ .get("version")
385
+ .and_then(JsonValue::as_str)
386
+ .map(ToOwned::to_owned),
387
+ packager,
388
+ })
389
+ }
390
+
391
+ fn parse_packager_config(value: &JsonValue) -> PackagerConfig {
392
+ PackagerConfig {
393
+ name: string_value(value, "name"),
394
+ executable_name: string_value(value, "executableName"),
395
+ app_bundle_id: string_value(value, "appBundleId"),
396
+ app_category_type: string_value(value, "appCategoryType"),
397
+ app_version: string_value(value, "appVersion"),
398
+ build_version: string_value(value, "buildVersion"),
399
+ app_copyright: string_value(value, "appCopyright"),
400
+ icon: string_list(value.get("icon")),
401
+ extra_resource: string_list(value.get("extraResource")),
402
+ darwin_dark_mode_support: value
403
+ .get("darwinDarkModeSupport")
404
+ .and_then(JsonValue::as_bool)
405
+ .unwrap_or(false),
406
+ }
407
+ }
408
+
409
+ fn string_value(value: &JsonValue, key: &str) -> Option<String> {
410
+ value
411
+ .get(key)
412
+ .and_then(JsonValue::as_str)
413
+ .map(ToOwned::to_owned)
414
+ }
415
+
416
+ fn string_list(value: Option<&JsonValue>) -> Vec<String> {
417
+ match value {
418
+ Some(JsonValue::String(value)) => vec![value.clone()],
419
+ Some(JsonValue::Array(values)) => values
420
+ .iter()
421
+ .filter_map(JsonValue::as_str)
422
+ .map(ToOwned::to_owned)
423
+ .collect(),
424
+ _ => Vec::new(),
425
+ }
426
+ }
427
+
428
+ fn package_metadata(
429
+ root: &Path,
430
+ config: &PackageJsonConfig,
431
+ artifact_name: &str,
432
+ app_resources_dir: &Path,
433
+ platform: &str,
434
+ ) -> Result<(PackageMetadata, Vec<String>)> {
435
+ let mut warnings = Vec::new();
436
+ let icon = resolve_icon_resource(
437
+ root,
438
+ &config.packager.icon,
439
+ artifact_name,
440
+ app_resources_dir,
441
+ platform,
442
+ &mut warnings,
443
+ )?;
444
+ let extra_resources = resolve_extra_resources(
445
+ root,
446
+ &config.packager.extra_resource,
447
+ app_resources_dir,
448
+ &mut warnings,
449
+ )?;
450
+ let app_version = config
451
+ .packager
452
+ .app_version
453
+ .clone()
454
+ .or_else(|| config.app_version.clone());
455
+
456
+ Ok((
457
+ PackageMetadata {
458
+ bundle_identifier: config
459
+ .packager
460
+ .app_bundle_id
461
+ .clone()
462
+ .unwrap_or_else(|| default_bundle_identifier(artifact_name)),
463
+ app_version: app_version.clone(),
464
+ build_version: config
465
+ .packager
466
+ .build_version
467
+ .clone()
468
+ .or_else(|| app_version.clone()),
469
+ app_category_type: config.packager.app_category_type.clone(),
470
+ app_copyright: config.packager.app_copyright.clone(),
471
+ icon,
472
+ extra_resources,
473
+ darwin_dark_mode_support: config.packager.darwin_dark_mode_support,
474
+ },
475
+ warnings,
476
+ ))
477
+ }
478
+
479
+ fn resolve_icon_resource(
480
+ root: &Path,
481
+ configured_icons: &[String],
482
+ artifact_name: &str,
483
+ app_resources_dir: &Path,
484
+ platform: &str,
485
+ warnings: &mut Vec<String>,
486
+ ) -> Result<Option<IconResource>> {
487
+ let candidates = configured_icons
488
+ .iter()
489
+ .filter_map(|icon| icon_candidate(root, icon, platform))
490
+ .collect::<Vec<_>>();
491
+ let source = if platform == "darwin" {
492
+ candidates
493
+ .iter()
494
+ .find(|candidate| candidate.exists() && path_extension(candidate) == Some("icns"))
495
+ .cloned()
496
+ .or_else(|| {
497
+ candidates
498
+ .iter()
499
+ .find(|candidate| candidate.exists())
500
+ .cloned()
501
+ })
502
+ } else {
503
+ candidates
504
+ .iter()
505
+ .find(|candidate| candidate.exists())
506
+ .cloned()
507
+ };
508
+ let Some(source) = source else {
509
+ if let Some(first) = configured_icons.first() {
510
+ let expected = icon_candidate(root, first, platform)
511
+ .unwrap_or_else(|| resolve_project_path(root, first));
512
+ warnings.push(format!(
513
+ "Configured icon was not found for {platform}: {}.",
514
+ expected.display()
515
+ ));
516
+ }
517
+ return Ok(None);
518
+ };
519
+
520
+ if platform == "darwin" && path_extension(&source) == Some("icon") {
521
+ warnings.push(
522
+ "macOS .icon files are not applied yet; provide an .icns icon for now.".to_string(),
523
+ );
524
+ return Ok(None);
525
+ }
526
+
527
+ if platform == "win32" {
528
+ warnings.push("Windows executable icon embedding is not implemented yet.".to_string());
529
+ return Ok(None);
530
+ }
531
+
532
+ if platform == "linux" {
533
+ warnings.push(
534
+ "Linux executable icons are not embedded; set the BrowserWindow icon in app code."
535
+ .to_string(),
536
+ );
537
+ return Ok(None);
538
+ }
539
+
540
+ let extension = path_extension(&source).unwrap_or("icns");
541
+ let destination = app_resources_dir.join(format!("{artifact_name}.{extension}"));
542
+
543
+ Ok(Some(IconResource {
544
+ from: utf8_path(source)?,
545
+ to: utf8_path(destination)?,
546
+ }))
547
+ }
548
+
549
+ fn path_extension(path: &Path) -> Option<&str> {
550
+ path.extension().and_then(|extension| extension.to_str())
551
+ }
552
+
553
+ fn icon_candidate(root: &Path, configured_icon: &str, platform: &str) -> Option<PathBuf> {
554
+ if configured_icon.trim().is_empty() {
555
+ return None;
556
+ }
557
+
558
+ let path = resolve_project_path(root, configured_icon);
559
+ if path.extension().is_some() {
560
+ return Some(path);
561
+ }
562
+
563
+ let extension = match platform {
564
+ "darwin" => "icns",
565
+ "win32" => "ico",
566
+ "linux" => "png",
567
+ _ => return Some(path),
568
+ };
569
+ Some(path.with_extension(extension))
570
+ }
571
+
572
+ fn resolve_extra_resources(
573
+ root: &Path,
574
+ extra_resources: &[String],
575
+ app_resources_dir: &Path,
576
+ warnings: &mut Vec<String>,
577
+ ) -> Result<Vec<CopyStep>> {
578
+ extra_resources
579
+ .iter()
580
+ .filter(|resource| !resource.trim().is_empty())
581
+ .map(|resource| {
582
+ let source = resolve_project_path(root, resource);
583
+ if !source.exists() {
584
+ warnings.push(format!(
585
+ "Configured extra resource does not exist and packaging will fail: {}.",
586
+ source.display()
587
+ ));
588
+ }
589
+
590
+ let file_name = source
591
+ .file_name()
592
+ .with_context(|| format!("Extra resource has no file name: {}", source.display()))?
593
+ .to_owned();
594
+ Ok(CopyStep {
595
+ from: utf8_path(source)?,
596
+ to: utf8_path(app_resources_dir.join(file_name))?,
597
+ })
598
+ })
599
+ .collect()
600
+ }
601
+
602
+ fn resolve_project_path(root: &Path, path: &str) -> PathBuf {
603
+ let path = Path::new(path);
604
+ if path.is_absolute() {
605
+ path.to_path_buf()
606
+ } else {
607
+ root.join(path)
608
+ }
609
+ }
610
+
611
+ fn default_bundle_identifier(artifact_name: &str) -> String {
612
+ let component = artifact_name
613
+ .chars()
614
+ .map(|char| {
615
+ if char.is_ascii_alphanumeric() || char == '-' {
616
+ char
617
+ } else {
618
+ '.'
619
+ }
620
+ })
621
+ .collect::<String>()
622
+ .trim_matches(['.', '-'])
623
+ .to_string();
624
+ let component = if component.is_empty() {
625
+ "electron-app".to_string()
626
+ } else {
627
+ component
628
+ };
629
+ format!("com.electron.{component}")
630
+ }
631
+
269
632
  fn copy_project_files(source: &Path, destination: &Path, output_dir: &Path) -> Result<()> {
270
633
  for entry in
271
634
  fs::read_dir(source).with_context(|| format!("Could not read {}", source.display()))?
@@ -300,6 +663,273 @@ fn copy_project_files(source: &Path, destination: &Path, output_dir: &Path) -> R
300
663
  Ok(())
301
664
  }
302
665
 
666
+ #[derive(Debug)]
667
+ struct DependencyRequest {
668
+ name: String,
669
+ requested_by: Option<PathBuf>,
670
+ optional: bool,
671
+ }
672
+
673
+ fn copy_runtime_dependencies(
674
+ root: &Path,
675
+ app_dir: &Path,
676
+ snapshot: &ProjectSnapshot,
677
+ ) -> Result<()> {
678
+ if !has_runtime_dependencies(snapshot) {
679
+ return Ok(());
680
+ }
681
+
682
+ let root_node_modules = root.join("node_modules");
683
+ let app_node_modules = app_dir.join("node_modules");
684
+ let mut queue = VecDeque::new();
685
+ let mut copied_paths = BTreeSet::new();
686
+
687
+ for name in snapshot.dependencies.keys() {
688
+ queue.push_back(DependencyRequest {
689
+ name: name.clone(),
690
+ requested_by: None,
691
+ optional: false,
692
+ });
693
+ }
694
+
695
+ for name in snapshot.optional_dependencies.keys() {
696
+ queue.push_back(DependencyRequest {
697
+ name: name.clone(),
698
+ requested_by: None,
699
+ optional: true,
700
+ });
701
+ }
702
+
703
+ while let Some(request) = queue.pop_front() {
704
+ let Some(package_dir) = resolve_dependency_dir(
705
+ &root_node_modules,
706
+ request.requested_by.as_deref(),
707
+ &request.name,
708
+ ) else {
709
+ if request.optional {
710
+ continue;
711
+ }
712
+
713
+ bail!(
714
+ "Runtime dependency '{}' is not installed. Run your package manager install first.",
715
+ request.name
716
+ );
717
+ };
718
+
719
+ let canonical_package_dir = package_dir
720
+ .canonicalize()
721
+ .with_context(|| format!("Could not resolve {}", package_dir.display()))?;
722
+ let canonical_root_node_modules = root_node_modules
723
+ .canonicalize()
724
+ .with_context(|| format!("Could not resolve {}", root_node_modules.display()))?;
725
+ if !copied_paths.insert(canonical_package_dir.clone()) {
726
+ continue;
727
+ }
728
+
729
+ let relative_path = canonical_package_dir
730
+ .strip_prefix(&canonical_root_node_modules)
731
+ .with_context(|| {
732
+ format!(
733
+ "Could not make dependency {} relative to {}",
734
+ canonical_package_dir.display(),
735
+ canonical_root_node_modules.display()
736
+ )
737
+ })?;
738
+ let destination = app_node_modules.join(relative_path);
739
+ copy_recursively(&canonical_package_dir, &destination).with_context(|| {
740
+ format!(
741
+ "Could not copy runtime dependency {} to {}",
742
+ canonical_package_dir.display(),
743
+ destination.display()
744
+ )
745
+ })?;
746
+
747
+ let package_json = read_dependency_package_json(&canonical_package_dir)?;
748
+ for name in string_map(package_json.get("dependencies")).keys() {
749
+ queue.push_back(DependencyRequest {
750
+ name: name.clone(),
751
+ requested_by: Some(canonical_package_dir.clone()),
752
+ optional: false,
753
+ });
754
+ }
755
+ for name in string_map(package_json.get("optionalDependencies")).keys() {
756
+ queue.push_back(DependencyRequest {
757
+ name: name.clone(),
758
+ requested_by: Some(canonical_package_dir.clone()),
759
+ optional: true,
760
+ });
761
+ }
762
+ }
763
+
764
+ Ok(())
765
+ }
766
+
767
+ fn apply_package_metadata(report: &PackageReport) -> Result<()> {
768
+ if report.platform == "darwin" {
769
+ apply_macos_metadata(report)?;
770
+ }
771
+
772
+ Ok(())
773
+ }
774
+
775
+ fn apply_macos_metadata(report: &PackageReport) -> Result<()> {
776
+ let bundle_dir = Path::new(report.bundle_dir.as_str());
777
+ let info_plist_path = bundle_dir.join("Contents/Info.plist");
778
+ let mut dictionary = if info_plist_path.exists() {
779
+ match PlistValue::from_file(&info_plist_path)
780
+ .with_context(|| format!("Could not read {}", info_plist_path.display()))?
781
+ {
782
+ PlistValue::Dictionary(dictionary) => dictionary,
783
+ _ => bail!("{} is not a plist dictionary", info_plist_path.display()),
784
+ }
785
+ } else {
786
+ PlistDictionary::new()
787
+ };
788
+
789
+ set_plist_string(&mut dictionary, "CFBundleName", &report.app_name);
790
+ set_plist_string(&mut dictionary, "CFBundleDisplayName", &report.app_name);
791
+ set_plist_string(
792
+ &mut dictionary,
793
+ "CFBundleExecutable",
794
+ &report.executable_name,
795
+ );
796
+ set_plist_string(
797
+ &mut dictionary,
798
+ "CFBundleIdentifier",
799
+ &report.metadata.bundle_identifier,
800
+ );
801
+
802
+ if let Some(version) = &report.metadata.app_version {
803
+ set_plist_string(&mut dictionary, "CFBundleShortVersionString", version);
804
+ }
805
+ if let Some(version) = &report.metadata.build_version {
806
+ set_plist_string(&mut dictionary, "CFBundleVersion", version);
807
+ }
808
+ if let Some(category) = &report.metadata.app_category_type {
809
+ set_plist_string(&mut dictionary, "LSApplicationCategoryType", category);
810
+ }
811
+ if let Some(copyright) = &report.metadata.app_copyright {
812
+ set_plist_string(&mut dictionary, "NSHumanReadableCopyright", copyright);
813
+ }
814
+ if let Some(icon) = &report.metadata.icon {
815
+ let icon_name = Path::new(icon.to.as_str())
816
+ .file_name()
817
+ .and_then(|file_name| file_name.to_str())
818
+ .context("Icon destination has no file name")?;
819
+ set_plist_string(&mut dictionary, "CFBundleIconFile", icon_name);
820
+ }
821
+ if report.metadata.darwin_dark_mode_support {
822
+ dictionary.insert(
823
+ "NSRequiresAquaSystemAppearance".to_string(),
824
+ PlistValue::Boolean(false),
825
+ );
826
+ }
827
+
828
+ if let Some(parent) = info_plist_path.parent() {
829
+ fs::create_dir_all(parent)
830
+ .with_context(|| format!("Could not create {}", parent.display()))?;
831
+ }
832
+ PlistValue::Dictionary(dictionary)
833
+ .to_file_xml(&info_plist_path)
834
+ .with_context(|| format!("Could not write {}", info_plist_path.display()))?;
835
+
836
+ Ok(())
837
+ }
838
+
839
+ fn set_plist_string(dictionary: &mut PlistDictionary, key: &str, value: &str) {
840
+ dictionary.insert(key.to_string(), PlistValue::String(value.to_string()));
841
+ }
842
+
843
+ fn copy_package_resources(report: &PackageReport) -> Result<()> {
844
+ if let Some(icon) = &report.metadata.icon {
845
+ copy_recursively(Path::new(icon.from.as_str()), Path::new(icon.to.as_str()))
846
+ .with_context(|| format!("Could not copy icon to {}", icon.to))?;
847
+ }
848
+
849
+ for resource in &report.metadata.extra_resources {
850
+ copy_recursively(
851
+ Path::new(resource.from.as_str()),
852
+ Path::new(resource.to.as_str()),
853
+ )
854
+ .with_context(|| format!("Could not copy extra resource to {}", resource.to))?;
855
+ }
856
+
857
+ Ok(())
858
+ }
859
+
860
+ fn runtime_dependency_warnings(root: &Path, snapshot: &ProjectSnapshot) -> Vec<String> {
861
+ let mut warnings = Vec::new();
862
+ let root_node_modules = root.join("node_modules");
863
+
864
+ for name in snapshot.dependencies.keys() {
865
+ if resolve_dependency_dir(&root_node_modules, None, name).is_none() {
866
+ warnings.push(format!(
867
+ "Runtime dependency is not installed and packaging will fail: {name}."
868
+ ));
869
+ }
870
+ }
871
+
872
+ for name in snapshot.optional_dependencies.keys() {
873
+ if resolve_dependency_dir(&root_node_modules, None, name).is_none() {
874
+ warnings.push(format!(
875
+ "Optional runtime dependency is not installed and will be skipped: {name}."
876
+ ));
877
+ }
878
+ }
879
+
880
+ warnings
881
+ }
882
+
883
+ fn resolve_dependency_dir(
884
+ root_node_modules: &Path,
885
+ requested_by: Option<&Path>,
886
+ name: &str,
887
+ ) -> Option<PathBuf> {
888
+ let relative_path = dependency_relative_path(name);
889
+
890
+ if let Some(requested_by) = requested_by {
891
+ let nested = requested_by.join("node_modules").join(&relative_path);
892
+ if nested.exists() {
893
+ return Some(nested);
894
+ }
895
+ }
896
+
897
+ let hoisted = root_node_modules.join(relative_path);
898
+ hoisted.exists().then_some(hoisted)
899
+ }
900
+
901
+ fn dependency_relative_path(name: &str) -> PathBuf {
902
+ let mut path = PathBuf::new();
903
+ for part in name.split('/') {
904
+ if !part.is_empty() {
905
+ path.push(part);
906
+ }
907
+ }
908
+ path
909
+ }
910
+
911
+ fn read_dependency_package_json(package_dir: &Path) -> Result<JsonValue> {
912
+ let package_json_path = package_dir.join("package.json");
913
+ let raw = fs::read_to_string(&package_json_path)
914
+ .with_context(|| format!("Could not read {}", package_json_path.display()))?;
915
+ serde_json::from_str::<JsonValue>(&raw)
916
+ .with_context(|| format!("Could not parse {}", package_json_path.display()))
917
+ }
918
+
919
+ fn string_map(value: Option<&JsonValue>) -> BTreeMap<String, String> {
920
+ value
921
+ .and_then(JsonValue::as_object)
922
+ .map(|object| {
923
+ object
924
+ .iter()
925
+ .filter_map(|(key, value)| {
926
+ value.as_str().map(|value| (key.clone(), value.to_string()))
927
+ })
928
+ .collect()
929
+ })
930
+ .unwrap_or_default()
931
+ }
932
+
303
933
  fn copy_recursively(source: &Path, destination: &Path) -> Result<()> {
304
934
  if source.is_dir() {
305
935
  fs::create_dir_all(destination)
@@ -350,16 +980,18 @@ fn rename_runtime_executable(
350
980
  executable_name: &str,
351
981
  platform: &str,
352
982
  ) -> Result<()> {
353
- if platform == "darwin" {
354
- return Ok(());
355
- }
356
-
357
- let current = if platform == "win32" {
983
+ let current = if platform == "darwin" {
984
+ bundle_dir.join("Contents/MacOS/Electron")
985
+ } else if platform == "win32" {
358
986
  bundle_dir.join("electron.exe")
359
987
  } else {
360
988
  bundle_dir.join("electron")
361
989
  };
362
- let target = bundle_dir.join(executable_name);
990
+ let target = if platform == "darwin" {
991
+ bundle_dir.join("Contents/MacOS").join(executable_name)
992
+ } else {
993
+ bundle_dir.join(executable_name)
994
+ };
363
995
 
364
996
  if current.exists() && current != target {
365
997
  fs::rename(&current, &target).with_context(|| {
@@ -508,6 +1140,30 @@ impl PackageStatus {
508
1140
  }
509
1141
  }
510
1142
 
1143
+ impl PackagerConfig {
1144
+ fn merge(&mut self, other: PackagerConfig) {
1145
+ self.name = other.name.or_else(|| self.name.take());
1146
+ self.executable_name = other
1147
+ .executable_name
1148
+ .or_else(|| self.executable_name.take());
1149
+ self.app_bundle_id = other.app_bundle_id.or_else(|| self.app_bundle_id.take());
1150
+ self.app_category_type = other
1151
+ .app_category_type
1152
+ .or_else(|| self.app_category_type.take());
1153
+ self.app_version = other.app_version.or_else(|| self.app_version.take());
1154
+ self.build_version = other.build_version.or_else(|| self.build_version.take());
1155
+ self.app_copyright = other.app_copyright.or_else(|| self.app_copyright.take());
1156
+ if !other.icon.is_empty() {
1157
+ self.icon = other.icon;
1158
+ }
1159
+ if !other.extra_resource.is_empty() {
1160
+ self.extra_resource = other.extra_resource;
1161
+ }
1162
+ self.darwin_dark_mode_support =
1163
+ other.darwin_dark_mode_support || self.darwin_dark_mode_support;
1164
+ }
1165
+ }
1166
+
511
1167
  impl PackageReport {
512
1168
  pub(crate) fn project(&self) -> &ProjectSnapshot {
513
1169
  &self.project
@@ -622,15 +1278,26 @@ mod tests {
622
1278
  }
623
1279
 
624
1280
  #[test]
625
- fn refuses_runtime_dependencies_until_pruning_exists() {
1281
+ fn packages_runtime_dependency_closure_from_node_modules() {
626
1282
  let root = unique_temp_dir("runtime-deps");
627
1283
  fs::write(
628
1284
  root.join("package.json"),
629
- r#"{"name":"starter-app","version":"0.1.0","main":"src/main.js","dependencies":{"left-pad":"1.3.0"},"devDependencies":{"electron":"30.0.0"}}"#,
1285
+ 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"}}"#,
630
1286
  )
631
1287
  .expect("package.json should be written");
632
1288
  write_app_file(&root);
633
1289
  write_fake_electron_dist(&root);
1290
+ write_dependency_package(
1291
+ &root,
1292
+ "dep-a",
1293
+ r#"{"name":"dep-a","version":"1.0.0","dependencies":{"dep-b":"1.0.0"}}"#,
1294
+ );
1295
+ write_dependency_package(&root, "dep-b", r#"{"name":"dep-b","version":"1.0.0"}"#);
1296
+ write_dependency_package(
1297
+ &root,
1298
+ "dev-only",
1299
+ r#"{"name":"dev-only","version":"1.0.0"}"#,
1300
+ );
634
1301
 
635
1302
  let args = PackageArgs {
636
1303
  cwd: root.clone(),
@@ -645,18 +1312,221 @@ mod tests {
645
1312
  let snapshot = crate::project::inspect(&root).expect("project should inspect");
646
1313
  let report = build_report(snapshot, &args).expect("report should build");
647
1314
 
1315
+ assert!(report.warnings.is_empty());
1316
+ execute_package(&report, false).expect("package should succeed");
1317
+
1318
+ let app_node_modules = Path::new(report.app_resources_dir.as_str())
1319
+ .join("app")
1320
+ .join("node_modules");
1321
+ assert!(app_node_modules.join("dep-a/package.json").exists());
1322
+ assert!(app_node_modules.join("dep-b/package.json").exists());
1323
+ assert!(!app_node_modules.join("dev-only").exists());
1324
+
1325
+ let _ = fs::remove_dir_all(root);
1326
+ }
1327
+
1328
+ #[test]
1329
+ fn plans_packager_metadata_from_package_json() {
1330
+ let root = unique_temp_dir("metadata-plan");
1331
+ write_metadata_package_json(&root);
1332
+ write_app_file(&root);
1333
+ write_fake_electron_dist(&root);
1334
+ write_icon_and_resource_files(&root);
1335
+
1336
+ let args = PackageArgs {
1337
+ cwd: root.clone(),
1338
+ out_dir: PathBuf::from("out"),
1339
+ name: None,
1340
+ platform: None,
1341
+ arch: None,
1342
+ force: false,
1343
+ dry_run: true,
1344
+ json: true,
1345
+ };
1346
+ let snapshot = crate::project::inspect(&root).expect("project should inspect");
1347
+ let report = build_report(snapshot, &args).expect("report should build");
1348
+
1349
+ assert_eq!(report.app_name, "Starter Pro");
1350
+ assert_eq!(
1351
+ report.executable_name,
1352
+ executable_name("StarterExec", &report.platform)
1353
+ );
1354
+ assert_eq!(report.metadata.bundle_identifier, "com.example.starter");
1355
+ assert_eq!(report.metadata.app_version.as_deref(), Some("2.3.4"));
1356
+ assert_eq!(report.metadata.build_version.as_deref(), Some("234"));
1357
+ assert_eq!(
1358
+ report.metadata.app_category_type.as_deref(),
1359
+ Some("public.app-category.developer-tools")
1360
+ );
1361
+ assert_eq!(
1362
+ report.metadata.app_copyright.as_deref(),
1363
+ Some("Copyright 2026 Example")
1364
+ );
1365
+ assert_eq!(report.metadata.extra_resources.len(), 1);
648
1366
  assert!(report
649
- .warnings
650
- .contains(&"Packaging production node_modules is not implemented yet; this project declares runtime dependencies.".to_string()));
1367
+ .copy_steps
1368
+ .iter()
1369
+ .any(|step| step.to.as_str().ends_with("config.json")));
1370
+
1371
+ if current_platform() == "darwin" {
1372
+ assert!(report.metadata.icon.is_some());
1373
+ assert!(report.warnings.is_empty());
1374
+ }
1375
+
1376
+ let _ = fs::remove_dir_all(root);
1377
+ }
1378
+
1379
+ #[test]
1380
+ fn packages_macos_info_plist_metadata() {
1381
+ if current_platform() != "darwin" {
1382
+ return;
1383
+ }
1384
+
1385
+ let root = unique_temp_dir("metadata-execute");
1386
+ write_metadata_package_json(&root);
1387
+ write_app_file(&root);
1388
+ write_fake_electron_dist(&root);
1389
+ write_icon_and_resource_files(&root);
1390
+
1391
+ let args = PackageArgs {
1392
+ cwd: root.clone(),
1393
+ out_dir: PathBuf::from("out"),
1394
+ name: None,
1395
+ platform: None,
1396
+ arch: None,
1397
+ force: false,
1398
+ dry_run: false,
1399
+ json: false,
1400
+ };
1401
+ let snapshot = crate::project::inspect(&root).expect("project should inspect");
1402
+ let report = build_report(snapshot, &args).expect("report should build");
1403
+
1404
+ execute_package(&report, false).expect("package should succeed");
1405
+
1406
+ let bundle_dir = Path::new(report.bundle_dir.as_str());
1407
+ assert!(bundle_dir
1408
+ .join("Contents/MacOS")
1409
+ .join(&report.executable_name)
1410
+ .exists());
1411
+ assert!(bundle_dir
1412
+ .join("Contents/Resources/starter-pro.icns")
1413
+ .exists());
1414
+ assert!(bundle_dir.join("Contents/Resources/config.json").exists());
1415
+
1416
+ let plist = PlistValue::from_file(bundle_dir.join("Contents/Info.plist"))
1417
+ .expect("Info.plist should parse");
1418
+ let dictionary = plist
1419
+ .as_dictionary()
1420
+ .expect("Info.plist should be a dictionary");
1421
+
1422
+ assert_eq!(
1423
+ plist_string(dictionary, "CFBundleDisplayName"),
1424
+ Some("Starter Pro")
1425
+ );
1426
+ assert_eq!(
1427
+ plist_string(dictionary, "CFBundleExecutable"),
1428
+ Some(report.executable_name.as_str())
1429
+ );
1430
+ assert_eq!(
1431
+ plist_string(dictionary, "CFBundleIdentifier"),
1432
+ Some("com.example.starter")
1433
+ );
1434
+ assert_eq!(
1435
+ plist_string(dictionary, "CFBundleShortVersionString"),
1436
+ Some("2.3.4")
1437
+ );
1438
+ assert_eq!(plist_string(dictionary, "CFBundleVersion"), Some("234"));
1439
+ assert_eq!(
1440
+ plist_string(dictionary, "LSApplicationCategoryType"),
1441
+ Some("public.app-category.developer-tools")
1442
+ );
1443
+ assert_eq!(
1444
+ plist_string(dictionary, "CFBundleIconFile"),
1445
+ Some("starter-pro.icns")
1446
+ );
1447
+ assert_eq!(
1448
+ dictionary
1449
+ .get("NSRequiresAquaSystemAppearance")
1450
+ .and_then(PlistValue::as_boolean),
1451
+ Some(false)
1452
+ );
1453
+
1454
+ let _ = fs::remove_dir_all(root);
1455
+ }
1456
+
1457
+ #[test]
1458
+ fn missing_required_runtime_dependency_fails() {
1459
+ let root = unique_temp_dir("runtime-deps");
1460
+ fs::write(
1461
+ root.join("package.json"),
1462
+ r#"{"name":"starter-app","version":"0.1.0","main":"src/main.js","dependencies":{"left-pad":"1.3.0"},"devDependencies":{"electron":"30.0.0"}}"#,
1463
+ )
1464
+ .expect("package.json should be written");
1465
+ write_app_file(&root);
1466
+ write_fake_electron_dist(&root);
1467
+
1468
+ let args = PackageArgs {
1469
+ cwd: root.clone(),
1470
+ out_dir: PathBuf::from("out"),
1471
+ name: None,
1472
+ platform: None,
1473
+ arch: None,
1474
+ force: false,
1475
+ dry_run: false,
1476
+ json: false,
1477
+ };
1478
+ let snapshot = crate::project::inspect(&root).expect("project should inspect");
1479
+ let report = build_report(snapshot, &args).expect("report should build");
1480
+
1481
+ assert!(report.warnings.contains(
1482
+ &"Runtime dependency is not installed and packaging will fail: left-pad.".to_string()
1483
+ ));
651
1484
  assert!(execute_package(&report, false).is_err());
652
1485
 
653
1486
  let _ = fs::remove_dir_all(root);
654
1487
  }
655
1488
 
1489
+ #[test]
1490
+ fn missing_optional_runtime_dependency_is_skipped() {
1491
+ let root = unique_temp_dir("optional-runtime-deps");
1492
+ fs::write(
1493
+ root.join("package.json"),
1494
+ r#"{"name":"starter-app","version":"0.1.0","main":"src/main.js","optionalDependencies":{"optional-native":"1.0.0"},"devDependencies":{"electron":"30.0.0"}}"#,
1495
+ )
1496
+ .expect("package.json should be written");
1497
+ write_app_file(&root);
1498
+ write_fake_electron_dist(&root);
1499
+
1500
+ let args = PackageArgs {
1501
+ cwd: root.clone(),
1502
+ out_dir: PathBuf::from("out"),
1503
+ name: None,
1504
+ platform: None,
1505
+ arch: None,
1506
+ force: false,
1507
+ dry_run: false,
1508
+ json: false,
1509
+ };
1510
+ let snapshot = crate::project::inspect(&root).expect("project should inspect");
1511
+ let report = build_report(snapshot, &args).expect("report should build");
1512
+
1513
+ assert!(report.warnings.contains(
1514
+ &"Optional runtime dependency is not installed and will be skipped: optional-native."
1515
+ .to_string()
1516
+ ));
1517
+ execute_package(&report, false).expect("optional dependency should be skipped");
1518
+
1519
+ let _ = fs::remove_dir_all(root);
1520
+ }
1521
+
656
1522
  #[test]
657
1523
  fn cleans_scoped_package_names_for_bundle_paths() {
658
1524
  assert_eq!(clean_app_name("@scope/app"), "scope-app");
659
1525
  assert_eq!(sanitize_artifact_name("Starter App"), "starter-app");
1526
+ assert_eq!(
1527
+ dependency_relative_path("@scope/app"),
1528
+ PathBuf::from("@scope/app")
1529
+ );
660
1530
  }
661
1531
 
662
1532
  fn write_package_json(root: &Path) {
@@ -667,12 +1537,61 @@ mod tests {
667
1537
  .expect("package.json should be written");
668
1538
  }
669
1539
 
1540
+ fn write_metadata_package_json(root: &Path) {
1541
+ fs::write(
1542
+ root.join("package.json"),
1543
+ r#"{
1544
+ "name": "starter-app",
1545
+ "productName": "Starter Pro",
1546
+ "version": "2.3.4",
1547
+ "main": "src/main.js",
1548
+ "devDependencies": {
1549
+ "electron": "30.0.0"
1550
+ },
1551
+ "electronCli": {
1552
+ "packagerConfig": {
1553
+ "executableName": "StarterExec",
1554
+ "appBundleId": "com.example.starter",
1555
+ "appCategoryType": "public.app-category.developer-tools",
1556
+ "buildVersion": "234",
1557
+ "appCopyright": "Copyright 2026 Example",
1558
+ "icon": "assets/starter",
1559
+ "extraResource": "assets/config.json",
1560
+ "darwinDarkModeSupport": true
1561
+ }
1562
+ }
1563
+ }"#,
1564
+ )
1565
+ .expect("package.json should be written");
1566
+ }
1567
+
670
1568
  fn write_app_file(root: &Path) {
671
1569
  fs::create_dir_all(root.join("src")).expect("src should be created");
672
1570
  fs::write(root.join("src/main.js"), "console.log('hello');")
673
1571
  .expect("main file should be written");
674
1572
  }
675
1573
 
1574
+ fn write_dependency_package(root: &Path, name: &str, package_json: &str) {
1575
+ let package_dir = root
1576
+ .join("node_modules")
1577
+ .join(dependency_relative_path(name));
1578
+ fs::create_dir_all(&package_dir).expect("dependency package dir should be created");
1579
+ fs::write(package_dir.join("package.json"), package_json)
1580
+ .expect("dependency package.json should be written");
1581
+ fs::write(package_dir.join("index.js"), "module.exports = true;")
1582
+ .expect("dependency index should be written");
1583
+ }
1584
+
1585
+ fn write_icon_and_resource_files(root: &Path) {
1586
+ fs::create_dir_all(root.join("assets")).expect("assets should be created");
1587
+ fs::write(root.join("assets/starter.icns"), b"icns").expect("icon should be written");
1588
+ fs::write(root.join("assets/config.json"), "{}").expect("resource should be written");
1589
+ }
1590
+
1591
+ fn plist_string<'a>(dictionary: &'a PlistDictionary, key: &str) -> Option<&'a str> {
1592
+ dictionary.get(key).and_then(PlistValue::as_string)
1593
+ }
1594
+
676
1595
  fn write_fake_electron_dist(root: &Path) {
677
1596
  let dist = root.join("node_modules/electron/dist");
678
1597
  if current_platform() == "darwin" {