electron-cli 0.3.0-alpha.6 → 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.
package/Cargo.lock CHANGED
@@ -64,6 +64,12 @@ version = "1.0.102"
64
64
  source = "registry+https://github.com/rust-lang/crates.io-index"
65
65
  checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
66
66
 
67
+ [[package]]
68
+ name = "base64"
69
+ version = "0.22.1"
70
+ source = "registry+https://github.com/rust-lang/crates.io-index"
71
+ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
72
+
67
73
  [[package]]
68
74
  name = "camino"
69
75
  version = "1.2.2"
@@ -134,13 +140,23 @@ dependencies = [
134
140
  "cfg-if",
135
141
  ]
136
142
 
143
+ [[package]]
144
+ name = "deranged"
145
+ version = "0.5.8"
146
+ source = "registry+https://github.com/rust-lang/crates.io-index"
147
+ checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
148
+ dependencies = [
149
+ "powerfmt",
150
+ ]
151
+
137
152
  [[package]]
138
153
  name = "electron-cli"
139
- version = "0.3.0-alpha.6"
154
+ version = "0.3.0-alpha.7"
140
155
  dependencies = [
141
156
  "anyhow",
142
157
  "camino",
143
158
  "clap",
159
+ "plist",
144
160
  "serde",
145
161
  "serde_json",
146
162
  "zip",
@@ -212,12 +228,37 @@ dependencies = [
212
228
  "simd-adler32",
213
229
  ]
214
230
 
231
+ [[package]]
232
+ name = "num-conv"
233
+ version = "0.2.2"
234
+ source = "registry+https://github.com/rust-lang/crates.io-index"
235
+ checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
236
+
215
237
  [[package]]
216
238
  name = "once_cell_polyfill"
217
239
  version = "1.70.2"
218
240
  source = "registry+https://github.com/rust-lang/crates.io-index"
219
241
  checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
220
242
 
243
+ [[package]]
244
+ name = "plist"
245
+ version = "1.9.0"
246
+ source = "registry+https://github.com/rust-lang/crates.io-index"
247
+ checksum = "092791278e026273c1b65bbdcfbba3a300f2994c896bd01ab01da613c29c46f1"
248
+ dependencies = [
249
+ "base64",
250
+ "indexmap",
251
+ "quick-xml",
252
+ "serde",
253
+ "time",
254
+ ]
255
+
256
+ [[package]]
257
+ name = "powerfmt"
258
+ version = "0.2.0"
259
+ source = "registry+https://github.com/rust-lang/crates.io-index"
260
+ checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
261
+
221
262
  [[package]]
222
263
  name = "proc-macro2"
223
264
  version = "1.0.106"
@@ -227,6 +268,15 @@ dependencies = [
227
268
  "unicode-ident",
228
269
  ]
229
270
 
271
+ [[package]]
272
+ name = "quick-xml"
273
+ version = "0.39.4"
274
+ source = "registry+https://github.com/rust-lang/crates.io-index"
275
+ checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e"
276
+ dependencies = [
277
+ "memchr",
278
+ ]
279
+
230
280
  [[package]]
231
281
  name = "quote"
232
282
  version = "1.0.45"
@@ -302,6 +352,37 @@ dependencies = [
302
352
  "unicode-ident",
303
353
  ]
304
354
 
355
+ [[package]]
356
+ name = "time"
357
+ version = "0.3.47"
358
+ source = "registry+https://github.com/rust-lang/crates.io-index"
359
+ checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c"
360
+ dependencies = [
361
+ "deranged",
362
+ "itoa",
363
+ "num-conv",
364
+ "powerfmt",
365
+ "serde_core",
366
+ "time-core",
367
+ "time-macros",
368
+ ]
369
+
370
+ [[package]]
371
+ name = "time-core"
372
+ version = "0.1.8"
373
+ source = "registry+https://github.com/rust-lang/crates.io-index"
374
+ checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca"
375
+
376
+ [[package]]
377
+ name = "time-macros"
378
+ version = "0.2.27"
379
+ source = "registry+https://github.com/rust-lang/crates.io-index"
380
+ checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215"
381
+ dependencies = [
382
+ "num-conv",
383
+ "time-core",
384
+ ]
385
+
305
386
  [[package]]
306
387
  name = "typed-path"
307
388
  version = "0.12.3"
package/Cargo.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "electron-cli"
3
- version = "0.3.0-alpha.6"
3
+ version = "0.3.0-alpha.7"
4
4
  edition = "2021"
5
5
  description = "Experimental Rust CLI for Electron project diagnostics and workflow automation"
6
6
  license = "MIT"
@@ -12,4 +12,5 @@ camino = { version = "1.1", features = ["serde1"] }
12
12
  clap = { version = "4.6", features = ["derive"] }
13
13
  serde = { version = "1.0", features = ["derive"] }
14
14
  serde_json = "1.0"
15
+ plist = "1"
15
16
  zip = { version = "8.6.0", default-features = false, features = ["deflate-flate2-zlib-rs"] }
package/README.md CHANGED
@@ -41,11 +41,29 @@ The Rust-native flow currently owns:
41
41
 
42
42
  - `init --template minimal`: writes a local Electron starter without Electron Forge.
43
43
  - `start`: launches the installed Electron runtime directly.
44
- - `package`: copies the installed Electron runtime, app files, and installed production dependency closure into a local app bundle for the current platform and architecture.
44
+ - `package`: copies the installed Electron runtime, app files, installed production dependency closure, app metadata, macOS icon, and extra resources into a local app bundle for the current platform and architecture.
45
45
  - `make`: runs `package` and writes a ZIP distributable under `out/make/zip/<platform>/<arch>/`.
46
46
  - `publish`: runs `make` and publishes the distributable to a local directory with a manifest.
47
47
 
48
- Remote publishers such as GitHub Releases are not implemented yet. Platform-specific makers, app metadata, signing, and notarization are also still TODO.
48
+ Remote publishers such as GitHub Releases are not implemented yet. Platform-specific makers, Windows/Linux icon embedding, signing, and notarization are also still TODO.
49
+
50
+ Package metadata can be configured in `package.json`:
51
+
52
+ ```json
53
+ {
54
+ "productName": "My App",
55
+ "electronCli": {
56
+ "packagerConfig": {
57
+ "appBundleId": "com.example.my-app",
58
+ "appCategoryType": "public.app-category.developer-tools",
59
+ "icon": "assets/icon",
60
+ "extraResource": "assets/config.json"
61
+ }
62
+ }
63
+ }
64
+ ```
65
+
66
+ The package command also reads JSON-shaped `config.forge.packagerConfig` and `electronPackagerConfig` entries for the same fields. JavaScript Forge config files are not evaluated.
49
67
 
50
68
  ## Install
51
69
 
@@ -103,7 +121,7 @@ The inspection and planning commands support `--json` so agents and scripts can
103
121
  `plan` is designed around that workflow: it recommends stable commands and reports missing project conventions as structured data.
104
122
  `init --dry-run --json` shows whether the CLI will write native template files or delegate to `create-electron-app`.
105
123
  `start --dry-run --json` shows the Electron executable that will be launched.
106
- `package --dry-run --json` shows the runtime and app file copy plan.
124
+ `package --dry-run --json` shows the runtime, app file, metadata, icon, and extra-resource copy plan.
107
125
  `make --dry-run --json` shows the package prerequisite and ZIP artifact path.
108
126
  `publish --dry-run --json` shows the make prerequisite, destination artifact, and manifest path.
109
127
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "electron-cli",
3
- "version": "0.3.0-alpha.6",
3
+ "version": "0.3.0-alpha.7",
4
4
  "description": "Experimental Rust CLI for Electron project diagnostics and workflow automation",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -447,8 +447,11 @@ fn write_template_file(target: &Path, relative_path: &str, contents: &str) -> Re
447
447
  }
