packaging/wix4/wix4.py (379 lines of code) (raw):

#!/usr/bin/env python # # Copyright (c) 2023, 2024, Oracle and/or its affiliates. # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License, version 2.0, # as published by the Free Software Foundation. # # This program is designed to work with certain software (including # but not limited to OpenSSL) that is licensed under separate terms, # as designated in a particular file or component or in included license # documentation. The authors of MySQL hereby grant you an additional # permission to link the program and your derivative works with the # separately licensed software that they have either included with # the program or referenced in the documentation. # # This program is distributed in the hope that it will be useful, but # WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See # the GNU General Public License, version 2.0, for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software Foundation, Inc., # 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA # import argparse import hashlib import glob import json import os import os.path import shutil import string import subprocess import sys import uuid import xml.etree.ElementTree as ET def log(msg): sys.stderr.write(f"WIX4: {msg}\n") sys.stderr.flush() def run_process(*args, cwd=None, ignore_exit_code=False): try: return subprocess.check_output([*args], stderr=subprocess.STDOUT, cwd=cwd).decode("ascii").strip() except subprocess.CalledProcessError as e: if ignore_exit_code: return "" log(f"Failed to execute [{' '.join(args)}]: {e.output.decode('ascii').strip()}") raise def check_preconditions(): for exe in [ "dotnet", "cmake", "cpack" ]: try: log(f"{exe}: {run_process(exe, '--version')}") except: log(f"Could not execute '{exe}'") raise def safe_path(path): return path.replace("\\", "/") def guid(): return str(uuid.uuid4()).upper() def execute(l, status, failure, *args): try: log(status + "...") l(*args) except: log(failure) raise def split_path(path): return safe_path(path).split("/") class Rtf_file(): def __init__(self, path): self.__fh = open(path, "wb") self.__start_group() self.__write_header() self.__write_document_prefix() def __del__(self): self.__end_group() self.__fh.write(b"\r\n\0") self.__fh.close() def __control_word(self, word): self.__fh.write(b"\\") self.__fh.write(word) def __start_group(self): self.__fh.write(b"{") def __end_group(self): self.__fh.write(b"}") def __write_header(self): # RTF version 1 self.__control_word(b"rtf1") # character set self.__control_word(b"ansi") # ANSI code page - Western European self.__control_word(b"ansicpg1252") # default font - 0 self.__control_word(b"deff0") # default language for default formatting properties - English United States self.__control_word(b"deflang1033") self.__write_font_table() def __write_font_table(self): # start font table self.__start_group() self.__control_word(b"fonttbl") # font info self.__start_group() # font number - 0 self.__control_word(b"f0") # font family - proportionally spaced sans serif fonts self.__control_word(b"fswiss") # font charset - ANSI, font name - Consolas self.__control_word(b"fcharset0 Consolas;") self.__end_group() # end font table self.__end_group() def __write_document_prefix(self): # view mode - normal view self.__control_word(b"viewkind4") # number of bytes corresponding to a given \uN Unicode character - 1 # readers ignore 1 byte after \uN sequence, readers which do not support \uN ignore this sequence and display the next byte self.__control_word(b"uc1") # reset to default paragraph properties self.__control_word(b"pard") # use font 0 self.__control_word(b"f0") # font size in half-points - 14 self.__control_word(b"fs14") def append(self, text): t = text.encode('utf-8') i = 0 l = len(t) while i < l: c = t[i] if c == ord("\\"): self.__fh.write(b"\\\\") elif c == ord("{"): self.__fh.write(b"\\{") elif c == ord("}"): self.__fh.write(b"\\}") elif c == ord("\n"): self.__fh.write(b"\\par\r\n") elif c == ord("\r"): pass else: if c <= 0x7F: self.__fh.write(c.to_bytes(1, 'big')) else: if c <= 0xC0: # continuation bytes self.__write_invalid_codepoint() elif c < 0xE0 and i + 1 < l: # two byte sequence: 110xxxxx 10xxxxxx self.__write_unicode_codepoint(((c & 0x1F) << 6) | (t[i + 1] & 0x3F)) i += 1 elif c < 0xF0 and i + 2 < l: # three byte sequence: 1110xxxx 10xxxxxx 10xxxxxx self.__write_unicode_codepoint(((c & 0x0F) << 12) | ((t[i + 1] & 0x3F) << 6) | (t[i + 2] & 0x3F)) i += 2 elif c < 0xF8 and i + 3 < l: # four byte sequence: 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx self.__write_unicode_codepoint(((c & 0x07) << 18) | ((t[i + 1] & 0x3F) << 12) | ((t[i + 2] & 0x3F) << 6) | (t[i + 3] & 0x3F)) i += 3 else: self.__write_invalid_codepoint() i += 1 def __write_invalid_codepoint(self): self.__fh.write(b"?") def __write_unicode_codepoint(self, c): if c == 0xFEFF: # ignore BOM return if c <= 0xFFFF: self.__write_unicode_surrogate(c) else: # UTF-16 encoding using a surrogate pair c -= 0x10000 self.__write_unicode_surrogate(0xD800 + ((c >> 10) & 0x03FF)) self.__write_unicode_surrogate(0xDC00 + (c & 0x03FF)) def __write_unicode_surrogate(self, c): # single Unicode character, signed 16-bit number, values greater than 32767 must be expressed as negative numbers self.__control_word(b"u") if c > 32767: c -= 65536 self.__fh.write(str(c).encode('ascii')) # character displayed if reader doesn't support \uN sequence self.__fh.write(b"?") class CPack(): config_file = "CPackConfig.cmake" def __init__(self, src_dir, dst_dir): self.__src_dir = src_dir self.__dst_dir = dst_dir self.config_path = os.path.join(src_dir, CPack.config_file) if not os.path.isfile(self.config_path): msg = f"File '{CPack.config_file}' does not exist in '{src_dir}'" log(msg) raise Exception(msg) def generate(self): execute(self.__create_zip_package, "Creating ZIP package", "Failed to create ZIP package") execute(self.__create_output_dir, "Creating output directory", "Failed to create output directory") execute(self.__fetch_cpack_info, "Extracting CPack info", "Failed to extract CPack info") execute(self.__handle_license_file, "Converting license file", "Failed to convert licence file") execute(self.__create_cpack_vars_file, "Creating 'cpack_variables.wxi' file", "Failed to create 'cpack_variables.wxi' file") def __fetch_cpack_info(self): script = os.path.join(self.wix_dir, "fetch-cpack.cmake") cpack_vars = os.path.join(self.wix_dir, "cpack-vars.json") with open(script, "w", encoding="utf-8") as f: f.write(f"""# creates the main wxs file, extracts all CPack variables include("{safe_path(self.config_path)}") configure_file("${{CPACK_WIX_TEMPLATE}}" "{safe_path(self.wix_script)}" @ONLY) set(output_json "{safe_path(cpack_vars)}") get_cmake_property(all_vars VARIABLES) file(APPEND "${{output_json}}" "{{\\n") foreach(var ${{all_vars}}) if(var MATCHES "^CPACK_.*") string(REPLACE "\\\\" "\\\\\\\\" value "${{${{var}}}}") file(APPEND "${{output_json}}" " \\"${{var}}\\": \\"${{value}}\\",\\n") endif() endforeach() file(APPEND "${{output_json}}" " \\"ignore\\":\\"me\\"\\n}}\\n") """) run_process("cmake", "-P", script) with open(cpack_vars, "r", encoding="utf-8") as f: self.vars = json.load(f) # default values self.vars.setdefault("CPACK_WIX_PRODUCT_GUID", guid()) self.vars.setdefault("CPACK_WIX_UPGRADE_GUID", guid()) self.vars.setdefault("CPACK_WIX_UI_REF", "WixUI_FeatureTree") self.package_dir = os.path.join(self.zip_dir, self.vars["CPACK_PACKAGE_FILE_NAME"]) def __create_zip_package(self): run_process("cpack", "--config", self.config_path, "-G", "ZIP", "-C", "RelWithDebInfo", "-B", self.__dst_dir) def __create_output_dir(self): self.zip_dir = os.path.join(self.__dst_dir, glob.glob("**/ZIP", root_dir=self.__dst_dir, recursive=True)[0]) self.wix_dir = os.path.join(os.path.dirname(os.path.normpath(self.zip_dir)), "WIX4") self.wix_script = os.path.join(self.wix_dir, "main.wxs") if os.path.isdir(self.wix_dir): shutil.rmtree(self.wix_dir) os.mkdir(self.wix_dir) def __handle_license_file(self): if self.vars.get("CPACK_WIX_LICENSE_RTF") is None: license = self.vars["CPACK_RESOURCE_FILE_LICENSE"] if license.endswith(".txt"): rtf_license = os.path.join(self.wix_dir, os.path.splitext(os.path.basename(license))[0] + ".rtf") with open(license, "r", encoding="utf-8") as input: rtf = Rtf_file(rtf_license) for line in input: rtf.append(line) license = rtf_license self.vars["CPACK_WIX_LICENSE_RTF"] = license self.vars["CPACK_WIX_LICENSE_RTF"] = safe_path(self.vars["CPACK_WIX_LICENSE_RTF"]) def __create_cpack_vars_file(self): with open(os.path.join(self.wix_dir, "cpack_variables.wxi"), "w", encoding="utf-8") as f: f.write('<Include xmlns="http://wixtoolset.org/schemas/v4/wxs">\n') for var in [ "CPACK_WIX_PRODUCT_GUID", "CPACK_WIX_UPGRADE_GUID", "CPACK_PACKAGE_VENDOR", "CPACK_PACKAGE_NAME", "CPACK_PACKAGE_VERSION", "CPACK_WIX_LICENSE_RTF", "CPACK_WIX_PROGRAM_MENU_FOLDER", "CPACK_WIX_UI_REF" ]: f.write(f' <?define {var}="{self.vars[var]}"?>\n') f.write("</Include>\n") class Wix4(): extensions = ["WixToolset.UI.wixext", "WixToolset.Util.wixext"] version = "4.0.5" def __init__(self, cpack): self.__cpack = cpack self.__package_dir = os.path.join(cpack.zip_dir, cpack.vars["CPACK_PACKAGE_FILE_NAME"]) self.__components = cpack.vars["CPACK_COMPONENTS_ALL"].split(";") self.__executables = cpack.vars["CPACK_PACKAGE_EXECUTABLES"].split(";") # convert list into list of pairs (name, label) self.__executables = list(zip(self.__executables[::2], self.__executables[1::2])) self.msi = os.path.join(self.__cpack.wix_dir, cpack.vars["CPACK_PACKAGE_FILE_NAME"] + ".msi") self.__ids = {} self.__id_count = {} def generate(self): self.__directories, directories = self.__init_xml() self.__features, features = self.__init_xml() self.__files, files = self.__init_xml() self.__add_start_menu(directories) directories = self.__init_directories(directories) self.__init_features(features) for component in self.__components: self.__shortcuts = [] component_id = self.__component_id(component) self.__add_files_and_directories(os.path.join(self.__package_dir, component), "INSTALL_ROOT", self.__add_feature_ref(features, component_id), directories, files) if self.__shortcuts: self.__add_start_menu_shortcuts(component, self.__add_feature_ref(features, component_id), files) self.__write_wxs("directories", self.__directories) self.__write_wxs("features", self.__features) self.__write_wxs("files", self.__files) def create_msi(self): args = [ "wix", "build", "-arch", "x64" ] for extension in self.extensions: args.append("-ext") args.append(extension) args.append("-o") args.append(self.msi) args.append(os.path.join(self.__cpack.wix_dir, "*.wxs")) log(f"output:\n{run_process(*args)}") def __init_xml(self): root = ET.Element("Wix", attrib={"xmlns": "http://wixtoolset.org/schemas/v4/wxs"}) fragment = ET.SubElement(root, "Fragment") return (root, fragment) def __write_wxs(self, name, xml): tree = ET.ElementTree(xml) ET.indent(tree, " ") tree.write(os.path.join(self.__cpack.wix_dir, name + ".wxs"), encoding='utf-8') def __add_standard_directory(self, parent, id): return ET.SubElement(parent, "StandardDirectory", attrib={"Id": id}) def __add_directory(self, parent, id, name): return ET.SubElement(parent, "Directory", attrib={"Id": id, "Name": name}) def __add_start_menu(self, parent): sd = self.__add_standard_directory(parent, "ProgramMenuFolder") self.__add_directory(sd, "PROGRAM_MENU_FOLDER", self.__cpack.vars["CPACK_WIX_PROGRAM_MENU_FOLDER"]) def __init_directories(self, parent): d = self.__add_standard_directory(parent, "ProgramFiles64Folder") dirs = split_path(self.__cpack.vars["CPACK_PACKAGE_INSTALL_DIRECTORY"]) for i in range(len(dirs) - 1): d = self.__add_directory(d, f"INSTALL_PREFIX_{i + 1}", dirs[i]) return self.__add_directory(d, "INSTALL_ROOT", dirs[-1]) def __component_id(self, name): return "CM_C_" + name def __init_features(self, parent): f = ET.SubElement(parent, "Feature", attrib={"Id": "ProductFeature", "Display": "expand", "ConfigurableDirectory": "INSTALL_ROOT", "Title": self.__cpack.vars["CPACK_PACKAGE_NAME"], "Level": "1", "AllowAbsent": "no"}) for component in self.__components: ET.SubElement(f, "Feature", attrib={"Id": self.__component_id(component), "Title": component}) def __add_feature_ref(self, parent, id): return ET.SubElement(parent, "FeatureRef", attrib={"Id": id}) def __id(self, path): path = os.path.relpath(path, self.__package_dir) id = self.__ids.get(path) if id is None: id = self.__create_id(path) self.__ids[path] = id return id def __create_id(self, path): segments = split_path(path) replaced = 0 for i in range(len(segments)): segments[i], r = self.__normalize_id(segments[i]) replaced += r id = ".".join(segments) if replaced * 100 / len(id) > 33 or len(id) > 60: prefix = "H" id = self.__hash_id(path, segments[-1]) else: prefix = "P" id = prefix + "_" + id count = self.__id_count[id] = self.__id_count.get(id, 0) + 1 if count > 1: id += "_" + str(count) return id def __normalize_id(self, id): allowed = string.ascii_letters + string.digits + "_." replaced = 0 out = "" for i in id: if i in allowed: out += i else: out += "_" replaced += 1 return out, replaced def __hash_id(self, path, filename): max = 52 id = hashlib.sha1(safe_path(path).encode("utf8")).hexdigest()[0:7] + "_" + filename[0:max] if len(filename) > max: id += "..." return id def __add_files_and_directories(self, path, parent_id, feature, directory, files): with os.scandir(path) as it: for entry in it: id = self.__id(entry.path) if entry.is_dir(): id = "CM_D" + id self.__add_files_and_directories(entry.path, id, feature, self.__add_directory(directory, id, entry.name), files) else: component_id = self.__add_file(files, parent_id, entry.path, id) self.__add_component_ref(feature, component_id) for exe in self.__executables: if entry.name.lower() == exe[0].lower() + ".exe": self.__shortcuts.append((id, exe[1], parent_id)) def __add_file(self, parent, dir_id, file_path, file_id): component_id = "CM_C" + file_id c = self.__add_component(parent, dir_id, component_id) file_id = "CM_F" + file_id ET.SubElement(c, "File", attrib={"Id": file_id, "Source": safe_path(file_path), "KeyPath": "yes"}) return component_id def __add_component(self, parent, dir_id, component_id): d = ET.SubElement(parent, "DirectoryRef", attrib={"Id": dir_id}) return ET.SubElement(d, "Component", attrib={"Id": component_id}) def __add_component_ref(self, parent, id): return ET.SubElement(parent, "ComponentRef", attrib={"Id": id}) def __add_start_menu_shortcuts(self, component, feature, files): component_id = "CM_SHORTCUT_" + component c = self.__add_component(files, "PROGRAM_MENU_FOLDER", component_id) self.__add_component_ref(feature, component_id) for shortcut in self.__shortcuts: ET.SubElement(c, "Shortcut", attrib={"Id": "CM_S" + shortcut[0], "Name": shortcut[1], "Target": f"[#CM_F{shortcut[0]}]", "WorkingDirectory": shortcut[2]}) ET.SubElement(c, "RegistryValue", attrib={"Root": "HKCU", "Key": "\\".join(["Software", self.__cpack.vars["CPACK_PACKAGE_VENDOR"], self.__cpack.vars["CPACK_PACKAGE_NAME"]]), "Name": component + "_installed", "Type": "integer", "Value": "1", "KeyPath": "yes"}) ET.SubElement(c, "RemoveFolder", attrib={"Id": "CM_REMOVE_PROGRAM_MENU_FOLDER_" + component, "On": "uninstall"}) @staticmethod def install(): tools = run_process("dotnet", "tool", "list", "--global") # output is: wix x.y.z wix if "wix " not in tools and " wix" not in tools: log("wix not installed, installing...") run_process("dotnet", "tool", "install", "--global", "wix", "--version", Wix4.version) log(f"wix version: {run_process('wix', '--version')}") def wix_extensions(): # this returns non-zero exit code if list is empty return run_process("wix", "extension", "list", "--global", ignore_exit_code=True) extensions = wix_extensions() for ext in Wix4.extensions: if ext not in extensions: log(f"Installing wix extension '{ext}'...") run_process("wix", "extension", "add", "--global", f"{ext}/{Wix4.version}") log(f"Installed wix extensions:\n{wix_extensions()}") def main(): parser = argparse.ArgumentParser(description="Create MSI usin WIX4") parser.add_argument("-s", "--src", help="Source (build) directory", required=True) parser.add_argument("-d", "--dst", help="Destination directory", required=True) args = parser.parse_args() # disable telemetry os.environ["DOTNET_CLI_TELEMETRY_OPTOUT"] = "1" check_preconditions() cpack = CPack(args.src, args.dst) Wix4.install() cpack.generate() wix = Wix4(cpack) execute(wix.generate, "Creating WXS files...", "Failed to create WXS files") execute(wix.create_msi, "Creating MSI...", "Failed to create MSI") shutil.copy2(wix.msi, args.dst) if __name__ == "__main__": main()