electron-cli 0.3.0-alpha.10 → 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.
@@ -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
  }