contrib/packaging/hgpackaging/wix.py
changeset 43623 94eac340d212
parent 43522 ce96be208ea4
child 44153 e4344e463c0c
equal deleted inserted replaced
43622:45c15ed06f33 43623:94eac340d212
     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
    10 import os
    11 import os
    11 import pathlib
    12 import pathlib
    12 import re
    13 import re
       
    14 import shutil
    13 import subprocess
    15 import subprocess
    14 import tempfile
       
    15 import typing
    16 import typing
       
    17 import uuid
    16 import xml.dom.minidom
    18 import xml.dom.minidom
    17 
    19 
    18 from .downloads import download_entry
    20 from .downloads import download_entry
    19 from .py2exe import build_py2exe
    21 from .py2exe import (
       
    22     build_py2exe,
       
    23     stage_install,
       
    24 )
    20 from .util import (
    25 from .util import (
    21     extract_zip_to_directory,
    26     extract_zip_to_directory,
       
    27     process_install_rules,
    22     sign_with_signtool,
    28     sign_with_signtool,
    23 )
    29 )
    24 
       
    25 
       
    26 SUPPORT_WXS = [
       
    27     ('contrib.wxs', r'contrib'),
       
    28     ('dist.wxs', r'dist'),
       
    29     ('doc.wxs', r'doc'),
       
    30     ('help.wxs', r'mercurial\help'),
       
    31     ('locale.wxs', r'mercurial\locale'),
       
    32     ('templates.wxs', r'mercurial\templates'),
       
    33 ]
       
    34 
    30 
    35 
    31 
    36 EXTRA_PACKAGES = {
    32 EXTRA_PACKAGES = {
    37     'distutils',
    33     'distutils',
    38     'pygments',
    34     'pygments',
       
    35 }
       
    36 
       
    37 
       
    38 EXTRA_INSTALL_RULES = [
       
    39     ('contrib/packaging/wix/COPYING.rtf', 'COPYING.rtf'),
       
    40     ('contrib/win32/mercurial.ini', 'hgrc.d/mercurial.rc'),
       
    41 ]
       
    42 
       
    43 STAGING_REMOVE_FILES = [
       
    44     # We use the RTF variant.
       
    45     'copying.txt',
       
    46 ]
       
    47 
       
    48 SHORTCUTS = {
       
    49     # hg.1.html'
       
    50     'hg.file.5d3e441c_28d9_5542_afd0_cdd4234f12d5': {
       
    51         'Name': 'Mercurial Command Reference',
       
    52     },
       
    53     # hgignore.5.html
       
    54     'hg.file.5757d8e0_f207_5e10_a2ec_3ba0a062f431': {
       
    55         'Name': 'Mercurial Ignore Files',
       
    56     },
       
    57     # hgrc.5.html
       
    58     'hg.file.92e605fd_1d1a_5dc6_9fc0_5d2998eb8f5e': {
       
    59         'Name': 'Mercurial Configuration Files',
       
    60     },
    39 }
    61 }
    40 
    62 
    41 
    63 
    42 def find_version(source_dir: pathlib.Path):
    64 def find_version(source_dir: pathlib.Path):
    43     version_py = source_dir / 'mercurial' / '__version__.py'
    65     version_py = source_dir / 'mercurial' / '__version__.py'
   145         )
   167         )
   146 
   168 
   147     return post_build_sign
   169     return post_build_sign
   148 
   170 
   149 
   171 
   150 LIBRARIES_XML = '''
   172 def make_files_xml(staging_dir: pathlib.Path, is_x64) -> str:
   151 <?xml version="1.0" encoding="utf-8"?>
   173     """Create XML string listing every file to be installed."""
   152 <Wix xmlns="http://schemas.microsoft.com/wix/2006/wi">
   174 
   153 
   175     # We derive GUIDs from a deterministic file path identifier.
   154   <?include {wix_dir}/guids.wxi ?>
   176     # We shoehorn the name into something that looks like a URL because
   155   <?include {wix_dir}/defines.wxi ?>
   177     # the UUID namespaces are supposed to work that way (even though
   156 
   178     # the input data probably is never validated).
   157   <Fragment>
   179 
   158     <DirectoryRef Id="INSTALLDIR" FileSource="$(var.SourceDir)">
       
   159       <Directory Id="libdir" Name="lib" FileSource="$(var.SourceDir)/lib">
       
   160         <Component Id="libOutput" Guid="$(var.lib.guid)" Win64='$(var.IsX64)'>
       
   161         </Component>
       
   162       </Directory>
       
   163     </DirectoryRef>
       
   164   </Fragment>
       
   165 </Wix>
       
   166 '''.lstrip()
       
   167 
       
   168 
       
   169 def make_libraries_xml(wix_dir: pathlib.Path, dist_dir: pathlib.Path):
       
   170     """Make XML data for library components WXS."""
       
   171     # We can't use ElementTree because it doesn't handle the
       
   172     # <?include ?> directives.
       
   173     doc = xml.dom.minidom.parseString(
   180     doc = xml.dom.minidom.parseString(
   174         LIBRARIES_XML.format(wix_dir=str(wix_dir))
   181         '<?xml version="1.0" encoding="utf-8"?>'
   175     )
   182         '<Wix xmlns="http://schemas.microsoft.com/wix/2006/wi">'
   176 
   183         '</Wix>'
   177     component = doc.getElementsByTagName('Component')[0]
   184     )
   178 
   185 
   179     f = doc.createElement('File')
   186     # Assemble the install layout by directory. This makes it easier to
   180     f.setAttribute('Name', 'library.zip')
   187     # emit XML, since each directory has separate entities.
   181     f.setAttribute('KeyPath', 'yes')
   188     manifest = collections.defaultdict(dict)
   182     component.appendChild(f)
   189 
   183 
   190     for root, dirs, files in os.walk(staging_dir):
   184     lib_dir = dist_dir / 'lib'
   191         dirs.sort()
   185 
   192 
   186     for p in sorted(lib_dir.iterdir()):
   193         root = pathlib.Path(root)
   187         if not p.name.endswith(('.dll', '.pyd')):
   194         rel_dir = root.relative_to(staging_dir)
   188             continue
   195 
   189 
   196         for i in range(len(rel_dir.parts)):
   190         f = doc.createElement('File')
   197             parent = '/'.join(rel_dir.parts[0 : i + 1])
   191         f.setAttribute('Name', p.name)
   198             manifest.setdefault(parent, {})
   192         component.appendChild(f)
   199 
       
   200         for f in sorted(files):
       
   201             full = root / f
       
   202             manifest[str(rel_dir).replace('\\', '/')][full.name] = full
       
   203 
       
   204     component_groups = collections.defaultdict(list)
       
   205 
       
   206     # Now emit a <Fragment> for each directory.
       
   207     # Each directory is composed of a <DirectoryRef> pointing to its parent
       
   208     # and defines child <Directory>'s and a <Component> with all the files.
       
   209     for dir_name, entries in sorted(manifest.items()):
       
   210         # The directory id is derived from the path. But the root directory
       
   211         # is special.
       
   212         if dir_name == '.':
       
   213             parent_directory_id = 'INSTALLDIR'
       
   214         else:
       
   215             parent_directory_id = 'hg.dir.%s' % dir_name.replace('/', '.')
       
   216 
       
   217         fragment = doc.createElement('Fragment')
       
   218         directory_ref = doc.createElement('DirectoryRef')
       
   219         directory_ref.setAttribute('Id', parent_directory_id)
       
   220 
       
   221         # Add <Directory> entries for immediate children directories.
       
   222         for possible_child in sorted(manifest.keys()):
       
   223             if (
       
   224                 dir_name == '.'
       
   225                 and '/' not in possible_child
       
   226                 and possible_child != '.'
       
   227             ):
       
   228                 child_directory_id = 'hg.dir.%s' % possible_child
       
   229                 name = possible_child
       
   230             else:
       
   231                 if not possible_child.startswith('%s/' % dir_name):
       
   232                     continue
       
   233                 name = possible_child[len(dir_name) + 1 :]
       
   234                 if '/' in name:
       
   235                     continue
       
   236 
       
   237                 child_directory_id = 'hg.dir.%s' % possible_child.replace(
       
   238                     '/', '.'
       
   239                 )
       
   240 
       
   241             directory = doc.createElement('Directory')
       
   242             directory.setAttribute('Id', child_directory_id)
       
   243             directory.setAttribute('Name', name)
       
   244             directory_ref.appendChild(directory)
       
   245 
       
   246         # Add <Component>s for files in this directory.
       
   247         for rel, source_path in sorted(entries.items()):
       
   248             if dir_name == '.':
       
   249                 full_rel = rel
       
   250             else:
       
   251                 full_rel = '%s/%s' % (dir_name, rel)
       
   252 
       
   253             component_unique_id = (
       
   254                 'https://www.mercurial-scm.org/wix-installer/0/component/%s'
       
   255                 % full_rel
       
   256             )
       
   257             component_guid = uuid.uuid5(uuid.NAMESPACE_URL, component_unique_id)
       
   258             component_id = 'hg.component.%s' % str(component_guid).replace(
       
   259                 '-', '_'
       
   260             )
       
   261 
       
   262             component = doc.createElement('Component')
       
   263 
       
   264             component.setAttribute('Id', component_id)
       
   265             component.setAttribute('Guid', str(component_guid).upper())
       
   266             component.setAttribute('Win64', 'yes' if is_x64 else 'no')
       
   267 
       
   268             # Assign this component to a top-level group.
       
   269             if dir_name == '.':
       
   270                 component_groups['ROOT'].append(component_id)
       
   271             elif '/' in dir_name:
       
   272                 component_groups[dir_name[0 : dir_name.index('/')]].append(
       
   273                     component_id
       
   274                 )
       
   275             else:
       
   276                 component_groups[dir_name].append(component_id)
       
   277 
       
   278             unique_id = (
       
   279                 'https://www.mercurial-scm.org/wix-installer/0/%s' % full_rel
       
   280             )
       
   281             file_guid = uuid.uuid5(uuid.NAMESPACE_URL, unique_id)
       
   282 
       
   283             # IDs have length limits. So use GUID to derive them.
       
   284             file_guid_normalized = str(file_guid).replace('-', '_')
       
   285             file_id = 'hg.file.%s' % file_guid_normalized
       
   286 
       
   287             file_element = doc.createElement('File')
       
   288             file_element.setAttribute('Id', file_id)
       
   289             file_element.setAttribute('Source', str(source_path))
       
   290             file_element.setAttribute('KeyPath', 'yes')
       
   291             file_element.setAttribute('ReadOnly', 'yes')
       
   292 
       
   293             component.appendChild(file_element)
       
   294             directory_ref.appendChild(component)
       
   295 
       
   296         fragment.appendChild(directory_ref)
       
   297         doc.documentElement.appendChild(fragment)
       
   298 
       
   299     for group, component_ids in sorted(component_groups.items()):
       
   300         fragment = doc.createElement('Fragment')
       
   301         component_group = doc.createElement('ComponentGroup')
       
   302         component_group.setAttribute('Id', 'hg.group.%s' % group)
       
   303 
       
   304         for component_id in component_ids:
       
   305             component_ref = doc.createElement('ComponentRef')
       
   306             component_ref.setAttribute('Id', component_id)
       
   307             component_group.appendChild(component_ref)
       
   308 
       
   309         fragment.appendChild(component_group)
       
   310         doc.documentElement.appendChild(fragment)
       
   311 
       
   312     # Add <Shortcut> to files that have it defined.
       
   313     for file_id, metadata in sorted(SHORTCUTS.items()):
       
   314         els = doc.getElementsByTagName('File')
       
   315         els = [el for el in els if el.getAttribute('Id') == file_id]
       
   316 
       
   317         if not els:
       
   318             raise Exception('could not find File[Id=%s]' % file_id)
       
   319 
       
   320         for el in els:
       
   321             shortcut = doc.createElement('Shortcut')
       
   322             shortcut.setAttribute('Id', 'hg.shortcut.%s' % file_id)
       
   323             shortcut.setAttribute('Directory', 'ProgramMenuDir')
       
   324             shortcut.setAttribute('Icon', 'hgIcon.ico')
       
   325             shortcut.setAttribute('IconIndex', '0')
       
   326             shortcut.setAttribute('Advertise', 'yes')
       
   327             for k, v in sorted(metadata.items()):
       
   328                 shortcut.setAttribute(k, v)
       
   329 
       
   330             el.appendChild(shortcut)
   193 
   331 
   194     return doc.toprettyxml()
   332     return doc.toprettyxml()
   195 
   333 
   196 
   334 
   197 def build_installer(
   335 def build_installer(
   246 
   384 
   247     if post_build_fn:
   385     if post_build_fn:
   248         post_build_fn(source_dir, hg_build_dir, dist_dir, version)
   386         post_build_fn(source_dir, hg_build_dir, dist_dir, version)
   249 
   387 
   250     build_dir = hg_build_dir / ('wix-%s' % arch)
   388     build_dir = hg_build_dir / ('wix-%s' % arch)
       
   389     staging_dir = build_dir / 'stage'
   251 
   390 
   252     build_dir.mkdir(exist_ok=True)
   391     build_dir.mkdir(exist_ok=True)
       
   392 
       
   393     # Purge the staging directory for every build so packaging is pristine.
       
   394     if staging_dir.exists():
       
   395         print('purging %s' % staging_dir)
       
   396         shutil.rmtree(staging_dir)
       
   397 
       
   398     stage_install(source_dir, staging_dir, lower_case=True)
       
   399 
       
   400     # We also install some extra files.
       
   401     process_install_rules(EXTRA_INSTALL_RULES, source_dir, staging_dir)
       
   402 
       
   403     # And remove some files we don't want.
       
   404     for f in STAGING_REMOVE_FILES:
       
   405         p = staging_dir / f
       
   406         if p.exists():
       
   407             print('removing %s' % p)
       
   408             p.unlink()
   253 
   409 
   254     wix_pkg, wix_entry = download_entry('wix', hg_build_dir)
   410     wix_pkg, wix_entry = download_entry('wix', hg_build_dir)
   255     wix_path = hg_build_dir / ('wix-%s' % wix_entry['version'])
   411     wix_path = hg_build_dir / ('wix-%s' % wix_entry['version'])
   256 
   412 
   257     if not wix_path.exists():
   413     if not wix_path.exists():
   261 
   417 
   262     source_build_rel = pathlib.Path(os.path.relpath(source_dir, build_dir))
   418     source_build_rel = pathlib.Path(os.path.relpath(source_dir, build_dir))
   263 
   419 
   264     defines = {'Platform': arch}
   420     defines = {'Platform': arch}
   265 
   421 
   266     for wxs, rel_path in SUPPORT_WXS:
   422     # Derive a .wxs file with the staged files.
   267         wxs = wix_dir / wxs
   423     manifest_wxs = build_dir / 'stage.wxs'
   268         wxs_source_dir = source_dir / rel_path
   424     with manifest_wxs.open('w', encoding='utf-8') as fh:
   269         run_candle(wix_path, build_dir, wxs, wxs_source_dir, defines=defines)
   425         fh.write(make_files_xml(staging_dir, is_x64=arch == 'x64'))
       
   426 
       
   427     run_candle(wix_path, build_dir, manifest_wxs, staging_dir, defines=defines)
   270 
   428 
   271     for source, rel_path in sorted((extra_wxs or {}).items()):
   429     for source, rel_path in sorted((extra_wxs or {}).items()):
   272         run_candle(wix_path, build_dir, source, rel_path, defines=defines)
   430         run_candle(wix_path, build_dir, source, rel_path, defines=defines)
   273 
       
   274     # candle.exe doesn't like when we have an open handle on the file.
       
   275     # So use TemporaryDirectory() instead of NamedTemporaryFile().
       
   276     with tempfile.TemporaryDirectory() as td:
       
   277         td = pathlib.Path(td)
       
   278 
       
   279         tf = td / 'library.wxs'
       
   280         with tf.open('w') as fh:
       
   281             fh.write(make_libraries_xml(wix_dir, dist_dir))
       
   282 
       
   283         run_candle(wix_path, build_dir, tf, dist_dir, defines=defines)
       
   284 
   431 
   285     source = wix_dir / 'mercurial.wxs'
   432     source = wix_dir / 'mercurial.wxs'
   286     defines['Version'] = version
   433     defines['Version'] = version
   287     defines['Comments'] = 'Installs Mercurial version %s' % version
   434     defines['Comments'] = 'Installs Mercurial version %s' % version
   288     defines['VCRedistSrcDir'] = str(hg_build_dir)
   435     defines['VCRedistSrcDir'] = str(hg_build_dir)
   305         '-spdb',
   452         '-spdb',
   306         '-o',
   453         '-o',
   307         str(msi_path),
   454         str(msi_path),
   308     ]
   455     ]
   309 
   456 
   310     for source, rel_path in SUPPORT_WXS:
       
   311         assert source.endswith('.wxs')
       
   312         args.append(str(build_dir / ('%s.wixobj' % source[:-4])))
       
   313 
       
   314     for source, rel_path in sorted((extra_wxs or {}).items()):
   457     for source, rel_path in sorted((extra_wxs or {}).items()):
   315         assert source.endswith('.wxs')
   458         assert source.endswith('.wxs')
   316         source = os.path.basename(source)
   459         source = os.path.basename(source)
   317         args.append(str(build_dir / ('%s.wixobj' % source[:-4])))
   460         args.append(str(build_dir / ('%s.wixobj' % source[:-4])))
   318 
   461 
   319     args.extend(
   462     args.extend(
   320         [
   463         [str(build_dir / 'stage.wixobj'), str(build_dir / 'mercurial.wixobj'),]
   321             str(build_dir / 'library.wixobj'),
       
   322             str(build_dir / 'mercurial.wixobj'),
       
   323         ]
       
   324     )
   464     )
   325 
   465 
   326     subprocess.run(args, cwd=str(source_dir), check=True)
   466     subprocess.run(args, cwd=str(source_dir), check=True)
   327 
   467 
   328     print('%s created' % msi_path)
   468     print('%s created' % msi_path)