wix.py
547 lines
| 17.0 KiB
| text/x-python
|
PythonLexer
Gregory Szorc
|
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. | ||||
Gregory Szorc
|
r44022 | import collections | ||
Gregory Szorc
|
r42118 | import os | ||
import pathlib | ||||
import re | ||||
Gregory Szorc
|
r44022 | import shutil | ||
Gregory Szorc
|
r42118 | import subprocess | ||
Augie Fackler
|
r42215 | import typing | ||
Gregory Szorc
|
r44022 | import uuid | ||
Gregory Szorc
|
r42123 | import xml.dom.minidom | ||
Gregory Szorc
|
r42118 | |||
Augie Fackler
|
r43346 | from .downloads import download_entry | ||
Gregory Szorc
|
r44022 | from .py2exe import ( | ||
build_py2exe, | ||||
stage_install, | ||||
) | ||||
Gregory Szorc
|
r45274 | from .pyoxidizer import run_pyoxidizer | ||
Gregory Szorc
|
r42118 | from .util import ( | ||
extract_zip_to_directory, | ||||
Matt Harbison
|
r44707 | normalize_windows_version, | ||
Gregory Szorc
|
r44022 | process_install_rules, | ||
Gregory Szorc
|
r42118 | sign_with_signtool, | ||
) | ||||
EXTRA_PACKAGES = { | ||||
Matt Harbison
|
r44710 | 'dulwich', | ||
Gregory Szorc
|
r42118 | 'distutils', | ||
Matt Harbison
|
r44710 | 'keyring', | ||
Gregory Szorc
|
r42118 | 'pygments', | ||
Matt Harbison
|
r44710 | 'win32ctypes', | ||
Gregory Szorc
|
r42118 | } | ||
Matt Harbison
|
r47110 | EXTRA_INCLUDES = { | ||
'_curses', | ||||
'_curses_panel', | ||||
} | ||||
Gregory Szorc
|
r42118 | |||
Gregory Szorc
|
r44022 | EXTRA_INSTALL_RULES = [ | ||
('contrib/packaging/wix/COPYING.rtf', 'COPYING.rtf'), | ||||
Matt Harbison
|
r44614 | ('contrib/win32/mercurial.ini', 'defaultrc/mercurial.rc'), | ||
Gregory Szorc
|
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', | ||||
}, | ||||
} | ||||
Gregory Szorc
|
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 ensure_vc90_merge_modules(build_dir): | ||||
x86 = ( | ||||
Augie Fackler
|
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], | ||||
Gregory Szorc
|
r42118 | ) | ||
x64 = ( | ||||
Augie Fackler
|
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], | ||||
Gregory Szorc
|
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) | ||||
Gregory Szorc
|
r44022 | def make_files_xml(staging_dir: pathlib.Path, is_x64) -> str: | ||
"""Create XML string listing every file to be installed.""" | ||||
Gregory Szorc
|
r42123 | |||
Gregory Szorc
|
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). | ||||
Gregory Szorc
|
r42123 | |||
doc = xml.dom.minidom.parseString( | ||||
Gregory Szorc
|
r44022 | '<?xml version="1.0" encoding="utf-8"?>' | ||
'<Wix xmlns="http://schemas.microsoft.com/wix/2006/wi">' | ||||
'</Wix>' | ||||
Augie Fackler
|
r43346 | ) | ||
Gregory Szorc
|
r42123 | |||
Gregory Szorc
|
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: | ||||
Gregory Szorc
|
r46221 | parent_directory_id = 'hg.dir.%s' % dir_name.replace( | ||
'/', '.' | ||||
).replace('-', '_') | ||||
Gregory Szorc
|
r42123 | |||
Gregory Szorc
|
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 != '.' | ||||
): | ||||
Gregory Szorc
|
r46221 | child_directory_id = ('hg.dir.%s' % possible_child).replace( | ||
'-', '_' | ||||
) | ||||
Gregory Szorc
|
r44022 | 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( | ||||
'/', '.' | ||||
Gregory Szorc
|
r46221 | ).replace('-', '_') | ||
Gregory Szorc
|
r44022 | |||
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) | ||||
Gregory Szorc
|
r42123 | |||
Gregory Szorc
|
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 | ||||
Gregory Szorc
|
r42123 | |||
Gregory Szorc
|
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) | ||||
Gregory Szorc
|
r42123 | |||
Gregory Szorc
|
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) | ||||
Gregory Szorc
|
r42123 | |||
return doc.toprettyxml() | ||||
Gregory Szorc
|
r45274 | def build_installer_py2exe( | ||
Augie Fackler
|
r43346 | 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, | ||||
Gregory Szorc
|
r45272 | signing_info: typing.Optional[typing.Dict[str, str]] = None, | ||
Augie Fackler
|
r43346 | ): | ||
Gregory Szorc
|
r45274 | """Build a WiX MSI installer using py2exe. | ||
Gregory Szorc
|
r42118 | |||
``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. | ||||
Augie Fackler
|
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. | ||||
Augie Fackler
|
r42215 | ``extra_wxs`` is a dict of {wxs_name: working_dir_for_wxs_build}. | ||
Augie Fackler
|
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. | ||||
Gregory Szorc
|
r42118 | """ | ||
arch = 'x64' if r'\x64' in os.environ.get('LIB', '') else 'x86' | ||||
hg_build_dir = source_dir / 'build' | ||||
Matt Harbison
|
r44724 | requirements_txt = ( | ||
Gregory Szorc
|
r46344 | source_dir / 'contrib' / 'packaging' / 'requirements-windows-py2.txt' | ||
Matt Harbison
|
r44724 | ) | ||
Gregory Szorc
|
r42118 | |||
Augie Fackler
|
r43346 | build_py2exe( | ||
source_dir, | ||||
hg_build_dir, | ||||
python_exe, | ||||
'wix', | ||||
requirements_txt, | ||||
extra_packages=EXTRA_PACKAGES, | ||||
extra_packages_script=extra_packages_script, | ||||
Matt Harbison
|
r47110 | extra_includes=EXTRA_INCLUDES, | ||
Augie Fackler
|
r43346 | ) | ||
Gregory Szorc
|
r42118 | |||
build_dir = hg_build_dir / ('wix-%s' % arch) | ||||
Gregory Szorc
|
r44022 | staging_dir = build_dir / 'stage' | ||
Gregory Szorc
|
r42118 | |||
build_dir.mkdir(exist_ok=True) | ||||
Gregory Szorc
|
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() | ||||
Gregory Szorc
|
r45271 | return run_wix_packaging( | ||
source_dir, | ||||
build_dir, | ||||
staging_dir, | ||||
arch, | ||||
version=version, | ||||
Gregory Szorc
|
r45274 | python2=True, | ||
msi_name=msi_name, | ||||
Gregory Szorc
|
r45276 | suffix="-python2", | ||
Gregory Szorc
|
r45274 | 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, | ||||
Gregory Szorc
|
r45271 | msi_name=msi_name, | ||
extra_wxs=extra_wxs, | ||||
extra_features=extra_features, | ||||
Gregory Szorc
|
r45272 | signing_info=signing_info, | ||
Gregory Szorc
|
r45271 | ) | ||
def run_wix_packaging( | ||||
source_dir: pathlib.Path, | ||||
build_dir: pathlib.Path, | ||||
staging_dir: pathlib.Path, | ||||
arch: str, | ||||
version: str, | ||||
Gregory Szorc
|
r45274 | python2: bool, | ||
Gregory Szorc
|
r45271 | msi_name: typing.Optional[str] = "mercurial", | ||
Gregory Szorc
|
r45276 | suffix: str = "", | ||
Gregory Szorc
|
r45271 | extra_wxs: typing.Optional[typing.Dict[str, str]] = None, | ||
extra_features: typing.Optional[typing.List[str]] = None, | ||||
Gregory Szorc
|
r45272 | signing_info: typing.Optional[typing.Dict[str, str]] = None, | ||
Gregory Szorc
|
r45271 | ): | ||
Gregory Szorc
|
r45272 | """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. | ||||
""" | ||||
Gregory Szorc
|
r45273 | |||
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) | ||||
Gregory Szorc
|
r45272 | 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"], | ||||
) | ||||
Gregory Szorc
|
r45271 | |||
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']) | ||||
Gregory Szorc
|
r42118 | |||
if not wix_path.exists(): | ||||
extract_zip_to_directory(wix_pkg, wix_path) | ||||
Gregory Szorc
|
r45274 | if python2: | ||
ensure_vc90_merge_modules(build_dir) | ||||
Gregory Szorc
|
r42118 | |||
source_build_rel = pathlib.Path(os.path.relpath(source_dir, build_dir)) | ||||
defines = {'Platform': arch} | ||||
Gregory Szorc
|
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) | ||||
Gregory Szorc
|
r42118 | |||
Augie Fackler
|
r42215 | for source, rel_path in sorted((extra_wxs or {}).items()): | ||
run_candle(wix_path, build_dir, source, rel_path, defines=defines) | ||||
Gregory Szorc
|
r42122 | source = wix_dir / 'mercurial.wxs' | ||
Gregory Szorc
|
r42118 | defines['Version'] = version | ||
defines['Comments'] = 'Installs Mercurial version %s' % version | ||||
Gregory Szorc
|
r45274 | |||
if python2: | ||||
defines["PythonVersion"] = "2" | ||||
defines['VCRedistSrcDir'] = str(build_dir) | ||||
else: | ||||
defines["PythonVersion"] = "3" | ||||
if (staging_dir / "lib").exists(): | ||||
defines["MercurialHasLib"] = "1" | ||||
Augie Fackler
|
r42216 | if extra_features: | ||
assert all(';' not in f for f in extra_features) | ||||
defines['MercurialExtraFeatures'] = ';'.join(extra_features) | ||||
Gregory Szorc
|
r42118 | |||
run_candle(wix_path, build_dir, source, source_build_rel, defines=defines) | ||||
Augie Fackler
|
r43346 | msi_path = ( | ||
Gregory Szorc
|
r45276 | source_dir | ||
/ 'dist' | ||||
/ ('%s-%s-%s%s.msi' % (msi_name, orig_version, arch, suffix)) | ||||
Augie Fackler
|
r43346 | ) | ||
Gregory Szorc
|
r42118 | |||
args = [ | ||||
str(wix_path / 'light.exe'), | ||||
'-nologo', | ||||
Augie Fackler
|
r43346 | '-ext', | ||
'WixUIExtension', | ||||
Gregory Szorc
|
r42118 | '-sw1076', | ||
'-spdb', | ||||
Augie Fackler
|
r43346 | '-o', | ||
str(msi_path), | ||||
Gregory Szorc
|
r42118 | ] | ||
Augie Fackler
|
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]))) | ||||
Augie Fackler
|
r43346 | args.extend( | ||
Augie Fackler
|
r46554 | [ | ||
str(build_dir / 'stage.wixobj'), | ||||
str(build_dir / 'mercurial.wixobj'), | ||||
] | ||||
Augie Fackler
|
r43346 | ) | ||
Gregory Szorc
|
r42118 | |||
subprocess.run(args, cwd=str(source_dir), check=True) | ||||
print('%s created' % msi_path) | ||||
Gregory Szorc
|
r45272 | 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"], | ||||
) | ||||
Gregory Szorc
|
r42118 | return { | ||
'msi_path': msi_path, | ||||
} | ||||