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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/Cargo.lock CHANGED
@@ -144,6 +144,18 @@ version = "1.11.1"
144
144
  source = "registry+https://github.com/rust-lang/crates.io-index"
145
145
  checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
146
146
 
147
+ [[package]]
148
+ name = "cab"
149
+ version = "0.6.0"
150
+ source = "registry+https://github.com/rust-lang/crates.io-index"
151
+ checksum = "171228650e6721d5acc0868a462cd864f49ac5f64e4a42cde270406e64e404d2"
152
+ dependencies = [
153
+ "byteorder",
154
+ "flate2",
155
+ "lzxd",
156
+ "time",
157
+ ]
158
+
147
159
  [[package]]
148
160
  name = "camino"
149
161
  version = "1.2.2"
@@ -163,6 +175,17 @@ dependencies = [
163
175
  "shlex",
164
176
  ]
165
177
 
178
+ [[package]]
179
+ name = "cfb"
180
+ version = "0.14.0"
181
+ source = "registry+https://github.com/rust-lang/crates.io-index"
182
+ checksum = "a347dcabdae9c31b0825fd6a8bed285ec9c2acb89c47827126d52fa4f59cece3"
183
+ dependencies = [
184
+ "fnv",
185
+ "uuid",
186
+ "web-time",
187
+ ]
188
+
166
189
  [[package]]
167
190
  name = "cfg-if"
168
191
  version = "1.0.4"
@@ -353,25 +376,37 @@ checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e"
353
376
 
354
377
  [[package]]
355
378
  name = "electron-cli"
356
- version = "0.3.0-alpha.11"
379
+ version = "0.3.0-alpha.12"
357
380
  dependencies = [
358
381
  "anyhow",
359
382
  "apple-dmg",
383
+ "cab",
360
384
  "camino",
361
385
  "clap",
362
386
  "fatfs",
363
387
  "flate2",
364
388
  "fscommon",
365
389
  "md5",
390
+ "msi",
366
391
  "plist",
367
392
  "rpm",
368
393
  "serde",
369
394
  "serde_json",
370
395
  "tar",
371
396
  "ureq",
397
+ "uuid",
372
398
  "zip",
373
399
  ]
374
400
 
401
+ [[package]]
402
+ name = "encoding_rs"
403
+ version = "0.8.35"
404
+ source = "registry+https://github.com/rust-lang/crates.io-index"
405
+ checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
406
+ dependencies = [
407
+ "cfg-if",
408
+ ]
409
+
375
410
  [[package]]
376
411
  name = "enum-display-derive"
377
412
  version = "0.1.1"
@@ -449,6 +484,12 @@ dependencies = [
449
484
  "zlib-rs",
450
485
  ]
451
486
 
487
+ [[package]]
488
+ name = "fnv"
489
+ version = "1.0.7"
490
+ source = "registry+https://github.com/rust-lang/crates.io-index"
491
+ checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
492
+
452
493
  [[package]]
453
494
  name = "foldhash"
454
495
  version = "0.1.5"
@@ -809,6 +850,12 @@ version = "0.4.30"
809
850
  source = "registry+https://github.com/rust-lang/crates.io-index"
810
851
  checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5"
811
852
 
853
+ [[package]]
854
+ name = "lzxd"
855
+ version = "0.2.6"
856
+ source = "registry+https://github.com/rust-lang/crates.io-index"
857
+ checksum = "7b29dffab797218e12e4df08ef5d15ab9efca2504038b1b32b9b32fc844b39c9"
858
+
812
859
  [[package]]
813
860
  name = "md5"
814
861
  version = "0.7.0"
@@ -831,6 +878,18 @@ dependencies = [
831
878
  "simd-adler32",
832
879
  ]
833
880
 
881
+ [[package]]
882
+ name = "msi"
883
+ version = "0.10.0"
884
+ source = "registry+https://github.com/rust-lang/crates.io-index"
885
+ checksum = "b0325f8473ef1f5c38ee42345e2cd1678299cbbfa169d1776654a2a682867420"
886
+ dependencies = [
887
+ "byteorder",
888
+ "cfb",
889
+ "encoding_rs",
890
+ "uuid",
891
+ ]
892
+
834
893
  [[package]]
835
894
  name = "nom"
836
895
  version = "8.0.0"
@@ -1197,6 +1256,12 @@ dependencies = [
1197
1256
  "digest",
1198
1257
  ]
1199
1258
 
1259
+ [[package]]
1260
+ name = "sha1_smol"
1261
+ version = "1.0.1"
1262
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1263
+ checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d"
1264
+
1200
1265
  [[package]]
1201
1266
  name = "sha2"
1202
1267
  version = "0.10.9"
@@ -1471,6 +1536,7 @@ checksum = "d258b83ceec21034727ecee8c382cfa6c3e133699b0742c64571814fb420c9f7"
1471
1536
  dependencies = [
1472
1537
  "getrandom 0.4.2",
1473
1538
  "js-sys",
1539
+ "sha1_smol",
1474
1540
  "wasm-bindgen",
1475
1541
  ]
1476
1542
 
@@ -1583,6 +1649,16 @@ dependencies = [
1583
1649
  "semver",
1584
1650
  ]
1585
1651
 
1652
+ [[package]]
1653
+ name = "web-time"
1654
+ version = "1.1.0"
1655
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1656
+ checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
1657
+ dependencies = [
1658
+ "js-sys",
1659
+ "wasm-bindgen",
1660
+ ]
1661
+
1586
1662
  [[package]]
1587
1663
  name = "webpki-roots"
1588
1664
  version = "1.0.7"
package/Cargo.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "electron-cli"
3
- version = "0.3.0-alpha.11"
3
+ version = "0.3.0-alpha.12"
4
4
  edition = "2021"
5
5
  description = "Experimental Rust CLI for Electron project diagnostics and workflow automation"
6
6
  license = "MIT"
@@ -9,16 +9,19 @@ repository = "https://github.com/Ikana/electron-cli"
9
9
  [dependencies]
10
10
  anyhow = "1.0"
11
11
  apple-dmg = "0.5"
12
+ cab = "0.6"
12
13
  camino = { version = "1.1", features = ["serde1"] }
