electron-cli 0.3.0-alpha.6 → 0.3.0-alpha.8

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.
@@ -6,8 +6,9 @@ use std::{
6
6
 
7
7
  use anyhow::{bail, Context, Result};
8
8
  use camino::Utf8PathBuf;
9
+ use plist::{Dictionary as PlistDictionary, Value as PlistValue};
9
10
  use serde::Serialize;
10
- use serde_json::Value;
11
+ use serde_json::Value as JsonValue;
11
12
 
12
13
  use crate::{cli::PackageArgs, output, project::ProjectSnapshot};
13
14
 
@@ -16,6 +17,7 @@ pub(crate) struct PackageReport {
16
17
  project: ProjectSnapshot,
17
18
  app_name: String,
18
19
  executable_name: String,
20
+ metadata: PackageMetadata,
19
21
  platform: String,
20
22
  arch: String,
21
23
  electron_dist: Utf8PathBuf,
@@ -35,6 +37,24 @@ struct CopyStep {
35
37
  to: Utf8PathBuf,
36
38
  }
37
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
+
38
58
  #[derive(Debug, Serialize)]
39
59
  #[serde(rename_all = "kebab-case")]
40
60
  enum PackageStatus {
@@ -42,6 +62,27 @@ enum PackageStatus {
42
62
  Packaged,
43
63
  }
44
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
+
45
86
  pub fn run(args: PackageArgs) -> Result<()> {
46
87
  let snapshot = crate::project::inspect(&args.cwd)?;
47
88
  let mut report = build_report(snapshot, &args)?;
@@ -58,16 +99,25 @@ pub fn run(args: PackageArgs) -> Result<()> {
58
99
 
59
100
  pub(crate) fn build_report(snapshot: ProjectSnapshot, args: &PackageArgs) -> Result<PackageReport> {
60
101
  let root = Path::new(snapshot.root.as_str());
102
+ let package_config = read_package_json_config(&snapshot)?;
61
103
  let platform = args.platform.clone().unwrap_or_else(current_platform);
62
104
  let arch = args.arch.clone().unwrap_or_else(current_arch);
63
105
  let app_name = clean_app_name(
64
106
  &args
65
107
  .name
66
108
  .clone()
109
+ .or_else(|| package_config.packager.name.clone())
110
+ .or_else(|| package_config.product_name.clone())
67
111
  .or_else(|| snapshot.name.clone())
68
112
  .unwrap_or_else(|| "electron-app".to_string()),
69
113
  );
70
- 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);
71
121
  let artifact_name = sanitize_artifact_name(&app_name);
72
122
  let output_dir = resolve_output_dir(root, &args.out_dir);
73
123
  let package_root = output_dir.join(format!("{artifact_name}-{platform}-{arch}"));
@@ -75,6 +125,13 @@ pub(crate) fn build_report(snapshot: ProjectSnapshot, args: &PackageArgs) -> Res
75
125
  let app_resources_dir = app_resources_dir(&bundle_dir, &platform);
76
126
  let electron_dist = root.join("node_modules/electron/dist");
77
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
+ )?;
78
135
 
79
136
  let mut warnings = Vec::new();
80
137
  if snapshot.package_json.is_none() {
@@ -111,6 +168,7 @@ pub(crate) fn build_report(snapshot: ProjectSnapshot, args: &PackageArgs) -> Res
111
168
  }
112
169
 
113
170
  warnings.extend(runtime_dependency_warnings(root, &snapshot));
171
+ warnings.extend(metadata_warnings);
114
172
 
115
173
  let create_dirs = vec![package_root.clone(), app_resources_dir.clone()];
116
174
  let mut copy_steps = vec![
@@ -123,11 +181,24 @@ pub(crate) fn build_report(snapshot: ProjectSnapshot, args: &PackageArgs) -> Res
123
181
  app_resources_dir.join("app/node_modules"),
124
182
  ));
125
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
+ }
126
196
 
127
197
  Ok(PackageReport {
128
198
  project: snapshot,
129
199
  app_name,
130
200
  executable_name,
201
+ metadata,
131
202
  platform,
132
203
  arch,
133
204
  electron_dist: utf8_path(electron_dist)?,
@@ -212,6 +283,8 @@ pub(crate) fn execute_package(report: &PackageReport, force: bool) -> Result<()>
212
283
  )
213
284
  })?;
214
285
  rename_runtime_executable(bundle_dir, &report.executable_name, &report.platform)?;
286
+ apply_package_metadata(report)?;
287
+ copy_package_resources(report)?;
215
288
 
216
289
  fs::create_dir_all(&app_dir)
217
290
  .with_context(|| format!("Could not create {}", app_dir.display()))?;
@@ -244,6 +317,10 @@ fn print_report(report: &PackageReport, json: bool) -> Result<()> {
244
317
  }
245
318
  println!(" app name: {}", report.app_name);
246
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
+ }
247
324
  println!(" target: {} {}", report.platform, report.arch);
