3 # Copyright 2017-2018 Jussi Pakkanen et al
5 # Licensed under the Apache License, Version 2.0 (the "License");
6 # you may not use this file except in compliance with the License.
7 # You may obtain a copy of the License at
9 # http://www.apache.org/licenses/LICENSE-2.0
11 # Unless required by applicable law or agreed to in writing, software
12 # distributed under the License is distributed on an "AS IS" BASIS,
13 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14 # See the License for the specific language governing permissions and
15 # limitations under the License.
17 import sys
, os
, subprocess
, shutil
, uuid
, json
, re
20 import xml
.etree
.ElementTree
as ET
22 sys
.path
.append(os
.getcwd())
25 return str(uuid
.uuid4()).upper()
28 def __init__(self
, dirs
, files
):
29 assert(isinstance(dirs
, list))
30 assert(isinstance(files
, list))
37 self
.background
= None
39 class PackageGenerator
:
41 def __init__(self
, jsonfile
):
42 jsondata
= json
.load(open(jsonfile
, 'rb'))
43 self
.product_name
= jsondata
['product_name']
44 self
.manufacturer
= jsondata
['manufacturer']
45 self
.version
= jsondata
['version']
46 self
.comments
= jsondata
['comments']
47 self
.installdir
= jsondata
['installdir']
48 self
.license_file
= jsondata
.get('license_file', None)
49 self
.name
= jsondata
['name']
50 self
.guid
= jsondata
.get('product_guid', '*')
51 self
.upgrade_guid
= jsondata
['upgrade_guid']
52 self
.basename
= jsondata
['name_base']
53 self
.need_msvcrt
= jsondata
.get('need_msvcrt', False)
54 self
.addremove_icon
= jsondata
.get('addremove_icon', None)
55 self
.startmenu_shortcut
= jsondata
.get('startmenu_shortcut', None)
56 self
.desktop_shortcut
= jsondata
.get('desktop_shortcut', None)
57 self
.main_xml
= self
.basename
+ '.wxs'
58 self
.main_o
= self
.basename
+ '.wixobj'
60 self
.graphics
= UIGraphics()
61 if 'graphics' in jsondata
:
62 if 'banner' in jsondata
['graphics']:
63 self
.graphics
.banner
= jsondata
['graphics']['banner']
64 if 'background' in jsondata
['graphics']:
65 self
.graphics
.background
= jsondata
['graphics']['background']
66 if 'arch' in jsondata
:
67 self
.arch
= jsondata
['arch']
69 # rely on the environment variable since python architecture may not be the same as system architecture
70 if 'PROGRAMFILES(X86)' in os
.environ
:
73 self
.arch
= 32 if '32' in platform
.architecture()[0] else 64
74 self
.final_output
= '%s-%s-%d.msi' % (self
.basename
, self
.version
, self
.arch
)
76 self
.progfile_dir
= 'ProgramFiles64Folder'
77 if platform
.system() == "Windows":
78 redist_glob
= 'C:\\Program Files\\Microsoft Visual Studio\\*\\*\\VC\\Redist\\MSVC\\v*\\MergeModules\\Microsoft_VC*_CRT_x64.msm'
80 redist_glob
= '/usr/share/msicreator/Microsoft_VC141_CRT_x64.msm'
82 self
.progfile_dir
= 'ProgramFilesFolder'
83 if platform
.system() == "Windows":
84 redist_glob
= 'C:\\Program Files\\Microsoft Visual Studio\\*\\Community\\VC\\Redist\\MSVC\\*\\MergeModules\\Microsoft_VC*_CRT_x86.msm'
86 redist_glob
= '/usr/share/msicreator/Microsoft_VC141_CRT_x86.msm'
87 trials
= glob(redist_glob
)
90 sys
.exit('There are more than one redist dirs: ' +
93 sys
.exit('No redist dirs were detected, install MSM redistributables with VS installer.')
94 self
.redist_path
= trials
[0]
95 self
.component_num
= 0
96 self
.registry_entries
= jsondata
.get('registry_entries', None)
97 self
.major_upgrade
= jsondata
.get('major_upgrade', None)
98 self
.parts
= jsondata
['parts']
99 self
.feature_components
= {}
100 self
.feature_properties
= {}
102 def generate_files(self
):
103 self
.root
= ET
.Element('Wix', {'xmlns': 'http://schemas.microsoft.com/wix/2006/wi'})
104 product
= ET
.SubElement(self
.root
, 'Product', {
105 'Name': self
.product_name
,
106 'Manufacturer': self
.manufacturer
,
108 'UpgradeCode': self
.upgrade_guid
,
111 'Version': self
.version
,
114 package
= ET
.SubElement(product
, 'Package', {
116 'Keywords': 'Installer',
117 'Description': '%s %s installer' % (self
.name
, self
.version
),
118 'Comments': self
.comments
,
119 'Manufacturer': self
.manufacturer
,
120 'InstallerVersion': '500',
123 'SummaryCodepage': '1252',
126 if self
.major_upgrade
is not None:
127 majorupgrade
= ET
.SubElement(product
, 'MajorUpgrade', {})
128 for mkey
in self
.major_upgrade
.keys():
129 majorupgrade
.set(mkey
, self
.major_upgrade
[mkey
])
131 ET
.SubElement(product
, 'MajorUpgrade', {'DowngradeErrorMessage': 'A newer version of %s is already installed.' % self
.name
})
133 package
.set('Platform', 'x64')
134 ET
.SubElement(product
, 'Media', {
136 'Cabinet': self
.basename
+ '.cab',
139 targetdir
= ET
.SubElement(product
, 'Directory', {
143 progfiledir
= ET
.SubElement(targetdir
, 'Directory', {
144 'Id': self
.progfile_dir
,
146 pmf
= ET
.SubElement(targetdir
, 'Directory', {'Id': 'ProgramMenuFolder'},)
147 if self
.startmenu_shortcut
is not None:
148 ET
.SubElement(pmf
, 'Directory', {
149 'Id': 'ApplicationProgramsFolder',
150 'Name': self
.product_name
,
152 if self
.desktop_shortcut
is not None:
153 ET
.SubElement(pmf
, 'Directory', {'Id': 'DesktopFolder',
156 installdir
= ET
.SubElement(progfiledir
, 'Directory', {
158 'Name': self
.installdir
,
161 ET
.SubElement(installdir
, 'Merge', {
163 'SourceFile': self
.redist_path
,
168 if self
.startmenu_shortcut
is not None:
169 ap
= ET
.SubElement(product
, 'DirectoryRef', {'Id': 'ApplicationProgramsFolder'})
170 comp
= ET
.SubElement(ap
, 'Component', {'Id': 'ApplicationShortcut',
173 ET
.SubElement(comp
, 'Shortcut', {'Id': 'ApplicationStartMenuShortcut',
174 'Name': self
.product_name
,
175 'Description': self
.comments
,
176 'Target': '[INSTALLDIR]' + self
.startmenu_shortcut
,
177 'WorkingDirectory': 'INSTALLDIR',
179 ET
.SubElement(comp
, 'RemoveFolder', {'Id': 'RemoveApplicationProgramsFolder',
180 'Directory': 'ApplicationProgramsFolder',
183 ET
.SubElement(comp
, 'RegistryValue', {'Root': 'HKCU',
184 'Key': 'Software\\Microsoft\\' + self
.name
,
190 if self
.desktop_shortcut
is not None:
191 desk
= ET
.SubElement(product
, 'DirectoryRef', {'Id': 'DesktopFolder'})
192 comp
= ET
.SubElement(desk
, 'Component', {'Id':'ApplicationShortcutDesktop',
195 ET
.SubElement(comp
, 'Shortcut', {'Id': 'ApplicationDesktopShortcut',
196 'Name': self
.product_name
,
197 'Description': self
.comments
,
198 'Target': '[INSTALLDIR]' + self
.desktop_shortcut
,
199 'WorkingDirectory': 'INSTALLDIR',
201 ET
.SubElement(comp
, 'RemoveFolder', {'Id': 'RemoveDesktopFolder',
202 'Directory': 'DesktopFolder',
205 ET
.SubElement(comp
, 'RegistryValue', {'Root': 'HKCU',
206 'Key': 'Software\\Microsoft\\' + self
.name
,
213 ET
.SubElement(product
, 'Property', {
214 'Id': 'WIXUI_INSTALLDIR',
215 'Value': 'INSTALLDIR',
217 if platform
.system() == "Windows":
218 if self
.license_file
:
219 ET
.SubElement(product
, 'UIRef', {
220 'Id': 'WixUI_FeatureTree',
223 self
.create_licenseless_dialog_entries(product
)
225 if self
.graphics
.banner
is not None:
226 ET
.SubElement(product
, 'WixVariable', {
227 'Id': 'WixUIBannerBmp',
228 'Value': self
.graphics
.banner
,
230 if self
.graphics
.background
is not None:
231 ET
.SubElement(product
, 'WixVariable', {
232 'Id': 'WixUIDialogBmp',
233 'Value': self
.graphics
.background
,
236 top_feature
= ET
.SubElement(product
, 'Feature', {
238 'Title': self
.name
+ ' ' + self
.version
,
239 'Description': 'The complete package',
242 'ConfigurableDirectory': 'INSTALLDIR',
246 self
.scan_feature(top_feature
, installdir
, 1, f
)
249 vcredist_feature
= ET
.SubElement(top_feature
, 'Feature', {
251 'Title': 'Visual C++ runtime',
252 'AllowAdvertise': 'no',
256 ET
.SubElement(vcredist_feature
, 'MergeRef', {'Id': 'VCRedist'})
257 if self
.startmenu_shortcut
is not None:
258 ET
.SubElement(top_feature
, 'ComponentRef', {'Id': 'ApplicationShortcut'})
259 if self
.desktop_shortcut
is not None:
260 ET
.SubElement(top_feature
, 'ComponentRef', {'Id': 'ApplicationShortcutDesktop'})
261 if self
.addremove_icon
is not None:
262 icoid
= 'addremoveicon.ico'
263 ET
.SubElement(product
, 'Icon', {'Id': icoid
,
264 'SourceFile': self
.addremove_icon
,
266 ET
.SubElement(product
, 'Property', {'Id': 'ARPPRODUCTICON',
270 if self
.registry_entries
is not None:
271 registry_entries_directory
= ET
.SubElement(product
, 'DirectoryRef', {'Id': 'TARGETDIR'})
272 registry_entries_component
= ET
.SubElement(registry_entries_directory
, 'Component', {'Id': 'RegistryEntries', 'Guid': gen_guid()})
274 registry_entries_component
.set('Win64', 'yes')
275 ET
.SubElement(top_feature
, 'ComponentRef', {'Id': 'RegistryEntries'})
276 for r
in self
.registry_entries
:
277 self
.create_registry_entries(registry_entries_component
, r
)
279 ET
.ElementTree(self
.root
).write(self
.main_xml
, encoding
='utf-8', xml_declaration
=True)
280 # ElementTree can not do prettyprinting so do it manually
281 import xml
.dom
.minidom
282 doc
= xml
.dom
.minidom
.parse(self
.main_xml
)
283 with
open(self
.main_xml
, 'w') as of
:
284 of
.write(doc
.toprettyxml(indent
=' '))
286 def create_registry_entries(self
, comp
, reg
):
287 reg_key
= ET
.SubElement(comp
, 'RegistryKey', {
290 'Action': reg
['action'],
292 ET
.SubElement(reg_key
, 'RegistryValue', {
295 'Value': reg
['value'],
296 'KeyPath': reg
['key_path'],
299 def scan_feature(self
, top_feature
, installdir
, depth
, feature
):
300 for sd
in [feature
['staged_dir']]:
301 if '/' in sd
or '\\' in sd
:
302 sys
.exit('Staged_dir %s must not have a path segment.' % sd
)
304 for root
, dirs
, files
in os
.walk(sd
):
305 cur_node
= Node(dirs
, files
)
306 nodes
[root
] = cur_node
309 'Title': feature
['title'],
310 'Description': feature
['description'],
313 if feature
.get('absent', 'ab') == 'disallow':
314 fdict
['Absent'] = 'disallow'
315 self
.feature_properties
[sd
] = fdict
317 self
.feature_components
[sd
] = []
318 self
.create_xml(nodes
, sd
, installdir
, sd
)
319 self
.build_features(nodes
, top_feature
, sd
)
321 def build_features(self
, nodes
, top_feature
, staging_dir
):
322 feature
= ET
.SubElement(top_feature
, 'Feature', self
.feature_properties
[staging_dir
])
323 for component_id
in self
.feature_components
[staging_dir
]:
324 ET
.SubElement(feature
, 'ComponentRef', {
328 def path_to_id(self
, pathname
):
329 #return re.sub(r'[^a-zA-Z0-9_.]', '_', str(pathname))[-72:]
330 idstr
= f
'pathid{self.idnum}'
334 def create_xml(self
, nodes
, current_dir
, parent_xml_node
, staging_dir
):
335 cur_node
= nodes
[current_dir
]
337 component_id
= 'ApplicationFiles%d' % self
.component_num
338 comp_xml_node
= ET
.SubElement(parent_xml_node
, 'Component', {
342 self
.feature_components
[staging_dir
].append(component_id
)
344 comp_xml_node
.set('Win64', 'yes')
345 if platform
.system() == "Windows" and self
.component_num
== 0:
346 ET
.SubElement(comp_xml_node
, 'Environment', {
352 'Value': '[INSTALLDIR]',
354 self
.component_num
+= 1
355 for f
in cur_node
.files
:
356 file_id
= self
.path_to_id(os
.path
.join(current_dir
, f
))
357 ET
.SubElement(comp_xml_node
, 'File', {
360 'Source': os
.path
.join(current_dir
, f
),
363 for dirname
in cur_node
.dirs
:
364 dir_id
= self
.path_to_id(os
.path
.join(current_dir
, dirname
))
365 dir_node
= ET
.SubElement(parent_xml_node
, 'Directory', {
369 self
.create_xml(nodes
, os
.path
.join(current_dir
, dirname
), dir_node
, staging_dir
)
371 def create_licenseless_dialog_entries(self
, product_element
):
372 ui
= ET
.SubElement(product_element
, 'UI', {
373 'Id': 'WixUI_FeatureTree'
376 ET
.SubElement(ui
, 'TextStyle', {
377 'Id': 'WixUI_Font_Normal',
378 'FaceName': 'Tahoma',
382 ET
.SubElement(ui
, 'TextStyle', {
383 'Id': 'WixUI_Font_Bigger',
384 'FaceName': 'Tahoma',
388 ET
.SubElement(ui
, 'TextStyle', {
389 'Id': 'WixUI_Font_Title',
390 'FaceName': 'Tahoma',
395 ET
.SubElement(ui
, 'Property', {
396 'Id': 'DefaultUIFont',
397 'Value': 'WixUI_Font_Normal'
400 ET
.SubElement(ui
, 'Property', {
402 'Value': 'FeatureTree'
405 ET
.SubElement(ui
, 'DialogRef', {
409 ET
.SubElement(ui
, 'DialogRef', {
413 ET
.SubElement(ui
, 'DialogRef', {
417 ET
.SubElement(ui
, 'DialogRef', {
418 'Id': 'MsiRMFilesInUse'
421 ET
.SubElement(ui
, 'DialogRef', {
425 ET
.SubElement(ui
, 'DialogRef', {
429 ET
.SubElement(ui
, 'DialogRef', {
433 ET
.SubElement(ui
, 'DialogRef', {
437 pub_exit
= ET
.SubElement(ui
, 'Publish', {
438 'Dialog': 'ExitDialog',
440 'Event': 'EndDialog',
447 pub_welcome_next
= ET
.SubElement(ui
, 'Publish', {
448 'Dialog': 'WelcomeDlg',
450 'Event': 'NewDialog',
451 'Value': 'CustomizeDlg'
454 pub_welcome_next
.text
= 'NOT Installed'
456 pub_welcome_maint_next
= ET
.SubElement(ui
, 'Publish', {
457 'Dialog': 'WelcomeDlg',
459 'Event': 'NewDialog',
460 'Value': 'VerifyReadyDlg'
463 pub_welcome_maint_next
.text
= 'Installed AND PATCH'
465 pub_customize_back_maint
= ET
.SubElement(ui
, 'Publish', {
466 'Dialog': 'CustomizeDlg',
468 'Event': 'NewDialog',
469 'Value': 'MaintenanceTypeDlg',
473 pub_customize_back_maint
.text
= 'Installed'
475 pub_customize_back_welcome
= ET
.SubElement(ui
, 'Publish', {
476 'Dialog': 'CustomizeDlg',
478 'Event': 'NewDialog',
479 'Value': 'WelcomeDlg',
483 pub_customize_back_welcome
.text
= 'Not Installed'
485 pub_customize_next
= ET
.SubElement(ui
, 'Publish', {
486 'Dialog': 'CustomizeDlg',
488 'Event': 'NewDialog',
489 'Value': 'VerifyReadyDlg'
492 pub_customize_next
.text
= '1'
494 pub_verify_customize_back
= ET
.SubElement(ui
, 'Publish', {
495 'Dialog': 'VerifyReadyDlg',
497 'Event': 'NewDialog',
498 'Value': 'CustomizeDlg',
502 pub_verify_customize_back
.text
= 'NOT Installed OR WixUI_InstallMode = "Change"'
504 pub_verify_maint_back
= ET
.SubElement(ui
, 'Publish', {
505 'Dialog': 'VerifyReadyDlg',
507 'Event': 'NewDialog',
508 'Value': 'MaintenanceTypeDlg',
512 pub_verify_maint_back
.text
= 'Installed AND NOT PATCH'
514 pub_verify_welcome_back
= ET
.SubElement(ui
, 'Publish', {
515 'Dialog': 'VerifyReadyDlg',
517 'Event': 'NewDialog',
518 'Value': 'WelcomeDlg',
522 pub_verify_welcome_back
.text
= 'Installed AND PATCH'
524 pub_maint_welcome_next
= ET
.SubElement(ui
, 'Publish', {
525 'Dialog': 'MaintenanceWelcomeDlg',
527 'Event': 'NewDialog',
528 'Value': 'MaintenanceTypeDlg'
531 pub_maint_welcome_next
.text
= '1'
533 pub_maint_type_change
= ET
.SubElement(ui
, 'Publish', {
534 'Dialog': 'MaintenanceTypeDlg',
535 'Control': 'ChangeButton',
536 'Event': 'NewDialog',
537 'Value': 'CustomizeDlg'
540 pub_maint_type_change
.text
= '1'
542 pub_maint_type_repair
= ET
.SubElement(ui
, 'Publish', {
543 'Dialog': 'MaintenanceTypeDlg',
544 'Control': 'RepairButton',
545 'Event': 'NewDialog',
546 'Value': 'VerifyReadyDlg'
549 pub_maint_type_repair
.text
= '1'
551 pub_maint_type_remove
= ET
.SubElement(ui
, 'Publish', {
552 'Dialog': 'MaintenanceTypeDlg',
553 'Control': 'RemoveButton',
554 'Event': 'NewDialog',
555 'Value': 'VerifyReadyDlg'
558 pub_maint_type_remove
.text
= '1'
560 pub_maint_type_back
= ET
.SubElement(ui
, 'Publish', {
561 'Dialog': 'MaintenanceTypeDlg',
563 'Event': 'NewDialog',
564 'Value': 'MaintenanceWelcomeDlg'
567 pub_maint_type_back
.text
= '1'
569 ET
.SubElement(product_element
, 'UIRef', {
570 'Id': 'WixUI_Common',
573 def build_package(self
):
574 wixdir
= 'c:\\Program Files (x86)\\Wix Toolset v3.11\\bin'
575 if platform
.system() != "Windows":
577 if not os
.path
.isdir(wixdir
):
578 wixdir
= 'c:\\Program Files (x86)\\Wix Toolset v3.11\\bin'
579 if not os
.path
.isdir(wixdir
):
580 print("ERROR: This script requires WIX")
582 if platform
.system() == "Windows":
583 subprocess
.check_call([os
.path
.join(wixdir
, 'candle'), self
.main_xml
])
584 subprocess
.check_call([os
.path
.join(wixdir
, 'light'),
585 '-ext', 'WixUIExtension',
587 '-dWixUILicenseRtf=' + self
.license_file
if self
.license_file
else '',
589 '-out', self
.final_output
,
592 subprocess
.check_call([os
.path
.join(wixdir
, 'wixl'), '-o', self
.final_output
, self
.main_xml
])
596 sys
.exit('createmsi.py <msi definition json>')
598 if '/' in jsonfile
or '\\' in jsonfile
:
599 sys
.exit('Input file %s must not contain a path segment.' % jsonfile
)
600 p
= PackageGenerator(jsonfile
)
604 if __name__
== '__main__':