##// END OF EJS Templates
packaging: isolate invocation of WiX to own function...
Gregory Szorc -
r45271:92627c42 stable
parent child Browse files
Show More
@@ -1,499 +1,526 b''
1 1 # wix.py - WiX installer functionality
2 2 #
3 3 # Copyright 2019 Gregory Szorc <gregory.szorc@gmail.com>
4 4 #
5 5 # This software may be used and distributed according to the terms of the
6 6 # GNU General Public License version 2 or any later version.
7 7
8 8 # no-check-code because Python 3 native.
9 9
10 10 import collections
11 11 import os
12 12 import pathlib
13 13 import re
14 14 import shutil
15 15 import subprocess
16 16 import typing
17 17 import uuid
18 18 import xml.dom.minidom
19 19
20 20 from .downloads import download_entry
21 21 from .py2exe import (
22 22 build_py2exe,
23 23 stage_install,
24 24 )
25 25 from .util import (
26 26 extract_zip_to_directory,
27 27 normalize_windows_version,
28 28 process_install_rules,
29 29 sign_with_signtool,
30 30 )
31 31
32 32
33 33 EXTRA_PACKAGES = {
34 34 'dulwich',
35 35 'distutils',
36 36 'keyring',
37 37 'pygments',
38 38 'win32ctypes',
39 39 }
40 40
41 41
42 42 EXTRA_INSTALL_RULES = [
43 43 ('contrib/packaging/wix/COPYING.rtf', 'COPYING.rtf'),
44 44 ('contrib/win32/mercurial.ini', 'defaultrc/mercurial.rc'),
45 45 ]
46 46
47 47 STAGING_REMOVE_FILES = [
48 48 # We use the RTF variant.
49 49 'copying.txt',
50 50 ]
51 51
52 52 SHORTCUTS = {
53 53 # hg.1.html'
54 54 'hg.file.5d3e441c_28d9_5542_afd0_cdd4234f12d5': {
55 55 'Name': 'Mercurial Command Reference',
56 56 },
57 57 # hgignore.5.html
58 58 'hg.file.5757d8e0_f207_5e10_a2ec_3ba0a062f431': {
59 59 'Name': 'Mercurial Ignore Files',
60 60 },
61 61 # hgrc.5.html
62 62 'hg.file.92e605fd_1d1a_5dc6_9fc0_5d2998eb8f5e': {
63 63 'Name': 'Mercurial Configuration Files',
64 64 },
65 65 }
66 66
67 67
68 68 def find_version(source_dir: pathlib.Path):
69 69 version_py = source_dir / 'mercurial' / '__version__.py'
70 70
71 71 with version_py.open('r', encoding='utf-8') as fh:
72 72 source = fh.read().strip()
73 73
74 74 m = re.search('version = b"(.*)"', source)
75 75 return m.group(1)
76 76
77 77
78 78 def ensure_vc90_merge_modules(build_dir):
79 79 x86 = (
80 80 download_entry(
81 81 'vc9-crt-x86-msm',
82 82 build_dir,
83 83 local_name='microsoft.vcxx.crt.x86_msm.msm',
84 84 )[0],
85 85 download_entry(
86 86 'vc9-crt-x86-msm-policy',
87 87 build_dir,
88 88 local_name='policy.x.xx.microsoft.vcxx.crt.x86_msm.msm',
89 89 )[0],
90 90 )
91 91
92 92 x64 = (
93 93 download_entry(
94 94 'vc9-crt-x64-msm',
95 95 build_dir,
96 96 local_name='microsoft.vcxx.crt.x64_msm.msm',
97 97 )[0],
98 98 download_entry(
99 99 'vc9-crt-x64-msm-policy',
100 100 build_dir,
101 101 local_name='policy.x.xx.microsoft.vcxx.crt.x64_msm.msm',
102 102 )[0],
103 103 )
104 104 return {
105 105 'x86': x86,
106 106 'x64': x64,
107 107 }
108 108
109 109
110 110 def run_candle(wix, cwd, wxs, source_dir, defines=None):
111 111 args = [
112 112 str(wix / 'candle.exe'),
113 113 '-nologo',
114 114 str(wxs),
115 115 '-dSourceDir=%s' % source_dir,
116 116 ]
117 117
118 118 if defines:
119 119 args.extend('-d%s=%s' % define for define in sorted(defines.items()))
120 120
121 121 subprocess.run(args, cwd=str(cwd), check=True)
122 122
123 123
124 124 def make_post_build_signing_fn(
125 125 name,
126 126 subject_name=None,
127 127 cert_path=None,
128 128 cert_password=None,
129 129 timestamp_url=None,
130 130 ):
131 131 """Create a callable that will use signtool to sign hg.exe."""
132 132
133 133 def post_build_sign(source_dir, build_dir, dist_dir, version):
134 134 description = '%s %s' % (name, version)
135 135
136 136 sign_with_signtool(
137 137 dist_dir / 'hg.exe',
138 138 description,
139 139 subject_name=subject_name,
140 140 cert_path=cert_path,
141 141 cert_password=cert_password,
142 142 timestamp_url=timestamp_url,
143 143 )
144 144
145 145 return post_build_sign
146 146
147 147
148 148 def make_files_xml(staging_dir: pathlib.Path, is_x64) -> str:
149 149 """Create XML string listing every file to be installed."""
150 150
151 151 # We derive GUIDs from a deterministic file path identifier.
152 152 # We shoehorn the name into something that looks like a URL because
153 153 # the UUID namespaces are supposed to work that way (even though
154 154 # the input data probably is never validated).
155 155
156 156 doc = xml.dom.minidom.parseString(
157 157 '<?xml version="1.0" encoding="utf-8"?>'
158 158 '<Wix xmlns="http://schemas.microsoft.com/wix/2006/wi">'
159 159 '</Wix>'
160 160 )
161 161
162 162 # Assemble the install layout by directory. This makes it easier to
163 163 # emit XML, since each directory has separate entities.
164 164 manifest = collections.defaultdict(dict)
165 165
166 166 for root, dirs, files in os.walk(staging_dir):
167 167 dirs.sort()
168 168
169 169 root = pathlib.Path(root)
170 170 rel_dir = root.relative_to(staging_dir)
171 171
172 172 for i in range(len(rel_dir.parts)):
173 173 parent = '/'.join(rel_dir.parts[0 : i + 1])
174 174 manifest.setdefault(parent, {})
175 175
176 176 for f in sorted(files):
177 177 full = root / f
178 178 manifest[str(rel_dir).replace('\\', '/')][full.name] = full
179 179
180 180 component_groups = collections.defaultdict(list)
181 181
182 182 # Now emit a <Fragment> for each directory.
183 183 # Each directory is composed of a <DirectoryRef> pointing to its parent
184 184 # and defines child <Directory>'s and a <Component> with all the files.
185 185 for dir_name, entries in sorted(manifest.items()):
186 186 # The directory id is derived from the path. But the root directory
187 187 # is special.
188 188 if dir_name == '.':
189 189 parent_directory_id = 'INSTALLDIR'
190 190 else:
191 191 parent_directory_id = 'hg.dir.%s' % dir_name.replace('/', '.')
192 192
193 193 fragment = doc.createElement('Fragment')
194 194 directory_ref = doc.createElement('DirectoryRef')
195 195 directory_ref.setAttribute('Id', parent_directory_id)
196 196
197 197 # Add <Directory> entries for immediate children directories.
198 198 for possible_child in sorted(manifest.keys()):
199 199 if (
200 200 dir_name == '.'
201 201 and '/' not in possible_child
202 202 and possible_child != '.'
203 203 ):
204 204 child_directory_id = 'hg.dir.%s' % possible_child
205 205 name = possible_child
206 206 else:
207 207 if not possible_child.startswith('%s/' % dir_name):
208 208 continue
209 209 name = possible_child[len(dir_name) + 1 :]
210 210 if '/' in name:
211 211 continue
212 212
213 213 child_directory_id = 'hg.dir.%s' % possible_child.replace(
214 214 '/', '.'
215 215 )
216 216
217 217 directory = doc.createElement('Directory')
218 218 directory.setAttribute('Id', child_directory_id)
219 219 directory.setAttribute('Name', name)
220 220 directory_ref.appendChild(directory)
221 221
222 222 # Add <Component>s for files in this directory.
223 223 for rel, source_path in sorted(entries.items()):
224 224 if dir_name == '.':
225 225 full_rel = rel
226 226 else:
227 227 full_rel = '%s/%s' % (dir_name, rel)
228 228
229 229 component_unique_id = (
230 230 'https://www.mercurial-scm.org/wix-installer/0/component/%s'
231 231 % full_rel
232 232 )
233 233 component_guid = uuid.uuid5(uuid.NAMESPACE_URL, component_unique_id)
234 234 component_id = 'hg.component.%s' % str(component_guid).replace(
235 235 '-', '_'
236 236 )
237 237
238 238 component = doc.createElement('Component')
239 239
240 240 component.setAttribute('Id', component_id)
241 241 component.setAttribute('Guid', str(component_guid).upper())
242 242 component.setAttribute('Win64', 'yes' if is_x64 else 'no')
243 243
244 244 # Assign this component to a top-level group.
245 245 if dir_name == '.':
246 246 component_groups['ROOT'].append(component_id)
247 247 elif '/' in dir_name:
248 248 component_groups[dir_name[0 : dir_name.index('/')]].append(
249 249 component_id
250 250 )
251 251 else:
252 252 component_groups[dir_name].append(component_id)
253 253
254 254 unique_id = (
255 255 'https://www.mercurial-scm.org/wix-installer/0/%s' % full_rel
256 256 )
257 257 file_guid = uuid.uuid5(uuid.NAMESPACE_URL, unique_id)
258 258
259 259 # IDs have length limits. So use GUID to derive them.
260 260 file_guid_normalized = str(file_guid).replace('-', '_')
261 261 file_id = 'hg.file.%s' % file_guid_normalized
262 262
263 263 file_element = doc.createElement('File')
264 264 file_element.setAttribute('Id', file_id)
265 265 file_element.setAttribute('Source', str(source_path))
266 266 file_element.setAttribute('KeyPath', 'yes')
267 267 file_element.setAttribute('ReadOnly', 'yes')
268 268
269 269 component.appendChild(file_element)
270 270 directory_ref.appendChild(component)
271 271
272 272 fragment.appendChild(directory_ref)
273 273 doc.documentElement.appendChild(fragment)
274 274
275 275 for group, component_ids in sorted(component_groups.items()):
276 276 fragment = doc.createElement('Fragment')
277 277 component_group = doc.createElement('ComponentGroup')
278 278 component_group.setAttribute('Id', 'hg.group.%s' % group)
279 279
280 280 for component_id in component_ids:
281 281 component_ref = doc.createElement('ComponentRef')
282 282 component_ref.setAttribute('Id', component_id)
283 283 component_group.appendChild(component_ref)
284 284
285 285 fragment.appendChild(component_group)
286 286 doc.documentElement.appendChild(fragment)
287 287
288 288 # Add <Shortcut> to files that have it defined.
289 289 for file_id, metadata in sorted(SHORTCUTS.items()):
290 290 els = doc.getElementsByTagName('File')
291 291 els = [el for el in els if el.getAttribute('Id') == file_id]
292 292
293 293 if not els:
294 294 raise Exception('could not find File[Id=%s]' % file_id)
295 295
296 296 for el in els:
297 297 shortcut = doc.createElement('Shortcut')
298 298 shortcut.setAttribute('Id', 'hg.shortcut.%s' % file_id)
299 299 shortcut.setAttribute('Directory', 'ProgramMenuDir')
300 300 shortcut.setAttribute('Icon', 'hgIcon.ico')
301 301 shortcut.setAttribute('IconIndex', '0')
302 302 shortcut.setAttribute('Advertise', 'yes')
303 303 for k, v in sorted(metadata.items()):
304 304 shortcut.setAttribute(k, v)
305 305
306 306 el.appendChild(shortcut)
307 307
308 308 return doc.toprettyxml()
309 309
310 310
311 311 def build_installer(
312 312 source_dir: pathlib.Path,
313 313 python_exe: pathlib.Path,
314 314 msi_name='mercurial',
315 315 version=None,
316 316 post_build_fn=None,
317 317 extra_packages_script=None,
318 318 extra_wxs: typing.Optional[typing.Dict[str, str]] = None,
319 319 extra_features: typing.Optional[typing.List[str]] = None,
320 320 ):
321 321 """Build a WiX MSI installer.
322 322
323 323 ``source_dir`` is the path to the Mercurial source tree to use.
324 324 ``arch`` is the target architecture. either ``x86`` or ``x64``.
325 325 ``python_exe`` is the path to the Python executable to use/bundle.
326 326 ``version`` is the Mercurial version string. If not defined,
327 327 ``mercurial/__version__.py`` will be consulted.
328 328 ``post_build_fn`` is a callable that will be called after building
329 329 Mercurial but before invoking WiX. It can be used to e.g. facilitate
330 330 signing. It is passed the paths to the Mercurial source, build, and
331 331 dist directories and the resolved Mercurial version.
332 332 ``extra_packages_script`` is a command to be run to inject extra packages
333 333 into the py2exe binary. It should stage packages into the virtualenv and
334 334 print a null byte followed by a newline-separated list of packages that
335 335 should be included in the exe.
336 336 ``extra_wxs`` is a dict of {wxs_name: working_dir_for_wxs_build}.
337 337 ``extra_features`` is a list of additional named Features to include in
338 338 the build. These must match Feature names in one of the wxs scripts.
339 339 """
340 340 arch = 'x64' if r'\x64' in os.environ.get('LIB', '') else 'x86'
341 341
342 342 hg_build_dir = source_dir / 'build'
343 343 dist_dir = source_dir / 'dist'
344 wix_dir = source_dir / 'contrib' / 'packaging' / 'wix'
345 344
346 345 requirements_txt = (
347 346 source_dir / 'contrib' / 'packaging' / 'requirements_win32.txt'
348 347 )
349 348
350 349 build_py2exe(
351 350 source_dir,
352 351 hg_build_dir,
353 352 python_exe,
354 353 'wix',
355 354 requirements_txt,
356 355 extra_packages=EXTRA_PACKAGES,
357 356 extra_packages_script=extra_packages_script,
358 357 )
359 358
360 359 orig_version = version or find_version(source_dir)
361 360 version = normalize_windows_version(orig_version)
362 361 print('using version string: %s' % version)
363 362 if version != orig_version:
364 363 print('(normalized from: %s)' % orig_version)
365 364
366 365 if post_build_fn:
367 366 post_build_fn(source_dir, hg_build_dir, dist_dir, version)
368 367
369 368 build_dir = hg_build_dir / ('wix-%s' % arch)
370 369 staging_dir = build_dir / 'stage'
371 370
372 371 build_dir.mkdir(exist_ok=True)
373 372
374 373 # Purge the staging directory for every build so packaging is pristine.
375 374 if staging_dir.exists():
376 375 print('purging %s' % staging_dir)
377 376 shutil.rmtree(staging_dir)
378 377
379 378 stage_install(source_dir, staging_dir, lower_case=True)
380 379
381 380 # We also install some extra files.
382 381 process_install_rules(EXTRA_INSTALL_RULES, source_dir, staging_dir)
383 382
384 383 # And remove some files we don't want.
385 384 for f in STAGING_REMOVE_FILES:
386 385 p = staging_dir / f
387 386 if p.exists():
388 387 print('removing %s' % p)
389 388 p.unlink()
390 389
391 wix_pkg, wix_entry = download_entry('wix', hg_build_dir)
392 wix_path = hg_build_dir / ('wix-%s' % wix_entry['version'])
390 return run_wix_packaging(
391 source_dir,
392 build_dir,
393 staging_dir,
394 arch,
395 version=version,
396 orig_version=orig_version,
397 msi_name=msi_name,
398 extra_wxs=extra_wxs,
399 extra_features=extra_features,
400 )
401
402
403 def run_wix_packaging(
404 source_dir: pathlib.Path,
405 build_dir: pathlib.Path,
406 staging_dir: pathlib.Path,
407 arch: str,
408 version: str,
409 orig_version: str,
410 msi_name: typing.Optional[str] = "mercurial",
411 extra_wxs: typing.Optional[typing.Dict[str, str]] = None,
412 extra_features: typing.Optional[typing.List[str]] = None,
413 ):
414 """Invokes WiX to package up a built Mercurial."""
415
416 wix_dir = source_dir / 'contrib' / 'packaging' / 'wix'
417
418 wix_pkg, wix_entry = download_entry('wix', build_dir)
419 wix_path = build_dir / ('wix-%s' % wix_entry['version'])
393 420
394 421 if not wix_path.exists():
395 422 extract_zip_to_directory(wix_pkg, wix_path)
396 423
397 ensure_vc90_merge_modules(hg_build_dir)
424 ensure_vc90_merge_modules(build_dir)
398 425
399 426 source_build_rel = pathlib.Path(os.path.relpath(source_dir, build_dir))
400 427
401 428 defines = {'Platform': arch}
402 429
403 430 # Derive a .wxs file with the staged files.
404 431 manifest_wxs = build_dir / 'stage.wxs'
405 432 with manifest_wxs.open('w', encoding='utf-8') as fh:
406 433 fh.write(make_files_xml(staging_dir, is_x64=arch == 'x64'))
407 434
408 435 run_candle(wix_path, build_dir, manifest_wxs, staging_dir, defines=defines)
409 436
410 437 for source, rel_path in sorted((extra_wxs or {}).items()):
411 438 run_candle(wix_path, build_dir, source, rel_path, defines=defines)
412 439
413 440 source = wix_dir / 'mercurial.wxs'
414 441 defines['Version'] = version
415 442 defines['Comments'] = 'Installs Mercurial version %s' % version
416 defines['VCRedistSrcDir'] = str(hg_build_dir)
443 defines['VCRedistSrcDir'] = str(build_dir)
417 444 if extra_features:
418 445 assert all(';' not in f for f in extra_features)
419 446 defines['MercurialExtraFeatures'] = ';'.join(extra_features)
420 447
421 448 run_candle(wix_path, build_dir, source, source_build_rel, defines=defines)
422 449
423 450 msi_path = (
424 451 source_dir / 'dist' / ('%s-%s-%s.msi' % (msi_name, orig_version, arch))
425 452 )
426 453
427 454 args = [
428 455 str(wix_path / 'light.exe'),
429 456 '-nologo',
430 457 '-ext',
431 458 'WixUIExtension',
432 459 '-sw1076',
433 460 '-spdb',
434 461 '-o',
435 462 str(msi_path),
436 463 ]
437 464
438 465 for source, rel_path in sorted((extra_wxs or {}).items()):
439 466 assert source.endswith('.wxs')
440 467 source = os.path.basename(source)
441 468 args.append(str(build_dir / ('%s.wixobj' % source[:-4])))
442 469
443 470 args.extend(
444 471 [str(build_dir / 'stage.wixobj'), str(build_dir / 'mercurial.wixobj'),]
445 472 )
446 473
447 474 subprocess.run(args, cwd=str(source_dir), check=True)
448 475
449 476 print('%s created' % msi_path)
450 477
451 478 return {
452 479 'msi_path': msi_path,
453 480 }
454 481
455 482
456 483 def build_signed_installer(
457 484 source_dir: pathlib.Path,
458 485 python_exe: pathlib.Path,
459 486 name: str,
460 487 version=None,
461 488 subject_name=None,
462 489 cert_path=None,
463 490 cert_password=None,
464 491 timestamp_url=None,
465 492 extra_packages_script=None,
466 493 extra_wxs=None,
467 494 extra_features=None,
468 495 ):
469 496 """Build an installer with signed executables."""
470 497
471 498 post_build_fn = make_post_build_signing_fn(
472 499 name,
473 500 subject_name=subject_name,
474 501 cert_path=cert_path,
475 502 cert_password=cert_password,
476 503 timestamp_url=timestamp_url,
477 504 )
478 505
479 506 info = build_installer(
480 507 source_dir,
481 508 python_exe=python_exe,
482 509 msi_name=name.lower(),
483 510 version=version,
484 511 post_build_fn=post_build_fn,
485 512 extra_packages_script=extra_packages_script,
486 513 extra_wxs=extra_wxs,
487 514 extra_features=extra_features,
488 515 )
489 516
490 517 description = '%s %s' % (name, version)
491 518
492 519 sign_with_signtool(
493 520 info['msi_path'],
494 521 description,
495 522 subject_name=subject_name,
496 523 cert_path=cert_path,
497 524 cert_password=cert_password,
498 525 timestamp_url=timestamp_url,
499 526 )
General Comments 0
You need to be logged in to leave comments. Login now