diff -r 4561ec90d3c1 -r 17d5e25b8e78 contrib/packaging/hgpackaging/wix.py --- a/contrib/packaging/hgpackaging/wix.py Sat Feb 19 18:42:12 2022 -0700 +++ b/contrib/packaging/hgpackaging/wix.py Sat Feb 19 22:13:11 2022 -0700 @@ -7,376 +7,16 @@ # no-check-code because Python 3 native. -import collections import json 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 ( build_docs_html, - create_pyoxidizer_install_layout, 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( @@ -454,133 +94,3 @@ return { "msi_path": dist_path, } - - -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, - }