electron-cli 0.3.0-alpha.11 → 0.3.0-alpha.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,5 @@
1
1
  use std::{
2
+ collections::BTreeMap,
2
3
  fs,
3
4
  fs::File,
4
5
  io::{self, BufWriter, Cursor, Write},
@@ -6,25 +7,32 @@ use std::{
6
7
  };
7
8
 
8
9
  use anyhow::{bail, Context, Result};
10
+ use cab::{CabinetBuilder, CompressionType as CabCompressionType};
9
11
  use camino::Utf8PathBuf;
10
12
  use fatfs::{Dir as FatDir, FileSystem, FormatVolumeOptions, FsOptions, ReadWriteSeek};
11
13
  use flate2::{write::GzEncoder, Compression};
12
14
  use fscommon::BufStream;
15
+ use msi::{Column, Insert, Language, Package, PackageType, Value};
13
16
  use rpm::{BuildConfig, CompressionType, FileOptions, PackageBuilder};
14
17
  use serde::Serialize;
18
+ use serde_json::Value as JsonValue;
15
19
  use tar::{Builder as TarBuilder, Header as TarHeader};
20
+ use uuid::Uuid;
16
21
  use zip::{write::SimpleFileOptions, CompressionMethod, ZipWriter};
17
22
 
18
23
  use crate::{
19
24
  cli::{MakeArgs, MakeTarget, PackageArgs},
20
25
  commands::package::{self, PackageReport},
21
26
  output,
27
+ project::ProjectSnapshot,
22
28
  };
23
29
 
24
30
  #[derive(Debug, Serialize)]
25
31
  pub(crate) struct MakeReport {
26
32
  package: PackageReport,
27
33
  target: String,
34
+ #[serde(skip)]
35
+ target_kind: MakeTarget,
28
36
  skip_package: bool,
29
37
  dry_run: bool,
30
38
  make_dir: Utf8PathBuf,
@@ -34,27 +42,53 @@ pub(crate) struct MakeReport {
34
42
  warnings: Vec<String>,
35
43
  }
36
44
 
37
- #[derive(Debug, Serialize)]
45
+ #[derive(Clone, Copy, Debug, Serialize)]
38
46
  #[serde(rename_all = "kebab-case")]
39
47
  enum MakeStatus {
40
48
  Planned,
41
49
  Made,
42
50
  }
43
51
 
52
+ struct ResolvedMakeTargets {
53
+ targets: Vec<MakeTarget>,
54
+ warnings: Vec<String>,
55
+ }
56
+
57
+ #[derive(Debug, Serialize)]
58
+ struct MakeRunReport<'a> {
59
+ targets: &'a [MakeReport],
60
+ dry_run: bool,
61
+ status: MakeStatus,
62
+ warnings: Vec<String>,
63
+ }
64
+
44
65
  pub fn run(args: MakeArgs) -> Result<()> {
45
- let mut report = build_report(&args)?;
66
+ let mut reports = build_reports(&args)?;
46
67
 
47
68
  if args.dry_run {
48
- return print_report(&report, args.json);
69
+ return print_reports(&reports, args.json, MakeStatus::Planned);
49
70
  }
50
71
 
51
- execute_make(&mut report, &args)?;
52
- report.mark_made()?;
72
+ execute_make_reports(&mut reports, &args)?;
53
73
 
54
- print_report(&report, args.json)
74
+ print_reports(&reports, args.json, MakeStatus::Made)
55
75
  }
56
76
 
57
77
  pub(crate) fn build_report(args: &MakeArgs) -> Result<MakeReport> {
78
+ let reports = build_reports(args)?;
79
+ if reports.len() != 1 {
80
+ bail!(
81
+ "Expected one make target, but resolved {}. Pass --target to select one target.",
82
+ reports.len()
83
+ );
84
+ }
85
+ Ok(reports
86
+ .into_iter()
87
+ .next()
88
+ .expect("length was checked above"))
89
+ }
90
+
91
+ pub(crate) fn build_reports(args: &MakeArgs) -> Result<Vec<MakeReport>> {
58
92
  let package_args = PackageArgs {
59
93
  cwd: args.cwd.clone(),
60
94
  out_dir: args.out_dir.clone(),
@@ -66,28 +100,52 @@ pub(crate) fn build_report(args: &MakeArgs) -> Result<MakeReport> {
66
100
  json: false,
67
101
  };
68
102
  let snapshot = crate::project::inspect(&package_args.cwd)?;
69
- let package = package::build_report(snapshot, &package_args)?;
103
+ let resolved = resolve_make_targets(&snapshot, args)?;
104
+ let config_warnings = resolved.warnings;
105
+ resolved
106
+ .targets
107
+ .into_iter()
108
+ .map(|target| {
109
+ let package = package::build_report(snapshot.clone(), &package_args)?;
110
+ build_report_for_target(package, target, args, &config_warnings)
111
+ })
112
+ .collect()
113
+ }
114
+
115
+ fn build_report_for_target(
116
+ package: PackageReport,
117
+ target: MakeTarget,
118
+ args: &MakeArgs,
119
+ config_warnings: &[String],
120
+ ) -> Result<MakeReport> {
70
121
  let make_dir = Path::new(package.output_dir().as_str())
71
122
  .join("make")
72
- .join(args.target.as_str())
123
+ .join(target.as_str())
73
124
  .join(package.platform())
74
125
  .join(package.arch());
75
- let artifact = make_artifact_path(&make_dir, &package, args.target);
126
+ let artifact = make_artifact_path(&make_dir, &package, target);
76
127
 
77
128
  let mut warnings = package.warnings().to_vec();
78
- if matches!(args.target, MakeTarget::Deb | MakeTarget::Rpm) && package.platform() != "linux" {
129
+ warnings.extend(config_warnings.iter().cloned());
130
+ if matches!(target, MakeTarget::Deb | MakeTarget::Rpm) && package.platform() != "linux" {
79
131
  warnings.push(format!(
80
132
  "{} maker only supports linux packages; target platform is {}.",
81
- args.target.as_str(),
133
+ target.as_str(),
82
134
  package.platform()
83
135
  ));
84
136
  }
85
- if args.target == MakeTarget::Dmg && package.platform() != "darwin" {
137
+ if target == MakeTarget::Dmg && package.platform() != "darwin" {
86
138
  warnings.push(format!(
87
139
  "dmg maker only supports macOS packages; target platform is {}.",
88
140
  package.platform()
89
141
  ));
90
142
  }
143
+ if target == MakeTarget::Msi && package.platform() != "win32" {
144
+ warnings.push(format!(
145
+ "msi maker only supports Windows packages; target platform is {}.",
146
+ package.platform()
147
+ ));
148
+ }
91
149
  if args.skip_package && !Path::new(package.bundle_dir().as_str()).exists() {
92
150
  warnings.push(format!(
93
151
  "Package output does not exist: {}.",
@@ -104,7 +162,8 @@ pub(crate) fn build_report(args: &MakeArgs) -> Result<MakeReport> {
104
162
 
105
163
  Ok(MakeReport {
106
164
  package,
107
- target: args.target.as_str().to_string(),
165
+ target: target.as_str().to_string(),
166
+ target_kind: target,
108
167
  skip_package: args.skip_package,
109
168
  dry_run: args.dry_run,
110
169
  make_dir: utf8_path(make_dir)?,
@@ -115,17 +174,215 @@ pub(crate) fn build_report(args: &MakeArgs) -> Result<MakeReport> {
115
174
  })
116
175
  }
117
176
 
177
+ struct ConfiguredMaker {
178
+ label: String,
179
+ target: Option<MakeTarget>,
180
+ platforms: Vec<String>,
181
+ }
182
+
183
+ fn resolve_make_targets(
184
+ snapshot: &ProjectSnapshot,
185
+ args: &MakeArgs,
186
+ ) -> Result<ResolvedMakeTargets> {
187
+ if let Some(target) = args.target {
188
+ return Ok(ResolvedMakeTargets {
189
+ targets: vec![target],
190
+ warnings: Vec::new(),
191
+ });
192
+ }
193
+
194
+ let platform = args.platform.clone().unwrap_or_else(current_platform_label);
195
+ let makers = configured_makers(snapshot)?;
196
+ let mut warnings = Vec::new();
197
+ let mut targets = Vec::new();
198
+
199
+ for maker in &makers {
200
+ let Some(target) = maker.target else {
201
+ warnings.push(format!(
202
+ "Configured maker is not implemented yet and will be skipped: {}.",
203
+ maker.label
204
+ ));
205
+ continue;
206
+ };
207
+ if !maker_applies_to_platform(maker, &platform) {
208
+ continue;
209
+ }
210
+ if !targets.contains(&target) {
211
+ targets.push(target);
212
+ }
213
+ }
214
+
215
+ if targets.is_empty() {
216
+ if makers.is_empty() {
217
+ targets.push(MakeTarget::Zip);
218
+ } else {
219
+ warnings.push(format!(
220
+ "No supported configured makers apply to {platform}; defaulting to zip. Pass --target to override."
221
+ ));
222
+ targets.push(MakeTarget::Zip);
223
+ }
224
+ }
225
+
226
+ Ok(ResolvedMakeTargets { targets, warnings })
227
+ }
228
+
229
+ fn configured_makers(snapshot: &ProjectSnapshot) -> Result<Vec<ConfiguredMaker>> {
230
+ let Some(package_json_path) = &snapshot.package_json else {
231
+ return Ok(Vec::new());
232
+ };
233
+ let package_json_path = Path::new(package_json_path.as_str());
234
+ let raw = fs::read_to_string(package_json_path)
235
+ .with_context(|| format!("Could not read {}", package_json_path.display()))?;
236
+ let package = serde_json::from_str::<JsonValue>(&raw)
237
+ .with_context(|| format!("Could not parse {}", package_json_path.display()))?;
238
+
239
+ let mut makers = Vec::new();
240
+ for value in [
241
+ package
242
+ .get("config")
243
+ .and_then(|config| config.get("forge"))
244
+ .and_then(|forge| forge.get("makers")),
245
+ package
246
+ .get("electronCli")
247
+ .or_else(|| package.get("electron-cli"))
248
+ .and_then(|config| config.get("makers")),
249
+ ]
250
+ .into_iter()
251
+ .flatten()
252
+ {
253
+ makers.extend(parse_maker_list(value));
254
+ }
255
+
256
+ Ok(makers)
257
+ }
258
+
259
+ fn parse_maker_list(value: &JsonValue) -> Vec<ConfiguredMaker> {
260
+ match value {
261
+ JsonValue::Array(values) => values.iter().filter_map(parse_maker).collect(),
262
+ _ => Vec::new(),
263
+ }
264
+ }
265
+
266
+ fn parse_maker(value: &JsonValue) -> Option<ConfiguredMaker> {
267
+ match value {
268
+ JsonValue::String(label) => Some(ConfiguredMaker {
269
+ label: label.clone(),
270
+ target: maker_target(label),
271
+ platforms: Vec::new(),
272
+ }),
273
+ JsonValue::Object(object) => {
274
+ let label = object
275
+ .get("name")
276
+ .or_else(|| object.get("target"))
277
+ .or_else(|| object.get("maker"))
278
+ .and_then(JsonValue::as_str)?
279
+ .to_string();
280
+ Some(ConfiguredMaker {
281
+ target: maker_target(&label),
282
+ platforms: string_values(object.get("platforms")),
283
+ label,
284
+ })
285
+ }
286
+ _ => None,
287
+ }
288
+ }
289
+
290
+ fn maker_target(label: &str) -> Option<MakeTarget> {
291
+ let label = label.trim().to_ascii_lowercase();
292
+ let compact = label
293
+ .trim_start_matches("@electron-forge/")
294
+ .trim_start_matches("electron-forge-")
295
+ .trim_start_matches("maker-");
296
+
297
+ if matches!(compact, "zip" | "@electron-forge/maker-zip")
298
+ || label.ends_with("/maker-zip")
299
+ || label.ends_with("maker-zip")
300
+ {
301
+ Some(MakeTarget::Zip)
302
+ } else if compact == "dmg" || label.ends_with("/maker-dmg") || label.ends_with("maker-dmg") {
303
+ Some(MakeTarget::Dmg)
304
+ } else if compact == "deb" || label.ends_with("/maker-deb") || label.ends_with("maker-deb") {
305
+ Some(MakeTarget::Deb)
306
+ } else if compact == "rpm" || label.ends_with("/maker-rpm") || label.ends_with("maker-rpm") {
307
+ Some(MakeTarget::Rpm)
308
+ } else if matches!(compact, "msi" | "wix")
309
+ || label.ends_with("/maker-wix")
310
+ || label.ends_with("maker-wix")
311
+ {
312
+ Some(MakeTarget::Msi)
313
+ } else {
314
+ None
315
+ }
316
+ }
317
+
318
+ fn maker_applies_to_platform(maker: &ConfiguredMaker, platform: &str) -> bool {
319
+ maker.platforms.is_empty()
320
+ || maker
321
+ .platforms
322
+ .iter()
323
+ .any(|configured| configured == platform || configured == "*")
324
+ }
325
+
326
+ fn string_values(value: Option<&JsonValue>) -> Vec<String> {
327
+ match value {
328
+ Some(JsonValue::String(value)) => vec![value.clone()],
329
+ Some(JsonValue::Array(values)) => values
330
+ .iter()
331
+ .filter_map(JsonValue::as_str)
332
+ .map(ToOwned::to_owned)
333
+ .collect(),
334
+ _ => Vec::new(),
335
+ }
336
+ }
337
+
338
+ fn current_platform_label() -> String {
339
+ if cfg!(target_os = "macos") {
340
+ "darwin".to_string()
341
+ } else if cfg!(target_os = "windows") {
342
+ "win32".to_string()
343
+ } else {
344
+ "linux".to_string()
345
+ }
346
+ }
347
+
118
348
  pub(crate) fn execute_make(report: &mut MakeReport, args: &MakeArgs) -> Result<()> {
349
+ ensure_package_ready(std::slice::from_mut(report), args)?;
350
+ execute_make_artifact(report, args)?;
351
+ Ok(())
352
+ }
353
+
354
+ pub(crate) fn execute_make_reports(reports: &mut [MakeReport], args: &MakeArgs) -> Result<()> {
355
+ if reports.is_empty() {
356
+ bail!("No make targets were resolved.");
357
+ }
358
+ ensure_package_ready(reports, args)?;
359
+ for report in reports {
360
+ execute_make_artifact(report, args)?;
361
+ report.mark_made()?;
362
+ }
363
+ Ok(())
364
+ }
365
+
366
+ fn ensure_package_ready(reports: &mut [MakeReport], args: &MakeArgs) -> Result<()> {
367
+ let first = reports
368
+ .first_mut()
369
+ .context("No make targets were resolved.")?;
119
370
  if !args.skip_package {
120
- package::execute_package(&report.package, args.force)?;
121
- report.package.mark_packaged();
122
- } else if !Path::new(report.package.bundle_dir().as_str()).exists() {
371
+ package::execute_package(&first.package, args.force)?;
372
+ for report in reports {
373
+ report.package.mark_packaged();
374
+ }
375
+ } else if !Path::new(first.package.bundle_dir().as_str()).exists() {
123
376
  bail!(
124
377
  "Package output does not exist: {}. Run without --skip-package or run electron-cli package first.",
125
- report.package.bundle_dir()
378
+ first.package.bundle_dir()
126
379
  );
127
380
  }
128
381
 
382
+ Ok(())
383
+ }
384
+
385
+ fn execute_make_artifact(report: &mut MakeReport, args: &MakeArgs) -> Result<()> {
129
386
  let artifact = Path::new(report.artifact.as_str());
130
387
  if artifact.exists() {
131
388
  if args.force {
@@ -141,12 +398,13 @@ pub(crate) fn execute_make(report: &mut MakeReport, args: &MakeArgs) -> Result<(
141
398
 
142
399
  fs::create_dir_all(report.make_dir.as_str())
143
400
  .with_context(|| format!("Could not create {}", report.make_dir))?;
144
- match args.target {
401
+ match report.target_kind {
145
402
  MakeTarget::Zip => {
146
403
  write_zip_archive(Path::new(report.package.bundle_dir().as_str()), artifact)?
147
404
  }
148
405
  MakeTarget::Deb => write_deb_archive(&report.package, artifact)?,
149
406
  MakeTarget::Dmg => write_dmg_archive(&report.package, artifact)?,
407
+ MakeTarget::Msi => write_msi_archive(&report.package, artifact)?,
150
408
  MakeTarget::Rpm => write_rpm_archive(&report.package, artifact)?,
151
409
  }
152
410
 
@@ -193,6 +451,69 @@ fn print_report(report: &MakeReport, json: bool) -> Result<()> {
193
451
  Ok(())
194
452
  }
195
453
 
454
+ fn print_reports(reports: &[MakeReport], json: bool, status: MakeStatus) -> Result<()> {
455
+ if reports.len() == 1 {
456
+ return print_report(&reports[0], json);
457
+ }
458
+
459
+ let warnings = combined_warnings(reports);
460
+ if json {
461
+ return output::json(&MakeRunReport {
462
+ targets: reports,
463
+ dry_run: reports.iter().any(|report| report.dry_run),
464
+ status,
465
+ warnings,
466
+ });
467
+ }
468
+
469
+ println!("electron-cli make");
470
+ println!();
471
+ if let Some(first) = reports.first() {
472
+ println!("Project");
473
+ println!(" root: {}", first.package.project().root);
474
+ match first.package.project().package_label() {
475
+ Some(label) => println!(" package: {label}"),
476
+ None => println!(" package: not found"),
477
+ }
478
+ println!(" app name: {}", first.package.app_name());
479
+ println!(
480
+ " target platform: {} {}",
481
+ first.package.platform(),
482
+ first.package.arch()
483
+ );
484
+ println!(" status: {}", status.as_str());
485
+ }
486
+
487
+ println!();
488
+ println!("Artifacts");
489
+ for report in reports {
490
+ println!(" {}: {}", report.target, report.artifact);
491
+ if let Some(size) = report.artifact_size {
492
+ println!(" size: {size} bytes");
493
+ }
494
+ }
495
+
496
+ if !warnings.is_empty() {
497
+ println!();
498
+ println!("Warnings");
499
+ for warning in warnings {
500
+ println!(" {warning}");
501
+ }
502
+ }
503
+
504
+ Ok(())
505
+ }
506
+
507
+ fn combined_warnings(reports: &[MakeReport]) -> Vec<String> {
508
+ let mut warnings = Vec::new();
509
+ for warning in reports.iter().flat_map(|report| report.warnings()) {
510
+ if !warnings.contains(warning) {
511
+ warnings.push(warning.clone());
512
+ }
513
+ }
514
+ warnings
515
+ }
516
+
196
517
  fn make_artifact_path(make_dir: &Path, package: &PackageReport, target: MakeTarget) -> PathBuf {
197
518
  match target {
198
519
  MakeTarget::Zip => make_dir.join(format!(
@@ -213,6 +534,12 @@ fn make_artifact_path(make_dir: &Path, package: &PackageReport, target: MakeTarg
213
534
  dmg_version(package.project().version.as_deref()),
214
535
  package.arch()
215
536
  )),
537
+ MakeTarget::Msi => make_dir.join(format!(
538
+ "{}-{}-{}.msi",
539
+ package.artifact_stem(),
540
+ windows_artifact_version(package.project().version.as_deref()),
541
+ windows_arch(package.arch())
542
+ )),
216
543
  MakeTarget::Rpm => make_dir.join(format!(
217
544
  "{}-{}-1.{}.rpm",
218
545
  rpm_package_name(&package.artifact_stem()),
@@ -655,6 +982,627 @@ fn write_rpm_archive(package: &PackageReport, artifact: &Path) -> Result<()> {
655
982
  .with_context(|| format!("Could not write {}", artifact.display()))
656
983
  }
657
984
 
985
+ #[derive(Debug)]
986
+ struct MsiPayload {
987
+ directories: Vec<MsiDirectoryEntry>,
988
+ files: Vec<MsiFileEntry>,
989
+ shortcut_component: Option<String>,
990
+ shortcut_target_file: Option<String>,
991
+ }
992
+
993
+ #[derive(Debug)]
994
+ struct MsiDirectoryEntry {
995
+ id: String,
996
+ parent_id: String,
997
+ name: String,
998
+ }
999
+
1000
+ #[derive(Debug)]
1001
+ struct MsiFileEntry {
1002
+ id: String,
1003
+ component_id: String,
1004
+ component_guid: String,
1005
+ directory_id: String,
1006
+ source: PathBuf,
1007
+ file_name: String,
1008
+ cabinet_name: String,
1009
+ size: i32,
1010
+ sequence: i32,
1011
+ }
1012
+
1013
+ fn write_msi_archive(package: &PackageReport, artifact: &Path) -> Result<()> {
1014
+ if package.platform() != "win32" {
1015
+ bail!(
1016
+ "MSI maker only supports Windows packages. Requested {}.",
1017
+ package.platform()
1018
+ );
1019
+ }
1020
+
1021
+ let source = Path::new(package.bundle_dir().as_str());
1022
+ if !source.exists() {
1023
+ bail!("Package output does not exist: {}", source.display());
1024
+ }
1025
+
1026
+ let parent = artifact
1027
+ .parent()
1028
+ .with_context(|| format!("Artifact path has no parent: {}", artifact.display()))?;
1029
+ fs::create_dir_all(parent).with_context(|| format!("Could not create {}", parent.display()))?;
1030
+
1031
+ let payload = collect_msi_payload(package, source)?;
1032
+ if payload.files.is_empty() {
1033
+ bail!(
1034
+ "MSI maker requires at least one packaged file in {}",
1035
+ source.display()
1036
+ );
1037
+ }
1038
+
1039
+ let cabinet = create_msi_cabinet(&payload)?;
1040
+ let file = fs::OpenOptions::new()
1041
+ .read(true)
1042
+ .write(true)
1043
+ .create(true)
1044
+ .truncate(true)
1045
+ .open(artifact)
1046
+ .with_context(|| format!("Could not create {}", artifact.display()))?;
1047
+ let mut installer =
1048
+ Package::create(PackageType::Installer, file).context("Could not create MSI package")?;
1049
+
1050
+ write_msi_summary(&mut installer, package)?;
1051
+ create_msi_tables(&mut installer)?;
1052
+ insert_msi_rows(&mut installer, package, &payload)?;
1053
+ {
1054
+ let mut stream = installer
1055
+ .write_stream("app.cab")
1056
+ .context("Could not create embedded MSI cabinet stream")?;
1057
+ stream
1058
+ .write_all(&cabinet)
1059
+ .context("Could not write embedded MSI cabinet stream")?;
1060
+ }
1061
+ installer.flush().context("Could not flush MSI package")?;
1062
+ installer
1063
+ .into_inner()
1064
+ .context("Could not finish MSI package")?;
1065
+
1066
+ Ok(())
1067
+ }
1068
+
1069
+ fn write_msi_summary(installer: &mut Package<File>, package: &PackageReport) -> Result<()> {
1070
+ let package_code = deterministic_guid(
1071
+ "package-code",
1072
+ &[
1073
+ package.app_name(),
1074
+ package.project().version.as_deref().unwrap_or("0.1.0"),
1075
+ package.arch(),
1076
+ ],
1077
+ );
1078
+ let arch = msi_summary_arch(package.arch());
1079
+ let language = Language::from_code(1033);
1080
+ let summary = installer.summary_info_mut();
1081
+ summary.set_title(format!("{} Installer", package.app_name()));
1082
+ summary.set_subject(package.app_name().to_string());
1083
+ summary.set_author("electron-cli".to_string());
1084
+ summary.set_comments(format!(
1085
+ "{} packaged by electron-cli.",
1086
+ single_line(package.app_name())
1087
+ ));
1088
+ summary.set_creating_application("electron-cli".to_string());
1089
+ summary.set_uuid(package_code);
1090
+ summary.set_arch(arch.to_string());
1091
+ summary.set_languages(&[language]);
1092
+ summary.set_page_count(200);
1093
+ summary.set_word_count(2);
1094
+ Ok(())
1095
+ }
1096
+
1097
+ fn create_msi_tables(installer: &mut Package<File>) -> Result<()> {
1098
+ create_msi_table(
1099
+ installer,
1100
+ "Property",
1101
+ vec![
1102
+ Column::build("Property").primary_key().id_string(72),
1103
+ Column::build("Value").nullable().formatted_string(0),
1104
+ ],
1105
+ )?;
1106
+ create_msi_table(
1107
+ installer,
1108
+ "Directory",
1109
+ vec![
1110
+ Column::build("Directory").primary_key().id_string(72),
1111
+ Column::build("Directory_Parent").nullable().id_string(72),
1112
+ Column::build("DefaultDir").text_string(255),
1113
+ ],
1114
+ )?;
1115
+ create_msi_table(
1116
+ installer,
1117
+ "Feature",
1118
+ vec![
1119
+ Column::build("Feature").primary_key().id_string(38),
1120
+ Column::build("Feature_Parent").nullable().id_string(38),
1121
+ Column::build("Title").nullable().text_string(64),
1122
+ Column::build("Description").nullable().text_string(255),
1123
+ Column::build("Display").nullable().int16(),
1124
+ Column::build("Level").int16(),
1125
+ Column::build("Directory_").nullable().id_string(72),
1126
+ Column::build("Attributes").int16(),
1127
+ ],
1128
+ )?;
1129
+ create_msi_table(
1130
+ installer,
1131
+ "Component",
1132
+ vec![
1133
+ Column::build("Component").primary_key().id_string(72),
1134
+ Column::build("ComponentId").nullable().string(38),
1135
+ Column::build("Directory_").id_string(72),
1136
+ Column::build("Attributes").int16(),
1137
+ Column::build("Condition").nullable().formatted_string(255),
1138
+ Column::build("KeyPath").nullable().id_string(72),
1139
+ ],
1140
+ )?;
1141
+ create_msi_table(
1142
+ installer,
1143
+ "FeatureComponents",
1144
+ vec![
1145
+ Column::build("Feature_").primary_key().id_string(38),
1146
+ Column::build("Component_").primary_key().id_string(72),
1147
+ ],
1148
+ )?;
1149
+ create_msi_table(
1150
+ installer,
1151
+ "File",
1152
+ vec![
1153
+ Column::build("File").primary_key().id_string(72),
1154
+ Column::build("Component_").id_string(72),
1155
+ Column::build("FileName").text_string(255),
1156
+ Column::build("FileSize").int32(),
1157
+ Column::build("Version").nullable().string(72),
1158
+ Column::build("Language").nullable().string(20),
1159
+ Column::build("Attributes").nullable().int16(),
1160
+ Column::build("Sequence").int16(),
1161
+ ],
1162
+ )?;
1163
+ create_msi_table(
1164
+ installer,
1165
+ "Media",
1166
+ vec![
1167
+ Column::build("DiskId").primary_key().int16(),
1168
+ Column::build("LastSequence").int16(),
1169
+ Column::build("DiskPrompt").nullable().text_string(64),
1170
+ Column::build("Cabinet").nullable().string(255),
1171
+ Column::build("VolumeLabel").nullable().string(32),
1172
+ Column::build("Source").nullable().string(72),
1173
+ ],
1174
+ )?;
1175
+ create_msi_table(
1176
+ installer,
1177
+ "Shortcut",
1178
+ vec![
1179
+ Column::build("Shortcut").primary_key().id_string(72),
1180
+ Column::build("Directory_").id_string(72),
1181
+ Column::build("Name").text_string(128),
1182
+ Column::build("Component_").id_string(72),
1183
+ Column::build("Target").formatted_string(0),
1184
+ Column::build("Arguments").nullable().formatted_string(255),
1185
+ Column::build("Description").nullable().text_string(255),
1186
+ Column::build("Hotkey").nullable().int16(),
1187
+ Column::build("Icon_").nullable().id_string(72),
1188
+ Column::build("IconIndex").nullable().int16(),
1189
+ Column::build("ShowCmd").nullable().int16(),
1190
+ Column::build("WkDir").nullable().id_string(72),
1191
+ ],
1192
+ )?;
1193
+ create_msi_table(
1194
+ installer,
1195
+ "RemoveFile",
1196
+ vec![
1197
+ Column::build("FileKey").primary_key().id_string(72),
1198
+ Column::build("Component_").id_string(72),
1199
+ Column::build("FileName").nullable().text_string(255),
1200
+ Column::build("DirProperty").id_string(72),
1201
+ Column::build("InstallMode").int16(),
1202
+ ],
1203
+ )?;
1204
+ create_msi_table(
1205
+ installer,
1206
+ "InstallExecuteSequence",
1207
+ vec![
1208
+ Column::build("Action").primary_key().id_string(72),
1209
+ Column::build("Condition").nullable().formatted_string(255),
1210
+ Column::build("Sequence").nullable().int16(),
1211
+ ],
1212
+ )?;
1213
+ create_msi_table(
1214
+ installer,
1215
+ "ActionText",
1216
+ vec![
1217
+ Column::build("Action").primary_key().id_string(72),
1218
+ Column::build("Description").nullable().text_string(64),
1219
+ Column::build("Template").nullable().formatted_string(128),
1220
+ ],
1221
+ )
1222
+ }
1223
+
1224
+ fn create_msi_table(installer: &mut Package<File>, name: &str, columns: Vec<Column>) -> Result<()> {
1225
+ installer
1226
+ .create_table(name, columns)
1227
+ .with_context(|| format!("Could not create MSI {name} table"))
1228
+ }
1229
+
1230
+ fn insert_msi_rows(
1231
+ installer: &mut Package<File>,
1232
+ package: &PackageReport,
1233
+ payload: &MsiPayload,
1234
+ ) -> Result<()> {
1235
+ let product_version = msi_product_version(package.project().version.as_deref());
1236
+ let product_code = msi_guid(deterministic_guid(
1237
+ "product-code",
1238
+ &[package.app_name(), &product_version, package.arch()],
1239
+ ));
1240
+ let upgrade_code = msi_guid(deterministic_guid(
1241
+ "upgrade-code",
1242
+ &[
1243
+ package.app_name(),
1244
+ package.project().name.as_deref().unwrap_or(""),
1245
+ ],
1246
+ ));
1247
+ insert_msi_table_rows(
1248
+ installer,
1249
+ "Property",
1250
+ vec![
1251
+ vec![s("ProductCode"), s(product_code)],
1252
+ vec![s("ProductLanguage"), s("1033")],
1253
+ vec![s("ProductName"), s(package.app_name())],
1254
+ vec![s("ProductVersion"), s(product_version)],
1255
+ vec![s("Manufacturer"), s("electron-cli")],
1256
+ vec![s("UpgradeCode"), s(upgrade_code)],
1257
+ vec![s("ALLUSERS"), s("1")],
1258
+ vec![s("INSTALLLEVEL"), s("1")],
1259
+ ],
1260
+ )?;
1261
+
1262
+ let program_files_dir = msi_program_files_directory(package.arch());
1263
+ let install_folder = msi_filename("APPDIR", package.app_name());
1264
+ insert_msi_table_rows(
1265
+ installer,
1266
+ "Directory",
1267
+ vec![
1268
+ vec![s("TARGETDIR"), Value::Null, s("SourceDir")],
1269
+ vec![s(program_files_dir), s("TARGETDIR"), s(".")],
1270
+ vec![s("INSTALLFOLDER"), s(program_files_dir), s(install_folder)],
1271
+ vec![s("ProgramMenuFolder"), s("TARGETDIR"), s(".")],
1272
+ vec![
1273
+ s("ApplicationProgramsFolder"),
1274
+ s("ProgramMenuFolder"),
1275
+ s(msi_filename("APPMENU", package.app_name())),
1276
+ ],
1277
+ ],
1278
+ )?;
1279
+ insert_msi_table_rows(
1280
+ installer,
1281
+ "Directory",
1282
+ payload
1283
+ .directories
1284
+ .iter()
1285
+ .map(|directory| {
1286
+ vec![
1287
+ s(&directory.id),
1288
+ s(&directory.parent_id),
1289
+ s(&directory.name),
1290
+ ]
1291
+ })
1292
+ .collect(),
1293
+ )?;
1294
+
1295
+ insert_msi_table_rows(
1296
+ installer,
1297
+ "Feature",
1298
+ vec![vec![
1299
+ s("MainFeature"),
1300
+ Value::Null,
1301
+ s(package.app_name()),
1302
+ s(format!("Install {}.", single_line(package.app_name()))),
1303
+ Value::from(1),
1304
+ Value::from(1),
1305
+ s("INSTALLFOLDER"),
1306
+ Value::from(0),
1307
+ ]],
1308
+ )?;
1309
+
1310
+ let component_attributes = msi_component_attributes(package.arch());
1311
+ insert_msi_table_rows(
1312
+ installer,
1313
+ "Component",
1314
+ payload
1315
+ .files
1316
+ .iter()
1317
+ .map(|file| {
1318
+ vec![
1319
+ s(&file.component_id),
1320
+ s(&file.component_guid),
1321
+ s(&file.directory_id),
1322
+ Value::from(component_attributes),
1323
+ Value::Null,
1324
+ s(&file.id),
1325
+ ]
1326
+ })
1327
+ .collect(),
1328
+ )?;
1329
+ insert_msi_table_rows(
1330
+ installer,
1331
+ "FeatureComponents",
1332
+ payload
1333
+ .files
1334
+ .iter()
1335
+ .map(|file| vec![s("MainFeature"), s(&file.component_id)])
1336
+ .collect(),
1337
+ )?;
1338
+ insert_msi_table_rows(
1339
+ installer,
1340
+ "File",
1341
+ payload
1342
+ .files
1343
+ .iter()
1344
+ .map(|file| {
1345
+ vec![
1346
+ s(&file.id),
1347
+ s(&file.component_id),
1348
+ s(&file.file_name),
1349
+ Value::from(file.size),
1350
+ Value::Null,
1351
+ Value::Null,
1352
+ Value::from(0),
1353
+ Value::from(file.sequence),
1354
+ ]
1355
+ })
1356
+ .collect(),
1357
+ )?;
1358
+ insert_msi_table_rows(
1359
+ installer,
1360
+ "Media",
1361
+ vec![vec![
1362
+ Value::from(1),
1363
+ Value::from(payload.files.len() as i32),
1364
+ Value::Null,
1365
+ s("#app.cab"),
1366
+ Value::Null,
1367
+ Value::Null,
1368
+ ]],
1369
+ )?;
1370
+
1371
+ if let (Some(component), Some(target_file)) =
1372
+ (&payload.shortcut_component, &payload.shortcut_target_file)
1373
+ {
1374
+ insert_msi_table_rows(
1375
+ installer,
1376
+ "Shortcut",
1377
+ vec![vec![
1378
+ s("ApplicationShortcut"),
1379
+ s("ApplicationProgramsFolder"),
1380
+ s(msi_filename("SHORTCUT", package.app_name())),
1381
+ s(component),
1382
+ s(format!("[#{target_file}]")),
1383
+ Value::Null,
1384
+ s(format!("Launch {}.", single_line(package.app_name()))),
1385
+ Value::Null,
1386
+ Value::Null,
1387
+ Value::Null,
1388
+ Value::Null,
1389
+ s("INSTALLFOLDER"),
1390
+ ]],
1391
+ )?;
1392
+ insert_msi_table_rows(
1393
+ installer,
1394
+ "RemoveFile",
1395
+ vec![vec![
1396
+ s("RemoveStartMenuFolder"),
1397
+ s(component),
1398
+ Value::Null,
1399
+ s("ApplicationProgramsFolder"),
1400
+ Value::from(2),
1401
+ ]],
1402
+ )?;
1403
+ }
1404
+
1405
+ insert_msi_table_rows(
1406
+ installer,
1407
+ "InstallExecuteSequence",
1408
+ vec![
1409
+ standard_action("CostInitialize", 800),
1410
+ standard_action("FileCost", 900),
1411
+ standard_action("CostFinalize", 1000),
1412
+ standard_action("InstallValidate", 1400),
1413
+ standard_action("InstallInitialize", 1500),
1414
+ standard_action("ProcessComponents", 1600),
1415
+ standard_action("UnpublishFeatures", 1800),
1416
+ standard_action("RemoveShortcuts", 3200),
1417
+ standard_action("RemoveFiles", 3500),
1418
+ standard_action("InstallFiles", 4000),
1419
+ standard_action("CreateShortcuts", 4500),
1420
+ standard_action("RegisterUser", 6000),
1421
+ standard_action("RegisterProduct", 6100),
1422
+ standard_action("PublishFeatures", 6300),
1423
+ standard_action("PublishProduct", 6400),
1424
+ standard_action("InstallFinalize", 6600),
1425
+ ],
1426
+ )?;
1427
+ insert_msi_table_rows(
1428
+ installer,
1429
+ "ActionText",
1430
+ vec![
1431
+ action_text(
1432
+ "InstallFiles",
1433
+ "Copying new files",
1434
+ "File: [1], Directory: [9], Size: [6]",
1435
+ ),
1436
+ action_text("CreateShortcuts", "Creating shortcuts", "Shortcut: [1]"),
1437
+ action_text("RemoveFiles", "Removing files", "File: [1], Directory: [9]"),
1438
+ action_text("RemoveShortcuts", "Removing shortcuts", "Shortcut: [1]"),
1439
+ ],
1440
+ )
1441
+ }
1442
+
1443
+ fn insert_msi_table_rows(
1444
+ installer: &mut Package<File>,
1445
+ table: &str,
1446
+ rows: Vec<Vec<Value>>,
1447
+ ) -> Result<()> {
1448
+ if rows.is_empty() {
1449
+ return Ok(());
1450
+ }
1451
+ installer
1452
+ .insert_rows(Insert::into(table).rows(rows))
1453
+ .with_context(|| format!("Could not insert MSI {table} rows"))
1454
+ }
1455
+
1456
+ fn standard_action(action: &str, sequence: i32) -> Vec<Value> {
1457
+ vec![s(action), Value::Null, Value::from(sequence)]
1458
+ }
1459
+
1460
+ fn action_text(action: &str, description: &str, template: &str) -> Vec<Value> {
1461
+ vec![s(action), s(description), s(template)]
1462
+ }
1463
+
1464
+ fn collect_msi_payload(package: &PackageReport, source: &Path) -> Result<MsiPayload> {
1465
+ let mut payload = MsiPayload {
1466
+ directories: Vec::new(),
1467
+ files: Vec::new(),
1468
+ shortcut_component: None,
1469
+ shortcut_target_file: None,
1470
+ };
1471
+ let mut directory_ids = BTreeMap::from([(PathBuf::new(), "INSTALLFOLDER".to_string())]);
1472
+ collect_msi_directory(
1473
+ package,
1474
+ source,
1475
+ Path::new(""),
1476
+ "INSTALLFOLDER",
1477
+ &mut directory_ids,
1478
+ &mut payload,
1479
+ )?;
1480
+
1481
+ if payload.files.len() > i16::MAX as usize {
1482
+ bail!(
1483
+ "MSI maker supports up to {} files; package contains {}.",
1484
+ i16::MAX,
1485
+ payload.files.len()
1486
+ );
1487
+ }
1488
+
1489
+ Ok(payload)
1490
+ }
1491
+
1492
+ fn collect_msi_directory(
1493
+ package: &PackageReport,
1494
+ source: &Path,
1495
+ relative_dir: &Path,
1496
+ directory_id: &str,
1497
+ directory_ids: &mut BTreeMap<PathBuf, String>,
1498
+ payload: &mut MsiPayload,
1499
+ ) -> Result<()> {
1500
+ let mut entries = fs::read_dir(source)
1501
+ .with_context(|| format!("Could not read {}", source.display()))?
1502
+ .collect::<Result<Vec<_>, io::Error>>()?;
1503
+ entries.sort_by_key(|entry| entry.path());
1504
+
1505
+ for entry in entries {
1506
+ let path = entry.path();
1507
+ let file_name = utf8_file_name(&path)?.to_string();
1508
+ let relative_path = relative_dir.join(&file_name);
1509
+ let metadata = fs::symlink_metadata(&path)
1510
+ .with_context(|| format!("Could not stat {}", path.display()))?;
1511
+
1512
+ if metadata.file_type().is_symlink() {
1513
+ bail!(
1514
+ "MSI maker does not support symbolic links yet: {}",
1515
+ path.display()
1516
+ );
1517
+ }
1518
+
1519
+ if metadata.is_dir() {
1520
+ let dir_id = format!("D{:04}", directory_ids.len());
1521
+ directory_ids.insert(relative_path.clone(), dir_id.clone());
1522
+ payload.directories.push(MsiDirectoryEntry {
1523
+ id: dir_id.clone(),
1524
+ parent_id: directory_id.to_string(),
1525
+ name: msi_filename(&dir_id, &file_name),
1526
+ });
1527
+ collect_msi_directory(
1528
+ package,
1529
+ &path,
1530
+ &relative_path,
1531
+ &dir_id,
1532
+ directory_ids,
1533
+ payload,
1534
+ )?;
1535
+ } else if metadata.is_file() {
1536
+ let sequence = payload.files.len() + 1;
1537
+ let size = i32::try_from(metadata.len())
1538
+ .with_context(|| format!("MSI file is too large: {}", path.display()))?;
1539
+ let file_id = format!("F{sequence:04}");
1540
+ let component_id = format!("C{sequence:04}");
1541
+ let relative_key = relative_path.to_string_lossy().replace('\\', "/");
1542
+ let component_guid = msi_guid(deterministic_guid(
1543
+ "component",
1544
+ &[
1545
+ package.app_name(),
1546
+ package.project().name.as_deref().unwrap_or(""),
1547
+ &relative_key,
1548
+ ],
1549
+ ));
1550
+ let entry = MsiFileEntry {
1551
+ id: file_id.clone(),
1552
+ component_id: component_id.clone(),
1553
+ component_guid,
1554
+ directory_id: directory_id.to_string(),
1555
+ source: path.clone(),
1556
+ file_name: msi_filename(&file_id, &file_name),
1557
+ cabinet_name: file_id.clone(),
1558
+ size,
1559
+ sequence: sequence as i32,
1560
+ };
1561
+
1562
+ if file_name.eq_ignore_ascii_case(package.executable_name()) {
1563
+ payload.shortcut_component = Some(component_id);
1564
+ payload.shortcut_target_file = Some(file_id);
1565
+ }
1566
+
1567
+ payload.files.push(entry);
1568
+ }
1569
+ }
1570
+
1571
+ Ok(())
1572
+ }
1573
+
1574
+ fn create_msi_cabinet(payload: &MsiPayload) -> Result<Vec<u8>> {
1575
+ let mut builder = CabinetBuilder::new();
1576
+ {
1577
+ let folder = builder.add_folder(CabCompressionType::MsZip);
1578
+ for file in &payload.files {
1579
+ folder.add_file(&file.cabinet_name);
1580
+ }
1581
+ }
1582
+
1583
+ let cursor = Cursor::new(Vec::new());
1584
+ let mut cabinet = builder
1585
+ .build(cursor)
1586
+ .context("Could not start MSI cabinet")?;
1587
+ for file in &payload.files {
1588
+ let mut writer = cabinet
1589
+ .next_file()
1590
+ .context("Could not open next MSI cabinet file")?
1591
+ .context("MSI cabinet writer finished before all files were written")?;
1592
+ anyhow::ensure!(
1593
+ writer.file_name() == file.cabinet_name,
1594
+ "MSI cabinet file order drifted while writing {}",
1595
+ file.source.display()
1596
+ );
1597
+ let mut source = File::open(&file.source)
1598
+ .with_context(|| format!("Could not open {}", file.source.display()))?;
1599
+ io::copy(&mut source, &mut writer)
1600
+ .with_context(|| format!("Could not add {} to MSI cabinet", file.source.display()))?;
1601
+ }
1602
+ let cursor = cabinet.finish().context("Could not finish MSI cabinet")?;
1603
+ Ok(cursor.into_inner())
1604
+ }
1605
+
658
1606
  fn debian_control_file(
659
1607
  package: &PackageReport,
660
1608
  deb_package: &str,
@@ -1024,6 +1972,51 @@ fn rpm_version(version: Option<&str>) -> String {
1024
1972
  }
1025
1973
  }
1026
1974
 
1975
+ fn windows_artifact_version(version: Option<&str>) -> String {
1976
+ let version = version.unwrap_or("0.1.0");
1977
+ let sanitized = version
1978
+ .chars()
1979
+ .map(|char| {
1980
+ if char.is_ascii_alphanumeric() || matches!(char, '.' | '-' | '_') {
1981
+ char
1982
+ } else {
1983
+ '-'
1984
+ }
1985
+ })
1986
+ .collect::<String>()
1987
+ .trim_matches(['-', '.', '_'])
1988
+ .to_string();
1989
+
1990
+ if sanitized.is_empty() {
1991
+ "0.1.0".to_string()
1992
+ } else {
1993
+ sanitized
1994
+ }
1995
+ }
1996
+
1997
+ fn msi_product_version(version: Option<&str>) -> String {
1998
+ let mut numbers = version
1999
+ .unwrap_or("0.1.0")
2000
+ .split(|char: char| !char.is_ascii_digit())
2001
+ .filter(|part| !part.is_empty())
2002
+ .filter_map(|part| part.parse::<u32>().ok())
2003
+ .take(3)
2004
+ .collect::<Vec<_>>();
2005
+ while numbers.len() < 3 {
2006
+ numbers.push(0);
2007
+ }
2008
+ if numbers.iter().all(|number| *number == 0) {
2009
+ numbers = vec![0, 1, 0];
2010
+ }
2011
+
2012
+ format!(
2013
+ "{}.{}.{}",
2014
+ numbers[0].min(255),
2015
+ numbers[1].min(255),
2016
+ numbers[2].min(65_535)
2017
+ )
2018
+ }
2019
+
1027
2020
  fn debian_arch(arch: &str) -> String {
1028
2021
  match arch {
1029
2022
  "x64" => "amd64".to_string(),
@@ -1043,6 +2036,99 @@ fn rpm_arch(arch: &str) -> String {
1043
2036
  }
1044
2037
  }
1045
2038
 
2039
+ fn windows_arch(arch: &str) -> String {
2040
+ match arch {
2041
+ "ia32" => "x86".to_string(),
2042
+ arch => arch.to_string(),
2043
+ }
2044
+ }
2045
+
2046
+ fn msi_summary_arch(arch: &str) -> &'static str {
2047
+ match arch {
2048
+ "x64" => "x64",
2049
+ "arm64" => "Arm64",
2050
+ _ => "Intel",
2051
+ }
2052
+ }
2053
+
2054
+ fn msi_program_files_directory(arch: &str) -> &'static str {
2055
+ match arch {
2056
+ "x64" | "arm64" => "ProgramFiles64Folder",
2057
+ _ => "ProgramFilesFolder",
2058
+ }
2059
+ }
2060
+
2061
+ fn msi_component_attributes(arch: &str) -> i32 {
2062
+ match arch {
2063
+ "x64" | "arm64" => 256,
2064
+ _ => 0,
2065
+ }
2066
+ }
2067
+
2068
+ fn msi_filename(id: &str, long_name: &str) -> String {
2069
+ if is_msi_short_name(long_name) {
2070
+ return long_name.to_string();
2071
+ }
2072
+
2073
+ let extension = Path::new(long_name)
2074
+ .extension()
2075
+ .and_then(|extension| extension.to_str())
2076
+ .map(|extension| {
2077
+ extension
2078
+ .chars()
2079
+ .filter(|char| char.is_ascii_alphanumeric())
2080
+ .take(3)
2081
+ .collect::<String>()
2082
+ })
2083
+ .filter(|extension| !extension.is_empty());
2084
+ let stem = id
2085
+ .chars()
2086
+ .filter(|char| char.is_ascii_alphanumeric())
2087
+ .take(8)
2088
+ .collect::<String>();
2089
+ let short = match extension {
2090
+ Some(extension) => format!("{stem}.{extension}"),
2091
+ None => stem,
2092
+ };
2093
+ format!("{short}|{long_name}")
2094
+ }
2095
+
2096
+ fn is_msi_short_name(name: &str) -> bool {
2097
+ let Some(file_name) = Path::new(name).file_name().and_then(|name| name.to_str()) else {
2098
+ return false;
2099
+ };
2100
+ if file_name != name || file_name.is_empty() || file_name.contains(' ') {
2101
+ return false;
2102
+ }
2103
+
2104
+ let mut parts = file_name.split('.');
2105
+ let stem = parts.next().unwrap_or_default();
2106
+ let extension = parts.next();
2107
+ if parts.next().is_some() || stem.is_empty() || stem.len() > 8 {
2108
+ return false;
2109
+ }
2110
+ if extension.is_some_and(|extension| extension.is_empty() || extension.len() > 3) {
2111
+ return false;
2112
+ }
2113
+
2114
+ file_name
2115
+ .chars()
2116
+ .all(|char| char.is_ascii_alphanumeric() || matches!(char, '_' | '$' | '~' | '!' | '#'))
2117
+ }
2118
+
2119
+ fn deterministic_guid(kind: &str, parts: &[&str]) -> Uuid {
2120
+ let key = format!("electron-cli:{kind}:{}", parts.join(":"));
2121
+ Uuid::new_v5(&Uuid::NAMESPACE_URL, key.as_bytes())
2122
+ }
2123
+
2124
+ fn msi_guid(uuid: Uuid) -> String {
2125
+ format!("{{{}}}", uuid.hyphenated()).to_ascii_uppercase()
2126
+ }
2127
+
2128
+ fn s(value: impl Into<String>) -> Value {
2129
+ Value::from(value.into())
2130
+ }
2131
+
1046
2132
  fn single_line(value: &str) -> String {
1047
2133
  value
1048
2134
  .chars()
@@ -1133,7 +2219,7 @@ mod tests {
1133
2219
  name: None,
1134
2220
  platform: None,
1135
2221
  arch: None,
1136
- target: crate::cli::MakeTarget::Zip,
2222
+ target: Some(crate::cli::MakeTarget::Zip),
1137
2223
  skip_package: false,
1138
2224
  force: false,
1139
2225
  dry_run: true,
@@ -1170,7 +2256,7 @@ mod tests {
1170
2256
  name: None,
1171
2257
  platform: Some("linux".to_string()),
1172
2258
  arch: Some("x64".to_string()),
1173
- target: crate::cli::MakeTarget::Deb,
2259
+ target: Some(crate::cli::MakeTarget::Deb),
1174
2260
  skip_package: false,
1175
2261
  force: false,
1176
2262
  dry_run: true,
@@ -1204,7 +2290,7 @@ mod tests {
1204
2290
  name: None,
1205
2291
  platform: Some("darwin".to_string()),
1206
2292
  arch: Some("arm64".to_string()),
1207
- target: crate::cli::MakeTarget::Dmg,
2293
+ target: Some(crate::cli::MakeTarget::Dmg),
1208
2294
  skip_package: false,
1209
2295
  force: false,
1210
2296
  dry_run: true,
@@ -1238,7 +2324,7 @@ mod tests {
1238
2324
  name: None,
1239
2325
  platform: Some("linux".to_string()),
1240
2326
  arch: Some("x64".to_string()),
1241
- target: crate::cli::MakeTarget::Rpm,
2327
+ target: Some(crate::cli::MakeTarget::Rpm),
1242
2328
  skip_package: false,
1243
2329
  force: false,
1244
2330
  dry_run: true,
@@ -1259,6 +2345,113 @@ mod tests {
1259
2345
  let _ = fs::remove_dir_all(root);
1260
2346
  }
1261
2347
 
2348
+ #[test]
2349
+ fn builds_make_report_for_msi_target() {
2350
+ let root = unique_temp_dir("msi-plan");
2351
+ write_package_json(&root);
2352
+ write_app_file(&root);
2353
+ write_fake_electron_dist(&root);
2354
+
2355
+ let args = MakeArgs {
2356
+ cwd: root.clone(),
2357
+ out_dir: PathBuf::from("out"),
2358
+ name: None,
2359
+ platform: Some("win32".to_string()),
2360
+ arch: Some("x64".to_string()),
2361
+ target: Some(crate::cli::MakeTarget::Msi),
2362
+ skip_package: false,
2363
+ force: false,
2364
+ dry_run: true,
2365
+ json: true,
2366
+ };
2367
+ let report = build_report(&args).expect("report should build");
2368
+
2369
+ assert_eq!(report.target, "msi");
2370
+ assert!(Path::new(report.artifact.as_str()).ends_with(
2371
+ PathBuf::from("out")
2372
+ .join("make")
2373
+ .join("msi")
2374
+ .join("win32")
2375
+ .join("x64")
2376
+ .join("starter-app-0.1.0-x64.msi")
2377
+ ));
2378
+
2379
+ let _ = fs::remove_dir_all(root);
2380
+ }
2381
+
2382
+ #[test]
2383
+ fn builds_make_reports_from_configured_forge_makers() {
2384
+ let root = unique_temp_dir("configured-makers");
2385
+ write_package_json_with_makers(
2386
+ &root,
2387
+ r#"[
2388
+ {"name":"@electron-forge/maker-zip"},
2389
+ {"name":"@electron-forge/maker-deb","platforms":["linux"]},
2390
+ {"name":"@electron-forge/maker-rpm","platforms":["darwin"]},
2391
+ {"name":"@electron-forge/maker-squirrel","platforms":["linux"]}
2392
+ ]"#,
2393
+ );
2394
+ write_app_file(&root);
2395
+ write_fake_electron_dist(&root);
2396
+
2397
+ let args = MakeArgs {
2398
+ cwd: root.clone(),
2399
+ out_dir: PathBuf::from("out"),
2400
+ name: None,
2401
+ platform: Some("linux".to_string()),
2402
+ arch: Some("x64".to_string()),
2403
+ target: None,
2404
+ skip_package: false,
2405
+ force: false,
2406
+ dry_run: true,
2407
+ json: true,
2408
+ };
2409
+ let reports = build_reports(&args).expect("reports should build");
2410
+
2411
+ assert_eq!(reports.len(), 2);
2412
+ assert_eq!(reports[0].target(), "zip");
2413
+ assert_eq!(reports[1].target(), "deb");
2414
+ assert!(reports[0]
2415
+ .warnings()
2416
+ .iter()
2417
+ .any(|warning| warning.contains("@electron-forge/maker-squirrel")));
2418
+
2419
+ let _ = fs::remove_dir_all(root);
2420
+ }
2421
+
2422
+ #[test]
2423
+ fn explicit_make_target_overrides_configured_makers() {
2424
+ let root = unique_temp_dir("target-override");
2425
+ write_package_json_with_makers(
2426
+ &root,
2427
+ r#"[{"name":"@electron-forge/maker-zip"},{"name":"@electron-forge/maker-deb"}]"#,
2428
+ );
2429
+ write_app_file(&root);
2430
+ write_fake_electron_dist(&root);
2431
+
2432
+ let args = MakeArgs {
2433
+ cwd: root.clone(),
2434
+ out_dir: PathBuf::from("out"),
2435
+ name: None,
2436
+ platform: Some("win32".to_string()),
2437
+ arch: Some("x64".to_string()),
2438
+ target: Some(crate::cli::MakeTarget::Msi),
2439
+ skip_package: false,
2440
+ force: false,
2441
+ dry_run: true,
2442
+ json: true,
2443
+ };
2444
+ let report = build_report(&args).expect("report should build");
2445
+
2446
+ assert_eq!(report.target(), "msi");
2447
+ assert!(report
2448
+ .warnings()
2449
+ .iter()
2450
+ .all(|warning| !warning.contains("maker-deb")));
2451
+
2452
+ let _ = fs::remove_dir_all(root);
2453
+ }
2454
+
1262
2455
  #[test]
1263
2456
  fn makes_zip_artifact_after_packaging() {
1264
2457
  let root = unique_temp_dir("execute");
@@ -1272,7 +2465,7 @@ mod tests {
1272
2465
  name: None,
1273
2466
  platform: None,
1274
2467
  arch: None,
1275
- target: crate::cli::MakeTarget::Zip,
2468
+ target: Some(crate::cli::MakeTarget::Zip),
1276
2469
  skip_package: false,
1277
2470
  force: false,
1278
2471
  dry_run: false,
@@ -1301,6 +2494,44 @@ mod tests {
1301
2494
  let _ = fs::remove_dir_all(root);
1302
2495
  }
1303
2496
 
2497
+ #[test]
2498
+ fn makes_multiple_configured_artifacts_from_existing_package() {
2499
+ let root = unique_temp_dir("configured-execute");
2500
+ write_package_json_with_makers(
2501
+ &root,
2502
+ r#"[
2503
+ {"name":"@electron-forge/maker-zip","platforms":["win32"]},
2504
+ {"name":"@electron-forge/maker-wix","platforms":["win32"]}
2505
+ ]"#,
2506
+ );
2507
+ write_app_file(&root);
2508
+ write_fake_windows_bundle(&root.join("out/starter-app-win32-x64"), "starter-app.exe");
2509
+
2510
+ let args = MakeArgs {
2511
+ cwd: root.clone(),
2512
+ out_dir: PathBuf::from("out"),
2513
+ name: None,
2514
+ platform: Some("win32".to_string()),
2515
+ arch: Some("x64".to_string()),
2516
+ target: None,
2517
+ skip_package: true,
2518
+ force: false,
2519
+ dry_run: false,
2520
+ json: true,
2521
+ };
2522
+ let mut reports = build_reports(&args).expect("reports should build");
2523
+
2524
+ execute_make_reports(&mut reports, &args).expect("configured makers should execute");
2525
+
2526
+ assert_eq!(reports.len(), 2);
2527
+ assert_eq!(reports[0].target(), "zip");
2528
+ assert_eq!(reports[1].target(), "msi");
2529
+ assert!(Path::new(reports[0].artifact.as_str()).exists());
2530
+ assert!(Path::new(reports[1].artifact.as_str()).exists());
2531
+
2532
+ let _ = fs::remove_dir_all(root);
2533
+ }
2534
+
1304
2535
  #[test]
1305
2536
  fn writes_deb_archive_with_control_and_data_members() {
1306
2537
  let root = unique_temp_dir("deb-archive");
@@ -1314,7 +2545,7 @@ mod tests {
1314
2545
  name: None,
1315
2546
  platform: Some("linux".to_string()),
1316
2547
  arch: Some("x64".to_string()),
1317
- target: crate::cli::MakeTarget::Deb,
2548
+ target: Some(crate::cli::MakeTarget::Deb),
1318
2549
  skip_package: false,
1319
2550
  force: false,
1320
2551
  dry_run: true,
@@ -1373,7 +2604,7 @@ mod tests {
1373
2604
  name: None,
1374
2605
  platform: Some("darwin".to_string()),
1375
2606
  arch: Some("arm64".to_string()),
1376
- target: crate::cli::MakeTarget::Dmg,
2607
+ target: Some(crate::cli::MakeTarget::Dmg),
1377
2608
  skip_package: false,
1378
2609
  force: false,
1379
2610
  dry_run: true,
@@ -1441,7 +2672,7 @@ mod tests {
1441
2672
  name: None,
1442
2673
  platform: Some("linux".to_string()),
1443
2674
  arch: Some("x64".to_string()),
1444
- target: crate::cli::MakeTarget::Rpm,
2675
+ target: Some(crate::cli::MakeTarget::Rpm),
1445
2676
  skip_package: false,
1446
2677
  force: false,
1447
2678
  dry_run: true,
@@ -1487,6 +2718,76 @@ mod tests {
1487
2718
  let _ = fs::remove_dir_all(root);
1488
2719
  }
1489
2720
 
2721
+ #[test]
2722
+ fn writes_msi_archive_with_database_tables_and_embedded_cabinet() {
2723
+ let root = unique_temp_dir("msi-archive");
2724
+ write_package_json(&root);
2725
+ write_app_file(&root);
2726
+ write_fake_electron_dist(&root);
2727
+
2728
+ let args = MakeArgs {
2729
+ cwd: root.clone(),
2730
+ out_dir: PathBuf::from("out"),
2731
+ name: None,
2732
+ platform: Some("win32".to_string()),
2733
+ arch: Some("x64".to_string()),
2734
+ target: Some(crate::cli::MakeTarget::Msi),
2735
+ skip_package: false,
2736
+ force: false,
2737
+ dry_run: true,
2738
+ json: true,
2739
+ };
2740
+ let report = build_report(&args).expect("report should build");
2741
+ write_fake_windows_bundle(
2742
+ Path::new(report.package.bundle_dir().as_str()),
2743
+ "starter-app.exe",
2744
+ );
2745
+
2746
+ write_msi_archive(&report.package, Path::new(report.artifact.as_str()))
2747
+ .expect("msi should be written");
2748
+
2749
+ let mut installer = msi::open(report.artifact.as_str()).expect("msi should parse");
2750
+ assert_eq!(installer.summary_info().arch(), Some("x64"));
2751
+ assert!(installer.has_table("Property"));
2752
+ assert!(installer.has_table("Directory"));
2753
+ assert!(installer.has_table("File"));
2754
+ assert!(installer.has_table("Media"));
2755
+ assert!(installer.has_stream("app.cab"));
2756
+
2757
+ let properties = msi_rows(&mut installer, "Property");
2758
+ assert!(properties.contains(&vec![
2759
+ Value::from("ProductName"),
2760
+ Value::from("starter-app")
2761
+ ]));
2762
+ assert!(properties.contains(&vec![Value::from("ProductVersion"), Value::from("0.1.0")]));
2763
+
2764
+ let files = msi_rows(&mut installer, "File");
2765
+ assert!(files
2766
+ .iter()
2767
+ .any(|row| row[2] == Value::from("F0001.jso|package.json")));
2768
+ assert!(files
2769
+ .iter()
2770
+ .any(|row| row[2] == Value::from("F0002.exe|starter-app.exe")));
2771
+
2772
+ let mut cabinet_bytes = Vec::new();
2773
+ installer
2774
+ .read_stream("app.cab")
2775
+ .expect("cab stream should open")
2776
+ .read_to_end(&mut cabinet_bytes)
2777
+ .expect("cab stream should read");
2778
+ let mut cabinet =
2779
+ cab::Cabinet::new(Cursor::new(cabinet_bytes)).expect("cabinet should parse");
2780
+ let mut package_json = String::new();
2781
+ cabinet
2782
+ .read_file("F0001")
2783
+ .expect("package.json cabinet entry should open")
2784
+ .read_to_string(&mut package_json)
2785
+ .expect("package.json cabinet entry should read");
2786
+ assert_eq!(package_json, "{}");
2787
+
2788
+ let _ = fs::remove_dir_all(root);
2789
+ }
2790
+
1490
2791
  #[test]
1491
2792
  fn makes_deb_artifact_after_packaging_on_linux() {
1492
2793
  if !cfg!(target_os = "linux") {
@@ -1504,7 +2805,7 @@ mod tests {
1504
2805
  name: None,
1505
2806
  platform: None,
1506
2807
  arch: None,
1507
- target: crate::cli::MakeTarget::Deb,
2808
+ target: Some(crate::cli::MakeTarget::Deb),
1508
2809
  skip_package: false,
1509
2810
  force: false,
1510
2811
  dry_run: false,
@@ -1536,7 +2837,7 @@ mod tests {
1536
2837
  name: None,
1537
2838
  platform: None,
1538
2839
  arch: None,
1539
- target: crate::cli::MakeTarget::Dmg,
2840
+ target: Some(crate::cli::MakeTarget::Dmg),
1540
2841
  skip_package: false,
1541
2842
  force: false,
1542
2843
  dry_run: false,
@@ -1568,7 +2869,7 @@ mod tests {
1568
2869
  name: None,
1569
2870
  platform: None,
1570
2871
  arch: None,
1571
- target: crate::cli::MakeTarget::Rpm,
2872
+ target: Some(crate::cli::MakeTarget::Rpm),
1572
2873
  skip_package: false,
1573
2874
  force: false,
1574
2875
  dry_run: false,
@@ -1591,6 +2892,23 @@ mod tests {
1591
2892
  .expect("package.json should be written");
1592
2893
  }
1593
2894
 
2895
+ fn write_package_json_with_makers(root: &Path, makers: &str) {
2896
+ fs::write(
2897
+ root.join("package.json"),
2898
+ format!(
2899
+ r#"{{
2900
+ "name":"starter-app",
2901
+ "version":"0.1.0",
2902
+ "license":"MIT",
2903
+ "main":"src/main.js",
2904
+ "devDependencies":{{"electron":"30.0.0"}},
2905
+ "config":{{"forge":{{"makers":{makers}}}}}
2906
+ }}"#
2907
+ ),
2908
+ )
2909
+ .expect("package.json with makers should be written");
2910
+ }
2911
+
1594
2912
  fn write_app_file(root: &Path) {
1595
2913
  fs::create_dir_all(root.join("src")).expect("src should be created");
1596
2914
  fs::write(root.join("src/main.js"), "console.log('hello');")
@@ -1613,6 +2931,15 @@ mod tests {
1613
2931
  .expect("fake app package should be written");
1614
2932
  }
1615
2933
 
2934
+ fn write_fake_windows_bundle(bundle_dir: &Path, executable_name: &str) {
2935
+ fs::create_dir_all(bundle_dir.join("resources/app"))
2936
+ .expect("fake Windows resources should be created");
2937
+ fs::write(bundle_dir.join(executable_name), "fake exe")
2938
+ .expect("fake Windows executable should be written");
2939
+ fs::write(bundle_dir.join("resources/app/package.json"), "{}")
2940
+ .expect("fake app package should be written");
2941
+ }
2942
+
1616
2943
  fn write_fake_electron_dist(root: &Path) {
1617
2944
  let dist = root.join("node_modules/electron/dist");
1618
2945
  if cfg!(target_os = "macos") {
@@ -1707,4 +3034,12 @@ mod tests {
1707
3034
  == path
1708
3035
  })
1709
3036
  }
3037
+
3038
+ fn msi_rows(installer: &mut msi::Package<File>, table: &str) -> Vec<Vec<Value>> {
3039
+ installer
3040
+ .select_rows(msi::Select::table(table))
3041
+ .expect("msi rows should select")
3042
+ .map(|row| (0..row.len()).map(|index| row[index].clone()).collect())
3043
+ .collect()
3044
+ }
1710
3045
  }