wix.py
553 lines
| 17.1 KiB
| text/x-python
|
PythonLexer
|
r42118 | # wix.py - WiX installer functionality | ||
# | ||||
# Copyright 2019 Gregory Szorc <gregory.szorc@gmail.com> | ||||
# | ||||
# 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. | ||||
|
r44022 | import collections | ||
|
r42118 | import os | ||
import pathlib | ||||
import re | ||||
|
r44022 | import shutil | ||
|
r42118 | import subprocess | ||
|
r42215 | import typing | ||
|
r44022 | import uuid | ||
|
r42123 | import xml.dom.minidom | ||
|
r42118 | |||
|
r43346 | from .downloads import download_entry | ||
|
r44022 | from .py2exe import ( | ||
build_py2exe, | ||||
stage_install, | ||||
) | ||||
|
r42118 | from .util import ( | ||
extract_zip_to_directory, | ||||
|
r44022 | process_install_rules, | ||
|
r42118 | sign_with_signtool, | ||
) | ||||
EXTRA_PACKAGES = { | ||||
'distutils', | ||||
'pygments', | ||||
} | ||||
|
r44022 | EXTRA_INSTALL_RULES = [ | ||
('contrib/packaging/wix/COPYING.rtf', 'COPYING.rtf'), | ||||
|
r44614 | ('contrib/win32/mercurial.ini', 'defaultrc/mercurial.rc'), | ||
|
r44022 | ] | ||
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', | ||||
}, | ||||
} | ||||
|
r42118 | 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 normalize_version(version): | ||||
"""Normalize Mercurial version string so WiX accepts it. | ||||
|
r44632 | Version strings have to be numeric ``A.B.C[.D]`` to conform with MSI's | ||
requirements. | ||||
We normalize RC version or the commit count to a 4th version component. | ||||
We store this in the 4th component because ``A.B.C`` releases do occur | ||||
and we want an e.g. ``5.3rc0`` version to be semantically less than a | ||||
``5.3.1rc2`` version. This requires always reserving the 3rd version | ||||
component for the point release and the ``X.YrcN`` release is always | ||||
point release 0. | ||||
In the case of an RC and presence of ``+`` suffix data, we can't use both | ||||
because the version format is limited to 4 components. We choose to use | ||||
RC and throw away the commit count in the suffix. This means we could | ||||
produce multiple installers with the same normalized version string. | ||||
>>> normalize_version("5.3") | ||||
'5.3.0' | ||||
>>> normalize_version("5.3rc0") | ||||
'5.3.0.0' | ||||
>>> normalize_version("5.3rc1") | ||||
'5.3.0.1' | ||||
>>> normalize_version("5.3rc1+2-abcdef") | ||||
'5.3.0.1' | ||||
>>> normalize_version("5.3+2-abcdef") | ||||
'5.3.0.2' | ||||
|
r42118 | """ | ||
if '+' in version: | ||||
version, extra = version.split('+', 1) | ||||
else: | ||||
extra = None | ||||
# 4.9rc0 | ||||
if version[:-1].endswith('rc'): | ||||
|
r44632 | rc = int(version[-1:]) | ||
|
r42118 | version = version[:-3] | ||
|
r44632 | else: | ||
rc = None | ||||
|
r42118 | |||
|
r44632 | # Ensure we have at least X.Y version components. | ||
|
r42118 | versions = [int(v) for v in version.split('.')] | ||
while len(versions) < 3: | ||||
versions.append(0) | ||||
|
r44632 | if len(versions) < 4: | ||
if rc is not None: | ||||
versions.append(rc) | ||||
elif extra: | ||||
# <commit count>-<hash>+<date> | ||||
versions.append(int(extra.split('-')[0])) | ||||
|
r42118 | |||
|
r44632 | return '.'.join('%d' % x for x in versions[0:4]) | ||
|
r42118 | |||
def ensure_vc90_merge_modules(build_dir): | ||||
x86 = ( | ||||
|
r43346 | 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], | ||||
|
r42118 | ) | ||
x64 = ( | ||||
|
r43346 | 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], | ||||
|
r42118 | ) | ||
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) | ||||
|
r43346 | def make_post_build_signing_fn( | ||
name, | ||||
subject_name=None, | ||||
cert_path=None, | ||||
cert_password=None, | ||||
timestamp_url=None, | ||||
): | ||||
|
r42118 | """Create a callable that will use signtool to sign hg.exe.""" | ||
def post_build_sign(source_dir, build_dir, dist_dir, version): | ||||
description = '%s %s' % (name, version) | ||||
|
r43346 | sign_with_signtool( | ||
dist_dir / 'hg.exe', | ||||
description, | ||||
subject_name=subject_name, | ||||
cert_path=cert_path, | ||||
cert_password=cert_password, | ||||
timestamp_url=timestamp_url, | ||||
) | ||||
|
r42118 | |||
return post_build_sign | ||||
|
r44022 | def make_files_xml(staging_dir: pathlib.Path, is_x64) -> str: | ||
"""Create XML string listing every file to be installed.""" | ||||
|
r42123 | |||
|
r44022 | # 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). | ||||
|
r42123 | |||
doc = xml.dom.minidom.parseString( | ||||
|
r44022 | '<?xml version="1.0" encoding="utf-8"?>' | ||
'<Wix xmlns="http://schemas.microsoft.com/wix/2006/wi">' | ||||
'</Wix>' | ||||
|
r43346 | ) | ||
|
r42123 | |||
|
r44022 | # 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 <Fragment> for each directory. | ||||
# Each directory is composed of a <DirectoryRef> pointing to its parent | ||||
# and defines child <Directory>'s and a <Component> 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('/', '.') | ||||
|
r42123 | |||
|
r44022 | fragment = doc.createElement('Fragment') | ||
directory_ref = doc.createElement('DirectoryRef') | ||||
directory_ref.setAttribute('Id', parent_directory_id) | ||||
# Add <Directory> 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 | ||||
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( | ||||
'/', '.' | ||||
) | ||||
directory = doc.createElement('Directory') | ||||
directory.setAttribute('Id', child_directory_id) | ||||
directory.setAttribute('Name', name) | ||||
directory_ref.appendChild(directory) | ||||
# Add <Component>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) | ||||
|
r42123 | |||
|
r44022 | 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 | ||||
|
r42123 | |||
|
r44022 | 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) | ||||
|
r42123 | |||
|
r44022 | fragment.appendChild(component_group) | ||
doc.documentElement.appendChild(fragment) | ||||
# Add <Shortcut> 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) | ||||
|
r42123 | |||
return doc.toprettyxml() | ||||
|
r43346 | def build_installer( | ||
source_dir: pathlib.Path, | ||||
python_exe: pathlib.Path, | ||||
msi_name='mercurial', | ||||
version=None, | ||||
post_build_fn=None, | ||||
extra_packages_script=None, | ||||
extra_wxs: typing.Optional[typing.Dict[str, str]] = None, | ||||
extra_features: typing.Optional[typing.List[str]] = None, | ||||
): | ||||
|
r42118 | """Build a WiX MSI installer. | ||
``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. | ||||
``post_build_fn`` is a callable that will be called after building | ||||
Mercurial but before invoking WiX. It can be used to e.g. facilitate | ||||
signing. It is passed the paths to the Mercurial source, build, and | ||||
dist directories and the resolved Mercurial version. | ||||
|
r42214 | ``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. | ||||
|
r42215 | ``extra_wxs`` is a dict of {wxs_name: working_dir_for_wxs_build}. | ||
|
r42216 | ``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. | ||||
|
r42118 | """ | ||
arch = 'x64' if r'\x64' in os.environ.get('LIB', '') else 'x86' | ||||
hg_build_dir = source_dir / 'build' | ||||
dist_dir = source_dir / 'dist' | ||||
|
r42122 | wix_dir = source_dir / 'contrib' / 'packaging' / 'wix' | ||
|
r42118 | |||
|
r42122 | requirements_txt = wix_dir / 'requirements.txt' | ||
|
r42118 | |||
|
r43346 | build_py2exe( | ||
source_dir, | ||||
hg_build_dir, | ||||
python_exe, | ||||
'wix', | ||||
requirements_txt, | ||||
extra_packages=EXTRA_PACKAGES, | ||||
extra_packages_script=extra_packages_script, | ||||
) | ||||
|
r42118 | |||
|
r44633 | orig_version = version or find_version(source_dir) | ||
version = normalize_version(orig_version) | ||||
|
r42118 | print('using version string: %s' % version) | ||
|
r44633 | if version != orig_version: | ||
print('(normalized from: %s)' % orig_version) | ||||
|
r42118 | |||
if post_build_fn: | ||||
post_build_fn(source_dir, hg_build_dir, dist_dir, version) | ||||
build_dir = hg_build_dir / ('wix-%s' % arch) | ||||
|
r44022 | staging_dir = build_dir / 'stage' | ||
|
r42118 | |||
build_dir.mkdir(exist_ok=True) | ||||
|
r44022 | # 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() | ||||
|
r42118 | wix_pkg, wix_entry = download_entry('wix', hg_build_dir) | ||
wix_path = hg_build_dir / ('wix-%s' % wix_entry['version']) | ||||
if not wix_path.exists(): | ||||
extract_zip_to_directory(wix_pkg, wix_path) | ||||
ensure_vc90_merge_modules(hg_build_dir) | ||||
source_build_rel = pathlib.Path(os.path.relpath(source_dir, build_dir)) | ||||
defines = {'Platform': arch} | ||||
|
r44022 | # 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) | ||||
|
r42118 | |||
|
r42215 | for source, rel_path in sorted((extra_wxs or {}).items()): | ||
run_candle(wix_path, build_dir, source, rel_path, defines=defines) | ||||
|
r42122 | source = wix_dir / 'mercurial.wxs' | ||
|
r42118 | defines['Version'] = version | ||
defines['Comments'] = 'Installs Mercurial version %s' % version | ||||
defines['VCRedistSrcDir'] = str(hg_build_dir) | ||||
|
r42216 | if extra_features: | ||
assert all(';' not in f for f in extra_features) | ||||
defines['MercurialExtraFeatures'] = ';'.join(extra_features) | ||||
|
r42118 | |||
run_candle(wix_path, build_dir, source, source_build_rel, defines=defines) | ||||
|
r43346 | msi_path = ( | ||
|
r44634 | source_dir / 'dist' / ('%s-%s-%s.msi' % (msi_name, orig_version, arch)) | ||
|
r43346 | ) | ||
|
r42118 | |||
args = [ | ||||
str(wix_path / 'light.exe'), | ||||
'-nologo', | ||||
|
r43346 | '-ext', | ||
'WixUIExtension', | ||||
|
r42118 | '-sw1076', | ||
'-spdb', | ||||
|
r43346 | '-o', | ||
str(msi_path), | ||||
|
r42118 | ] | ||
|
r42215 | 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]))) | ||||
|
r43346 | args.extend( | ||
|
r44022 | [str(build_dir / 'stage.wixobj'), str(build_dir / 'mercurial.wixobj'),] | ||
|
r43346 | ) | ||
|
r42118 | |||
subprocess.run(args, cwd=str(source_dir), check=True) | ||||
print('%s created' % msi_path) | ||||
return { | ||||
'msi_path': msi_path, | ||||
} | ||||
|
r43346 | def build_signed_installer( | ||
source_dir: pathlib.Path, | ||||
python_exe: pathlib.Path, | ||||
name: str, | ||||
version=None, | ||||
subject_name=None, | ||||
cert_path=None, | ||||
cert_password=None, | ||||
timestamp_url=None, | ||||
extra_packages_script=None, | ||||
extra_wxs=None, | ||||
extra_features=None, | ||||
): | ||||
|
r42118 | """Build an installer with signed executables.""" | ||
post_build_fn = make_post_build_signing_fn( | ||||
name, | ||||
subject_name=subject_name, | ||||
cert_path=cert_path, | ||||
cert_password=cert_password, | ||||
|
r43346 | timestamp_url=timestamp_url, | ||
) | ||||
|
r42118 | |||
|
r43346 | info = build_installer( | ||
source_dir, | ||||
python_exe=python_exe, | ||||
msi_name=name.lower(), | ||||
version=version, | ||||
post_build_fn=post_build_fn, | ||||
extra_packages_script=extra_packages_script, | ||||
extra_wxs=extra_wxs, | ||||
extra_features=extra_features, | ||||
) | ||||
|
r42118 | |||
description = '%s %s' % (name, version) | ||||
|
r43346 | sign_with_signtool( | ||
info['msi_path'], | ||||
description, | ||||
subject_name=subject_name, | ||||
cert_path=cert_path, | ||||
cert_password=cert_password, | ||||
timestamp_url=timestamp_url, | ||||
) | ||||