248
325
  println!(" status: {}", report.status.as_str());
249
326
 
@@ -268,6 +345,290 @@ fn print_report(report: &PackageReport, json: bool) -> Result<()> {
268
345
  Ok(())
269
346
  }
270
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
+
271
632
  fn copy_project_files(source: &Path, destination: &Path, output_dir: &Path) -> Result<()> {
272
633
  for entry in
273
634
  fs::read_dir(source).with_context(|| format!("Could not read {}", source.display()))?
@@ -403,6 +764,99 @@ fn copy_runtime_dependencies(
403
764
  Ok(())
404
765
  }
405
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
+
406
860
  fn runtime_dependency_warnings(root: &Path, snapshot: &ProjectSnapshot) -> Vec<String> {
407
861
  let mut warnings = Vec::new();
408
862
  let root_node_modules = root.join("node_modules");
@@ -454,17 +908,17 @@ fn dependency_relative_path(name: &str) -> PathBuf {
454
908
  path
455
909
  }
456
910
 
457
- fn read_dependency_package_json(package_dir: &Path) -> Result<Value> {
911
+ fn read_dependency_package_json(package_dir: &Path) -> Result<JsonValue> {
458
912
  let package_json_path = package_dir.join("package.json");
459
913
  let raw = fs::read_to_string(&package_json_path)
460
914
  .with_context(|| format!("Could not read {}", package_json_path.display()))?;
461
- serde_json::from_str::<Value>(&raw)
915
+ serde_json::from_str::<JsonValue>(&raw)
462
916
  .with_context(|| format!("Could not parse {}", package_json_path.display()))
463
917
  }
464
918
 
465
- fn string_map(value: Option<&Value>) -> BTreeMap<String, String> {
919
+ fn string_map(value: Option<&JsonValue>) -> BTreeMap<String, String> {
466
920
  value
467
- .and_then(Value::as_object)
921
+ .and_then(JsonValue::as_object)
468
922
  .map(|object| {
469
923
  object
470
924
  .iter()
@@ -526,16 +980,18 @@ fn rename_runtime_executable(
526
980
  executable_name: &str,
527
981
  platform: &str,
528
982
  ) -> Result<()> {
529
- if platform == "darwin" {
530
- return Ok(());
531
- }
532
-
533
- let current = if platform == "win32" {
983
+ let current = if platform == "darwin" {
984
+ bundle_dir.join("Contents/MacOS/Electron")
985
+ } else if platform == "win32" {
534
986
  bundle_dir.join("electron.exe")
535
987
  } else {
536
988
  bundle_dir.join("electron")
537
989
  };
538
- 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
+ };
539
995
 
540
996
  if current.exists() && current != target {
541
997
  fs::rename(&current, &target).with_context(|| {
@@ -684,6 +1140,30 @@ impl PackageStatus {
684
1140
  }
685
1141
  }
686
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
+
687
1167
  impl PackageReport {
688
1168
  pub(crate) fn project(&self) -> &ProjectSnapshot {
689
1169
  &self.project
@@ -697,6 +1177,10 @@ impl PackageReport {
697
1177
  &self.app_name
698
1178
  }
699
1179
 
1180
+ pub(crate) fn executable_name(&self) -> &str {
1181
+ &self.executable_name
1182
+ }
1183
+
700
1184
  pub(crate) fn artifact_stem(&self) -> String {
701
1185
  sanitize_artifact_name(&self.app_name)
702
1186
  }
@@ -845,6 +1329,135 @@ mod tests {
845
1329
  let _ = fs::remove_dir_all(root);
846
1330
  }
847
1331
 
1332
+ #[test]
1333
+ fn plans_packager_metadata_from_package_json() {
1334
+ let root = unique_temp_dir("metadata-plan");
1335
+ write_metadata_package_json(&root);
1336
+ write_app_file(&root);
1337
+ write_fake_electron_dist(&root);
1338
+ write_icon_and_resource_files(&root);
1339
+
1340
+ let args = PackageArgs {
1341
+ cwd: root.clone(),
1342
+ out_dir: PathBuf::from("out"),
1343
+ name: None,
1344
+ platform: None,
1345
+ arch: None,
1346
+ force: false,
1347
+ dry_run: true,
1348
+ json: true,
1349
+ };
1350
+ let snapshot = crate::project::inspect(&root).expect("project should inspect");
1351
+ let report = build_report(snapshot, &args).expect("report should build");
1352
+
1353
+ assert_eq!(report.app_name, "Starter Pro");
1354
+ assert_eq!(
1355
+ report.executable_name,
1356
+ executable_name("StarterExec", &report.platform)
1357
+ );
1358
+ assert_eq!(report.metadata.bundle_identifier, "com.example.starter");
1359
+ assert_eq!(report.metadata.app_version.as_deref(), Some("2.3.4"));
1360
+ assert_eq!(report.metadata.build_version.as_deref(), Some("234"));
1361
+ assert_eq!(
1362
+ report.metadata.app_category_type.as_deref(),
1363
+ Some("public.app-category.developer-tools")
1364
+ );
1365
+ assert_eq!(
1366
+ report.metadata.app_copyright.as_deref(),
1367
+ Some("Copyright 2026 Example")
1368
+ );
1369
+ assert_eq!(report.metadata.extra_resources.len(), 1);
1370
+ assert!(report
1371
+ .copy_steps
1372
+ .iter()
1373
+ .any(|step| step.to.as_str().ends_with("config.json")));
1374
+
1375
+ if current_platform() == "darwin" {
1376
+ assert!(report.metadata.icon.is_some());
1377
+ assert!(report.warnings.is_empty());
1378
+ }
1379
+
1380
+ let _ = fs::remove_dir_all(root);
1381
+ }
1382
+
1383
+ #[test]
1384
+ fn packages_macos_info_plist_metadata() {
1385
+ if current_platform() != "darwin" {
1386
+ return;
1387
+ }
1388
+
1389
+ let root = unique_temp_dir("metadata-execute");
1390
+ write_metadata_package_json(&root);
1391
+ write_app_file(&root);
1392
+ write_fake_electron_dist(&root);
1393
+ write_icon_and_resource_files(&root);
1394
+
1395
+ let args = PackageArgs {
1396
+ cwd: root.clone(),
1397
+ out_dir: PathBuf::from("out"),
1398
+ name: None,
1399
+ platform: None,
1400
+ arch: None,
1401
+ force: false,
1402
+ dry_run: false,
1403
+ json: false,
1404
+ };
1405
+ let snapshot = crate::project::inspect(&root).expect("project should inspect");
1406
+ let report = build_report(snapshot, &args).expect("report should build");
1407
+
1408
+ execute_package(&report, false).expect("package should succeed");
1409
+
1410
+ let bundle_dir = Path::new(report.bundle_dir.as_str());
1411
+ assert!(bundle_dir
1412
+ .join("Contents/MacOS")
1413
+ .join(&report.executable_name)
1414
+ .exists());
1415
+ assert!(bundle_dir
1416
+ .join("Contents/Resources/starter-pro.icns")
1417
+ .exists());
1418
+ assert!(bundle_dir.join("Contents/Resources/config.json").exists());
1419
+
1420
+ let plist = PlistValue::from_file(bundle_dir.join("Contents/Info.plist"))
1421
+ .expect("Info.plist should parse");
1422
+ let dictionary = plist
1423
+ .as_dictionary()
1424
+ .expect("Info.plist should be a dictionary");
1425
+
1426
+ assert_eq!(
1427
+ plist_string(dictionary, "CFBundleDisplayName"),
1428
+ Some("Starter Pro")
1429
+ );
1430
+ assert_eq!(
1431
+ plist_string(dictionary, "CFBundleExecutable"),
1432
+ Some(report.executable_name.as_str())
1433
+ );
1434
+ assert_eq!(
1435
+ plist_string(dictionary, "CFBundleIdentifier"),
1436
+ Some("com.example.starter")
1437
+ );
1438
+ assert_eq!(
1439
+ plist_string(dictionary, "CFBundleShortVersionString"),
1440
+ Some("2.3.4")
1441
+ );
1442
+ assert_eq!(plist_string(dictionary, "CFBundleVersion"), Some("234"));
1443
+ assert_eq!(
1444
+ plist_string(dictionary, "LSApplicationCategoryType"),
1445
+ Some("public.app-category.developer-tools")
1446
+ );
1447
+ assert_eq!(
1448
+ plist_string(dictionary, "CFBundleIconFile"),
1449
+ Some("starter-pro.icns")
1450
+ );
1451
+ assert_eq!(
1452
+ dictionary
1453
+ .get("NSRequiresAquaSystemAppearance")
1454
+ .and_then(PlistValue::as_boolean),
1455
+ Some(false)
1456
+ );
1457
+
1458
+ let _ = fs::remove_dir_all(root);
1459
+ }
1460
+
848
1461
  #[test]
849
1462
  fn missing_required_runtime_dependency_fails() {
850
1463
  let root = unique_temp_dir("runtime-deps");
@@ -928,6 +1541,34 @@ mod tests {
928
1541
  .expect("package.json should be written");
929
1542
  }
930
1543
 
1544
+ fn write_metadata_package_json(root: &Path) {
1545
+ fs::write(
1546
+ root.join("package.json"),
1547
+ r#"{
1548
+ "name": "starter-app",
1549
+ "productName": "Starter Pro",
1550
+ "version": "2.3.4",
1551
+ "main": "src/main.js",
1552
+ "devDependencies": {
1553
+ "electron": "30.0.0"
1554
+ },
1555
+ "electronCli": {
1556
+ "packagerConfig": {
1557
+ "executableName": "StarterExec",
1558
+ "appBundleId": "com.example.starter",
1559
+ "appCategoryType": "public.app-category.developer-tools",
1560
+ "buildVersion": "234",
1561
+ "appCopyright": "Copyright 2026 Example",
1562
+ "icon": "assets/starter",
1563
+ "extraResource": "assets/config.json",
1564
+ "darwinDarkModeSupport": true
1565
+ }
1566
+ }
1567
+ }"#,
1568
+ )
1569
+ .expect("package.json should be written");
1570
+ }
1571
+
931
1572
  fn write_app_file(root: &Path) {
932
1573
  fs::create_dir_all(root.join("src")).expect("src should be created");
933
1574
  fs::write(root.join("src/main.js"), "console.log('hello');")
@@ -945,6 +1586,16 @@ mod tests {
945
1586
  .expect("dependency index should be written");
946
1587
  }
947
1588
 
1589
+ fn write_icon_and_resource_files(root: &Path) {
1590
+ fs::create_dir_all(root.join("assets")).expect("assets should be created");
1591
+ fs::write(root.join("assets/starter.icns"), b"icns").expect("icon should be written");
1592
+ fs::write(root.join("assets/config.json"), "{}").expect("resource should be written");
1593
+ }
1594
+
1595
+ fn plist_string<'a>(dictionary: &'a PlistDictionary, key: &str) -> Option<&'a str> {
1596
+ dictionary.get(key).and_then(PlistValue::as_string)
1597
+ }
1598
+
948
1599
  fn write_fake_electron_dist(root: &Path) {
949
1600
  let dist = root.join("node_modules/electron/dist");
950
1601
  if current_platform() == "darwin" {