electron-cli 0.3.0-alpha.0 → 0.3.0-alpha.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1626 @@
1
+ use std::{
2
+ collections::{BTreeMap, BTreeSet, VecDeque},
3
+ fs,
4
+ path::{Path, PathBuf},
5
+ };
6
+
7
+ use anyhow::{bail, Context, Result};
8
+ use camino::Utf8PathBuf;
9
+ use plist::{Dictionary as PlistDictionary, Value as PlistValue};
10
+ use serde::Serialize;
11
+ use serde_json::Value as JsonValue;
12
+
13
+ use crate::{cli::PackageArgs, output, project::ProjectSnapshot};
14
+
15
+ #[derive(Debug, Serialize)]
16
+ pub(crate) struct PackageReport {
17
+ project: ProjectSnapshot,
18
+ app_name: String,
19
+ executable_name: String,
20
+ metadata: PackageMetadata,
21
+ platform: String,
22
+ arch: String,
23
+ electron_dist: Utf8PathBuf,
24
+ output_dir: Utf8PathBuf,
25
+ bundle_dir: Utf8PathBuf,
26
+ app_resources_dir: Utf8PathBuf,
27
+ dry_run: bool,
28
+ status: PackageStatus,
29
+ create_dirs: Vec<Utf8PathBuf>,
30
+ copy_steps: Vec<CopyStep>,
31
+ warnings: Vec<String>,
32
+ }
33
+
34
+ #[derive(Debug, Serialize)]
35
+ struct CopyStep {
36
+ from: Utf8PathBuf,
37
+ to: Utf8PathBuf,
38
+ }
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
+
58
+ #[derive(Debug, Serialize)]
59
+ #[serde(rename_all = "kebab-case")]
60
+ enum PackageStatus {
61
+ Planned,
62
+ Packaged,
63
+ }
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
+
86
+ pub fn run(args: PackageArgs) -> Result<()> {
87
+ let snapshot = crate::project::inspect(&args.cwd)?;
88
+ let mut report = build_report(snapshot, &args)?;
89
+
90
+ if args.dry_run {
91
+ return print_report(&report, args.json);
92
+ }
93
+
94
+ execute_package(&report, args.force)?;
95
+ report.status = PackageStatus::Packaged;
96
+
97
+ print_report(&report, args.json)
98
+ }
99
+
100
+ pub(crate) fn build_report(snapshot: ProjectSnapshot, args: &PackageArgs) -> Result<PackageReport> {
101
+ let root = Path::new(snapshot.root.as_str());
102
+ let package_config = read_package_json_config(&snapshot)?;
103
+ let platform = args.platform.clone().unwrap_or_else(current_platform);
104
+ let arch = args.arch.clone().unwrap_or_else(current_arch);
105
+ let app_name = clean_app_name(
106
+ &args
107
+ .name
108
+ .clone()
109
+ .or_else(|| package_config.packager.name.clone())
110
+ .or_else(|| package_config.product_name.clone())
111
+ .or_else(|| snapshot.name.clone())
112
+ .unwrap_or_else(|| "electron-app".to_string()),
113
+ );
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);
121
+ let artifact_name = sanitize_artifact_name(&app_name);
122
+ let output_dir = resolve_output_dir(root, &args.out_dir);
123
+ let package_root = output_dir.join(format!("{artifact_name}-{platform}-{arch}"));
124
+ let bundle_dir = bundle_dir(&package_root, &app_name, &platform);
125
+ let app_resources_dir = app_resources_dir(&bundle_dir, &platform);
126
+ let electron_dist = root.join("node_modules/electron/dist");
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
+ )?;
135
+
136
+ let mut warnings = Vec::new();
137
+ if snapshot.package_json.is_none() {
138
+ warnings.push("No package.json found.".to_string());
139
+ }
140
+
141
+ if snapshot.electron_dependency.is_none() {
142
+ warnings.push("No electron dependency is declared in package.json.".to_string());
143
+ }
144
+
145
+ if snapshot.main.is_none() {
146
+ warnings.push("No package.json main field found.".to_string());
147
+ }
148
+
149
+ if !electron_source.exists() {
150
+ warnings.push(format!(
151
+ "Electron runtime was not found at {}.",
152
+ electron_source.display()
153
+ ));
154
+ }
155
+
156
+ if platform != current_platform() {
157
+ warnings.push(format!(
158
+ "Cross-platform packaging is not implemented yet; this host can package {}.",
159
+ current_platform()
160
+ ));
161
+ }
162
+
163
+ if arch != current_arch() {
164
+ warnings.push(format!(
165
+ "Cross-architecture packaging is not implemented yet; this host can package {}.",
166
+ current_arch()
167
+ ));
168
+ }
169
+
170
+ warnings.extend(runtime_dependency_warnings(root, &snapshot));
171
+ warnings.extend(metadata_warnings);
172
+
173
+ let create_dirs = vec![package_root.clone(), app_resources_dir.clone()];
174
+ let mut copy_steps = vec![
175
+ (electron_source, bundle_dir.clone()),
176
+ (root.to_path_buf(), app_resources_dir.join("app")),
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
+ }
196
+
197
+ Ok(PackageReport {
198
+ project: snapshot,
199
+ app_name,
200
+ executable_name,
201
+ metadata,
202
+ platform,
203
+ arch,
204
+ electron_dist: utf8_path(electron_dist)?,
205
+ output_dir: utf8_path(output_dir)?,
206
+ bundle_dir: utf8_path(bundle_dir)?,
207
+ app_resources_dir: utf8_path(app_resources_dir)?,
208
+ dry_run: args.dry_run,
209
+ status: PackageStatus::Planned,
210
+ create_dirs: create_dirs
211
+ .into_iter()
212
+ .map(utf8_path)
213
+ .collect::<Result<Vec<_>>>()?,
214
+ copy_steps: copy_steps
215
+ .into_iter()
216
+ .map(|(from, to)| {
217
+ Ok(CopyStep {
218
+ from: utf8_path(from)?,
219
+ to: utf8_path(to)?,
220
+ })
221
+ })
222
+ .collect::<Result<Vec<_>>>()?,
223
+ warnings,
224
+ })
225
+ }
226
+
227
+ pub(crate) fn execute_package(report: &PackageReport, force: bool) -> Result<()> {
228
+ if report.project.package_json.is_none() {
229
+ bail!("No package.json found. Run electron-cli package inside an Electron project.");
230
+ }
231
+
232
+ if report.project.electron_dependency.is_none() {
233
+ bail!("No electron dependency found. Install Electron before packaging the app.");
234
+ }
235
+
236
+ if report.platform != current_platform() {
237
+ bail!(
238
+ "Cross-platform packaging is not implemented yet. Requested {}, host is {}.",
239
+ report.platform,
240
+ current_platform()
241
+ );
242
+ }
243
+
244
+ if report.arch != current_arch() {
245
+ bail!(
246
+ "Cross-architecture packaging is not implemented yet. Requested {}, host is {}.",
247
+ report.arch,
248
+ current_arch()
249
+ );
250
+ }
251
+
252
+ let bundle_dir = Path::new(report.bundle_dir.as_str());
253
+ let package_root = package_root(bundle_dir, &report.platform);
254
+ let app_resources_dir = Path::new(report.app_resources_dir.as_str());
255
+ let app_dir = app_resources_dir.join("app");
256
+
257
+ if package_root.exists() {
258
+ if force {
259
+ fs::remove_dir_all(&package_root)
260
+ .with_context(|| format!("Could not remove {}", package_root.display()))?;
261
+ } else {
262
+ bail!(
263
+ "Package output already exists: {}. Use --force to overwrite it.",
264
+ package_root.display()
265
+ );
266
+ }
267
+ }
268
+
269
+ let electron_source = Path::new(report.copy_steps[0].from.as_str());
270
+ if !electron_source.exists() {
271
+ bail!(
272
+ "Electron runtime was not found at {}. Run your package manager install first.",
273
+ electron_source.display()
274
+ );
275
+ }
276
+
277
+ fs::create_dir_all(&package_root)
278
+ .with_context(|| format!("Could not create {}", package_root.display()))?;
279
+ copy_recursively(electron_source, bundle_dir).with_context(|| {
280
+ format!(
281
+ "Could not copy Electron runtime to {}",
282
+ bundle_dir.display()
283
+ )
284
+ })?;
285
+ rename_runtime_executable(bundle_dir, &report.executable_name, &report.platform)?;
286
+ apply_package_metadata(report)?;
287
+ copy_package_resources(report)?;
288
+
289
+ fs::create_dir_all(&app_dir)
290
+ .with_context(|| format!("Could not create {}", app_dir.display()))?;
291
+ copy_project_files(
292
+ Path::new(report.project.root.as_str()),
293
+ &app_dir,
294
+ Path::new(report.output_dir.as_str()),
295
+ )?;
296
+ copy_runtime_dependencies(
297
+ Path::new(report.project.root.as_str()),
298
+ &app_dir,
299
+ &report.project,
300
+ )?;
301
+
302
+ Ok(())
303
+ }
304
+
305
+ fn print_report(report: &PackageReport, json: bool) -> Result<()> {
306
+ if json {
307
+ return output::json(report);
308
+ }
309
+
310
+ println!("electron-cli package");
311
+ println!();
312
+ println!("Project");
313
+ println!(" root: {}", report.project.root);
314
+ match report.project.package_label() {
315
+ Some(label) => println!(" package: {label}"),
316
+ None => println!(" package: not found"),
317
+ }
318
+ println!(" app name: {}", report.app_name);
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
+ }
324
+ println!(" target: {} {}", report.platform, report.arch);
325
+ println!(" status: {}", report.status.as_str());
326
+
327
+ println!();
328
+ println!("Output");
329
+ println!(" {}", report.bundle_dir);
330
+
331
+ println!();
332
+ println!("Copy");
333
+ for step in &report.copy_steps {
334
+ println!(" {} -> {}", step.from, step.to);
335
+ }
336
+
337
+ if !report.warnings.is_empty() {
338
+ println!();
339
+ println!("Warnings");
340
+ for warning in &report.warnings {
341
+ println!(" {warning}");
342
+ }
343
+ }
344
+
345
+ Ok(())
346
+ }
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
+
632
+ fn copy_project_files(source: &Path, destination: &Path, output_dir: &Path) -> Result<()> {
633
+ for entry in
634
+ fs::read_dir(source).with_context(|| format!("Could not read {}", source.display()))?
635
+ {
636
+ let entry = entry?;
637
+ let source_path = entry.path();
638
+ let file_name = entry.file_name();
639
+ let file_name = file_name.to_string_lossy();
640
+
641
+ if should_skip_project_entry(&source_path, &file_name, output_dir) {
642
+ continue;
643
+ }
644
+
645
+ let destination_path = destination.join(file_name.as_ref());
646
+ if source_path.is_dir() {
647
+ copy_project_files(&source_path, &destination_path, output_dir)?;
648
+ } else {
649
+ if let Some(parent) = destination_path.parent() {
650
+ fs::create_dir_all(parent)
651
+ .with_context(|| format!("Could not create {}", parent.display()))?;
652
+ }
653
+ fs::copy(&source_path, &destination_path).with_context(|| {
654
+ format!(
655
+ "Could not copy {} to {}",
656
+ source_path.display(),
657
+ destination_path.display()
658
+ )
659
+ })?;
660
+ }
661
+ }
662
+
663
+ Ok(())
664
+ }
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
+
933
+ fn copy_recursively(source: &Path, destination: &Path) -> Result<()> {
934
+ if source.is_dir() {
935
+ fs::create_dir_all(destination)
936
+ .with_context(|| format!("Could not create {}", destination.display()))?;
937
+
938
+ for entry in
939
+ fs::read_dir(source).with_context(|| format!("Could not read {}", source.display()))?
940
+ {
941
+ let entry = entry?;
942
+ let source_path = entry.path();
943
+ let destination_path = destination.join(entry.file_name());
944
+ copy_recursively(&source_path, &destination_path)?;
945
+ }
946
+ } else {
947
+ if let Some(parent) = destination.parent() {
948
+ fs::create_dir_all(parent)
949
+ .with_context(|| format!("Could not create {}", parent.display()))?;
950
+ }
951
+ fs::copy(source, destination).with_context(|| {
952
+ format!(
953
+ "Could not copy {} to {}",
954
+ source.display(),
955
+ destination.display()
956
+ )
957
+ })?;
958
+ }
959
+
960
+ Ok(())
961
+ }
962
+
963
+ fn should_skip_project_entry(source_path: &Path, file_name: &str, output_dir: &Path) -> bool {
964
+ if matches!(file_name, ".git" | "node_modules" | "target") {
965
+ return true;
966
+ }
967
+
968
+ same_path_or_inside(source_path, output_dir)
969
+ }
970
+
971
+ fn same_path_or_inside(path: &Path, parent: &Path) -> bool {
972
+ match (path.canonicalize(), parent.canonicalize()) {
973
+ (Ok(path), Ok(parent)) => path == parent || path.starts_with(parent),
974
+ _ => false,
975
+ }
976
+ }
977
+
978
+ fn rename_runtime_executable(
979
+ bundle_dir: &Path,
980
+ executable_name: &str,
981
+ platform: &str,
982
+ ) -> Result<()> {
983
+ let current = if platform == "darwin" {
984
+ bundle_dir.join("Contents/MacOS/Electron")
985
+ } else if platform == "win32" {
986
+ bundle_dir.join("electron.exe")
987
+ } else {
988
+ bundle_dir.join("electron")
989
+ };
990
+ let target = if platform == "darwin" {
991
+ bundle_dir.join("Contents/MacOS").join(executable_name)
992
+ } else {
993
+ bundle_dir.join(executable_name)
994
+ };
995
+
996
+ if current.exists() && current != target {
997
+ fs::rename(&current, &target).with_context(|| {
998
+ format!(
999
+ "Could not rename {} to {}",
1000
+ current.display(),
1001
+ target.display()
1002
+ )
1003
+ })?;
1004
+ }
1005
+
1006
+ Ok(())
1007
+ }
1008
+
1009
+ fn resolve_output_dir(root: &Path, out_dir: &Path) -> PathBuf {
1010
+ if out_dir.is_absolute() {
1011
+ out_dir.to_path_buf()
1012
+ } else {
1013
+ root.join(out_dir)
1014
+ }
1015
+ }
1016
+
1017
+ fn electron_source(electron_dist: &Path, platform: &str) -> PathBuf {
1018
+ if platform == "darwin" {
1019
+ electron_dist.join("Electron.app")
1020
+ } else {
1021
+ electron_dist.to_path_buf()
1022
+ }
1023
+ }
1024
+
1025
+ fn bundle_dir(package_root: &Path, app_name: &str, platform: &str) -> PathBuf {
1026
+ if platform == "darwin" {
1027
+ package_root.join(format!("{app_name}.app"))
1028
+ } else {
1029
+ package_root.to_path_buf()
1030
+ }
1031
+ }
1032
+
1033
+ fn package_root(bundle_dir: &Path, platform: &str) -> PathBuf {
1034
+ if platform == "darwin" {
1035
+ bundle_dir
1036
+ .parent()
1037
+ .expect("macOS bundle should have package parent")
1038
+ .to_path_buf()
1039
+ } else {
1040
+ bundle_dir.to_path_buf()
1041
+ }
1042
+ }
1043
+
1044
+ fn app_resources_dir(bundle_dir: &Path, platform: &str) -> PathBuf {
1045
+ if platform == "darwin" {
1046
+ bundle_dir.join("Contents/Resources")
1047
+ } else {
1048
+ bundle_dir.join("resources")
1049
+ }
1050
+ }
1051
+
1052
+ fn executable_name(app_name: &str, platform: &str) -> String {
1053
+ let mut name = sanitize_artifact_name(app_name);
1054
+ if platform == "win32" {
1055
+ name.push_str(".exe");
1056
+ }
1057
+ name
1058
+ }
1059
+
1060
+ fn clean_app_name(name: &str) -> String {
1061
+ let cleaned = name
1062
+ .chars()
1063
+ .map(|char| {
1064
+ if char.is_ascii_alphanumeric() || matches!(char, ' ' | '-' | '_' | '.') {
1065
+ char
1066
+ } else {
1067
+ '-'
1068
+ }
1069
+ })
1070
+ .collect::<String>()
1071
+ .trim_matches([' ', '-', '.', '_'])
1072
+ .to_string();
1073
+
1074
+ if cleaned.is_empty() {
1075
+ "electron-app".to_string()
1076
+ } else {
1077
+ cleaned
1078
+ }
1079
+ }
1080
+
1081
+ fn sanitize_artifact_name(name: &str) -> String {
1082
+ let sanitized = name
1083
+ .to_ascii_lowercase()
1084
+ .chars()
1085
+ .map(|char| {
1086
+ if char.is_ascii_alphanumeric() || matches!(char, '-' | '_' | '.') {
1087
+ char
1088
+ } else {
1089
+ '-'
1090
+ }
1091
+ })
1092
+ .collect::<String>()
1093
+ .trim_matches(['-', '.', '_'])
1094
+ .to_string();
1095
+
1096
+ if sanitized.is_empty() {
1097
+ "electron-app".to_string()
1098
+ } else {
1099
+ sanitized
1100
+ }
1101
+ }
1102
+
1103
+ fn has_runtime_dependencies(snapshot: &ProjectSnapshot) -> bool {
1104
+ !snapshot.dependencies.is_empty() || !snapshot.optional_dependencies.is_empty()
1105
+ }
1106
+
1107
+ fn current_platform() -> String {
1108
+ if cfg!(target_os = "macos") {
1109
+ "darwin".to_string()
1110
+ } else if cfg!(target_os = "windows") {
1111
+ "win32".to_string()
1112
+ } else {
1113
+ "linux".to_string()
1114
+ }
1115
+ }
1116
+
1117
+ fn current_arch() -> String {
1118
+ match std::env::consts::ARCH {
1119
+ "aarch64" => "arm64".to_string(),
1120
+ "x86_64" => "x64".to_string(),
1121
+ arch => arch.to_string(),
1122
+ }
1123
+ }
1124
+
1125
+ fn utf8_path(path: PathBuf) -> Result<Utf8PathBuf> {
1126
+ Utf8PathBuf::from_path_buf(path).map_err(|path| {
1127
+ anyhow::anyhow!(
1128
+ "Path contains invalid UTF-8 and cannot be represented in JSON: {}",
1129
+ path.display()
1130
+ )
1131
+ })
1132
+ }
1133
+
1134
+ impl PackageStatus {
1135
+ fn as_str(&self) -> &'static str {
1136
+ match self {
1137
+ PackageStatus::Planned => "planned",
1138
+ PackageStatus::Packaged => "packaged",
1139
+ }
1140
+ }
1141
+ }
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
+
1167
+ impl PackageReport {
1168
+ pub(crate) fn project(&self) -> &ProjectSnapshot {
1169
+ &self.project
1170
+ }
1171
+
1172
+ pub(crate) fn mark_packaged(&mut self) {
1173
+ self.status = PackageStatus::Packaged;
1174
+ }
1175
+
1176
+ pub(crate) fn app_name(&self) -> &str {
1177
+ &self.app_name
1178
+ }
1179
+
1180
+ pub(crate) fn executable_name(&self) -> &str {
1181
+ &self.executable_name
1182
+ }
1183
+
1184
+ pub(crate) fn artifact_stem(&self) -> String {
1185
+ sanitize_artifact_name(&self.app_name)
1186
+ }
1187
+
1188
+ pub(crate) fn platform(&self) -> &str {
1189
+ &self.platform
1190
+ }
1191
+
1192
+ pub(crate) fn arch(&self) -> &str {
1193
+ &self.arch
1194
+ }
1195
+
1196
+ pub(crate) fn output_dir(&self) -> &Utf8PathBuf {
1197
+ &self.output_dir
1198
+ }
1199
+
1200
+ pub(crate) fn bundle_dir(&self) -> &Utf8PathBuf {
1201
+ &self.bundle_dir
1202
+ }
1203
+
1204
+ pub(crate) fn warnings(&self) -> &[String] {
1205
+ &self.warnings
1206
+ }
1207
+ }
1208
+
1209
+ #[cfg(test)]
1210
+ mod tests {
1211
+ use super::*;
1212
+
1213
+ #[test]
1214
+ fn plans_package_output_for_current_platform() {
1215
+ let root = unique_temp_dir("plan");
1216
+ write_package_json(&root);
1217
+ write_fake_electron_dist(&root);
1218
+
1219
+ let args = PackageArgs {
1220
+ cwd: root.clone(),
1221
+ out_dir: PathBuf::from("out"),
1222
+ name: None,
1223
+ platform: None,
1224
+ arch: None,
1225
+ force: false,
1226
+ dry_run: true,
1227
+ json: true,
1228
+ };
1229
+ let snapshot = crate::project::inspect(&root).expect("project should inspect");
1230
+ let report = build_report(snapshot, &args).expect("report should build");
1231
+
1232
+ assert_eq!(report.app_name, "starter-app");
1233
+ assert_eq!(report.platform, current_platform());
1234
+ assert_eq!(report.arch, current_arch());
1235
+ assert!(report.warnings.is_empty());
1236
+
1237
+ let _ = fs::remove_dir_all(root);
1238
+ }
1239
+
1240
+ #[test]
1241
+ fn packages_fake_electron_runtime_and_app_files() {
1242
+ let root = unique_temp_dir("execute");
1243
+ write_package_json(&root);
1244
+ write_app_file(&root);
1245
+ write_fake_electron_dist(&root);
1246
+ fs::create_dir_all(root.join("node_modules/ignored"))
1247
+ .expect("node_modules should be created");
1248
+ fs::write(root.join("node_modules/ignored/file.js"), "")
1249
+ .expect("ignored node module should be written");
1250
+
1251
+ let args = PackageArgs {
1252
+ cwd: root.clone(),
1253
+ out_dir: PathBuf::from("out"),
1254
+ name: Some("Starter App".to_string()),
1255
+ platform: None,
1256
+ arch: None,
1257
+ force: false,
1258
+ dry_run: false,
1259
+ json: false,
1260
+ };
1261
+ let snapshot = crate::project::inspect(&root).expect("project should inspect");
1262
+ let report = build_report(snapshot, &args).expect("report should build");
1263
+
1264
+ execute_package(&report, false).expect("package should succeed");
1265
+
1266
+ let app_dir = Path::new(report.app_resources_dir.as_str()).join("app");
1267
+ assert!(app_dir.join("package.json").exists());
1268
+ assert!(app_dir.join("src/main.js").exists());
1269
+ assert!(!app_dir.join("node_modules").exists());
1270
+
1271
+ if current_platform() == "darwin" {
1272
+ assert!(Path::new(report.bundle_dir.as_str())
1273
+ .join("Contents")
1274
+ .exists());
1275
+ } else {
1276
+ assert!(Path::new(report.bundle_dir.as_str())
1277
+ .join(report.executable_name)
1278
+ .exists());
1279
+ }
1280
+
1281
+ let _ = fs::remove_dir_all(root);
1282
+ }
1283
+
1284
+ #[test]
1285
+ fn packages_runtime_dependency_closure_from_node_modules() {
1286
+ let root = unique_temp_dir("runtime-deps");
1287
+ fs::write(
1288
+ root.join("package.json"),
1289
+ 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"}}"#,
1290
+ )
1291
+ .expect("package.json should be written");
1292
+ write_app_file(&root);
1293
+ write_fake_electron_dist(&root);
1294
+ write_dependency_package(
1295
+ &root,
1296
+ "dep-a",
1297
+ r#"{"name":"dep-a","version":"1.0.0","dependencies":{"dep-b":"1.0.0"}}"#,
1298
+ );
1299
+ write_dependency_package(&root, "dep-b", r#"{"name":"dep-b","version":"1.0.0"}"#);
1300
+ write_dependency_package(
1301
+ &root,
1302
+ "dev-only",
1303
+ r#"{"name":"dev-only","version":"1.0.0"}"#,
1304
+ );
1305
+
1306
+ let args = PackageArgs {
1307
+ cwd: root.clone(),
1308
+ out_dir: PathBuf::from("out"),
1309
+ name: None,
1310
+ platform: None,
1311
+ arch: None,
1312
+ force: false,
1313
+ dry_run: false,
1314
+ json: false,
1315
+ };
1316
+ let snapshot = crate::project::inspect(&root).expect("project should inspect");
1317
+ let report = build_report(snapshot, &args).expect("report should build");
1318
+
1319
+ assert!(report.warnings.is_empty());
1320
+ execute_package(&report, false).expect("package should succeed");
1321
+
1322
+ let app_node_modules = Path::new(report.app_resources_dir.as_str())
1323
+ .join("app")
1324
+ .join("node_modules");
1325
+ assert!(app_node_modules.join("dep-a/package.json").exists());
1326
+ assert!(app_node_modules.join("dep-b/package.json").exists());
1327
+ assert!(!app_node_modules.join("dev-only").exists());
1328
+
1329
+ let _ = fs::remove_dir_all(root);
1330
+ }
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
+
1461
+ #[test]
1462
+ fn missing_required_runtime_dependency_fails() {
1463
+ let root = unique_temp_dir("runtime-deps");
1464
+ fs::write(
1465
+ root.join("package.json"),
1466
+ r#"{"name":"starter-app","version":"0.1.0","main":"src/main.js","dependencies":{"left-pad":"1.3.0"},"devDependencies":{"electron":"30.0.0"}}"#,
1467
+ )
1468
+ .expect("package.json should be written");
1469
+ write_app_file(&root);
1470
+ write_fake_electron_dist(&root);
1471
+
1472
+ let args = PackageArgs {
1473
+ cwd: root.clone(),
1474
+ out_dir: PathBuf::from("out"),
1475
+ name: None,
1476
+ platform: None,
1477
+ arch: None,
1478
+ force: false,
1479
+ dry_run: false,
1480
+ json: false,
1481
+ };
1482
+ let snapshot = crate::project::inspect(&root).expect("project should inspect");
1483
+ let report = build_report(snapshot, &args).expect("report should build");
1484
+
1485
+ assert!(report.warnings.contains(
1486
+ &"Runtime dependency is not installed and packaging will fail: left-pad.".to_string()
1487
+ ));
1488
+ assert!(execute_package(&report, false).is_err());
1489
+
1490
+ let _ = fs::remove_dir_all(root);
1491
+ }
1492
+
1493
+ #[test]
1494
+ fn missing_optional_runtime_dependency_is_skipped() {
1495
+ let root = unique_temp_dir("optional-runtime-deps");
1496
+ fs::write(
1497
+ root.join("package.json"),
1498
+ r#"{"name":"starter-app","version":"0.1.0","main":"src/main.js","optionalDependencies":{"optional-native":"1.0.0"},"devDependencies":{"electron":"30.0.0"}}"#,
1499
+ )
1500
+ .expect("package.json should be written");
1501
+ write_app_file(&root);
1502
+ write_fake_electron_dist(&root);
1503
+
1504
+ let args = PackageArgs {
1505
+ cwd: root.clone(),
1506
+ out_dir: PathBuf::from("out"),
1507
+ name: None,
1508
+ platform: None,
1509
+ arch: None,
1510
+ force: false,
1511
+ dry_run: false,
1512
+ json: false,
1513
+ };
1514
+ let snapshot = crate::project::inspect(&root).expect("project should inspect");
1515
+ let report = build_report(snapshot, &args).expect("report should build");
1516
+
1517
+ assert!(report.warnings.contains(
1518
+ &"Optional runtime dependency is not installed and will be skipped: optional-native."
1519
+ .to_string()
1520
+ ));
1521
+ execute_package(&report, false).expect("optional dependency should be skipped");
1522
+
1523
+ let _ = fs::remove_dir_all(root);
1524
+ }
1525
+
1526
+ #[test]
1527
+ fn cleans_scoped_package_names_for_bundle_paths() {
1528
+ assert_eq!(clean_app_name("@scope/app"), "scope-app");
1529
+ assert_eq!(sanitize_artifact_name("Starter App"), "starter-app");
1530
+ assert_eq!(
1531
+ dependency_relative_path("@scope/app"),
1532
+ PathBuf::from("@scope/app")
1533
+ );
1534
+ }
1535
+
1536
+ fn write_package_json(root: &Path) {
1537
+ fs::write(
1538
+ root.join("package.json"),
1539
+ r#"{"name":"starter-app","version":"0.1.0","main":"src/main.js","devDependencies":{"electron":"30.0.0"}}"#,
1540
+ )
1541
+ .expect("package.json should be written");
1542
+ }
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
+
1572
+ fn write_app_file(root: &Path) {
1573
+ fs::create_dir_all(root.join("src")).expect("src should be created");
1574
+ fs::write(root.join("src/main.js"), "console.log('hello');")
1575
+ .expect("main file should be written");
1576
+ }
1577
+
1578
+ fn write_dependency_package(root: &Path, name: &str, package_json: &str) {
1579
+ let package_dir = root
1580
+ .join("node_modules")
1581
+ .join(dependency_relative_path(name));
1582
+ fs::create_dir_all(&package_dir).expect("dependency package dir should be created");
1583
+ fs::write(package_dir.join("package.json"), package_json)
1584
+ .expect("dependency package.json should be written");
1585
+ fs::write(package_dir.join("index.js"), "module.exports = true;")
1586
+ .expect("dependency index should be written");
1587
+ }
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
+
1599
+ fn write_fake_electron_dist(root: &Path) {
1600
+ let dist = root.join("node_modules/electron/dist");
1601
+ if current_platform() == "darwin" {
1602
+ let app = dist.join("Electron.app/Contents/MacOS");
1603
+ fs::create_dir_all(&app).expect("fake macOS electron app should be created");
1604
+ fs::write(app.join("Electron"), "").expect("fake macOS binary should be written");
1605
+ } else if current_platform() == "win32" {
1606
+ fs::create_dir_all(&dist).expect("fake electron dist should be created");
1607
+ fs::write(dist.join("electron.exe"), "").expect("fake exe should be written");
1608
+ } else {
1609
+ fs::create_dir_all(&dist).expect("fake electron dist should be created");
1610
+ fs::write(dist.join("electron"), "").expect("fake binary should be written");
1611
+ }
1612
+ }
1613
+
1614
+ fn unique_temp_dir(label: &str) -> PathBuf {
1615
+ let nanos = std::time::SystemTime::now()
1616
+ .duration_since(std::time::UNIX_EPOCH)
1617
+ .expect("clock should be after epoch")
1618
+ .as_nanos();
1619
+ let path = std::env::temp_dir().join(format!(
1620
+ "electron-cli-package-{label}-{}-{nanos}",
1621
+ std::process::id()
1622
+ ));
1623
+ fs::create_dir_all(&path).expect("temp dir should be created");
1624
+ path
1625
+ }
1626
+ }