# wix.py - WiX installer functionality # # Copyright 2019 Gregory Szorc # # This software may be used and distributed according to the terms of the # GNU General Public License version 2 or any later version. # no-check-code because Python 3 native. import collections import os import pathlib import re import shutil import subprocess import typing import uuid import xml.dom.minidom from .downloads import download_entry from .py2exe import ( build_py2exe, stage_install, ) from .pyoxidizer import run_pyoxidizer from .util import ( extract_zip_to_directory, normalize_windows_version, process_install_rules, sign_with_signtool, ) EXTRA_PACKAGES = { 'dulwich', 'distutils', 'keyring', 'pygments', 'win32ctypes', } EXTRA_INCLUDES = { '_curses', '_curses_panel', } EXTRA_INSTALL_RULES = [ ('contrib/packaging/wix/COPYING.rtf', 'COPYING.rtf'), ('contrib/win32/mercurial.ini', 'defaultrc/mercurial.rc'), ] STAGING_REMOVE_FILES = [ # We use the RTF variant. 'copying.txt', ] SHORTCUTS = { # hg.1.html' 'hg.file.5d3e441c_28d9_5542_afd0_cdd4234f12d5': { 'Name': 'Mercurial Command Reference', }, # hgignore.5.html 'hg.file.5757d8e0_f207_5e10_a2ec_3ba0a062f431': { 'Name': 'Mercurial Ignore Files', }, # hgrc.5.html 'hg.file.92e605fd_1d1a_5dc6_9fc0_5d2998eb8f5e': { 'Name': 'Mercurial Configuration Files', }, } def find_version(source_dir: pathlib.Path): version_py = source_dir / 'mercurial' / '__version__.py' with version_py.open('r', encoding='utf-8') as fh: source = fh.read().strip() m = re.search('version = b"(.*)"', source) return m.group(1) def ensure_vc90_merge_modules(build_dir): x86 = ( download_entry( 'vc9-crt-x86-msm', build_dir, local_name='microsoft.vcxx.crt.x86_msm.msm', )[0], download_entry( 'vc9-crt-x86-msm-policy', build_dir, local_name='policy.x.xx.microsoft.vcxx.crt.x86_msm.msm', )[0], ) x64 = ( download_entry( 'vc9-crt-x64-msm', build_dir, local_name='microsoft.vcxx.crt.x64_msm.msm', )[0], download_entry( 'vc9-crt-x64-msm-policy', build_dir, local_name='policy.x.xx.microsoft.vcxx.crt.x64_msm.msm', )[0], ) return { 'x86': x86, 'x64': x64, } def run_candle(wix, cwd, wxs, source_dir, defines=None): args = [ str(wix / 'candle.exe'), '-nologo', str(wxs), '-dSourceDir=%s' % source_dir, ] if defines: args.extend('-d%s=%s' % define for define in sorted(defines.items())) subprocess.run(args, cwd=str(cwd), check=True) def make_files_xml(staging_dir: pathlib.Path, is_x64) -> str: """Create XML string listing every file to be installed.""" # We derive GUIDs from a deterministic file path identifier. # We shoehorn the name into something that looks like a URL because # the UUID namespaces are supposed to work that way (even though # the input data probably is never validated). doc = xml.dom.minidom.parseString( '' '' '' ) # Assemble the install layout by directory. This makes it easier to # emit XML, since each directory has separate entities. manifest = collections.defaultdict(dict) for root, dirs, files in os.walk(staging_dir): dirs.sort() root = pathlib.Path(root) rel_dir = root.relative_to(staging_dir) for i in range(len(rel_dir.parts)): parent = '/'.join(rel_dir.parts[0 : i + 1]) manifest.setdefault(parent, {}) for f in sorted(files): full = root / f manifest[str(rel_dir).replace('\\', '/')][full.name] = full component_groups = collections.defaultdict(list) # Now emit a for each directory. # Each directory is composed of a pointing to its parent # and defines child 's and a with all the files. for dir_name, entries in sorted(manifest.items()): # The directory id is derived from the path. But the root directory # is special. if dir_name == '.': parent_directory_id = 'INSTALLDIR' else: parent_directory_id = 'hg.dir.%s' % dir_name.replace( '/', '.' ).replace('-', '_') fragment = doc.createElement('Fragment') directory_ref = doc.createElement('DirectoryRef') directory_ref.setAttribute('Id', parent_directory_id) # Add entries for immediate children directories. for possible_child in sorted(manifest.keys()): if ( dir_name == '.' and '/' not in possible_child and possible_child != '.' ): child_directory_id = ('hg.dir.%s' % possible_child).replace( '-', '_' ) name = possible_child else: if not possible_child.startswith('%s/' % dir_name): continue name = possible_child[len(dir_name) + 1 :] if '/' in name: continue child_directory_id = 'hg.dir.%s' % possible_child.replace( '/', '.' ).replace('-', '_') directory = doc.createElement('Directory') directory.setAttribute('Id', child_directory_id) directory.setAttribute('Name', name) directory_ref.appendChild(directory) # Add s for files in this directory. for rel, source_path in sorted(entries.items()): if dir_name == '.': full_rel = rel else: full_rel = '%s/%s' % (dir_name, rel) component_unique_id = ( 'https://www.mercurial-scm.org/wix-installer/0/component/%s' % full_rel ) component_guid = uuid.uuid5(uuid.NAMESPACE_URL, component_unique_id) component_id = 'hg.component.%s' % str(component_guid).replace( '-', '_' ) component = doc.createElement('Component') component.setAttribute('Id', component_id) component.setAttribute('Guid', str(component_guid).upper()) component.setAttribute('Win64', 'yes' if is_x64 else 'no') # Assign this component to a top-level group. if dir_name == '.': component_groups['ROOT'].append(component_id) elif '/' in dir_name: component_groups[dir_name[0 : dir_name.index('/')]].append( component_id ) else: component_groups[dir_name].append(component_id) unique_id = ( 'https://www.mercurial-scm.org/wix-installer/0/%s' % full_rel ) file_guid = uuid.uuid5(uuid.NAMESPACE_URL, unique_id) # IDs have length limits. So use GUID to derive them. file_guid_normalized = str(file_guid).replace('-', '_') file_id = 'hg.file.%s' % file_guid_normalized file_element = doc.createElement('File') file_element.setAttribute('Id', file_id) file_element.setAttribute('Source', str(source_path)) file_element.setAttribute('KeyPath', 'yes') file_element.setAttribute('ReadOnly', 'yes') component.appendChild(file_element) directory_ref.appendChild(component) fragment.appendChild(directory_ref) doc.documentElement.appendChild(fragment) for group, component_ids in sorted(component_groups.items()): fragment = doc.createElement('Fragment') component_group = doc.createElement('ComponentGroup') component_group.setAttribute('Id', 'hg.group.%s' % group) for component_id in component_ids: component_ref = doc.createElement('ComponentRef') component_ref.setAttribute('Id', component_id) component_group.appendChild(component_ref) fragment.appendChild(component_group) doc.documentElement.appendChild(fragment) # Add to files that have it defined. for file_id, metadata in sorted(SHORTCUTS.items()): els = doc.getElementsByTagName('File') els = [el for el in els if el.getAttribute('Id') == file_id] if not els: raise Exception('could not find File[Id=%s]' % file_id) for el in els: shortcut = doc.createElement('Shortcut') shortcut.setAttribute('Id', 'hg.shortcut.%s' % file_id) shortcut.setAttribute('Directory', 'ProgramMenuDir') shortcut.setAttribute('Icon', 'hgIcon.ico') shortcut.setAttribute('IconIndex', '0') shortcut.setAttribute('Advertise', 'yes') for k, v in sorted(metadata.items()): shortcut.setAttribute(k, v) el.appendChild(shortcut) return doc.toprettyxml() def build_installer_py2exe( source_dir: pathlib.Path, python_exe: pathlib.Path, msi_name='mercurial', version=None, extra_packages_script=None, extra_wxs: typing.Optional[typing.Dict[str, str]] = None, extra_features: typing.Optional[typing.List[str]] = None, signing_info: typing.Optional[typing.Dict[str, str]] = None, ): """Build a WiX MSI installer using py2exe. ``source_dir`` is the path to the Mercurial source tree to use. ``arch`` is the target architecture. either ``x86`` or ``x64``. ``python_exe`` is the path to the Python executable to use/bundle. ``version`` is the Mercurial version string. If not defined, ``mercurial/__version__.py`` will be consulted. ``extra_packages_script`` is a command to be run to inject extra packages into the py2exe binary. It should stage packages into the virtualenv and print a null byte followed by a newline-separated list of packages that should be included in the exe. ``extra_wxs`` is a dict of {wxs_name: working_dir_for_wxs_build}. ``extra_features`` is a list of additional named Features to include in the build. These must match Feature names in one of the wxs scripts. """ arch = 'x64' if r'\x64' in os.environ.get('LIB', '') else 'x86' hg_build_dir = source_dir / 'build' requirements_txt = ( source_dir / 'contrib' / 'packaging' / 'requirements-windows-py2.txt' ) build_py2exe( source_dir, hg_build_dir, python_exe, 'wix', requirements_txt, extra_packages=EXTRA_PACKAGES, extra_packages_script=extra_packages_script, extra_includes=EXTRA_INCLUDES, ) build_dir = hg_build_dir / ('wix-%s' % arch) staging_dir = build_dir / 'stage' build_dir.mkdir(exist_ok=True) # Purge the staging directory for every build so packaging is pristine. if staging_dir.exists(): print('purging %s' % staging_dir) shutil.rmtree(staging_dir) stage_install(source_dir, staging_dir, lower_case=True) # We also install some extra files. process_install_rules(EXTRA_INSTALL_RULES, source_dir, staging_dir) # And remove some files we don't want. for f in STAGING_REMOVE_FILES: p = staging_dir / f if p.exists(): print('removing %s' % p) p.unlink() return run_wix_packaging( source_dir, build_dir, staging_dir, arch, version=version, python2=True, msi_name=msi_name, suffix="-python2", extra_wxs=extra_wxs, extra_features=extra_features, signing_info=signing_info, ) def build_installer_pyoxidizer( source_dir: pathlib.Path, target_triple: str, msi_name='mercurial', version=None, extra_wxs: typing.Optional[typing.Dict[str, str]] = None, extra_features: typing.Optional[typing.List[str]] = None, signing_info: typing.Optional[typing.Dict[str, str]] = None, ): """Build a WiX MSI installer using PyOxidizer.""" hg_build_dir = source_dir / "build" build_dir = hg_build_dir / ("wix-%s" % target_triple) staging_dir = build_dir / "stage" arch = "x64" if "x86_64" in target_triple else "x86" build_dir.mkdir(parents=True, exist_ok=True) run_pyoxidizer(source_dir, build_dir, staging_dir, target_triple) # We also install some extra files. process_install_rules(EXTRA_INSTALL_RULES, source_dir, staging_dir) # And remove some files we don't want. for f in STAGING_REMOVE_FILES: p = staging_dir / f if p.exists(): print('removing %s' % p) p.unlink() return run_wix_packaging( source_dir, build_dir, staging_dir, arch, version, python2=False, msi_name=msi_name, extra_wxs=extra_wxs, extra_features=extra_features, signing_info=signing_info, ) def run_wix_packaging( source_dir: pathlib.Path, build_dir: pathlib.Path, staging_dir: pathlib.Path, arch: str, version: str, python2: bool, msi_name: typing.Optional[str] = "mercurial", suffix: str = "", extra_wxs: typing.Optional[typing.Dict[str, str]] = None, extra_features: typing.Optional[typing.List[str]] = None, signing_info: typing.Optional[typing.Dict[str, str]] = None, ): """Invokes WiX to package up a built Mercurial. ``signing_info`` is a dict defining properties to facilitate signing the installer. Recognized keys include ``name``, ``subject_name``, ``cert_path``, ``cert_password``, and ``timestamp_url``. If populated, we will sign both the hg.exe and the .msi using the signing credentials specified. """ orig_version = version or find_version(source_dir) version = normalize_windows_version(orig_version) print('using version string: %s' % version) if version != orig_version: print('(normalized from: %s)' % orig_version) if signing_info: sign_with_signtool( staging_dir / "hg.exe", "%s %s" % (signing_info["name"], version), subject_name=signing_info["subject_name"], cert_path=signing_info["cert_path"], cert_password=signing_info["cert_password"], timestamp_url=signing_info["timestamp_url"], ) wix_dir = source_dir / 'contrib' / 'packaging' / 'wix' wix_pkg, wix_entry = download_entry('wix', build_dir) wix_path = build_dir / ('wix-%s' % wix_entry['version']) if not wix_path.exists(): extract_zip_to_directory(wix_pkg, wix_path) if python2: ensure_vc90_merge_modules(build_dir) source_build_rel = pathlib.Path(os.path.relpath(source_dir, build_dir)) defines = {'Platform': arch} # Derive a .wxs file with the staged files. manifest_wxs = build_dir / 'stage.wxs' with manifest_wxs.open('w', encoding='utf-8') as fh: fh.write(make_files_xml(staging_dir, is_x64=arch == 'x64')) run_candle(wix_path, build_dir, manifest_wxs, staging_dir, defines=defines) for source, rel_path in sorted((extra_wxs or {}).items()): run_candle(wix_path, build_dir, source, rel_path, defines=defines) source = wix_dir / 'mercurial.wxs' defines['Version'] = version defines['Comments'] = 'Installs Mercurial version %s' % version if python2: defines["PythonVersion"] = "2" defines['VCRedistSrcDir'] = str(build_dir) else: defines["PythonVersion"] = "3" if (staging_dir / "lib").exists(): defines["MercurialHasLib"] = "1" if extra_features: assert all(';' not in f for f in extra_features) defines['MercurialExtraFeatures'] = ';'.join(extra_features) run_candle(wix_path, build_dir, source, source_build_rel, defines=defines) msi_path = ( source_dir / 'dist' / ('%s-%s-%s%s.msi' % (msi_name, orig_version, arch, suffix)) ) args = [ str(wix_path / 'light.exe'), '-nologo', '-ext', 'WixUIExtension', '-sw1076', '-spdb', '-o', str(msi_path), ] for source, rel_path in sorted((extra_wxs or {}).items()): assert source.endswith('.wxs') source = os.path.basename(source) args.append(str(build_dir / ('%s.wixobj' % source[:-4]))) args.extend( [ str(build_dir / 'stage.wixobj'), str(build_dir / 'mercurial.wixobj'), ] ) subprocess.run(args, cwd=str(source_dir), check=True) print('%s created' % msi_path) if signing_info: sign_with_signtool( msi_path, "%s %s" % (signing_info["name"], version), subject_name=signing_info["subject_name"], cert_path=signing_info["cert_path"], cert_password=signing_info["cert_password"], timestamp_url=signing_info["timestamp_url"], ) return { 'msi_path': msi_path, }