448
448
 
449
449
  fn package_json(package_name: &str, electron_version: &str) -> Result<String> {
450
+ let product_name = product_name(package_name);
451
+ let app_bundle_id = format!("com.electron.{package_name}");
450
452
  let package = serde_json::json!({
451
453
  "name": package_name,
454
+ "productName": product_name,
452
455
  "version": "0.1.0",
453
456
  "private": true,
454
457
  "description": "Minimal Electron app generated by electron-cli",
@@ -459,6 +462,11 @@ fn package_json(package_name: &str, electron_version: &str) -> Result<String> {
459
462
  },
460
463
  "devDependencies": {
461
464
  "electron": electron_version
465
+ },
466
+ "electronCli": {
467
+ "packagerConfig": {
468
+ "appBundleId": app_bundle_id
469
+ }
462
470
  }
463
471
  });
464
472
 
@@ -639,6 +647,25 @@ fn sanitize_package_name(raw_name: &str) -> String {
639
647
  }
640
648
  }
641
649
 
650
+ fn product_name(package_name: &str) -> String {
651
+ package_name
652
+ .split(['-', '_', '.'])
653
+ .filter(|part| !part.is_empty())
654
+ .map(|part| {
655
+ let mut chars = part.chars();
656
+ match chars.next() {
657
+ Some(first) => {
658
+ let mut word = first.to_uppercase().collect::<String>();
659
+ word.push_str(chars.as_str());
660
+ word
661
+ }
662
+ None => String::new(),
663
+ }
664
+ })
665
+ .collect::<Vec<_>>()
666
+ .join(" ")
667
+ }
668
+
642
669
  fn utf8_path(path: PathBuf) -> Result<Utf8PathBuf> {
643
670
  Utf8PathBuf::from_path_buf(path).map_err(|path| {
644
671
  anyhow::anyhow!(
@@ -756,7 +783,9 @@ mod tests {
756
783
  assert!(target.join("src/renderer.js").exists());
757
784
  assert!(target.join(".github/workflows/ci.yml").exists());
758
785
  assert!(package_json.contains("\"name\": \"native-app\""));
786
+ assert!(package_json.contains("\"productName\": \"Native App\""));
759
787
  assert!(package_json.contains("\"electron\": \"30.0.0\""));
788
+ assert!(package_json.contains("\"appBundleId\": \"com.electron.native-app\""));
760
789
  assert!(config.contains("\"generator\": \"electron-cli\""));
761
790
 
762
791
  let _ = fs::remove_dir_all(cwd);
@@ -767,6 +796,7 @@ mod tests {
767
796
  assert_eq!(sanitize_package_name("Native App"), "native-app");
768
797
  assert_eq!(sanitize_package_name("..."), "electron-app");
769
798
  assert_eq!(sanitize_package_name("@Scope/App"), "scope-app");
799
+ assert_eq!(product_name("native-app"), "Native App");
770
800
  }
771
801
 
772
802
  fn unique_temp_dir() -> PathBuf {
@@ -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
@@ -845,6 +1325,135 @@ mod tests {
845
1325
  let _ = fs::remove_dir_all(root);
846
1326
  }
847
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);
1366
+ assert!(report
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
+
848
1457
  #[test]
849
1458
  fn missing_required_runtime_dependency_fails() {
850
1459
  let root = unique_temp_dir("runtime-deps");
@@ -928,6 +1537,34 @@ mod tests {
928
1537
  .expect("package.json should be written");
929
1538
  }
930
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
+
931
1568
  fn write_app_file(root: &Path) {
932
1569
  fs::create_dir_all(root.join("src")).expect("src should be created");
933
1570
  fs::write(root.join("src/main.js"), "console.log('hello');")
@@ -945,6 +1582,16 @@ mod tests {
945
1582
  .expect("dependency index should be written");
946
1583
  }
947
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
+
948
1595
  fn write_fake_electron_dist(root: &Path) {
949
1596
  let dist = root.join("node_modules/electron/dist");
950
1597
  if current_platform() == "darwin" {