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 with
open(jsonfile
, 'rb') as f
:
43 jsondata
= json
.load(f
)
44 self
.product_name
= jsondata
['product_name']
45 self
.manufacturer
= jsondata
['manufacturer']
46 self
.version
= jsondata
['version']
47 self
.comments
= jsondata
['comments']
48 self
.installdir
= jsondata
['installdir']
49 self
.license_file
= jsondata
.get('license_file', None)
50 self
.name
= jsondata
['name']
51 self
.guid
= jsondata
.get('product_guid', '*')
52 self
.upgrade_guid
= jsondata
['upgrade_guid']
53 self
.basename
= jsondata
['name_base']
54 self
.need_msvcrt
= jsondata
.get('need_msvcrt', False)
55 self
.addremove_icon
= jsondata
.get('addremove_icon', None)
56 self
.startmenu_shortcut
= jsondata
.get('startmenu_shortcut', None)
57 self
.desktop_shortcut
= jsondata
.get('desktop_shortcut', None)
58 self
.main_xml
= self
.basename
+ '.wxs'
59 self
.main_o
= self
.basename
+ '.wixobj'
61 self
.graphics
= UIGraphics()
62 if 'graphics' in jsondata
:
63 if 'banner' in jsondata
['graphics']:
64 self
.graphics
.banner
= jsondata
['graphics']['banner']
65 if 'background' in jsondata
['graphics']:
66 self
.graphics
.background
= jsondata
['graphics']['background']
67 if 'arch' in jsondata
:
68 self
.arch
= jsondata
['arch']
70 # rely on the environment variable since python architecture may not be the same as system architecture
71 if 'PROGRAMFILES(X86)' in os
.environ
:
74 self
.arch
= 32 if '32' in platform
.architecture()[0] else 64
75 self
.final_output
= '%s-%s-%d.msi' % (self
.basename
, self
.version
, self
.arch
)
77 self
.progfile_dir
= 'ProgramFiles64Folder'
78 if platform
.system() == "Windows":
79 redist_glob
= 'C:\\Program Files\\Microsoft Visual Studio\\*\\*\\VC\\Redist\\MSVC\\v*\\MergeModules\\Microsoft_VC*_CRT_x64.msm'
81 redist_glob
= '/usr/share/msicreator/Microsoft_VC141_CRT_x64.msm'
83 self
.progfile_dir
= 'ProgramFilesFolder'
84 if platform
.system() == "Windows":
85 redist_glob
= 'C:\\Program Files\\Microsoft Visual Studio\\*\\Community\\VC\\Redist\\MSVC\\*\\MergeModules\\Microsoft_VC*_CRT_x86.msm'
87 redist_glob
= '/usr/share/msicreator/Microsoft_VC141_CRT_x86.msm'
88 trials
= glob(redist_glob
)
91 sys
.exit('There are more than one redist dirs: ' +
94 sys
.exit('No redist dirs were detected, install MSM redistributables with VS installer.')
95 self
.redist_path
= trials
[0]
96 self
.component_num
= 0
97 self
.registry_entries
= jsondata
.get('registry_entries', None)
98 self
.major_upgrade
= jsondata
.get('major_upgrade', None)
99 self
.parts
= jsondata
['parts']
100 self
.feature_components
= {}
101 self
.feature_properties
= {}
103 def generate_files(self
):
104 self
.root
= ET
.Element('Wix', {'xmlns': 'http://schemas.microsoft.com/wix/2006/wi'})
105 product
= ET
.SubElement(self
.root
, 'Product', {
106 'Name': self
.product_name
,
107 'Manufacturer': self
.manufacturer
,
109 'UpgradeCode': self
.upgrade_guid
,
112 'Version': self
.version
,
115 package
= ET
.SubElement(product
, 'Package', {
117 'Keywords': 'Installer',
118 'Description': '%s %s installer' % (self
.name
, self
.version
),
119 'Comments': self
.comments
,
120 'Manufacturer': self
.manufacturer
,
121 'InstallerVersion': '500',
124 'SummaryCodepage': '1252',
127 if self
.major_upgrade
is not None:
128 majorupgrade
= ET
.SubElement(product
, 'MajorUpgrade', {})
129 for mkey
in self
.major_upgrade
.keys():
130 majorupgrade
.set(mkey
, self
.major_upgrade
[mkey
])
132 ET
.SubElement(product
, 'MajorUpgrade', {'DowngradeErrorMessage': 'A newer version of %s is already installed.' % self
.name
})
134 package
.set('Platform', 'x64')
135 ET
.SubElement(product
, 'Media', {
137 'Cabinet': self
.basename
+ '.cab',
140 targetdir
= ET
.SubElement(product
, 'Directory', {
144 progfiledir
= ET
.SubElement(targetdir
, 'Directory', {
145 'Id': self
.progfile_dir
,
147 pmf
= ET
.SubElement(targetdir
, 'Directory', {'Id': 'ProgramMenuFolder'},)
148 if self
.startmenu_shortcut
is not None:
149 ET
.SubElement(pmf
, 'Directory', {
150 'Id': 'ApplicationProgramsFolder',
151 'Name': self
.product_name
,
153 if self
.desktop_shortcut
is not None:
154 ET
.SubElement(pmf
, 'Directory', {'Id': 'DesktopFolder',
157 installdir
= ET
.SubElement(progfiledir
, 'Directory', {
159 'Name': self
.installdir
,
162 ET
.SubElement(installdir
, 'Merge', {
164 'SourceFile': self
.redist_path
,
169 if self
.startmenu_shortcut
is not None:
170 ap
= ET
.SubElement(product
, 'DirectoryRef', {'Id': 'ApplicationProgramsFolder'})
171 comp
= ET
.SubElement(ap
, 'Component', {'Id': 'ApplicationShortcut',
174 ET
.SubElement(comp
, 'Shortcut', {'Id': 'ApplicationStartMenuShortcut',
175 'Name': self
.product_name
,
176 'Description': self
.comments
,
177 'Target': '[INSTALLDIR]' + self
.startmenu_shortcut
,
178 'WorkingDirectory': 'INSTALLDIR',
180 ET
.SubElement(comp
, 'RemoveFolder', {'Id': 'RemoveApplicationProgramsFolder',
181 'Directory': 'ApplicationProgramsFolder',
184 ET
.SubElement(comp
, 'RegistryValue', {'Root': 'HKCU',
185 'Key': 'Software\\Microsoft\\' + self
.name
,
191 if self
.desktop_shortcut
is not None:
192 desk
= ET
.SubElement(product
, 'DirectoryRef', {'Id': 'DesktopFolder'})
193 comp
= ET
.SubElement(desk
, 'Component', {'Id':'ApplicationShortcutDesktop',
196 ET
.SubElement(comp
, 'Shortcut', {'Id': 'ApplicationDesktopShortcut',
197 'Name': self
.product_name
,
198 'Description': self
.comments
,
199 'Target': '[INSTALLDIR]' + self
.desktop_shortcut
,
200 'WorkingDirectory': 'INSTALLDIR',
202 ET
.SubElement(comp
, 'RemoveFolder', {'Id': 'RemoveDesktopFolder',
203 'Directory': 'DesktopFolder',
206 ET
.SubElement(comp
, 'RegistryValue', {'Root': 'HKCU',
207 'Key': 'Software\\Microsoft\\' + self
.name
,
214 ET
.SubElement(product
, 'Property', {
215 'Id': 'WIXUI_INSTALLDIR',
216 'Value': 'INSTALLDIR',
218 if platform
.system() == "Windows":
219 if self
.license_file
:
220 ET
.SubElement(product
, 'UIRef', {
221 'Id': 'WixUI_FeatureTree',
224 self
.create_licenseless_dialog_entries(product
)
226 if self
.graphics
.banner
is not None:
227 ET
.SubElement(product
, 'WixVariable', {
228 'Id': 'WixUIBannerBmp',
229 'Value': self
.graphics
.banner
,
231 if self
.graphics
.background
is not None:
232 ET
.SubElement(product
, 'WixVariable', {
233 'Id': 'WixUIDialogBmp',
234 'Value': self
.graphics
.background
,
237 top_feature
= ET
.SubElement(product
, 'Feature', {
239 'Title': self
.name
+ ' ' + self
.version
,
240 'Description': 'The complete package',
243 'ConfigurableDirectory': 'INSTALLDIR',
247 self
.scan_feature(top_feature
, installdir
, 1, f
)
250 vcredist_feature
= ET
.SubElement(top_feature
, 'Feature', {
252 'Title': 'Visual C++ runtime',
253 'AllowAdvertise': 'no',
257 ET
.SubElement(vcredist_feature
, 'MergeRef', {'Id': 'VCRedist'})
258 if self
.startmenu_shortcut
is not None:
259 ET
.SubElement(top_feature
, 'ComponentRef', {'Id': 'ApplicationShortcut'})
260 if self
.desktop_shortcut
is not None:
261 ET
.SubElement(top_feature
, 'ComponentRef', {'Id': 'ApplicationShortcutDesktop'})
262 if self
.addremove_icon
is not None:
263 icoid
= 'addremoveicon.ico'
264 ET
.SubElement(product
, 'Icon', {'Id': icoid
,
265 'SourceFile': self
.addremove_icon
,
267 ET
.SubElement(product
, 'Property', {'Id': 'ARPPRODUCTICON',
271 if self
.registry_entries
is not None:
272 registry_entries_directory
= ET
.SubElement(product
, 'DirectoryRef', {'Id': 'TARGETDIR'})
273 registry_entries_component
= ET
.SubElement(registry_entries_directory
, 'Component', {'Id': 'RegistryEntries', 'Guid': gen_guid()})
275 registry_entries_component
.set('Win64', 'yes')
276 ET
.SubElement(top_feature
, 'ComponentRef', {'Id': 'RegistryEntries'})
277 for r
in self
.registry_entries
:
278 self
.create_registry_entries(registry_entries_component
, r
)
280 ET
.ElementTree(self
.root
).write(self
.main_xml
, encoding
='utf-8', xml_declaration
=True)
281 # ElementTree can not do prettyprinting so do it manually
282 import xml
.dom
.minidom
283 doc
= xml
.dom
.minidom
.parse(self
.main_xml
)
284 with
open(self
.main_xml
, 'w') as of
:
285 of
.write(doc
.toprettyxml(indent
=' '))
287 def create_registry_entries(self
, comp
, reg
):
288 reg_key
= ET
.SubElement(comp
, 'RegistryKey', {
291 'Action': reg
['action'],
293 ET
.SubElement(reg_key
, 'RegistryValue', {
296 'Value': reg
['value'],
297 'KeyPath': reg
['key_path'],
300 def scan_feature(self
, top_feature
, installdir
, depth
, feature
):
301 for sd
in [feature
['staged_dir']]:
302 if '/' in sd
or '\\' in sd
:
303 sys
.exit('Staged_dir %s must not have a path segment.' % sd
)
305 for root
, dirs
, files
in os
.walk(sd
):
306 cur_node
= Node(dirs
, files
)
307 nodes
[root
] = cur_node
310 'Title': feature
['title'],
311 'Description': feature
['description'],
314 if feature
.get('absent', 'ab') == 'disallow':
315 fdict
['Absent'] = 'disallow'
316 self
.feature_properties
[sd
] = fdict
318 self
.feature_components
[sd
] = []
319 self
.create_xml(nodes
, sd
, installdir
, sd
)
320 self
.build_features(nodes
, top_feature
, sd
)
322 def build_features(self
, nodes
, top_feature
, staging_dir
):
323 feature
= ET
.SubElement(top_feature
, 'Feature', self
.feature_properties
[staging_dir
])
324 for component_id
in self
.feature_components
[staging_dir
]:
325 ET
.SubElement(feature
, 'ComponentRef', {
329 def path_to_id(self
, pathname
):
330 #return re.sub(r'[^a-zA-Z0-9_.]', '_', str(pathname))[-72:]
331 idstr
= f
'pathid{self.idnum}'
335 def create_xml(self
, nodes
, current_dir
, parent_xml_node
, staging_dir
):
336 cur_node
= nodes
[current_dir
]
338 component_id
= 'ApplicationFiles%d' % self
.component_num
339 comp_xml_node
= ET
.SubElement(parent_xml_node
, 'Component', {
343 self
.feature_components
[staging_dir
].append(component_id
)
345 comp_xml_node
.set('Win64', 'yes')
346 if platform
.system() == "Windows" and self
.component_num
== 0:
347 ET
.SubElement(comp_xml_node
, 'Environment', {
353 'Value': '[INSTALLDIR]',
355 self
.component_num
+= 1
356 for f
in cur_node
.files
:
357 file_id
= self
.path_to_id(os
.path
.join(current_dir
, f
))
358 ET
.SubElement(comp_xml_node
, 'File', {
361 'Source': os
.path
.join(current_dir
, f
),
364 for dirname
in cur_node
.dirs
:
365 dir_id
= self
.path_to_id(os
.path
.join(current_dir
, dirname
))
366 dir_node
= ET
.SubElement(parent_xml_node
, 'Directory', {
370 self
.create_xml(nodes
, os
.path
.join(current_dir
, dirname
), dir_node
, staging_dir
)
372 def create_licenseless_dialog_entries(self
, product_element
):
373 ui
= ET
.SubElement(product_element
, 'UI', {
374 'Id': 'WixUI_FeatureTree'
377 ET
.SubElement(ui
, 'TextStyle', {
378 'Id': 'WixUI_Font_Normal',
379 'FaceName': 'Tahoma',
383 ET
.SubElement(ui
, 'TextStyle', {
384 'Id': 'WixUI_Font_Bigger',
385 'FaceName': 'Tahoma',
389 ET
.SubElement(ui
, 'TextStyle', {
390 'Id': 'WixUI_Font_Title',
391 'FaceName': 'Tahoma',
396 ET
.SubElement(ui
, 'Property', {
397 'Id': 'DefaultUIFont',
398 'Value': 'WixUI_Font_Normal'
401 ET
.SubElement(ui
, 'Property', {
403 'Value': 'FeatureTree'
406 ET
.SubElement(ui
, 'DialogRef', {
410 ET
.SubElement(ui
, 'DialogRef', {
414 ET
.SubElement(ui
, 'DialogRef', {
418 ET
.SubElement(ui
, 'DialogRef', {
419 'Id': 'MsiRMFilesInUse'
422 ET
.SubElement(ui
, 'DialogRef', {
426 ET
.SubElement(ui
, 'DialogRef', {
430 ET
.SubElement(ui
, 'DialogRef', {
434 ET
.SubElement(ui
, 'DialogRef', {
438 pub_exit
= ET
.SubElement(ui
, 'Publish', {
439 'Dialog': 'ExitDialog',
441 'Event': 'EndDialog',
448 pub_welcome_next
= ET
.SubElement(ui
, 'Publish', {
449 'Dialog': 'WelcomeDlg',
451 'Event': 'NewDialog',
452 'Value': 'CustomizeDlg'
455 pub_welcome_next
.text
= 'NOT Installed'
457 pub_welcome_maint_next
= ET
.SubElement(ui
, 'Publish', {
458 'Dialog': 'WelcomeDlg',
460 'Event': 'NewDialog',
461 'Value': 'VerifyReadyDlg'
464 pub_welcome_maint_next
.text
= 'Installed AND PATCH'
466 pub_customize_back_maint
= ET
.SubElement(ui
, 'Publish', {
467 'Dialog': 'CustomizeDlg',
469 'Event': 'NewDialog',
470 'Value': 'MaintenanceTypeDlg',
474 pub_customize_back_maint
.text
= 'Installed'
476 pub_customize_back_welcome
= ET
.SubElement(ui
, 'Publish', {
477 'Dialog': 'CustomizeDlg',
479 'Event': 'NewDialog',
480 'Value': 'WelcomeDlg',
484 pub_customize_back_welcome
.text
= 'Not Installed'
486 pub_customize_next
= ET
.SubElement(ui
, 'Publish', {
487 'Dialog': 'CustomizeDlg',
489 'Event': 'NewDialog',
490 'Value': 'VerifyReadyDlg'
493 pub_customize_next
.text
= '1'
495 pub_verify_customize_back
= ET
.SubElement(ui
, 'Publish', {
496 'Dialog': 'VerifyReadyDlg',
498 'Event': 'NewDialog',
499 'Value': 'CustomizeDlg',
503 pub_verify_customize_back
.text
= 'NOT Installed OR WixUI_InstallMode = "Change"'
505 pub_verify_maint_back
= ET
.SubElement(ui
, 'Publish', {
506 'Dialog': 'VerifyReadyDlg',
508 'Event': 'NewDialog',
509 'Value': 'MaintenanceTypeDlg',
513 pub_verify_maint_back
.text
= 'Installed AND NOT PATCH'
515 pub_verify_welcome_back
= ET
.SubElement(ui
, 'Publish', {
516 'Dialog': 'VerifyReadyDlg',
518 'Event': 'NewDialog',
519 'Value': 'WelcomeDlg',
523 pub_verify_welcome_back
.text
= 'Installed AND PATCH'
525 pub_maint_welcome_next
= ET
.SubElement(ui
, 'Publish', {
526 'Dialog': 'MaintenanceWelcomeDlg',
528 'Event': 'NewDialog',
529 'Value': 'MaintenanceTypeDlg'
532 pub_maint_welcome_next
.text
= '1'
534 pub_maint_type_change
= ET
.SubElement(ui
, 'Publish', {
535 'Dialog': 'MaintenanceTypeDlg',
536 'Control': 'ChangeButton',
537 'Event': 'NewDialog',
538 'Value': 'CustomizeDlg'
541 pub_maint_type_change
.text
= '1'
543 pub_maint_type_repair
= ET
.SubElement(ui
, 'Publish', {
544 'Dialog': 'MaintenanceTypeDlg',
545 'Control': 'RepairButton',
546 'Event': 'NewDialog',
547 'Value': 'VerifyReadyDlg'
550 pub_maint_type_repair
.text
= '1'
552 pub_maint_type_remove
= ET
.SubElement(ui
, 'Publish', {
553 'Dialog': 'MaintenanceTypeDlg',
554 'Control': 'RemoveButton',
555 'Event': 'NewDialog',
556 'Value': 'VerifyReadyDlg'
559 pub_maint_type_remove
.text
= '1'
561 pub_maint_type_back
= ET
.SubElement(ui
, 'Publish', {
562 'Dialog': 'MaintenanceTypeDlg',
564 'Event': 'NewDialog',
565 'Value': 'MaintenanceWelcomeDlg'
568 pub_maint_type_back
.text
= '1'
570 ET
.SubElement(product_element
, 'UIRef', {
571 'Id': 'WixUI_Common',
574 def build_package(self
):
575 wixdir
= 'c:\\Program Files (x86)\\Wix Toolset v3.11\\bin'
576 if platform
.system() != "Windows":
578 if not os
.path
.isdir(wixdir
):
579 wixdir
= 'c:\\Program Files (x86)\\Wix Toolset v3.11\\bin'
580 if not os
.path
.isdir(wixdir
):
581 print("ERROR: This script requires WIX")
583 if platform
.system() == "Windows":
584 subprocess
.check_call([os
.path
.join(wixdir
, 'candle'), self
.main_xml
])
585 subprocess
.check_call([os
.path
.join(wixdir
, 'light'),
586 '-ext', 'WixUIExtension',
588 '-dWixUILicenseRtf=' + self
.license_file
if self
.license_file
else '',
590 '-out', self
.final_output
,
593 subprocess
.check_call([os
.path
.join(wixdir
, 'wixl'), '-o', self
.final_output
, self
.main_xml
])
597 sys
.exit('createmsi.py <msi definition json>')
599 if '/' in jsonfile
or '\\' in jsonfile
:
600 sys
.exit('Input file %s must not contain a path segment.' % jsonfile
)
601 p
= PackageGenerator(jsonfile
)
605 if __name__
== '__main__':