13
14
  clap = { version = "4.6", features = ["derive"] }
14
15
  fatfs = "0.3"
15
16
  flate2 = { version = "1.1", default-features = false, features = ["rust_backend"] }
16
17
  fscommon = "0.1"
17
18
  md5 = "0.7"
19
+ msi = "0.10"
18
20
  plist = "1"
19
21
  rpm = { version = "0.24", default-features = false, features = ["payload", "gzip-compression"] }
20
22
  serde = { version = "1.0", features = ["derive"] }
21
23
  serde_json = "1.0"
22
24
  tar = "0.4"
23
25
  ureq = { version = "3.3", features = ["json"] }
26
+ uuid = { version = "1", features = ["v5"] }
24
27
  zip = { version = "8.6.0", default-features = false, features = ["deflate-flate2-zlib-rs"] }
package/README.md CHANGED
@@ -36,12 +36,12 @@ The Rust-native flow currently owns:
36
36
  - `init --template minimal`: writes a local Electron starter without Electron Forge.
37
37
  - `start`: launches the installed Electron runtime directly.
38
38
  - `package`: copies the installed Electron runtime, app files, installed production dependency closure, app metadata, macOS icon, and extra resources into a local app bundle for the current platform and architecture.
39
- - `make`: runs `package` and writes a distributable under `out/make/<target>/<platform>/<arch>/`; ZIP works on all platforms, `--target dmg` writes a basic macOS disk image, and `--target deb` / `--target rpm` write Linux packages.
39
+ - `make`: runs `package` and writes a distributable under `out/make/<target>/<platform>/<arch>/`; ZIP works on all platforms, `--target dmg` writes a basic macOS disk image, `--target deb` / `--target rpm` write Linux packages, and `--target msi` writes a basic Windows Installer package.
40
40
  - `publish`: runs `make` and publishes the distributable to a local directory with a manifest or to GitHub Releases.
41
41
 
42
42
  The GitHub publisher creates or reuses a release, uploads the selected make artifact, and can replace an existing asset with `--force`. It reads `GITHUB_TOKEN` or `GH_TOKEN` and can infer `OWNER/REPO` from `package.json` `repository`, or you can pass `--github-repo`.
43
43
 
44
- The DMG maker is currently a pure-Rust FAT32 image with the app bundle and an Applications entry. HFS+/APFS layout customization, Windows installers, Windows/Linux icon embedding, signing, and notarization are still TODO.
44
+ The DMG maker is currently a pure-Rust FAT32 image with the app bundle and an Applications entry. The MSI maker writes a compressed embedded CAB, Windows Installer database tables, and a Start Menu shortcut when the packaged executable is present. HFS+/APFS DMG layout customization, installer UI customization, Windows/Linux icon embedding, signing, and notarization are still TODO.
45
45
 
46
46
  Package metadata can be configured in `package.json`:
47
47
 
@@ -97,6 +97,7 @@ cargo run -- make --dry-run
97
97
  cargo run -- make --target dmg --dry-run
98
98
  cargo run -- make --target deb --dry-run
99
99
  cargo run -- make --target rpm --dry-run
100
+ cargo run -- make --target msi --platform win32 --dry-run
100
101
  cargo run -- publish --dry-run
101
102
  cargo run -- publish --publisher github --dry-run
102
103
  cargo run -- publish --publisher github --github-repo OWNER/REPO --github-tag v0.1.0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "electron-cli",
3
- "version": "0.3.0-alpha.11",
3
+ "version": "0.3.0-alpha.12",
4
4
  "description": "Experimental Rust CLI for Electron project diagnostics and workflow automation",
5
5
  "license": "MIT",
