diff --git a/contrib/packaging/hgpackaging/py2exe.py b/contrib/packaging/hgpackaging/py2exe.py --- a/contrib/packaging/hgpackaging/py2exe.py +++ b/contrib/packaging/hgpackaging/py2exe.py @@ -209,13 +209,26 @@ ) -def stage_install(source_dir: pathlib.Path, staging_dir: pathlib.Path): +def stage_install( + source_dir: pathlib.Path, staging_dir: pathlib.Path, lower_case=False +): """Copy all files to be installed to a directory. This allows packaging to simply walk a directory tree to find source files. """ - process_install_rules(STAGING_RULES, source_dir, staging_dir) + if lower_case: + rules = [] + for source, dest in STAGING_RULES: + # Only lower directory names. + if '/' in dest: + parent, leaf = dest.rsplit('/', 1) + dest = '%s/%s' % (parent.lower(), leaf) + rules.append((source, dest)) + else: + rules = STAGING_RULES + + process_install_rules(rules, source_dir, staging_dir) # An empty .exe.local file enables DLL Redirection and forces # Windows to look for DLLs relative to the .exe. diff --git a/contrib/packaging/hgpackaging/wix.py b/contrib/packaging/hgpackaging/wix.py --- a/contrib/packaging/hgpackaging/wix.py +++ b/contrib/packaging/hgpackaging/wix.py @@ -7,38 +7,60 @@ # no-check-code because Python 3 native. +import collections import os import pathlib import re +import shutil import subprocess -import tempfile import typing +import uuid import xml.dom.minidom from .downloads import download_entry -from .py2exe import build_py2exe +from .py2exe import ( + build_py2exe, + stage_install, +) from .util import ( extract_zip_to_directory, + process_install_rules, sign_with_signtool, ) -SUPPORT_WXS = [ - ('contrib.wxs', r'contrib'), - ('dist.wxs', r'dist'), - ('doc.wxs', r'doc'), - ('help.wxs', r'mercurial\help'), - ('locale.wxs', r'mercurial\locale'), - ('templates.wxs', r'mercurial\templates'), -] - - EXTRA_PACKAGES = { 'distutils', 'pygments', } +EXTRA_INSTALL_RULES = [ + ('contrib/packaging/wix/COPYING.rtf', 'COPYING.rtf'), + ('contrib/win32/mercurial.ini', 'hgrc.d/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' @@ -147,49 +169,165 @@ return post_build_sign -LIBRARIES_XML = ''' - - - - - +def make_files_xml(staging_dir: pathlib.Path, is_x64) -> str: + """Create XML string listing every file to be installed.""" - - - - - - - - - -'''.lstrip() + # 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). - -def make_libraries_xml(wix_dir: pathlib.Path, dist_dir: pathlib.Path): - """Make XML data for library components WXS.""" - # We can't use ElementTree because it doesn't handle the - # directives. doc = xml.dom.minidom.parseString( - LIBRARIES_XML.format(wix_dir=str(wix_dir)) + '' + '' + '' ) - component = doc.getElementsByTagName('Component')[0] + # 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('/', '.') - f = doc.createElement('File') - f.setAttribute('Name', 'library.zip') - f.setAttribute('KeyPath', 'yes') - component.appendChild(f) + 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 + 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 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) - lib_dir = dist_dir / 'lib' + 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 - for p in sorted(lib_dir.iterdir()): - if not p.name.endswith(('.dll', '.pyd')): - continue + 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) - f = doc.createElement('File') - f.setAttribute('Name', p.name) - component.appendChild(f) + 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() @@ -248,9 +386,27 @@ post_build_fn(source_dir, hg_build_dir, dist_dir, version) 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() + wix_pkg, wix_entry = download_entry('wix', hg_build_dir) wix_path = hg_build_dir / ('wix-%s' % wix_entry['version']) @@ -263,25 +419,16 @@ defines = {'Platform': arch} - for wxs, rel_path in SUPPORT_WXS: - wxs = wix_dir / wxs - wxs_source_dir = source_dir / rel_path - run_candle(wix_path, build_dir, wxs, wxs_source_dir, defines=defines) + # 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) - # candle.exe doesn't like when we have an open handle on the file. - # So use TemporaryDirectory() instead of NamedTemporaryFile(). - with tempfile.TemporaryDirectory() as td: - td = pathlib.Path(td) - - tf = td / 'library.wxs' - with tf.open('w') as fh: - fh.write(make_libraries_xml(wix_dir, dist_dir)) - - run_candle(wix_path, build_dir, tf, dist_dir, defines=defines) - source = wix_dir / 'mercurial.wxs' defines['Version'] = version defines['Comments'] = 'Installs Mercurial version %s' % version @@ -307,20 +454,13 @@ str(msi_path), ] - for source, rel_path in SUPPORT_WXS: - assert source.endswith('.wxs') - args.append(str(build_dir / ('%s.wixobj' % source[:-4]))) - 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 / 'library.wixobj'), - str(build_dir / 'mercurial.wixobj'), - ] + [str(build_dir / 'stage.wixobj'), str(build_dir / 'mercurial.wixobj'),] ) subprocess.run(args, cwd=str(source_dir), check=True) diff --git a/contrib/packaging/wix/contrib.wxs b/contrib/packaging/wix/contrib.wxs deleted file mode 100644 --- a/contrib/packaging/wix/contrib.wxs +++ /dev/null @@ -1,44 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/contrib/packaging/wix/dist.wxs b/contrib/packaging/wix/dist.wxs deleted file mode 100644 --- a/contrib/packaging/wix/dist.wxs +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - - - - - - - - - diff --git a/contrib/packaging/wix/doc.wxs b/contrib/packaging/wix/doc.wxs deleted file mode 100644 --- a/contrib/packaging/wix/doc.wxs +++ /dev/null @@ -1,50 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/contrib/packaging/wix/guids.wxi b/contrib/packaging/wix/guids.wxi --- a/contrib/packaging/wix/guids.wxi +++ b/contrib/packaging/wix/guids.wxi @@ -4,46 +4,9 @@ and replace 'Mercurial' in this notice with the name of your project. Component GUIDs have global namespace! --> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/contrib/packaging/wix/help.wxs b/contrib/packaging/wix/help.wxs deleted file mode 100644 --- a/contrib/packaging/wix/help.wxs +++ /dev/null @@ -1,70 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/contrib/packaging/wix/locale.wxs b/contrib/packaging/wix/locale.wxs deleted file mode 100644 --- a/contrib/packaging/wix/locale.wxs +++ /dev/null @@ -1,34 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/contrib/packaging/wix/mercurial.wxs b/contrib/packaging/wix/mercurial.wxs --- a/contrib/packaging/wix/mercurial.wxs +++ b/contrib/packaging/wix/mercurial.wxs @@ -60,30 +60,10 @@ - + - - - - - - - - - - - - - - - - @@ -117,15 +97,12 @@ - - - - - - - - + + + + + @@ -135,13 +112,13 @@ - + - + - + diff --git a/contrib/packaging/wix/templates.wxs b/contrib/packaging/wix/templates.wxs deleted file mode 100644 --- a/contrib/packaging/wix/templates.wxs +++ /dev/null @@ -1,251 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/contrib/win32/mercurial.ini b/contrib/win32/mercurial.ini --- a/contrib/win32/mercurial.ini +++ b/contrib/win32/mercurial.ini @@ -16,7 +16,7 @@ [ui] ; editor used to enter commit logs, etc. Most text editors will work. -editor = notepad +; editor = notepad ; show changed files and be a bit more verbose if True ; verbose = True ; colorize commands output diff --git a/tests/test-install.t b/tests/test-install.t --- a/tests/test-install.t +++ b/tests/test-install.t @@ -162,80 +162,6 @@ "fsmonitor-watchman": "false", "fsmonitor-watchman-error": "warning: Watchman unavailable: watchman exited with code 1", - -#if test-repo - $ . "$TESTDIR/helpers-testrepo.sh" - - $ cat >> wixxml.py << EOF - > import os - > import subprocess - > import sys - > import xml.etree.ElementTree as ET - > from mercurial import pycompat - > - > # MSYS mangles the path if it expands $TESTDIR - > testdir = os.environ['TESTDIR'] - > ns = {'wix' : 'http://schemas.microsoft.com/wix/2006/wi'} - > - > def directory(node, relpath): - > '''generator of files in the xml node, rooted at relpath''' - > dirs = node.findall('./{%(wix)s}Directory' % ns) - > - > for d in dirs: - > for subfile in directory(d, relpath + d.attrib['Name'] + '/'): - > yield subfile - > - > files = node.findall('./{%(wix)s}Component/{%(wix)s}File' % ns) - > - > for f in files: - > yield pycompat.sysbytes(relpath + f.attrib['Name']) - > - > def hgdirectory(relpath): - > '''generator of tracked files, rooted at relpath''' - > hgdir = "%s/../mercurial" % (testdir) - > args = ['hg', '--cwd', hgdir, 'files', relpath] - > proc = subprocess.Popen(args, stdout=subprocess.PIPE, - > stderr=subprocess.PIPE) - > output = proc.communicate()[0] - > - > for line in output.splitlines(): - > if os.name == 'nt': - > yield line.replace(pycompat.sysbytes(os.sep), b'/') - > else: - > yield line - > - > tracked = [f for f in hgdirectory(sys.argv[1])] - > - > xml = ET.parse("%s/../contrib/packaging/wix/%s.wxs" % (testdir, sys.argv[1])) - > root = xml.getroot() - > dir = root.find('.//{%(wix)s}DirectoryRef' % ns) - > - > installed = [f for f in directory(dir, '')] - > - > print('Not installed:') - > for f in sorted(set(tracked) - set(installed)): - > print(' %s' % pycompat.sysstr(f)) - > - > print('Not tracked:') - > for f in sorted(set(installed) - set(tracked)): - > print(' %s' % pycompat.sysstr(f)) - > EOF - - $ ( testrepohgenv; "$PYTHON" wixxml.py help ) - Not installed: - help/common.txt - help/hg-ssh.8.txt - help/hg.1.txt - help/hgignore.5.txt - help/hgrc.5.txt - Not tracked: - - $ ( testrepohgenv; "$PYTHON" wixxml.py templates ) - Not installed: - Not tracked: - -#endif - #if py3 $ HGALLOWPYTHON3=1 $ export HGALLOWPYTHON3