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