6
6
  "repository": {
package/src/cli.rs CHANGED
@@ -289,6 +289,7 @@ impl PackageManager {
289
289
  pub enum MakeTarget {
290
290
  Deb,
291
291
  Dmg,
292
+ Msi,
292
293
  Rpm,
293
294
  Zip,
294
295
  }
@@ -298,6 +299,7 @@ impl MakeTarget {
298
299
  match self {
299
300
  MakeTarget::Deb => "deb",
300
301
  MakeTarget::Dmg => "dmg",
302
+ MakeTarget::Msi => "msi",
301
303
  MakeTarget::Rpm => "rpm",
302
304
  MakeTarget::Zip => "zip",
303
305
  }
@@ -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,13 +7,16 @@ 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;
15
18
  use tar::{Builder as TarBuilder, Header as TarHeader};
19
+ use uuid::Uuid;
16
20
  use zip::{write::SimpleFileOptions, CompressionMethod, ZipWriter};
17
21
 
18
22
  use crate::{
@@ -88,6 +92,12 @@ pub(crate) fn build_report(args: &MakeArgs) -> Result<MakeReport> {
88
92
  package.platform()
89
93
  ));
90
94
  }
95
+ if args.target == MakeTarget::Msi && package.platform() != "win32" {
96
+ warnings.push(format!(
97
+ "msi maker only supports Windows packages; target platform is {}.",
98
+ package.platform()
99
+ ));
100
+ }
91
101
  if args.skip_package && !Path::new(package.bundle_dir().as_str()).exists() {
92
102
  warnings.push(format!(
93
103
  "Package output does not exist: {}.",
@@ -147,6 +157,7 @@ pub(crate) fn execute_make(report: &mut MakeReport, args: &MakeArgs) -> Result<(
147
157
  }
148
158
  MakeTarget::Deb => write_deb_archive(&report.package, artifact)?,
149
159
  MakeTarget::Dmg => write_dmg_archive(&report.package, artifact)?,
160
+ MakeTarget::Msi => write_msi_archive(&report.package, artifact)?,
150
161
  MakeTarget::Rpm => write_rpm_archive(&report.package, artifact)?,
151
162
  }
152
163
 
@@ -213,6 +224,12 @@ fn make_artifact_path(make_dir: &Path, package: &PackageReport, target: MakeTarg
213
224
  dmg_version(package.project().version.as_deref()),
214
225
  package.arch()
215
226
  )),
227
+ MakeTarget::Msi => make_dir.join(format!(
228
+ "{}-{}-{}.msi",
229
+ package.artifact_stem(),
230
+ windows_artifact_version(package.project().version.as_deref()),
231
+ windows_arch(package.arch())
232
+ )),
216
233
  MakeTarget::Rpm => make_dir.join(format!(
217
234
  "{}-{}-1.{}.rpm",
218
235
  rpm_package_name(&package.artifact_stem()),
@@ -655,6 +672,627 @@ fn write_rpm_archive(package: &PackageReport, artifact: &Path) -> Result<()> {
655
672
  .with_context(|| format!("Could not write {}", artifact.display()))
656
673
  }
657
674
 
675
+ #[derive(Debug)]
676
+ struct MsiPayload {
677
+ directories: Vec<MsiDirectoryEntry>,
678
+ files: Vec<MsiFileEntry>,
679
+ shortcut_component: Option<String>,
680
+ shortcut_target_file: Option<String>,
681
+ }
682
+
683
+ #[derive(Debug)]
684
+ struct MsiDirectoryEntry {
685
+ id: String,
686
+ parent_id: String,
687
+ name: String,
688
+ }
689
+
690
+ #[derive(Debug)]
691
+ struct MsiFileEntry {
692
+ id: String,
693
+ component_id: String,
694
+ component_guid: String,
695
+ directory_id: String,
696
+ source: PathBuf,
697
+ file_name: String,
698
+ cabinet_name: String,
699
+ size: i32,
700
+ sequence: i32,
701
+ }
702
+
703
+ fn write_msi_archive(package: &PackageReport, artifact: &Path) -> Result<()> {
704
+ if package.platform() != "win32" {
705
+ bail!(
706
+ "MSI maker only supports Windows packages. Requested {}.",
707
+ package.platform()
708
+ );
709
+ }
710
+
711
+ let source = Path::new(package.bundle_dir().as_str());
712
+ if !source.exists() {
713
+ bail!("Package output does not exist: {}", source.display());
714
+ }
715
+
716
+ let parent = artifact
717
+ .parent()
718
+ .with_context(|| format!("Artifact path has no parent: {}", artifact.display()))?;
719
+ fs::create_dir_all(parent).with_context(|| format!("Could not create {}", parent.display()))?;
720
+
721
+ let payload = collect_msi_payload(package, source)?;
722
+ if payload.files.is_empty() {
723
+ bail!(
724
+ "MSI maker requires at least one packaged file in {}",
725
+ source.display()
726
+ );
727
+ }
728
+
729
+ let cabinet = create_msi_cabinet(&payload)?;
730
+ let file = fs::OpenOptions::new()
731
+ .read(true)
732
+ .write(true)
733
+ .create(true)
734
+ .truncate(true)
735
+ .open(artifact)
736
+ .with_context(|| format!("Could not create {}", artifact.display()))?;
737
+ let mut installer =
738
+ Package::create(PackageType::Installer, file).context("Could not create MSI package")?;
739
+
740
+ write_msi_summary(&mut installer, package)?;
741
+ create_msi_tables(&mut installer)?;
742
+ insert_msi_rows(&mut installer, package, &payload)?;
743
+ {
744
+ let mut stream = installer
745
+ .write_stream("app.cab")
746
+ .context("Could not create embedded MSI cabinet stream")?;
747
+ stream
748
+ .write_all(&cabinet)
749
+ .context("Could not write embedded MSI cabinet stream")?;
750
+ }
751
+ installer.flush().context("Could not flush MSI package")?;
752
+ installer
753
+ .into_inner()
754
+ .context("Could not finish MSI package")?;
755
+
756
+ Ok(())
757
+ }
758
+
759
+ fn write_msi_summary(installer: &mut Package<File>, package: &PackageReport) -> Result<()> {
760
+ let package_code = deterministic_guid(
761
+ "package-code",
762
+ &[
763
+ package.app_name(),
764
+ package.project().version.as_deref().unwrap_or("0.1.0"),
765
+ package.arch(),
766
+ ],
767
+ );
768
+ let arch = msi_summary_arch(package.arch());
769
+ let language = Language::from_code(1033);
770
+ let summary = installer.summary_info_mut();
771
+ summary.set_title(format!("{} Installer", package.app_name()));
772
+ summary.set_subject(package.app_name().to_string());
773
+ summary.set_author("electron-cli".to_string());
774
+ summary.set_comments(format!(
775
+ "{} packaged by electron-cli.",
776
+ single_line(package.app_name())
777
+ ));
778
+ summary.set_creating_application("electron-cli".to_string());
779
+ summary.set_uuid(package_code);
780
+ summary.set_arch(arch.to_string());
781
+ summary.set_languages(&[language]);
782
+ summary.set_page_count(200);
783
+ summary.set_word_count(2);
784
+ Ok(())
785
+ }
786
+
787
+ fn create_msi_tables(installer: &mut Package<File>) -> Result<()> {
788
+ create_msi_table(
789
+ installer,
790
+ "Property",
791
+ vec![
792
+ Column::build("Property").primary_key().id_string(72),
793
+ Column::build("Value").nullable().formatted_string(0),
794
+ ],
795
+ )?;
796
+ create_msi_table(
797
+ installer,
798
+ "Directory",
799
+ vec![
800
+ Column::build("Directory").primary_key().id_string(72),
801
+ Column::build("Directory_Parent").nullable().id_string(72),
802
+ Column::build("DefaultDir").text_string(255),
803
+ ],
804
+ )?;
805
+ create_msi_table(
806
+ installer,
807
+ "Feature",
808
+ vec![
809
+ Column::build("Feature").primary_key().id_string(38),
810
+ Column::build("Feature_Parent").nullable().id_string(38),
811
+ Column::build("Title").nullable().text_string(64),
812
+ Column::build("Description").nullable().text_string(255),
813
+ Column::build("Display").nullable().int16(),
814
+ Column::build("Level").int16(),
815
+ Column::build("Directory_").nullable().id_string(72),
816
+ Column::build("Attributes").int16(),
817
+ ],
818
+ )?;
819
+ create_msi_table(
820
+ installer,
821
+ "Component",
822
+ vec![
823
+ Column::build("Component").primary_key().id_string(72),
824
+ Column::build("ComponentId").nullable().string(38),
825
+ Column::build("Directory_").id_string(72),
826
+ Column::build("Attributes").int16(),
827
+ Column::build("Condition").nullable().formatted_string(255),
828
+ Column::build("KeyPath").nullable().id_string(72),
829
+ ],
830
+ )?;
831
+ create_msi_table(
832
+ installer,
833
+ "FeatureComponents",
834
+ vec![
835
+ Column::build("Feature_").primary_key().id_string(38),
836
+ Column::build("Component_").primary_key().id_string(72),
837
+ ],
838
+ )?;
839
+ create_msi_table(
840
+ installer,
841
+ "File",
842
+ vec![
843
+ Column::build("File").primary_key().id_string(72),
844
+ Column::build("Component_").id_string(72),
845
+ Column::build("FileName").text_string(255),
846
+ Column::build("FileSize").int32(),
847
+ Column::build("Version").nullable().string(72),
848
+ Column::build("Language").nullable().string(20),
849
+ Column::build("Attributes").nullable().int16(),
850
+ Column::build("Sequence").int16(),
851
+ ],
852
+ )?;
853
+ create_msi_table(
854
+ installer,
855
+ "Media",
856
+ vec![
857
+ Column::build("DiskId").primary_key().int16(),
858
+ Column::build("LastSequence").int16(),
859
+ Column::build("DiskPrompt").nullable().text_string(64),
860
+ Column::build("Cabinet").nullable().string(255),
861
+ Column::build("VolumeLabel").nullable().string(32),
862
+ Column::build("Source").nullable().string(72),
863
+ ],
864
+ )?;
865
+ create_msi_table(
866
+ installer,
867
+ "Shortcut",
868
+ vec![
869
+ Column::build("Shortcut").primary_key().id_string(72),
870
+ Column::build("Directory_").id_string(72),
871
+ Column::build("Name").text_string(128),
872
+ Column::build("Component_").id_string(72),
873
+ Column::build("Target").formatted_string(0),
874
+ Column::build("Arguments").nullable().formatted_string(255),
875
+ Column::build("Description").nullable().text_string(255),
876
+ Column::build("Hotkey").nullable().int16(),
877
+ Column::build("Icon_").nullable().id_string(72),
878
+ Column::build("IconIndex").nullable().int16(),
879
+ Column::build("ShowCmd").nullable().int16(),
880
+ Column::build("WkDir").nullable().id_string(72),
881
+ ],
882
+ )?;
883
+ create_msi_table(
884
+ installer,
885
+ "RemoveFile",
886
+ vec![
887
+ Column::build("FileKey").primary_key().id_string(72),
888
+ Column::build("Component_").id_string(72),
889
+ Column::build("FileName").nullable().text_string(255),
890
+ Column::build("DirProperty").id_string(72),
891
+ Column::build("InstallMode").int16(),
892
+ ],
893
+ )?;
894
+ create_msi_table(
895
+ installer,
896
+ "InstallExecuteSequence",
897
+ vec![
898
+ Column::build("Action").primary_key().id_string(72),
899
+ Column::build("Condition").nullable().formatted_string(255),
900
+ Column::build("Sequence").nullable().int16(),
901
+ ],
902
+ )?;
903
+ create_msi_table(
904
+ installer,
905
+ "ActionText",
906
+ vec![
907
+ Column::build("Action").primary_key().id_string(72),
908
+ Column::build("Description").nullable().text_string(64),
909
+ Column::build("Template").nullable().formatted_string(128),
910
+ ],
911
+ )
912
+ }
913
+
914
+ fn create_msi_table(installer: &mut Package<File>, name: &str, columns: Vec<Column>) -> Result<()> {
915
+ installer
916
+ .create_table(name, columns)
917
+ .with_context(|| format!("Could not create MSI {name} table"))
918
+ }
919
+
920
+ fn insert_msi_rows(
921
+ installer: &mut Package<File>,
922
+ package: &PackageReport,
923
+ payload: &MsiPayload,
924
+ ) -> Result<()> {
925
+ let product_version = msi_product_version(package.project().version.as_deref());
926
+ let product_code = msi_guid(deterministic_guid(
927
+ "product-code",
928
+ &[package.app_name(), &product_version, package.arch()],
929
+ ));
930
+ let upgrade_code = msi_guid(deterministic_guid(
931
+ "upgrade-code",
932
+ &[
933
+ package.app_name(),
934
+ package.project().name.as_deref().unwrap_or(""),
935
+ ],
936
+ ));
937
+ insert_msi_table_rows(
938
+ installer,
939
+ "Property",
940
+ vec![
941
+ vec![s("ProductCode"), s(product_code)],
942
+ vec![s("ProductLanguage"), s("1033")],
943
+ vec![s("ProductName"), s(package.app_name())],
944
+ vec![s("ProductVersion"), s(product_version)],
945
+ vec![s("Manufacturer"), s("electron-cli")],
946
+ vec![s("UpgradeCode"), s(upgrade_code)],
947
+ vec![s("ALLUSERS"), s("1")],
948
+ vec![s("INSTALLLEVEL"), s("1")],
949
+ ],
950
+ )?;
951
+
952
+ let program_files_dir = msi_program_files_directory(package.arch());
953
+ let install_folder = msi_filename("APPDIR", package.app_name());
954
+ insert_msi_table_rows(
955
+ installer,
956
+ "Directory",
957
+ vec![
958
+ vec![s("TARGETDIR"), Value::Null, s("SourceDir")],
959
+ vec![s(program_files_dir), s("TARGETDIR"), s(".")],
960
+ vec![s("INSTALLFOLDER"), s(program_files_dir), s(install_folder)],
961
+ vec![s("ProgramMenuFolder"), s("TARGETDIR"), s(".")],
962
+ vec![
963
+ s("ApplicationProgramsFolder"),
964
+ s("ProgramMenuFolder"),
965
+ s(msi_filename("APPMENU", package.app_name())),
966
+ ],
967
+ ],
968
+ )?;
969
+ insert_msi_table_rows(
970
+ installer,
971
+ "Directory",
972
+ payload
973
+ .directories
974
+ .iter()
975
+ .map(|directory| {
976
+ vec![
977
+ s(&directory.id),
978
+ s(&directory.parent_id),
979
+ s(&directory.name),
980
+ ]
981
+ })
982
+ .collect(),
983
+ )?;
984
+
985
+ insert_msi_table_rows(
986
+ installer,
987
+ "Feature",
988
+ vec![vec![
989
+ s("MainFeature"),
990
+ Value::Null,
991
+ s(package.app_name()),
992
+ s(format!("Install {}.", single_line(package.app_name()))),
993
+ Value::from(1),
994
+ Value::from(1),
995
+ s("INSTALLFOLDER"),
996
+ Value::from(0),
997
+ ]],
998
+ )?;
999
+
1000
+ let component_attributes = msi_component_attributes(package.arch());
1001
+ insert_msi_table_rows(
1002
+ installer,
1003
+ "Component",
1004
+ payload
1005
+ .files
1006
+ .iter()
1007
+ .map(|file| {
1008
+ vec![
1009
+ s(&file.component_id),
1010
+ s(&file.component_guid),
1011
+ s(&file.directory_id),
1012
+ Value::from(component_attributes),
1013
+ Value::Null,
1014
+ s(&file.id),
1015
+ ]
1016
+ })
1017
+ .collect(),
1018
+ )?;
1019
+ insert_msi_table_rows(
1020
+ installer,
1021
+ "FeatureComponents",
1022
+ payload
1023
+ .files
1024
+ .iter()
1025
+ .map(|file| vec![s("MainFeature"), s(&file.component_id)])
1026
+ .collect(),
1027
+ )?;
1028
+ insert_msi_table_rows(
1029
+ installer,
1030
+ "File",
1031
+ payload
1032
+ .files
1033
+ .iter()
1034
+ .map(|file| {
1035
+ vec![
1036
+ s(&file.id),
1037
+ s(&file.component_id),
1038
+ s(&file.file_name),
1039
+ Value::from(file.size),
1040
+ Value::Null,
1041
+ Value::Null,
1042
+ Value::from(0),
1043
+ Value::from(file.sequence),
1044
+ ]
1045
+ })
1046
+ .collect(),
1047
+ )?;
1048
+ insert_msi_table_rows(
1049
+ installer,
1050
+ "Media",
1051
+ vec![vec![
1052
+ Value::from(1),
1053
+ Value::from(payload.files.len() as i32),
1054
+ Value::Null,
1055
+ s("#app.cab"),
1056
+ Value::Null,
1057
+ Value::Null,
1058
+ ]],
1059
+ )?;
1060
+
1061
+ if let (Some(component), Some(target_file)) =
1062
+ (&payload.shortcut_component, &payload.shortcut_target_file)
1063
+ {
1064
+ insert_msi_table_rows(
1065
+ installer,
1066
+ "Shortcut",
1067
+ vec![vec![
1068
+ s("ApplicationShortcut"),
1069
+ s("ApplicationProgramsFolder"),
1070
+ s(msi_filename("SHORTCUT", package.app_name())),
1071
+ s(component),
1072
+ s(format!("[#{target_file}]")),
1073
+ Value::Null,
1074
+ s(format!("Launch {}.", single_line(package.app_name()))),
1075
+ Value::Null,
1076
+ Value::Null,
1077
+ Value::Null,
1078
+ Value::Null,
1079
+ s("INSTALLFOLDER"),
1080
+ ]],
1081
+ )?;
1082
+ insert_msi_table_rows(
1083
+ installer,
1084
+ "RemoveFile",
1085
+ vec![vec![
1086
+ s("RemoveStartMenuFolder"),
1087
+ s(component),
1088
+ Value::Null,
1089
+ s("ApplicationProgramsFolder"),
1090
+ Value::from(2),
1091
+ ]],
1092
+ )?;
1093
+ }
1094
+
1095
+ insert_msi_table_rows(
1096
+ installer,
1097
+ "InstallExecuteSequence",
1098
+ vec![
1099
+ standard_action("CostInitialize", 800),
1100
+ standard_action("FileCost", 900),
1101
+ standard_action("CostFinalize", 1000),
1102
+ standard_action("InstallValidate", 1400),
1103
+ standard_action("InstallInitialize", 1500),
1104
+ standard_action("ProcessComponents", 1600),
1105
+ standard_action("UnpublishFeatures", 1800),
1106
+ standard_action("RemoveShortcuts", 3200),
1107
+ standard_action("RemoveFiles", 3500),
1108
+ standard_action("InstallFiles", 4000),
1109
+ standard_action("CreateShortcuts", 4500),
1110
+ standard_action("RegisterUser", 6000),
1111
+ standard_action("RegisterProduct", 6100),
1112
+ standard_action("PublishFeatures", 6300),
1113
+ standard_action("PublishProduct", 6400),
1114
+ standard_action("InstallFinalize", 6600),
1115
+ ],
1116
+ )?;
1117
+ insert_msi_table_rows(
1118
+ installer,
1119
+ "ActionText",
1120
+ vec![
1121
+ action_text(
1122
+ "InstallFiles",
1123
+ "Copying new files",
1124
+ "File: [1], Directory: [9], Size: [6]",
1125
+ ),
1126
+ action_text("CreateShortcuts", "Creating shortcuts", "Shortcut: [1]"),
1127
+ action_text("RemoveFiles", "Removing files", "File: [1], Directory: [9]"),
1128
+ action_text("RemoveShortcuts", "Removing shortcuts", "Shortcut: [1]"),
1129
+ ],
1130
+ )
1131
+ }
1132
+
1133
+ fn insert_msi_table_rows(
1134
+ installer: &mut Package<File>,
1135
+ table: &str,
1136
+ rows: Vec<Vec<Value>>,
1137
+ ) -> Result<()> {
1138
+ if rows.is_empty() {
1139
+ return Ok(());
1140
+ }
1141
+ installer
1142
+ .insert_rows(Insert::into(table).rows(rows))
1143
+ .with_context(|| format!("Could not insert MSI {table} rows"))
1144
+ }
1145
+
1146
+ fn standard_action(action: &str, sequence: i32) -> Vec<Value> {
1147
+ vec![s(action), Value::Null, Value::from(sequence)]
1148
+ }
1149
+
1150
+ fn action_text(action: &str, description: &str, template: &str) -> Vec<Value> {
1151
+ vec![s(action), s(description), s(template)]
1152
+ }
1153
+
1154
+ fn collect_msi_payload(package: &PackageReport, source: &Path) -> Result<MsiPayload> {
1155
+ let mut payload = MsiPayload {
1156
+ directories: Vec::new(),
1157
+ files: Vec::new(),
1158
+ shortcut_component: None,
1159
+ shortcut_target_file: None,
1160
+ };
1161
+ let mut directory_ids = BTreeMap::from([(PathBuf::new(), "INSTALLFOLDER".to_string())]);
1162
+ collect_msi_directory(
1163
+ package,
1164
+ source,
1165
+ Path::new(""),
1166
+ "INSTALLFOLDER",
1167
+ &mut directory_ids,
1168
+ &mut payload,
1169
+ )?;
1170
+
1171
+ if payload.files.len() > i16::MAX as usize {
1172
+ bail!(
1173
+ "MSI maker supports up to {} files; package contains {}.",
1174
+ i16::MAX,
1175
+ payload.files.len()
1176
+ );
1177
+ }
1178
+
1179
+ Ok(payload)
1180
+ }
1181
+
1182
+ fn collect_msi_directory(
1183
+ package: &PackageReport,
1184
+ source: &Path,
1185
+ relative_dir: &Path,
1186
+ directory_id: &str,
1187
+ directory_ids: &mut BTreeMap<PathBuf, String>,
1188
+ payload: &mut MsiPayload,
1189
+ ) -> Result<()> {
1190
+ let mut entries = fs::read_dir(source)
1191
+ .with_context(|| format!("Could not read {}", source.display()))?
1192
+ .collect::<Result<Vec<_>, io::Error>>()?;
1193
+ entries.sort_by_key(|entry| entry.path());
1194
+
1195
+ for entry in entries {
1196
+ let path = entry.path();
1197
+ let file_name = utf8_file_name(&path)?.to_string();
1198
+ let relative_path = relative_dir.join(&file_name);
1199
+ let metadata = fs::symlink_metadata(&path)
1200
+ .with_context(|| format!("Could not stat {}", path.display()))?;
1201
+
1202
+ if metadata.file_type().is_symlink() {
1203
+ bail!(
1204
+ "MSI maker does not support symbolic links yet: {}",
1205
+ path.display()
1206
+ );
1207
+ }
1208
+
1209
+ if metadata.is_dir() {
1210
+ let dir_id = format!("D{:04}", directory_ids.len());
1211
+ directory_ids.insert(relative_path.clone(), dir_id.clone());
1212
+ payload.directories.push(MsiDirectoryEntry {
1213
+ id: dir_id.clone(),
1214
+ parent_id: directory_id.to_string(),
1215
+ name: msi_filename(&dir_id, &file_name),
1216
+ });
1217
+ collect_msi_directory(
1218
+ package,
1219
+ &path,
1220
+ &relative_path,
1221
+ &dir_id,
1222
+ directory_ids,
1223
+ payload,
1224
+ )?;
1225
+ } else if metadata.is_file() {
1226
+ let sequence = payload.files.len() + 1;
1227
+ let size = i32::try_from(metadata.len())
1228
+ .with_context(|| format!("MSI file is too large: {}", path.display()))?;
1229
+ let file_id = format!("F{sequence:04}");
1230
+ let component_id = format!("C{sequence:04}");
1231
+ let relative_key = relative_path.to_string_lossy().replace('\\', "/");
1232
+ let component_guid = msi_guid(deterministic_guid(
1233
+ "component",
1234
+ &[
1235
+ package.app_name(),
1236
+ package.project().name.as_deref().unwrap_or(""),
1237
+ &relative_key,
1238
+ ],
1239
+ ));
1240
+ let entry = MsiFileEntry {
1241
+ id: file_id.clone(),
1242
+ component_id: component_id.clone(),
1243
+ component_guid,
1244
+ directory_id: directory_id.to_string(),
1245
+ source: path.clone(),
1246
+ file_name: msi_filename(&file_id, &file_name),
1247
+ cabinet_name: file_id.clone(),
1248
+ size,
1249
+ sequence: sequence as i32,
1250
+ };
1251
+
1252
+ if file_name.eq_ignore_ascii_case(package.executable_name()) {
1253
+ payload.shortcut_component = Some(component_id);
1254
+ payload.shortcut_target_file = Some(file_id);
1255
+ }
1256
+
1257
+ payload.files.push(entry);
1258
+ }
1259
+ }
1260
+
1261
+ Ok(())
1262
+ }
1263
+
1264
+ fn create_msi_cabinet(payload: &MsiPayload) -> Result<Vec<u8>> {
1265
+ let mut builder = CabinetBuilder::new();
1266
+ {
1267
+ let folder = builder.add_folder(CabCompressionType::MsZip);
1268
+ for file in &payload.files {
1269
+ folder.add_file(&file.cabinet_name);
1270
+ }
1271
+ }
1272
+
1273
+ let cursor = Cursor::new(Vec::new());
1274
+ let mut cabinet = builder
1275
+ .build(cursor)
1276
+ .context("Could not start MSI cabinet")?;
1277
+ for file in &payload.files {
1278
+ let mut writer = cabinet
1279
+ .next_file()
1280
+ .context("Could not open next MSI cabinet file")?
1281
+ .context("MSI cabinet writer finished before all files were written")?;
1282
+ anyhow::ensure!(
1283
+ writer.file_name() == file.cabinet_name,
1284
+ "MSI cabinet file order drifted while writing {}",
1285
+ file.source.display()
1286
+ );
1287
+ let mut source = File::open(&file.source)
1288
+ .with_context(|| format!("Could not open {}", file.source.display()))?;
1289
+ io::copy(&mut source, &mut writer)
1290
+ .with_context(|| format!("Could not add {} to MSI cabinet", file.source.display()))?;
1291
+ }
1292
+ let cursor = cabinet.finish().context("Could not finish MSI cabinet")?;
1293
+ Ok(cursor.into_inner())
1294
+ }
1295
+
658
1296
  fn debian_control_file(
659
1297
  package: &PackageReport,
660
1298
  deb_package: &str,
@@ -1024,6 +1662,51 @@ fn rpm_version(version: Option<&str>) -> String {
1024
1662
  }
1025
1663
  }
1026
1664
 
1665
+ fn windows_artifact_version(version: Option<&str>) -> String {
1666
+ let version = version.unwrap_or("0.1.0");
1667
+ let sanitized = version
1668
+ .chars()
1669
+ .map(|char| {
1670
+ if char.is_ascii_alphanumeric() || matches!(char, '.' | '-' | '_') {
1671
+ char
1672
+ } else {
1673
+ '-'
1674
+ }
1675
+ })
1676
+ .collect::<String>()
1677
+ .trim_matches(['-', '.', '_'])
1678
+ .to_string();
1679
+
1680
+ if sanitized.is_empty() {
1681
+ "0.1.0".to_string()
1682
+ } else {
1683
+ sanitized
1684
+ }
1685
+ }
1686
+
1687
+ fn msi_product_version(version: Option<&str>) -> String {
1688
+ let mut numbers = version
1689
+ .unwrap_or("0.1.0")
1690
+ .split(|char: char| !char.is_ascii_digit())
1691
+ .filter(|part| !part.is_empty())
1692
+ .filter_map(|part| part.parse::<u32>().ok())
1693
+ .take(3)
1694
+ .collect::<Vec<_>>();
1695
+ while numbers.len() < 3 {
1696
+ numbers.push(0);
1697
+ }
1698
+ if numbers.iter().all(|number| *number == 0) {
1699
+ numbers = vec![0, 1, 0];
1700
+ }
1701
+
1702
+ format!(
1703
+ "{}.{}.{}",
1704
+ numbers[0].min(255),
1705
+ numbers[1].min(255),
1706
+ numbers[2].min(65_535)
1707
+ )
1708
+ }
1709
+
1027
1710
  fn debian_arch(arch: &str) -> String {
1028
1711
  match arch {
1029
1712
  "x64" => "amd64".to_string(),
@@ -1043,6 +1726,99 @@ fn rpm_arch(arch: &str) -> String {
1043
1726
  }
1044
1727
  }
1045
1728
 
1729
+ fn windows_arch(arch: &str) -> String {
1730
+ match arch {
1731
+ "ia32" => "x86".to_string(),
1732
+ arch => arch.to_string(),
1733
+ }
1734
+ }
1735
+
1736
+ fn msi_summary_arch(arch: &str) -> &'static str {
1737
+ match arch {
1738
+ "x64" => "x64",
1739
+ "arm64" => "Arm64",
1740
+ _ => "Intel",
1741
+ }
1742
+ }
1743
+
1744
+ fn msi_program_files_directory(arch: &str) -> &'static str {
1745
+ match arch {
1746
+ "x64" | "arm64" => "ProgramFiles64Folder",
1747
+ _ => "ProgramFilesFolder",
1748
+ }
1749
+ }
1750
+
1751
+ fn msi_component_attributes(arch: &str) -> i32 {
1752
+ match arch {
1753
+ "x64" | "arm64" => 256,
1754
+ _ => 0,
1755
+ }
1756
+ }
1757
+
1758
+ fn msi_filename(id: &str, long_name: &str) -> String {
1759
+ if is_msi_short_name(long_name) {
1760
+ return long_name.to_string();
1761
+ }
1762
+
1763
+ let extension = Path::new(long_name)
1764
+ .extension()
1765
+ .and_then(|extension| extension.to_str())
1766
+ .map(|extension| {
1767
+ extension
1768
+ .chars()
1769
+ .filter(|char| char.is_ascii_alphanumeric())
1770
+ .take(3)
1771
+ .collect::<String>()
1772
+ })
1773
+ .filter(|extension| !extension.is_empty());
1774
+ let stem = id
1775
+ .chars()
1776
+ .filter(|char| char.is_ascii_alphanumeric())
1777
+ .take(8)
1778
+ .collect::<String>();
1779
+ let short = match extension {
1780
+ Some(extension) => format!("{stem}.{extension}"),
1781
+ None => stem,
1782
+ };
1783
+ format!("{short}|{long_name}")
1784
+ }
1785
+
1786
+ fn is_msi_short_name(name: &str) -> bool {
1787
+ let Some(file_name) = Path::new(name).file_name().and_then(|name| name.to_str()) else {
1788
+ return false;
1789
+ };
1790
+ if file_name != name || file_name.is_empty() || file_name.contains(' ') {
1791
+ return false;
1792
+ }
1793
+
1794
+ let mut parts = file_name.split('.');
1795
+ let stem = parts.next().unwrap_or_default();
1796
+ let extension = parts.next();
1797
+ if parts.next().is_some() || stem.is_empty() || stem.len() > 8 {
1798
+ return false;
1799
+ }
1800
+ if extension.is_some_and(|extension| extension.is_empty() || extension.len() > 3) {
1801
+ return false;
1802
+ }
1803
+
1804
+ file_name
1805
+ .chars()
1806
+ .all(|char| char.is_ascii_alphanumeric() || matches!(char, '_' | '$' | '~' | '!' | '#'))
1807
+ }
1808
+
1809
+ fn deterministic_guid(kind: &str, parts: &[&str]) -> Uuid {
1810
+ let key = format!("electron-cli:{kind}:{}", parts.join(":"));
1811
+ Uuid::new_v5(&Uuid::NAMESPACE_URL, key.as_bytes())
1812
+ }
1813
+
1814
+ fn msi_guid(uuid: Uuid) -> String {
1815
+ format!("{{{}}}", uuid.hyphenated()).to_ascii_uppercase()
1816
+ }
1817
+
1818
+ fn s(value: impl Into<String>) -> Value {
1819
+ Value::from(value.into())
1820
+ }
1821
+
1046
1822
  fn single_line(value: &str) -> String {
1047
1823
  value
1048
1824
  .chars()
@@ -1259,6 +2035,40 @@ mod tests {
1259
2035
  let _ = fs::remove_dir_all(root);
1260
2036
  }
1261
2037
 
2038
+ #[test]
2039
+ fn builds_make_report_for_msi_target() {
2040
+ let root = unique_temp_dir("msi-plan");
2041
+ write_package_json(&root);
2042
+ write_app_file(&root);
2043
+ write_fake_electron_dist(&root);
2044
+
2045
+ let args = MakeArgs {
2046
+ cwd: root.clone(),
2047
+ out_dir: PathBuf::from("out"),
2048
+ name: None,
2049
+ platform: Some("win32".to_string()),
2050
+ arch: Some("x64".to_string()),
2051
+ target: crate::cli::MakeTarget::Msi,
2052
+ skip_package: false,
2053
+ force: false,
2054
+ dry_run: true,
2055
+ json: true,
2056
+ };
2057
+ let report = build_report(&args).expect("report should build");
2058
+
2059
+ assert_eq!(report.target, "msi");
2060
+ assert!(Path::new(report.artifact.as_str()).ends_with(
2061
+ PathBuf::from("out")
2062
+ .join("make")
2063
+ .join("msi")
2064
+ .join("win32")
2065
+ .join("x64")
2066
+ .join("starter-app-0.1.0-x64.msi")
2067
+ ));
2068
+
2069
+ let _ = fs::remove_dir_all(root);
2070
+ }
2071
+
1262
2072
  #[test]
1263
2073
  fn makes_zip_artifact_after_packaging() {
1264
2074
  let root = unique_temp_dir("execute");
@@ -1487,6 +2297,76 @@ mod tests {
1487
2297
  let _ = fs::remove_dir_all(root);
1488
2298
  }
1489
2299
 
2300
+ #[test]
2301
+ fn writes_msi_archive_with_database_tables_and_embedded_cabinet() {
2302
+ let root = unique_temp_dir("msi-archive");
2303
+ write_package_json(&root);
2304
+ write_app_file(&root);
2305
+ write_fake_electron_dist(&root);
2306
+
2307
+ let args = MakeArgs {
2308
+ cwd: root.clone(),
2309
+ out_dir: PathBuf::from("out"),
2310
+ name: None,
2311
+ platform: Some("win32".to_string()),
2312
+ arch: Some("x64".to_string()),
2313
+ target: crate::cli::MakeTarget::Msi,
2314
+ skip_package: false,
2315
+ force: false,
2316
+ dry_run: true,
2317
+ json: true,
2318
+ };
2319
+ let report = build_report(&args).expect("report should build");
2320
+ write_fake_windows_bundle(
2321
+ Path::new(report.package.bundle_dir().as_str()),
2322
+ "starter-app.exe",
2323
+ );
2324
+
2325
+ write_msi_archive(&report.package, Path::new(report.artifact.as_str()))
2326
+ .expect("msi should be written");
2327
+
2328
+ let mut installer = msi::open(report.artifact.as_str()).expect("msi should parse");
2329
+ assert_eq!(installer.summary_info().arch(), Some("x64"));
2330
+ assert!(installer.has_table("Property"));
2331
+ assert!(installer.has_table("Directory"));
2332
+ assert!(installer.has_table("File"));
2333
+ assert!(installer.has_table("Media"));
2334
+ assert!(installer.has_stream("app.cab"));
2335
+
2336
+ let properties = msi_rows(&mut installer, "Property");
2337
+ assert!(properties.contains(&vec![
2338
+ Value::from("ProductName"),
2339
+ Value::from("starter-app")
2340
+ ]));
2341
+ assert!(properties.contains(&vec![Value::from("ProductVersion"), Value::from("0.1.0")]));
2342
+
2343
+ let files = msi_rows(&mut installer, "File");
2344
+ assert!(files
2345
+ .iter()
2346
+ .any(|row| row[2] == Value::from("F0001.jso|package.json")));
2347
+ assert!(files
2348
+ .iter()
2349
+ .any(|row| row[2] == Value::from("F0002.exe|starter-app.exe")));
2350
+
2351
+ let mut cabinet_bytes = Vec::new();
2352
+ installer
2353
+ .read_stream("app.cab")
2354
+ .expect("cab stream should open")
2355
+ .read_to_end(&mut cabinet_bytes)
2356
+ .expect("cab stream should read");
2357
+ let mut cabinet =
2358
+ cab::Cabinet::new(Cursor::new(cabinet_bytes)).expect("cabinet should parse");
2359
+ let mut package_json = String::new();
2360
+ cabinet
2361
+ .read_file("F0001")
2362
+ .expect("package.json cabinet entry should open")
2363
+ .read_to_string(&mut package_json)
2364
+ .expect("package.json cabinet entry should read");
2365
+ assert_eq!(package_json, "{}");
2366
+
2367
+ let _ = fs::remove_dir_all(root);
2368
+ }
2369
+
1490
2370
  #[test]
1491
2371
  fn makes_deb_artifact_after_packaging_on_linux() {
1492
2372
  if !cfg!(target_os = "linux") {
@@ -1613,6 +2493,15 @@ mod tests {
1613
2493
  .expect("fake app package should be written");
1614
2494
  }
1615
2495
 
2496
+ fn write_fake_windows_bundle(bundle_dir: &Path, executable_name: &str) {
2497
+ fs::create_dir_all(bundle_dir.join("resources/app"))
2498
+ .expect("fake Windows resources should be created");
2499
+ fs::write(bundle_dir.join(executable_name), "fake exe")
2500
+ .expect("fake Windows executable should be written");
2501
+ fs::write(bundle_dir.join("resources/app/package.json"), "{}")
2502
+ .expect("fake app package should be written");
2503
+ }
2504
+
1616
2505
  fn write_fake_electron_dist(root: &Path) {
1617
2506
  let dist = root.join("node_modules/electron/dist");
1618
2507
  if cfg!(target_os = "macos") {
@@ -1707,4 +2596,12 @@ mod tests {
1707
2596
  == path
1708
2597
  })
1709
2598
  }
2599
+
2600
+ fn msi_rows(installer: &mut msi::Package<File>, table: &str) -> Vec<Vec<Value>> {
2601
+ installer
2602
+ .select_rows(msi::Select::table(table))
2603
+ .expect("msi rows should select")
2604
+ .map(|row| (0..row.len()).map(|index| row[index].clone()).collect())
2605
+ .collect()
2606
+ }
1710
2607
  }