summaryrefslogtreecommitdiffstats
path: root/python/mozbuild/mozbuild/repackaging
diff options
context:
space:
mode:
Diffstat (limited to 'python/mozbuild/mozbuild/repackaging')
-rw-r--r--python/mozbuild/mozbuild/repackaging/__init__.py0
-rw-r--r--python/mozbuild/mozbuild/repackaging/application_ini.py66
-rw-r--r--python/mozbuild/mozbuild/repackaging/deb.py694
-rw-r--r--python/mozbuild/mozbuild/repackaging/dmg.py56
-rw-r--r--python/mozbuild/mozbuild/repackaging/installer.py55
-rw-r--r--python/mozbuild/mozbuild/repackaging/mar.py93
-rw-r--r--python/mozbuild/mozbuild/repackaging/msi.py122
-rw-r--r--python/mozbuild/mozbuild/repackaging/msix.py1193
-rw-r--r--python/mozbuild/mozbuild/repackaging/pkg.py46
-rw-r--r--python/mozbuild/mozbuild/repackaging/test/python.ini4
-rw-r--r--python/mozbuild/mozbuild/repackaging/test/test_msix.py53
11 files changed, 2382 insertions, 0 deletions
diff --git a/python/mozbuild/mozbuild/repackaging/__init__.py b/python/mozbuild/mozbuild/repackaging/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
--- /dev/null
+++ b/python/mozbuild/mozbuild/repackaging/__init__.py
diff --git a/python/mozbuild/mozbuild/repackaging/application_ini.py b/python/mozbuild/mozbuild/repackaging/application_ini.py
new file mode 100644
index 0000000000..f11c94f781
--- /dev/null
+++ b/python/mozbuild/mozbuild/repackaging/application_ini.py
@@ -0,0 +1,66 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this file,
+# You can obtain one at http://mozilla.org/MPL/2.0/.
+
+from mozpack.files import FileFinder
+from six import string_types
+from six.moves import configparser
+
+
+def get_application_ini_value(
+ finder_or_application_directory, section, value, fallback=None
+):
+ """Find string with given `section` and `value` in any `application.ini`
+ under given directory or finder.
+
+ If string is not found and `fallback` is given, find string with given
+ `section` and `fallback` instead.
+
+ Raises an `Exception` if no string is found."""
+
+ return next(
+ get_application_ini_values(
+ finder_or_application_directory,
+ dict(section=section, value=value, fallback=fallback),
+ )
+ )
+
+
+def get_application_ini_values(finder_or_application_directory, *args):
+ """Find multiple strings for given `section` and `value` pairs.
+ Additional `args` should be dictionaries with keys `section`, `value`,
+ and optional `fallback`. Returns an iterable of strings, one for each
+ dictionary provided.
+
+ `fallback` is treated as with `get_application_ini_value`.
+
+ Raises an `Exception` if any string is not found."""
+
+ if isinstance(finder_or_application_directory, string_types):
+ finder = FileFinder(finder_or_application_directory)
+ else:
+ finder = finder_or_application_directory
+
+ # Packages usually have a top-level `firefox/` directory; search below it.
+ for p, f in finder.find("**/application.ini"):
+ data = f.open().read().decode("utf-8")
+ parser = configparser.ConfigParser()
+ parser.read_string(data)
+
+ for d in args:
+ rc = None
+ try:
+ rc = parser.get(d["section"], d["value"])
+ except configparser.NoOptionError:
+ if "fallback" not in d:
+ raise
+ else:
+ rc = parser.get(d["section"], d["fallback"])
+
+ if rc is None:
+ raise Exception("Input does not contain an application.ini file")
+
+ yield rc
+
+ # Process only the first `application.ini`.
+ break
diff --git a/python/mozbuild/mozbuild/repackaging/deb.py b/python/mozbuild/mozbuild/repackaging/deb.py
new file mode 100644
index 0000000000..3e01680437
--- /dev/null
+++ b/python/mozbuild/mozbuild/repackaging/deb.py
@@ -0,0 +1,694 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this file,
+# You can obtain one at http://mozilla.org/MPL/2.0/.
+
+import datetime
+import json
+import logging
+import os
+import shutil
+import subprocess
+import tarfile
+import tempfile
+import zipfile
+from email.utils import format_datetime
+from pathlib import Path
+from string import Template
+
+import mozfile
+import mozpack.path as mozpath
+import requests
+from mozilla_version.gecko import GeckoVersion
+from redo import retry
+
+from mozbuild.repackaging.application_ini import get_application_ini_values
+
+
+class NoDebPackageFound(Exception):
+ """Raised when no .deb is found after calling dpkg-buildpackage"""
+
+ def __init__(self, deb_file_path) -> None:
+ super().__init__(
+ f"No {deb_file_path} package found after calling dpkg-buildpackage"
+ )
+
+
+class HgServerError(Exception):
+ """Raised when Hg responds with an error code that is not 404 (i.e. when there is an outage)"""
+
+ def __init__(self, msg) -> None:
+ super().__init__(msg)
+
+
+_DEB_ARCH = {
+ "all": "all",
+ "x86": "i386",
+ "x86_64": "amd64",
+}
+# At the moment the Firefox build baseline is jessie.
+# The debian-repackage image defined in taskcluster/docker/debian-repackage/Dockerfile
+# bootstraps the /srv/jessie-i386 and /srv/jessie-amd64 chroot environments we use to
+# create the `.deb` repackages. By running the repackage using chroot we generate shared
+# library dependencies that match the Firefox build baseline
+# defined in taskcluster/scripts/misc/build-sysroot.sh
+_DEB_DIST = "jessie"
+
+
+def repackage_deb(
+ log,
+ infile,
+ output,
+ template_dir,
+ arch,
+ version,
+ build_number,
+ release_product,
+ release_type,
+ fluent_localization,
+ fluent_resource_loader,
+):
+ if not tarfile.is_tarfile(infile):
+ raise Exception("Input file %s is not a valid tarfile." % infile)
+
+ tmpdir = _create_temporary_directory(arch)
+ source_dir = os.path.join(tmpdir, "source")
+ try:
+ mozfile.extract_tarball(infile, source_dir)
+ application_ini_data = _extract_application_ini_data(infile)
+ build_variables = _get_build_variables(
+ application_ini_data,
+ arch,
+ version,
+ build_number,
+ depends="${shlibs:Depends},",
+ )
+
+ _copy_plain_deb_config(template_dir, source_dir)
+ _render_deb_templates(template_dir, source_dir, build_variables)
+
+ app_name = application_ini_data["name"]
+ with open(
+ mozpath.join(source_dir, app_name.lower(), "is-packaged-app"), "w"
+ ) as f:
+ f.write("This is a packaged app.\n")
+
+ _inject_deb_distribution_folder(source_dir, app_name)
+ _inject_deb_desktop_entry_file(
+ log,
+ source_dir,
+ build_variables,
+ release_product,
+ release_type,
+ fluent_localization,
+ fluent_resource_loader,
+ )
+ _generate_deb_archive(
+ source_dir,
+ target_dir=tmpdir,
+ output_file_path=output,
+ build_variables=build_variables,
+ arch=arch,
+ )
+
+ finally:
+ shutil.rmtree(tmpdir)
+
+
+def repackage_deb_l10n(
+ input_xpi_file, input_tar_file, output, template_dir, version, build_number
+):
+ arch = "all"
+
+ tmpdir = _create_temporary_directory(arch)
+ source_dir = os.path.join(tmpdir, "source")
+ try:
+ langpack_metadata = _extract_langpack_metadata(input_xpi_file)
+ langpack_dir = mozpath.join(source_dir, "firefox", "distribution", "extensions")
+ application_ini_data = _extract_application_ini_data(input_tar_file)
+ langpack_id = langpack_metadata["langpack_id"]
+ build_variables = _get_build_variables(
+ application_ini_data,
+ arch,
+ version,
+ build_number,
+ depends=application_ini_data["remoting_name"],
+ # Debian package names are only lowercase
+ package_name_suffix=f"-l10n-{langpack_id.lower()}",
+ description_suffix=f" - {langpack_metadata['description']}",
+ )
+ _copy_plain_deb_config(template_dir, source_dir)
+ _render_deb_templates(template_dir, source_dir, build_variables)
+
+ os.makedirs(langpack_dir, exist_ok=True)
+ shutil.copy(
+ input_xpi_file,
+ mozpath.join(
+ langpack_dir,
+ f"{langpack_metadata['browser_specific_settings']['gecko']['id']}.xpi",
+ ),
+ )
+ _generate_deb_archive(
+ source_dir=source_dir,
+ target_dir=tmpdir,
+ output_file_path=output,
+ build_variables=build_variables,
+ arch=arch,
+ )
+ finally:
+ shutil.rmtree(tmpdir)
+
+
+def _extract_application_ini_data(input_tar_file):
+ with tempfile.TemporaryDirectory() as d:
+ with tarfile.open(input_tar_file) as tar:
+ application_ini_files = [
+ tar_info
+ for tar_info in tar.getmembers()
+ if tar_info.name.endswith("/application.ini")
+ ]
+ if len(application_ini_files) == 0:
+ raise ValueError(
+ f"Cannot find any application.ini file in archive {input_tar_file}"
+ )
+ if len(application_ini_files) > 1:
+ raise ValueError(
+ f"Too many application.ini files found in archive {input_tar_file}. "
+ f"Found: {application_ini_files}"
+ )
+
+ tar.extract(application_ini_files[0], path=d)
+
+ return _extract_application_ini_data_from_directory(d)
+
+
+def _extract_application_ini_data_from_directory(application_directory):
+ values = get_application_ini_values(
+ application_directory,
+ dict(section="App", value="Name"),
+ dict(section="App", value="CodeName", fallback="Name"),
+ dict(section="App", value="Vendor"),
+ dict(section="App", value="RemotingName"),
+ dict(section="App", value="BuildID"),
+ )
+
+ data = {
+ "name": next(values),
+ "display_name": next(values),
+ "vendor": next(values),
+ "remoting_name": next(values),
+ "build_id": next(values),
+ }
+ data["timestamp"] = datetime.datetime.strptime(data["build_id"], "%Y%m%d%H%M%S")
+
+ return data
+
+
+def _get_build_variables(
+ application_ini_data,
+ arch,
+ version_string,
+ build_number,
+ depends,
+ package_name_suffix="",
+ description_suffix="",
+):
+ version = GeckoVersion.parse(version_string)
+ # Nightlies don't have build numbers
+ deb_pkg_version = (
+ f"{version}~{application_ini_data['build_id']}"
+ if version.is_nightly
+ else f"{version}~build{build_number}"
+ )
+ remoting_name = application_ini_data["remoting_name"].lower()
+
+ return {
+ "DEB_DESCRIPTION": f"{application_ini_data['vendor']} {application_ini_data['display_name']}"
+ f"{description_suffix}",
+ "DEB_PKG_INSTALL_PATH": f"usr/lib/{remoting_name}",
+ "DEB_PKG_NAME": f"{remoting_name}{package_name_suffix}",
+ "DEB_PKG_VERSION": deb_pkg_version,
+ "DEB_CHANGELOG_DATE": format_datetime(application_ini_data["timestamp"]),
+ "DEB_ARCH_NAME": _DEB_ARCH[arch],
+ "DEB_DEPENDS": depends,
+ }
+
+
+def _copy_plain_deb_config(input_template_dir, source_dir):
+ template_dir_filenames = os.listdir(input_template_dir)
+ plain_filenames = [
+ mozpath.basename(filename)
+ for filename in template_dir_filenames
+ if not filename.endswith(".in")
+ ]
+ os.makedirs(mozpath.join(source_dir, "debian"), exist_ok=True)
+
+ for filename in plain_filenames:
+ shutil.copy(
+ mozpath.join(input_template_dir, filename),
+ mozpath.join(source_dir, "debian", filename),
+ )
+
+
+def _render_deb_templates(
+ input_template_dir, source_dir, build_variables, exclude_file_names=None
+):
+ exclude_file_names = [] if exclude_file_names is None else exclude_file_names
+
+ template_dir_filenames = os.listdir(input_template_dir)
+ template_filenames = [
+ mozpath.basename(filename)
+ for filename in template_dir_filenames
+ if filename.endswith(".in") and filename not in exclude_file_names
+ ]
+ os.makedirs(mozpath.join(source_dir, "debian"), exist_ok=True)
+
+ for file_name in template_filenames:
+ with open(mozpath.join(input_template_dir, file_name)) as f:
+ template = Template(f.read())
+ with open(mozpath.join(source_dir, "debian", Path(file_name).stem), "w") as f:
+ f.write(template.substitute(build_variables))
+
+
+def _inject_deb_distribution_folder(source_dir, app_name):
+ with tempfile.TemporaryDirectory() as git_clone_dir:
+ subprocess.check_call(
+ [
+ "git",
+ "clone",
+ "https://github.com/mozilla-partners/deb.git",
+ git_clone_dir,
+ ],
+ )
+ shutil.copytree(
+ mozpath.join(git_clone_dir, "desktop/deb/distribution"),
+ mozpath.join(source_dir, app_name.lower(), "distribution"),
+ )
+
+
+def _inject_deb_desktop_entry_file(
+ log,
+ source_dir,
+ build_variables,
+ release_product,
+ release_type,
+ fluent_localization,
+ fluent_resource_loader,
+):
+ desktop_entry_file_text = _generate_browser_desktop_entry_file_text(
+ log,
+ build_variables,
+ release_product,
+ release_type,
+ fluent_localization,
+ fluent_resource_loader,
+ )
+ desktop_entry_file_filename = f"{build_variables['DEB_PKG_NAME']}.desktop"
+ os.makedirs(mozpath.join(source_dir, "debian"), exist_ok=True)
+ with open(
+ mozpath.join(source_dir, "debian", desktop_entry_file_filename), "w"
+ ) as f:
+ f.write(desktop_entry_file_text)
+
+
+def _generate_browser_desktop_entry_file_text(
+ log,
+ build_variables,
+ release_product,
+ release_type,
+ fluent_localization,
+ fluent_resource_loader,
+):
+ localizations = _create_fluent_localizations(
+ fluent_resource_loader, fluent_localization, release_type, release_product, log
+ )
+ desktop_entry = _generate_browser_desktop_entry(build_variables, localizations)
+ desktop_entry_file_text = "\n".join(desktop_entry)
+ return desktop_entry_file_text
+
+
+def _create_fluent_localizations(
+ fluent_resource_loader, fluent_localization, release_type, release_product, log
+):
+ brand_fluent_filename = "brand.ftl"
+ l10n_central_url = "https://hg.mozilla.org/l10n-central"
+ desktop_entry_fluent_filename = "linuxDesktopEntry.ftl"
+
+ l10n_dir = tempfile.mkdtemp()
+
+ loader = fluent_resource_loader(os.path.join(l10n_dir, "{locale}"))
+
+ localizations = {}
+ linux_l10n_changesets = _load_linux_l10n_changesets(
+ "browser/locales/l10n-changesets.json"
+ )
+ locales = ["en-US"]
+ locales.extend(linux_l10n_changesets.keys())
+ en_US_brand_fluent_filename = _get_en_US_brand_fluent_filename(
+ brand_fluent_filename, release_type, release_product
+ )
+
+ for locale in locales:
+ locale_dir = os.path.join(l10n_dir, locale)
+ os.mkdir(locale_dir)
+ localized_desktop_entry_filename = os.path.join(
+ locale_dir, desktop_entry_fluent_filename
+ )
+ if locale == "en-US":
+ en_US_desktop_entry_fluent_filename = os.path.join(
+ "browser/locales/en-US/browser", desktop_entry_fluent_filename
+ )
+ shutil.copyfile(
+ en_US_desktop_entry_fluent_filename,
+ localized_desktop_entry_filename,
+ )
+ else:
+ non_en_US_desktop_entry_fluent_filename = os.path.join(
+ "browser/browser", desktop_entry_fluent_filename
+ )
+ non_en_US_fluent_resource_file_url = os.path.join(
+ l10n_central_url,
+ locale,
+ "raw-file",
+ linux_l10n_changesets[locale]["revision"],
+ non_en_US_desktop_entry_fluent_filename,
+ )
+ response = requests.get(non_en_US_fluent_resource_file_url)
+ response = retry(
+ requests.get,
+ args=[non_en_US_fluent_resource_file_url],
+ attempts=5,
+ sleeptime=3,
+ jitter=2,
+ )
+ mgs = "Missing {fluent_resource_file_name} for {locale}: received HTTP {status_code} for GET {resource_file_url}"
+ params = {
+ "fluent_resource_file_name": desktop_entry_fluent_filename,
+ "locale": locale,
+ "resource_file_url": non_en_US_fluent_resource_file_url,
+ "status_code": response.status_code,
+ }
+ action = "repackage-deb"
+ if response.status_code == 404:
+ log(
+ logging.WARNING,
+ action,
+ params,
+ mgs,
+ )
+ continue
+ if response.status_code != 200:
+ log(
+ logging.ERROR,
+ action,
+ params,
+ mgs,
+ )
+ raise HgServerError(mgs.format(**params))
+
+ with open(localized_desktop_entry_filename, "w", encoding="utf-8") as f:
+ f.write(response.text)
+
+ shutil.copyfile(
+ en_US_brand_fluent_filename,
+ os.path.join(locale_dir, brand_fluent_filename),
+ )
+
+ fallbacks = [locale]
+ if locale != "en-US":
+ fallbacks.append("en-US")
+ localizations[locale] = fluent_localization(
+ fallbacks, [desktop_entry_fluent_filename, brand_fluent_filename], loader
+ )
+
+ return localizations
+
+
+def _get_en_US_brand_fluent_filename(
+ brand_fluent_filename, release_type, release_product
+):
+ branding_fluent_filename_template = os.path.join(
+ "browser/branding/{brand}/locales/en-US", brand_fluent_filename
+ )
+ if release_type == "nightly":
+ return branding_fluent_filename_template.format(brand="nightly")
+ elif release_type == "beta" and release_product == "firefox":
+ return branding_fluent_filename_template.format(brand="official")
+ elif release_type == "beta" and release_product == "devedition":
+ return branding_fluent_filename_template.format(brand="aurora")
+ else:
+ return branding_fluent_filename_template.format(brand="unofficial")
+
+
+def _load_linux_l10n_changesets(l10n_changesets_filename):
+ with open(l10n_changesets_filename) as l10n_changesets_file:
+ l10n_changesets = json.load(l10n_changesets_file)
+ return {
+ locale: changeset
+ for locale, changeset in l10n_changesets.items()
+ if any(platform.startswith("linux") for platform in changeset["platforms"])
+ }
+
+
+def _generate_browser_desktop_entry(build_variables, localizations):
+ mime_types = [
+ "application/json",
+ "application/pdf",
+ "application/rdf+xml",
+ "application/rss+xml",
+ "application/x-xpinstall",
+ "application/xhtml+xml",
+ "application/xml",
+ "audio/flac",
+ "audio/ogg",
+ "audio/webm",
+ "image/avif",
+ "image/gif",
+ "image/jpeg",
+ "image/png",
+ "image/svg+xml",
+ "image/webp",
+ "text/html",
+ "text/xml",
+ "video/ogg",
+ "video/webm",
+ "x-scheme-handler/chrome",
+ "x-scheme-handler/http",
+ "x-scheme-handler/https",
+ ]
+
+ categories = [
+ "GNOME",
+ "GTK",
+ "Network",
+ "WebBrowser",
+ ]
+
+ actions = [
+ {
+ "name": "new-window",
+ "message": "desktop-action-new-window-name",
+ "command": f"{build_variables['DEB_PKG_NAME']} --new-window %u",
+ },
+ {
+ "name": "new-private-window",
+ "message": "desktop-action-new-private-window-name",
+ "command": f"{build_variables['DEB_PKG_NAME']} --private-window %u",
+ },
+ {
+ "name": "open-profile-manager",
+ "message": "desktop-action-open-profile-manager",
+ "command": f"{build_variables['DEB_PKG_NAME']} --ProfileManager",
+ },
+ ]
+
+ desktop_entry = _desktop_entry_section(
+ "Desktop Entry",
+ [
+ {
+ "key": "Version",
+ "value": "1.0",
+ },
+ {
+ "key": "Type",
+ "value": "Application",
+ },
+ {
+ "key": "Exec",
+ "value": f"{build_variables['DEB_PKG_NAME']} %u",
+ },
+ {
+ "key": "Terminal",
+ "value": "false",
+ },
+ {
+ "key": "X-MultipleArgs",
+ "value": "false",
+ },
+ {
+ "key": "Icon",
+ "value": build_variables["DEB_PKG_NAME"],
+ },
+ {
+ "key": "StartupWMClass",
+ "value": build_variables["DEB_PKG_NAME"],
+ },
+ {
+ "key": "Categories",
+ "value": _desktop_entry_list(categories),
+ },
+ {
+ "key": "MimeType",
+ "value": _desktop_entry_list(mime_types),
+ },
+ {
+ "key": "StartupNotify",
+ "value": "true",
+ },
+ {
+ "key": "Actions",
+ "value": _desktop_entry_list([action["name"] for action in actions]),
+ },
+ {"key": "Name", "value": "desktop-entry-name", "l10n": True},
+ {"key": "Comment", "value": "desktop-entry-comment", "l10n": True},
+ {"key": "GenericName", "value": "desktop-entry-generic-name", "l10n": True},
+ {"key": "Keywords", "value": "desktop-entry-keywords", "l10n": True},
+ {
+ "key": "X-GNOME-FullName",
+ "value": "desktop-entry-x-gnome-full-name",
+ "l10n": True,
+ },
+ ],
+ localizations,
+ )
+
+ for action in actions:
+ desktop_entry.extend(
+ _desktop_entry_section(
+ f"Desktop Action {action['name']}",
+ [
+ {
+ "key": "Name",
+ "value": action["message"],
+ "l10n": True,
+ },
+ {
+ "key": "Exec",
+ "value": action["command"],
+ },
+ ],
+ localizations,
+ )
+ )
+
+ return desktop_entry
+
+
+def _desktop_entry_list(iterable):
+ delimiter = ";"
+ return f"{delimiter.join(iterable)}{delimiter}"
+
+
+def _desktop_entry_attribute(key, value, locale=None, localizations=None):
+ if not locale and not localizations:
+ return f"{key}={value}"
+ if locale and locale == "en-US":
+ return f"{key}={localizations[locale].format_value(value)}"
+ else:
+ return f"{key}[{locale.replace('-', '_')}]={localizations[locale].format_value(value)}"
+
+
+def _desktop_entry_section(header, attributes, localizations):
+ desktop_entry_section = [f"[{header}]"]
+ l10n_attributes = [attribute for attribute in attributes if attribute.get("l10n")]
+ non_l10n_attributes = [
+ attribute for attribute in attributes if not attribute.get("l10n")
+ ]
+ for attribute in non_l10n_attributes:
+ desktop_entry_section.append(
+ _desktop_entry_attribute(attribute["key"], attribute["value"])
+ )
+ for attribute in l10n_attributes:
+ for locale in localizations:
+ desktop_entry_section.append(
+ _desktop_entry_attribute(
+ attribute["key"], attribute["value"], locale, localizations
+ )
+ )
+ desktop_entry_section.append("")
+ return desktop_entry_section
+
+
+def _generate_deb_archive(
+ source_dir, target_dir, output_file_path, build_variables, arch
+):
+ command = _get_command(arch)
+ subprocess.check_call(command, cwd=source_dir)
+ deb_arch = _DEB_ARCH[arch]
+ deb_file_name = f"{build_variables['DEB_PKG_NAME']}_{build_variables['DEB_PKG_VERSION']}_{deb_arch}.deb"
+ deb_file_path = mozpath.join(target_dir, deb_file_name)
+
+ if not os.path.exists(deb_file_path):
+ raise NoDebPackageFound(deb_file_path)
+
+ subprocess.check_call(["dpkg-deb", "--info", deb_file_path])
+ shutil.move(deb_file_path, output_file_path)
+
+
+def _get_command(arch):
+ deb_arch = _DEB_ARCH[arch]
+ command = [
+ "dpkg-buildpackage",
+ # TODO: Use long options once we stop supporting Debian Jesse. They're more
+ # explicit.
+ #
+ # Long options were added in dpkg 1.18.8 which is part of Debian Stretch.
+ #
+ # https://git.dpkg.org/cgit/dpkg/dpkg.git/commit/?h=1.18.x&id=293bd243a19149165fc4fd8830b16a51d471a5e9
+ # https://packages.debian.org/stretch/dpkg-dev
+ "-us", # --unsigned-source
+ "-uc", # --unsigned-changes
+ "-b", # --build=binary
+ ]
+
+ if deb_arch != "all":
+ command.append(f"--host-arch={deb_arch}")
+
+ if _is_chroot_available(arch):
+ flattened_command = " ".join(command)
+ command = [
+ "chroot",
+ _get_chroot_path(arch),
+ "bash",
+ "-c",
+ f"cd /tmp/*/source; {flattened_command}",
+ ]
+
+ return command
+
+
+def _create_temporary_directory(arch):
+ if _is_chroot_available(arch):
+ return tempfile.mkdtemp(dir=f"{_get_chroot_path(arch)}/tmp")
+ else:
+ return tempfile.mkdtemp()
+
+
+def _is_chroot_available(arch):
+ return os.path.isdir(_get_chroot_path(arch))
+
+
+def _get_chroot_path(arch):
+ deb_arch = "amd64" if arch == "all" else _DEB_ARCH[arch]
+ return f"/srv/{_DEB_DIST}-{deb_arch}"
+
+
+_MANIFEST_FILE_NAME = "manifest.json"
+
+
+def _extract_langpack_metadata(input_xpi_file):
+ with tempfile.TemporaryDirectory() as d:
+ with zipfile.ZipFile(input_xpi_file) as zip:
+ zip.extract(_MANIFEST_FILE_NAME, path=d)
+
+ with open(mozpath.join(d, _MANIFEST_FILE_NAME)) as f:
+ return json.load(f)
diff --git a/python/mozbuild/mozbuild/repackaging/dmg.py b/python/mozbuild/mozbuild/repackaging/dmg.py
new file mode 100644
index 0000000000..883927f214
--- /dev/null
+++ b/python/mozbuild/mozbuild/repackaging/dmg.py
@@ -0,0 +1,56 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this file,
+# You can obtain one at http://mozilla.org/MPL/2.0/.
+
+import tarfile
+from pathlib import Path
+
+import mozfile
+from mozpack.dmg import create_dmg
+
+from mozbuild.bootstrap import bootstrap_toolchain
+from mozbuild.repackaging.application_ini import get_application_ini_value
+
+
+def repackage_dmg(infile, output):
+
+ if not tarfile.is_tarfile(infile):
+ raise Exception("Input file %s is not a valid tarfile." % infile)
+
+ # Resolve required tools
+ dmg_tool = bootstrap_toolchain("dmg/dmg")
+ if not dmg_tool:
+ raise Exception("DMG tool not found")
+ hfs_tool = bootstrap_toolchain("dmg/hfsplus")
+ if not hfs_tool:
+ raise Exception("HFS tool not found")
+ mkfshfs_tool = bootstrap_toolchain("hfsplus/newfs_hfs")
+ if not mkfshfs_tool:
+ raise Exception("MKFSHFS tool not found")
+
+ with mozfile.TemporaryDirectory() as tmp:
+ tmpdir = Path(tmp)
+ mozfile.extract_tarball(infile, tmpdir)
+
+ # Remove the /Applications symlink. If we don't, an rsync command in
+ # create_dmg() will break, and create_dmg() re-creates the symlink anyway.
+ symlink = tmpdir / " "
+ if symlink.is_file():
+ symlink.unlink()
+
+ volume_name = get_application_ini_value(
+ str(tmpdir), "App", "CodeName", fallback="Name"
+ )
+
+ # The extra_files argument is empty [] because they are already a part
+ # of the original dmg produced by the build, and they remain in the
+ # tarball generated by the signing task.
+ create_dmg(
+ source_directory=tmpdir,
+ output_dmg=Path(output),
+ volume_name=volume_name,
+ extra_files=[],
+ dmg_tool=Path(dmg_tool),
+ hfs_tool=Path(hfs_tool),
+ mkfshfs_tool=Path(mkfshfs_tool),
+ )
diff --git a/python/mozbuild/mozbuild/repackaging/installer.py b/python/mozbuild/mozbuild/repackaging/installer.py
new file mode 100644
index 0000000000..9bd17613bf
--- /dev/null
+++ b/python/mozbuild/mozbuild/repackaging/installer.py
@@ -0,0 +1,55 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this file,
+# You can obtain one at http://mozilla.org/MPL/2.0/.
+
+import os
+import shutil
+import tempfile
+import zipfile
+
+import mozpack.path as mozpath
+
+from mozbuild.action.exe_7z_archive import archive_exe
+from mozbuild.util import ensureParentDir
+
+
+def repackage_installer(
+ topsrcdir, tag, setupexe, package, output, package_name, sfx_stub, use_upx
+):
+ if package and not zipfile.is_zipfile(package):
+ raise Exception("Package file %s is not a valid .zip file." % package)
+ if package is not None and package_name is None:
+ raise Exception("Package name must be provided, if a package is provided.")
+ if package is None and package_name is not None:
+ raise Exception(
+ "Package name must not be provided, if a package is not provided."
+ )
+
+ # We need the full path for the tag and output, since we chdir later.
+ tag = mozpath.realpath(tag)
+ output = mozpath.realpath(output)
+ ensureParentDir(output)
+
+ tmpdir = tempfile.mkdtemp()
+ old_cwd = os.getcwd()
+ try:
+ if package:
+ z = zipfile.ZipFile(package)
+ z.extractall(tmpdir)
+ z.close()
+
+ # Copy setup.exe into the root of the install dir, alongside the
+ # package.
+ shutil.copyfile(setupexe, mozpath.join(tmpdir, mozpath.basename(setupexe)))
+
+ # archive_exe requires us to be in the directory where the package is
+ # unpacked (the tmpdir)
+ os.chdir(tmpdir)
+
+ sfx_package = mozpath.join(topsrcdir, sfx_stub)
+
+ archive_exe(package_name, tag, sfx_package, output, use_upx)
+
+ finally:
+ os.chdir(old_cwd)
+ shutil.rmtree(tmpdir)
diff --git a/python/mozbuild/mozbuild/repackaging/mar.py b/python/mozbuild/mozbuild/repackaging/mar.py
new file mode 100644
index 0000000000..f215c17238
--- /dev/null
+++ b/python/mozbuild/mozbuild/repackaging/mar.py
@@ -0,0 +1,93 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this file,
+# You can obtain one at http://mozilla.org/MPL/2.0/.
+
+import os
+import shutil
+import subprocess
+import sys
+import tarfile
+import tempfile
+import zipfile
+from pathlib import Path
+
+import mozfile
+import mozpack.path as mozpath
+
+from mozbuild.repackaging.application_ini import get_application_ini_value
+from mozbuild.util import ensureParentDir
+
+_BCJ_OPTIONS = {
+ "x86": ["--x86"],
+ "x86_64": ["--x86"],
+ "aarch64": [],
+ # macOS Universal Builds
+ "macos-x86_64-aarch64": [],
+}
+
+
+def repackage_mar(topsrcdir, package, mar, output, arch=None, mar_channel_id=None):
+ if not zipfile.is_zipfile(package) and not tarfile.is_tarfile(package):
+ raise Exception("Package file %s is not a valid .zip or .tar file." % package)
+ if arch and arch not in _BCJ_OPTIONS:
+ raise Exception(
+ "Unknown architecture {}, available architectures: {}".format(
+ arch, list(_BCJ_OPTIONS.keys())
+ )
+ )
+
+ ensureParentDir(output)
+ tmpdir = tempfile.mkdtemp()
+ try:
+ if tarfile.is_tarfile(package):
+ filelist = mozfile.extract_tarball(package, tmpdir)
+ else:
+ z = zipfile.ZipFile(package)
+ z.extractall(tmpdir)
+ filelist = z.namelist()
+ z.close()
+
+ toplevel_dirs = set([mozpath.split(f)[0] for f in filelist])
+ excluded_stuff = set([" ", ".background", ".DS_Store", ".VolumeIcon.icns"])
+ toplevel_dirs = toplevel_dirs - excluded_stuff
+ # Make sure the .zip file just contains a directory like 'firefox/' at
+ # the top, and find out what it is called.
+ if len(toplevel_dirs) != 1:
+ raise Exception(
+ "Package file is expected to have a single top-level directory"
+ "(eg: 'firefox'), not: %s" % toplevel_dirs
+ )
+ ffxdir = mozpath.join(tmpdir, toplevel_dirs.pop())
+
+ make_full_update = mozpath.join(
+ topsrcdir, "tools/update-packaging/make_full_update.sh"
+ )
+
+ env = os.environ.copy()
+ env["MOZ_PRODUCT_VERSION"] = get_application_ini_value(tmpdir, "App", "Version")
+ env["MAR"] = mozpath.normpath(mar)
+ if arch:
+ env["BCJ_OPTIONS"] = " ".join(_BCJ_OPTIONS[arch])
+ if mar_channel_id:
+ env["MAR_CHANNEL_ID"] = mar_channel_id
+ # The Windows build systems have xz installed but it isn't in the path
+ # like it is on Linux and Mac OS X so just use the XZ env var so the mar
+ # generation scripts can find it.
+ xz_path = mozpath.join(topsrcdir, "xz/xz.exe")
+ if os.path.exists(xz_path):
+ env["XZ"] = mozpath.normpath(xz_path)
+
+ cmd = [make_full_update, output, ffxdir]
+ if sys.platform == "win32":
+ # make_full_update.sh is a bash script, and Windows needs to
+ # explicitly call out the shell to execute the script from Python.
+
+ mozillabuild = os.environ["MOZILLABUILD"]
+ if (Path(mozillabuild) / "msys2").exists():
+ cmd.insert(0, mozillabuild + "/msys2/usr/bin/bash.exe")
+ else:
+ cmd.insert(0, mozillabuild + "/msys/bin/bash.exe")
+ subprocess.check_call(cmd, env=env)
+
+ finally:
+ shutil.rmtree(tmpdir)
diff --git a/python/mozbuild/mozbuild/repackaging/msi.py b/python/mozbuild/mozbuild/repackaging/msi.py
new file mode 100644
index 0000000000..b0b1b09983
--- /dev/null
+++ b/python/mozbuild/mozbuild/repackaging/msi.py
@@ -0,0 +1,122 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this file,
+# You can obtain one at http://mozilla.org/MPL/2.0/.
+
+import os
+import shutil
+import subprocess
+import sys
+import tempfile
+from xml.dom import minidom
+
+import mozpack.path as mozpath
+
+from mozbuild.util import ensureParentDir
+
+_MSI_ARCH = {
+ "x86": "x86",
+ "x86_64": "x64",
+}
+
+
+def update_wsx(wfile, pvalues):
+
+ parsed = minidom.parse(wfile)
+
+ # construct a dictinary for the pre-processing options
+ # iterate over that list and add them to the wsx xml doc
+ for k, v in pvalues.items():
+ entry = parsed.createProcessingInstruction("define", k + ' = "' + v + '"')
+ root = parsed.firstChild
+ parsed.insertBefore(entry, root)
+ # write out xml to new wfile
+ new_w_file = wfile + ".new"
+ with open(new_w_file, "w") as fh:
+ parsed.writexml(fh)
+ shutil.move(new_w_file, wfile)
+ return wfile
+
+
+def repackage_msi(
+ topsrcdir, wsx, version, locale, arch, setupexe, candle, light, output
+):
+ if sys.platform != "win32":
+ raise Exception("repackage msi only works on windows")
+ if not os.path.isdir(topsrcdir):
+ raise Exception("%s does not exist." % topsrcdir)
+ if not os.path.isfile(wsx):
+ raise Exception("%s does not exist." % wsx)
+ if version is None:
+ raise Exception("version name must be provided.")
+ if locale is None:
+ raise Exception("locale name must be provided.")
+ if arch is None or arch not in _MSI_ARCH.keys():
+ raise Exception(
+ "arch name must be provided and one of {}.".format(_MSI_ARCH.keys())
+ )
+ if not os.path.isfile(setupexe):
+ raise Exception("%s does not exist." % setupexe)
+ if candle is not None and not os.path.isfile(candle):
+ raise Exception("%s does not exist." % candle)
+ if light is not None and not os.path.isfile(light):
+ raise Exception("%s does not exist." % light)
+ embeddedVersion = "0.0.0.0"
+ # Version string cannot contain 'a' or 'b' when embedding in msi manifest.
+ if "a" not in version and "b" not in version:
+ if version.endswith("esr"):
+ parts = version[:-3].split(".")
+ else:
+ parts = version.split(".")
+ while len(parts) < 4:
+ parts.append("0")
+ embeddedVersion = ".".join(parts)
+
+ wsx = mozpath.realpath(wsx)
+ setupexe = mozpath.realpath(setupexe)
+ output = mozpath.realpath(output)
+ ensureParentDir(output)
+
+ if sys.platform == "win32":
+ tmpdir = tempfile.mkdtemp()
+ old_cwd = os.getcwd()
+ try:
+ wsx_file = os.path.split(wsx)[1]
+ shutil.copy(wsx, tmpdir)
+ temp_wsx_file = os.path.join(tmpdir, wsx_file)
+ temp_wsx_file = mozpath.realpath(temp_wsx_file)
+ pre_values = {
+ "Vendor": "Mozilla",
+ "BrandFullName": "Mozilla Firefox",
+ "Version": version,
+ "AB_CD": locale,
+ "Architecture": _MSI_ARCH[arch],
+ "ExeSourcePath": setupexe,
+ "EmbeddedVersionCode": embeddedVersion,
+ }
+ # update wsx file with inputs from
+ newfile = update_wsx(temp_wsx_file, pre_values)
+ wix_object_file = os.path.join(tmpdir, "installer.wixobj")
+ env = os.environ.copy()
+ if candle is None:
+ candle = "candle.exe"
+ cmd = [candle, "-out", wix_object_file, newfile]
+ subprocess.check_call(cmd, env=env)
+ wix_installer = wix_object_file.replace(".wixobj", ".msi")
+ if light is None:
+ light = "light.exe"
+ light_cmd = [
+ light,
+ "-cultures:neutral",
+ "-sw1076",
+ "-sw1079",
+ "-out",
+ wix_installer,
+ wix_object_file,
+ ]
+ subprocess.check_call(light_cmd, env=env)
+ os.remove(wix_object_file)
+ # mv file to output dir
+ shutil.move(wix_installer, output)
+ finally:
+ os.chdir(old_cwd)
+ shutil.rmtree(tmpdir)
diff --git a/python/mozbuild/mozbuild/repackaging/msix.py b/python/mozbuild/mozbuild/repackaging/msix.py
new file mode 100644
index 0000000000..707096c499
--- /dev/null
+++ b/python/mozbuild/mozbuild/repackaging/msix.py
@@ -0,0 +1,1193 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this file,
+# You can obtain one at http://mozilla.org/MPL/2.0/.
+
+r"""Repackage ZIP archives (or directories) into MSIX App Packages.
+
+# Known issues
+
+- The icons in the Start Menu have a solid colour tile behind them. I think
+ this is an issue with plating.
+"""
+
+import functools
+import itertools
+import logging
+import os
+import re
+import shutil
+import subprocess
+import sys
+import time
+import urllib
+from collections import defaultdict
+from pathlib import Path
+
+import mozpack.path as mozpath
+from mach.util import get_state_dir
+from mozfile import which
+from mozpack.copier import FileCopier
+from mozpack.files import FileFinder, JarFinder
+from mozpack.manifests import InstallManifest
+from mozpack.mozjar import JarReader
+from mozpack.packager.unpack import UnpackFinder
+from six.moves import shlex_quote
+
+from mozbuild.repackaging.application_ini import get_application_ini_values
+from mozbuild.util import ensureParentDir
+
+
+def log_copy_result(log, elapsed, destdir, result):
+ COMPLETE = (
+ "Elapsed: {elapsed:.2f}s; From {dest}: Kept {existing} existing; "
+ "Added/updated {updated}; "
+ "Removed {rm_files} files and {rm_dirs} directories."
+ )
+ copy_result = COMPLETE.format(
+ elapsed=elapsed,
+ dest=destdir,
+ existing=result.existing_files_count,
+ updated=result.updated_files_count,
+ rm_files=result.removed_files_count,
+ rm_dirs=result.removed_directories_count,
+ )
+ log(logging.INFO, "msix", {"copy_result": copy_result}, "{copy_result}")
+
+
+# See https://docs.microsoft.com/en-us/uwp/schemas/appxpackage/uapmanifestschema/element-identity.
+_MSIX_ARCH = {"x86": "x86", "x86_64": "x64", "aarch64": "arm64"}
+
+
+@functools.lru_cache(maxsize=None)
+def sdk_tool_search_path():
+ from mozbuild.configure import ConfigureSandbox
+
+ sandbox = ConfigureSandbox({}, argv=["configure"])
+ sandbox.include_file(
+ str(Path(__file__).parent.parent.parent.parent.parent / "moz.configure")
+ )
+ return sandbox._value_for(sandbox["sdk_bin_path"]) + [
+ "c:/Windows/System32/WindowsPowershell/v1.0"
+ ]
+
+
+def find_sdk_tool(binary, log=None):
+ if binary.lower().endswith(".exe"):
+ binary = binary[:-4]
+
+ maybe = os.environ.get(binary.upper())
+ if maybe:
+ log(
+ logging.DEBUG,
+ "msix",
+ {"binary": binary, "path": maybe},
+ "Found {binary} in environment: {path}",
+ )
+ return mozpath.normsep(maybe)
+
+ maybe = which(binary, extra_search_dirs=sdk_tool_search_path())
+ if maybe:
+ log(
+ logging.DEBUG,
+ "msix",
+ {"binary": binary, "path": maybe},
+ "Found {binary} on path: {path}",
+ )
+ return mozpath.normsep(maybe)
+
+ return None
+
+
+def get_embedded_version(version, buildid):
+ r"""Turn a display version into "dotted quad" notation.
+
+ N.b.: some parts of the MSIX packaging ecosystem require the final part of
+ the dotted quad to be identically 0, so we enforce that here.
+ """
+
+ # It's irritating to roll our own version parsing, but the tree doesn't seem
+ # to contain exactly what we need at this time.
+ version = version.rsplit("esr", 1)[0]
+ alpha = "a" in version
+
+ tail = None
+ if "a" in version:
+ head, tail = version.rsplit("a", 1)
+ if tail != "1":
+ # Disallow anything beyond `X.Ya1`.
+ raise ValueError(
+ f"Alpha version not of the form X.0a1 is not supported: {version}"
+ )
+ tail = buildid
+ elif "b" in version:
+ head, tail = version.rsplit("b", 1)
+ if len(head.split(".")) > 2:
+ raise ValueError(
+ f"Beta version not of the form X.YbZ is not supported: {version}"
+ )
+ elif "rc" in version:
+ head, tail = version.rsplit("rc", 1)
+ if len(head.split(".")) > 2:
+ raise ValueError(
+ f"Release candidate version not of the form X.YrcZ is not supported: {version}"
+ )
+ else:
+ head = version
+
+ components = (head.split(".") + ["0", "0", "0"])[:3]
+ if tail:
+ components[2] = tail
+
+ if alpha:
+ # Nightly builds are all `X.0a1`, which isn't helpful. Include build ID
+ # to disambiguate. But each part of the dotted quad is 16 bits, so we
+ # have to squash.
+ if components[1] != "0":
+ # Disallow anything beyond `X.0a1`.
+ raise ValueError(
+ f"Alpha version not of the form X.0a1 is not supported: {version}"
+ )
+
+ # Last two digits only to save space. Nightly builds in 2066 and 2099
+ # will be impacted, but future us can deal with that.
+ year = buildid[2:4]
+ if year[0] == "0":
+ # Avoid leading zero, like `.0YMm`.
+ year = year[1:]
+ month = buildid[4:6]
+ day = buildid[6:8]
+ if day[0] == "0":
+ # Avoid leading zero, like `.0DHh`.
+ day = day[1:]
+ hour = buildid[8:10]
+
+ components[1] = "".join((year, month))
+ components[2] = "".join((day, hour))
+
+ version = "{}.{}.{}.0".format(*components)
+
+ return version
+
+
+def get_appconstants_sys_mjs_values(finder, *args):
+ r"""Extract values, such as the display version like `MOZ_APP_VERSION_DISPLAY:
+ "...";`, from the omnijar. This allows to determine the beta number, like
+ `X.YbW`, where the regular beta version is only `X.Y`. Takes a list of
+ names and returns an iterator of the unique such value found for each name.
+ Raises an exception if a name is not found or if multiple values are found.
+ """
+ lines = defaultdict(list)
+ for _, f in finder.find("**/modules/AppConstants.sys.mjs"):
+ # MOZ_OFFICIAL_BRANDING is split across two lines, so remove line breaks
+ # immediately following ":"s so those values can be read.
+ data = f.open().read().decode("utf-8").replace(":\n", ":")
+ for line in data.splitlines():
+ for arg in args:
+ if arg in line:
+ lines[arg].append(line)
+
+ for arg in args:
+ (value,) = lines[arg] # We expect exactly one definition.
+ _, _, value = value.partition(":")
+ value = value.strip().strip('",;')
+ yield value
+
+
+def get_branding(use_official, topsrcdir, build_app, finder, log=None):
+ """Figure out which branding directory to use."""
+ conf_vars = mozpath.join(topsrcdir, build_app, "confvars.sh")
+
+ def conf_vars_value(key):
+ lines = open(conf_vars).readlines()
+ for line in lines:
+ line = line.strip()
+ if line and line[0] == "#":
+ continue
+ if key not in line:
+ continue
+ _, _, value = line.partition("=")
+ if not value:
+ continue
+ log(
+ logging.INFO,
+ "msix",
+ {"key": key, "conf_vars": conf_vars, "value": value},
+ "Read '{key}' from {conf_vars}: {value}",
+ )
+ return value
+ log(
+ logging.ERROR,
+ "msix",
+ {"key": key, "conf_vars": conf_vars},
+ "Unable to find '{key}' in {conf_vars}!",
+ )
+
+ # Branding defaults
+ branding_reason = "No branding set"
+ branding = conf_vars_value("MOZ_BRANDING_DIRECTORY")
+
+ if use_official:
+ # Read MOZ_OFFICIAL_BRANDING_DIRECTORY from confvars.sh
+ branding_reason = "'MOZ_OFFICIAL_BRANDING' set"
+ branding = conf_vars_value("MOZ_OFFICIAL_BRANDING_DIRECTORY")
+ else:
+ # Check if --with-branding was used when building
+ log(
+ logging.INFO,
+ "msix",
+ {},
+ "Checking buildconfig.html for --with-branding build flag.",
+ )
+ for _, f in finder.find("**/chrome/toolkit/content/global/buildconfig.html"):
+ data = f.open().read().decode("utf-8")
+ match = re.search(r"--with-branding=([a-z/]+)", data)
+ if match:
+ branding_reason = "'--with-branding' set"
+ branding = match.group(1)
+
+ log(
+ logging.INFO,
+ "msix",
+ {
+ "branding_reason": branding_reason,
+ "branding": branding,
+ },
+ "{branding_reason}; Using branding from '{branding}'.",
+ )
+ return mozpath.join(topsrcdir, branding)
+
+
+def unpack_msix(input_msix, output, log=None, verbose=False):
+ r"""Unpack the given MSIX to the given output directory.
+
+ MSIX packages are ZIP files, but they are Zip64/version 4.5 ZIP files, so
+ `mozjar.py` doesn't yet handle. Unpack using `unzip{.exe}` for simplicity.
+
+ In addition, file names inside the MSIX package are URL quoted. URL unquote
+ here.
+ """
+
+ log(
+ logging.INFO,
+ "msix",
+ {
+ "input_msix": input_msix,
+ "output": output,
+ },
+ "Unpacking input MSIX '{input_msix}' to directory '{output}'",
+ )
+
+ unzip = find_sdk_tool("unzip.exe", log=log)
+ if not unzip:
+ raise ValueError("unzip is required; set UNZIP or PATH")
+
+ subprocess.check_call(
+ [unzip, input_msix, "-d", output] + (["-q"] if not verbose else []),
+ universal_newlines=True,
+ )
+
+ # Sanity check: is this an MSIX?
+ temp_finder = FileFinder(output)
+ if not temp_finder.contains("AppxManifest.xml"):
+ raise ValueError("MSIX file does not contain 'AppxManifest.xml'?")
+
+ # Files in the MSIX are URL encoded/quoted; unquote here.
+ for dirpath, dirs, files in os.walk(output):
+ # This is a one way to update (in place, for os.walk) the variable `dirs` while iterating
+ # over it and `files`.
+ for i, (p, var) in itertools.chain(
+ enumerate((f, files) for f in files), enumerate((g, dirs) for g in dirs)
+ ):
+ q = urllib.parse.unquote(p)
+ if p != q:
+ log(
+ logging.DEBUG,
+ "msix",
+ {
+ "dirpath": dirpath,
+ "p": p,
+ "q": q,
+ },
+ "URL unquoting '{p}' -> '{q}' in {dirpath}",
+ )
+
+ var[i] = q
+ os.rename(os.path.join(dirpath, p), os.path.join(dirpath, q))
+
+ # The "package root" of our MSIX packages is like "Mozilla Firefox Beta Package Root", i.e., it
+ # varies by channel. This is an easy way to determine it.
+ for p, _ in temp_finder.find("**/application.ini"):
+ relpath = os.path.split(p)[0]
+
+ # The application executable, like `firefox.exe`, is in this directory.
+ return mozpath.normpath(mozpath.join(output, relpath))
+
+
+def repackage_msix(
+ dir_or_package,
+ topsrcdir,
+ channel=None,
+ distribution_dirs=[],
+ version=None,
+ vendor=None,
+ displayname=None,
+ app_name=None,
+ identity=None,
+ publisher=None,
+ publisher_display_name="Mozilla Corporation",
+ arch=None,
+ output=None,
+ force=False,
+ log=None,
+ verbose=False,
+ makeappx=None,
+):
+ if not channel:
+ raise Exception("channel is required")
+ if channel not in (
+ "official",
+ "beta",
+ "aurora",
+ "nightly",
+ "unofficial",
+ ):
+ raise Exception("channel is unrecognized: {}".format(channel))
+
+ # TODO: maybe we can fish this from the package directly? Maybe from a DLL,
+ # maybe from application.ini?
+ if arch is None or arch not in _MSIX_ARCH.keys():
+ raise Exception(
+ "arch name must be provided and one of {}.".format(_MSIX_ARCH.keys())
+ )
+
+ if not os.path.exists(dir_or_package):
+ raise Exception("{} does not exist".format(dir_or_package))
+
+ if (
+ os.path.isfile(dir_or_package)
+ and os.path.splitext(dir_or_package)[1] == ".msix"
+ ):
+ # The convention is $MOZBUILD_STATE_PATH/cache/$FEATURE.
+ msix_dir = mozpath.normsep(
+ mozpath.join(
+ get_state_dir(),
+ "cache",
+ "mach-msix",
+ "msix-unpack",
+ )
+ )
+
+ if os.path.exists(msix_dir):
+ shutil.rmtree(msix_dir)
+ ensureParentDir(msix_dir)
+
+ dir_or_package = unpack_msix(dir_or_package, msix_dir, log=log, verbose=verbose)
+
+ log(
+ logging.INFO,
+ "msix",
+ {
+ "input": dir_or_package,
+ },
+ "Adding files from '{input}'",
+ )
+
+ if os.path.isdir(dir_or_package):
+ finder = FileFinder(dir_or_package)
+ else:
+ finder = JarFinder(dir_or_package, JarReader(dir_or_package))
+
+ values = get_application_ini_values(
+ finder,
+ dict(section="App", value="CodeName", fallback="Name"),
+ dict(section="App", value="Vendor"),
+ )
+
+ first = next(values)
+ if not displayname:
+ displayname = "Mozilla {}".format(first)
+
+ if channel == "beta":
+ # Release (official) and Beta share branding. Differentiate Beta a little bit.
+ displayname += " Beta"
+
+ second = next(values)
+ vendor = vendor or second
+
+ # For `AppConstants.sys.mjs` and `brand.properties`, which are in the omnijar in packaged
+ # builds. The nested langpack XPI files can't be read by `mozjar.py`.
+ unpack_finder = UnpackFinder(finder, unpack_xpi=False)
+
+ values = get_appconstants_sys_mjs_values(
+ unpack_finder,
+ "MOZ_OFFICIAL_BRANDING",
+ "MOZ_BUILD_APP",
+ "MOZ_APP_NAME",
+ "MOZ_APP_VERSION_DISPLAY",
+ "MOZ_BUILDID",
+ )
+ try:
+ use_official_branding = {"true": True, "false": False}[next(values)]
+ except KeyError as err:
+ raise Exception(
+ f"Unexpected value '{err.args[0]}' found for 'MOZ_OFFICIAL_BRANDING'."
+ ) from None
+
+ build_app = next(values)
+
+ _temp = next(values)
+ if not app_name:
+ app_name = _temp
+
+ if not version:
+ display_version = next(values)
+ buildid = next(values)
+ version = get_embedded_version(display_version, buildid)
+ log(
+ logging.INFO,
+ "msix",
+ {
+ "version": version,
+ "display_version": display_version,
+ "buildid": buildid,
+ },
+ "AppConstants.sys.mjs display version is '{display_version}' and build ID is"
+ + " '{buildid}': embedded version will be '{version}'",
+ )
+
+ # TODO: Bug 1721922: localize this description via Fluent.
+ lines = []
+ for _, f in unpack_finder.find("**/chrome/en-US/locale/branding/brand.properties"):
+ lines.extend(
+ line
+ for line in f.open().read().decode("utf-8").splitlines()
+ if "brandFullName" in line
+ )
+ (brandFullName,) = lines # We expect exactly one definition.
+ _, _, brandFullName = brandFullName.partition("=")
+ brandFullName = brandFullName.strip()
+
+ if channel == "beta":
+ # Release (official) and Beta share branding. Differentiate Beta a little bit.
+ brandFullName += " Beta"
+
+ branding = get_branding(
+ use_official_branding, topsrcdir, build_app, unpack_finder, log
+ )
+ if not os.path.isdir(branding):
+ raise Exception("branding dir {} does not exist".format(branding))
+
+ template = os.path.join(topsrcdir, build_app, "installer", "windows", "msix")
+
+ # Discard everything after a '#' comment character.
+ locale_allowlist = set(
+ locale.partition("#")[0].strip().lower()
+ for locale in open(os.path.join(template, "msix-all-locales")).readlines()
+ if locale.partition("#")[0].strip()
+ )
+
+ # The convention is $MOZBUILD_STATE_PATH/cache/$FEATURE.
+ output_dir = mozpath.normsep(
+ mozpath.join(
+ get_state_dir(), "cache", "mach-msix", "msix-temp-{}".format(channel)
+ )
+ )
+
+ # Like 'Firefox Package Root', 'Firefox Nightly Package Root', 'Firefox Beta
+ # Package Root'. This is `BrandFullName` in the installer, and we want to
+ # be close but to not match. By not matching, we hope to prevent confusion
+ # and/or errors between regularly installed builds and App Package builds.
+ instdir = "{} Package Root".format(displayname)
+
+ # The standard package name is like "CompanyNoSpaces.ProductNoSpaces".
+ identity = identity or "{}.{}".format(vendor, displayname).replace(" ", "")
+
+ # We might want to include the publisher ID hash here. I.e.,
+ # "__{publisherID}". My locally produced MSIX was named like
+ # `Mozilla.MozillaFirefoxNightly_89.0.0.0_x64__4gf61r4q480j0`, suggesting also a
+ # missing field, but it's not necessary, since this is just an output file name.
+ package_output_name = "{identity}_{version}_{arch}".format(
+ identity=identity, version=version, arch=_MSIX_ARCH[arch]
+ )
+ # The convention is $MOZBUILD_STATE_PATH/cache/$FEATURE.
+ default_output = mozpath.normsep(
+ mozpath.join(
+ get_state_dir(), "cache", "mach-msix", "{}.msix".format(package_output_name)
+ )
+ )
+ output = output or default_output
+ log(logging.INFO, "msix", {"output": output}, "Repackaging to: {output}")
+
+ m = InstallManifest()
+ m.add_copy(mozpath.join(template, "Resources.pri"), "Resources.pri")
+
+ m.add_pattern_copy(mozpath.join(branding, "msix", "Assets"), "**", "Assets")
+ m.add_pattern_copy(mozpath.join(template, "VFS"), "**", "VFS")
+
+ copier = FileCopier()
+
+ # TODO: Bug 1710147: filter out MSVCRT files and use a dependency instead.
+ for p, f in finder:
+ if not os.path.isdir(dir_or_package):
+ # In archived builds, `p` is like "firefox/firefox.exe"; we want just "firefox.exe".
+ pp = os.path.relpath(p, app_name)
+ else:
+ # In local builds and unpacked MSIX directories, `p` is like "firefox.exe" already.
+ pp = p
+
+ if pp.startswith("distribution"):
+ # Treat any existing distribution as a distribution directory,
+ # potentially with language packs. This makes it easy to repack
+ # unpacked MSIXes.
+ distribution_dir = mozpath.join(dir_or_package, "distribution")
+ if distribution_dir not in distribution_dirs:
+ distribution_dirs.append(distribution_dir)
+
+ continue
+
+ copier.add(mozpath.normsep(mozpath.join("VFS", "ProgramFiles", instdir, pp)), f)
+
+ # Locales to declare as supported in `AppxManifest.xml`.
+ locales = set(["en-US"])
+
+ for distribution_dir in [
+ mozpath.join(template, "distribution")
+ ] + distribution_dirs:
+ log(
+ logging.INFO,
+ "msix",
+ {"dir": distribution_dir},
+ "Adding distribution files from {dir}",
+ )
+
+ # In automation, we have no easy way to remap the names of artifacts fetched from dependent
+ # tasks. In particular, langpacks will be named like `target.langpack.xpi`. The fetch
+ # tasks do allow us to put them in a per-locale directory, so that the entire set can be
+ # fetched. Here we remap the names.
+ finder = FileFinder(distribution_dir)
+
+ for p, f in finder:
+ locale = None
+ if os.path.basename(p) == "target.langpack.xpi":
+ # Turn "/path/to/LOCALE/target.langpack.xpi" into "LOCALE". This is how langpacks
+ # are presented in CI.
+ base, locale = os.path.split(os.path.dirname(p))
+
+ # Like "locale-LOCALE/langpack-LOCALE@firefox.mozilla.org.xpi". This is what AMO
+ # serves and how flatpak builds name langpacks, but not how snap builds name
+ # langpacks. I can't explain the discrepancy.
+ dest = mozpath.normsep(
+ mozpath.join(
+ base,
+ f"locale-{locale}",
+ f"langpack-{locale}@{app_name}.mozilla.org.xpi",
+ )
+ )
+
+ log(
+ logging.DEBUG,
+ "msix",
+ {"path": p, "dest": dest},
+ "Renaming langpack {path} to {dest}",
+ )
+
+ elif os.path.basename(p).startswith("langpack-"):
+ # Turn "/path/to/langpack-LOCALE@firefox.mozilla.org.xpi" into "LOCALE". This is
+ # how langpacks are presented from an unpacked MSIX.
+ _, _, locale = os.path.basename(p).partition("langpack-")
+ locale, _, _ = locale.partition("@")
+ dest = p
+
+ else:
+ dest = p
+
+ if locale:
+ locale = locale.strip().lower()
+ locales.add(locale)
+ log(
+ logging.DEBUG,
+ "msix",
+ {"locale": locale, "dest": dest},
+ "Distributing locale '{locale}' from {dest}",
+ )
+
+ dest = mozpath.normsep(
+ mozpath.join("VFS", "ProgramFiles", instdir, "distribution", dest)
+ )
+ if copier.contains(dest):
+ log(
+ logging.INFO,
+ "msix",
+ {"dest": dest, "path": mozpath.join(finder.base, p)},
+ "Skipping duplicate: {dest} from {path}",
+ )
+ continue
+
+ log(
+ logging.DEBUG,
+ "msix",
+ {"dest": dest, "path": mozpath.join(finder.base, p)},
+ "Adding distribution path: {dest} from {path}",
+ )
+
+ copier.add(
+ dest,
+ f,
+ )
+
+ locales.remove("en-US")
+
+ # Windows MSIX packages support a finite set of locales: see
+ # https://docs.microsoft.com/en-us/windows/uwp/publish/supported-languages, which is encoded in
+ # https://searchfox.org/mozilla-central/source/browser/installer/windows/msix/msix-all-locales.
+ # We distribute all of the langpacks supported by the release channel in our MSIX, which is
+ # encoded in https://searchfox.org/mozilla-central/source/browser/locales/all-locales. But we
+ # only advertise support in the App manifest for the intersection of that set and the set of
+ # supported locales.
+ #
+ # We distribute all langpacks to avoid the following issue. Suppose a user manually installs a
+ # langpack that is not supported by Windows, and then updates the installed MSIX package. MSIX
+ # package upgrades are essentially paveover installs, so there is no opportunity for Firefox to
+ # update the langpack before the update. But, since all langpacks are bundled with the MSIX,
+ # that langpack will be up-to-date, preventing one class of YSOD.
+ unadvertised = set()
+ if locale_allowlist:
+ unadvertised = locales - locale_allowlist
+ locales = locales & locale_allowlist
+ for locale in sorted(unadvertised):
+ log(
+ logging.INFO,
+ "msix",
+ {"locale": locale},
+ "Not advertising distributed locale '{locale}' that is not recognized by Windows",
+ )
+
+ locales = ["en-US"] + list(sorted(locales))
+ resource_language_list = "\n".join(
+ f' <Resource Language="{locale}" />' for locale in locales
+ )
+
+ defines = {
+ "APPX_ARCH": _MSIX_ARCH[arch],
+ "APPX_DISPLAYNAME": brandFullName,
+ "APPX_DESCRIPTION": brandFullName,
+ # Like 'Mozilla.MozillaFirefox', 'Mozilla.MozillaFirefoxBeta', or
+ # 'Mozilla.MozillaFirefoxNightly'.
+ "APPX_IDENTITY": identity,
+ # Like 'Firefox Package Root', 'Firefox Nightly Package Root', 'Firefox
+ # Beta Package Root'. See above.
+ "APPX_INSTDIR": instdir,
+ # Like 'Firefox%20Package%20Root'.
+ "APPX_INSTDIR_QUOTED": urllib.parse.quote(instdir),
+ "APPX_PUBLISHER": publisher,
+ "APPX_PUBLISHER_DISPLAY_NAME": publisher_display_name,
+ "APPX_RESOURCE_LANGUAGE_LIST": resource_language_list,
+ "APPX_VERSION": version,
+ "MOZ_APP_DISPLAYNAME": displayname,
+ "MOZ_APP_NAME": app_name,
+ # Keep synchronized with `toolkit\mozapps\notificationserver\NotificationComServer.cpp`.
+ "MOZ_INOTIFICATIONACTIVATION_CLSID": "916f9b5d-b5b2-4d36-b047-03c7a52f81c8",
+ }
+
+ m.add_preprocess(
+ mozpath.join(template, "AppxManifest.xml.in"),
+ "AppxManifest.xml",
+ [],
+ defines=defines,
+ marker="<!-- #", # So that we can have well-formed XML.
+ )
+ m.populate_registry(copier)
+
+ output_dir = mozpath.abspath(output_dir)
+ ensureParentDir(output_dir)
+
+ start = time.monotonic()
+ result = copier.copy(
+ output_dir, remove_empty_directories=True, skip_if_older=not force
+ )
+ if log:
+ log_copy_result(log, time.monotonic() - start, output_dir, result)
+
+ if verbose:
+ # Dump AppxManifest.xml contents for ease of debugging.
+ log(logging.DEBUG, "msix", {}, "AppxManifest.xml")
+ log(logging.DEBUG, "msix", {}, ">>>")
+ for line in open(mozpath.join(output_dir, "AppxManifest.xml")).readlines():
+ log(logging.DEBUG, "msix", {}, line[:-1]) # Drop trailing line terminator.
+ log(logging.DEBUG, "msix", {}, "<<<")
+
+ if not makeappx:
+ makeappx = find_sdk_tool("makeappx.exe", log=log)
+ if not makeappx:
+ raise ValueError(
+ "makeappx is required; " "set MAKEAPPX or WINDOWSSDKDIR or PATH"
+ )
+
+ # `makeappx.exe` supports both slash and hyphen style arguments; `makemsix`
+ # supports only hyphen style. `makeappx.exe` allows to overwrite and to
+ # provide more feedback, so we prefer invoking with these flags. This will
+ # also accommodate `wine makeappx.exe`.
+ stdout = subprocess.run(
+ [makeappx], check=False, capture_output=True, universal_newlines=True
+ ).stdout
+ is_makeappx = "MakeAppx Tool" in stdout
+
+ if is_makeappx:
+ args = [makeappx, "pack", "/d", output_dir, "/p", output, "/overwrite"]
+ else:
+ args = [makeappx, "pack", "-d", output_dir, "-p", output]
+ if verbose and is_makeappx:
+ args.append("/verbose")
+ joined = " ".join(shlex_quote(arg) for arg in args)
+ log(logging.INFO, "msix", {"args": args, "joined": joined}, "Invoking: {joined}")
+
+ sys.stdout.flush() # Otherwise the subprocess output can be interleaved.
+ if verbose:
+ subprocess.check_call(args, universal_newlines=True)
+ else:
+ # Suppress output unless we fail.
+ try:
+ subprocess.check_output(args, universal_newlines=True)
+ except subprocess.CalledProcessError as e:
+ sys.stderr.write(e.output)
+ raise
+
+ return output
+
+
+def _sign_msix_win(output, force, log, verbose):
+ powershell_exe = find_sdk_tool("powershell.exe", log=log)
+ if not powershell_exe:
+ raise ValueError("powershell is required; " "set POWERSHELL or PATH")
+
+ def powershell(argstring, check=True):
+ "Invoke `powershell.exe`. Arguments are given as a string to allow consumer to quote."
+ args = [powershell_exe, "-c", argstring]
+ joined = " ".join(shlex_quote(arg) for arg in args)
+ log(
+ logging.INFO, "msix", {"args": args, "joined": joined}, "Invoking: {joined}"
+ )
+ return subprocess.run(
+ args, check=check, universal_newlines=True, capture_output=True
+ ).stdout
+
+ signtool = find_sdk_tool("signtool.exe", log=log)
+ if not signtool:
+ raise ValueError(
+ "signtool is required; " "set SIGNTOOL or WINDOWSSDKDIR or PATH"
+ )
+
+ # Our first order of business is to find, or generate, a (self-signed)
+ # certificate.
+
+ # These are baked into enough places under `browser/` that we need not
+ # extract constants.
+ vendor = "Mozilla"
+ publisher = "CN=Mozilla Corporation, OU=MSIX Packaging"
+ friendly_name = "Mozilla Corporation MSIX Packaging Test Certificate"
+
+ # The convention is $MOZBUILD_STATE_PATH/cache/$FEATURE.
+ crt_path = mozpath.join(
+ get_state_dir(),
+ "cache",
+ "mach-msix",
+ "{}.crt".format(friendly_name).replace(" ", "_").lower(),
+ )
+ crt_path = mozpath.abspath(crt_path)
+ ensureParentDir(crt_path)
+
+ pfx_path = crt_path.replace(".crt", ".pfx")
+
+ # TODO: maybe use an actual password. For now, just something that won't be
+ # brute-forced.
+ password = "193dbfc6-8ff7-4a95-8f32-6b4468626bd0"
+
+ if force or not os.path.isfile(crt_path):
+ log(
+ logging.INFO,
+ "msix",
+ {"crt_path": crt_path},
+ "Creating new self signed certificate at: {}".format(crt_path),
+ )
+
+ thumbprints = [
+ thumbprint.strip()
+ for thumbprint in powershell(
+ (
+ "Get-ChildItem -Path Cert:\CurrentUser\My"
+ '| Where-Object {{$_.Subject -Match "{}"}}'
+ '| Where-Object {{$_.FriendlyName -Match "{}"}}'
+ "| Select-Object -ExpandProperty Thumbprint"
+ ).format(vendor, friendly_name)
+ ).splitlines()
+ ]
+ if len(thumbprints) > 1:
+ raise Exception(
+ "Multiple certificates with friendly name found: {}".format(
+ friendly_name
+ )
+ )
+
+ if len(thumbprints) == 1:
+ thumbprint = thumbprints[0]
+ else:
+ thumbprint = None
+
+ if not thumbprint:
+ thumbprint = (
+ powershell(
+ (
+ 'New-SelfSignedCertificate -Type Custom -Subject "{}" '
+ '-KeyUsage DigitalSignature -FriendlyName "{}"'
+ " -CertStoreLocation Cert:\CurrentUser\My"
+ ' -TextExtension @("2.5.29.37={{text}}1.3.6.1.5.5.7.3.3", '
+ '"2.5.29.19={{text}}")'
+ "| Select-Object -ExpandProperty Thumbprint"
+ ).format(publisher, friendly_name)
+ )
+ .strip()
+ .upper()
+ )
+
+ if not thumbprint:
+ raise Exception(
+ "Failed to find or create certificate with friendly name: {}".format(
+ friendly_name
+ )
+ )
+
+ powershell(
+ 'Export-Certificate -Cert Cert:\CurrentUser\My\{} -FilePath "{}"'.format(
+ thumbprint, crt_path
+ )
+ )
+ log(
+ logging.INFO,
+ "msix",
+ {"crt_path": crt_path},
+ "Exported public certificate: {crt_path}",
+ )
+
+ powershell(
+ (
+ 'Export-PfxCertificate -Cert Cert:\CurrentUser\My\{} -FilePath "{}"'
+ ' -Password (ConvertTo-SecureString -String "{}" -Force -AsPlainText)'
+ ).format(thumbprint, pfx_path, password)
+ )
+ log(
+ logging.INFO,
+ "msix",
+ {"pfx_path": pfx_path},
+ "Exported private certificate: {pfx_path}",
+ )
+
+ # Second, to find the right thumbprint to use. We do this here in case
+ # we're coming back to an existing certificate.
+
+ log(
+ logging.INFO,
+ "msix",
+ {"crt_path": crt_path},
+ "Signing with existing self signed certificate: {crt_path}",
+ )
+
+ thumbprints = [
+ thumbprint.strip()
+ for thumbprint in powershell(
+ 'Get-PfxCertificate -FilePath "{}" | Select-Object -ExpandProperty Thumbprint'.format(
+ crt_path
+ )
+ ).splitlines()
+ ]
+ if len(thumbprints) > 1:
+ raise Exception("Multiple thumbprints found for PFX: {}".format(pfx_path))
+ if len(thumbprints) == 0:
+ raise Exception("No thumbprints found for PFX: {}".format(pfx_path))
+ thumbprint = thumbprints[0]
+ log(
+ logging.INFO,
+ "msix",
+ {"thumbprint": thumbprint},
+ "Signing with certificate with thumbprint: {thumbprint}",
+ )
+
+ # Third, do the actual signing.
+
+ args = [
+ signtool,
+ "sign",
+ "/a",
+ "/fd",
+ "SHA256",
+ "/f",
+ pfx_path,
+ "/p",
+ password,
+ output,
+ ]
+ if not verbose:
+ subprocess.check_call(args, universal_newlines=True)
+ else:
+ # Suppress output unless we fail.
+ try:
+ subprocess.check_output(args, universal_newlines=True)
+ except subprocess.CalledProcessError as e:
+ sys.stderr.write(e.output)
+ raise
+
+ # As a convenience to the user, tell how to use this certificate if it's not
+ # already trusted, and how to work with MSIX files more generally.
+ if verbose:
+ root_thumbprints = [
+ root_thumbprint.strip()
+ for root_thumbprint in powershell(
+ "Get-ChildItem -Path Cert:\LocalMachine\Root\{} "
+ "| Select-Object -ExpandProperty Thumbprint".format(thumbprint),
+ check=False,
+ ).splitlines()
+ ]
+ if thumbprint not in root_thumbprints:
+ log(
+ logging.INFO,
+ "msix",
+ {"thumbprint": thumbprint},
+ "Certificate with thumbprint not found in trusted roots: {thumbprint}",
+ )
+ log(
+ logging.INFO,
+ "msix",
+ {"crt_path": crt_path, "output": output},
+ r"""\
+# Usage
+To trust this certificate (requires an elevated shell):
+powershell -c 'Import-Certificate -FilePath "{crt_path}" -Cert Cert:\LocalMachine\Root\'
+To verify this MSIX signature exists and is trusted:
+powershell -c 'Get-AuthenticodeSignature -FilePath "{output}" | Format-List *'
+To install this MSIX:
+powershell -c 'Add-AppPackage -path "{output}"'
+To see details after installing:
+powershell -c 'Get-AppPackage -name Mozilla.MozillaFirefox(Beta,...)'
+ """.strip(),
+ )
+
+ return 0
+
+
+def _sign_msix_posix(output, force, log, verbose):
+ makeappx = find_sdk_tool("makeappx", log=log)
+
+ if not makeappx:
+ raise ValueError("makeappx is required; " "set MAKEAPPX or PATH")
+
+ openssl = find_sdk_tool("openssl", log=log)
+
+ if not openssl:
+ raise ValueError("openssl is required; " "set OPENSSL or PATH")
+
+ if "sign" not in subprocess.run(makeappx, capture_output=True).stdout.decode(
+ "utf-8"
+ ):
+ raise ValueError(
+ "makeappx must support 'sign' operation. ",
+ "You probably need to build Mozilla's version of it: ",
+ "https://github.com/mozilla/msix-packaging/tree/johnmcpms/signing",
+ )
+
+ def run_openssl(args, check=True, capture_output=True):
+ full_args = [openssl, *args]
+ joined = " ".join(shlex_quote(arg) for arg in full_args)
+ log(
+ logging.INFO,
+ "msix",
+ {"args": args},
+ f"Invoking: {joined}",
+ )
+ return subprocess.run(
+ full_args,
+ check=check,
+ capture_output=capture_output,
+ universal_newlines=True,
+ )
+
+ # These are baked into enough places under `browser/` that we need not
+ # extract constants.
+ cn = "Mozilla Corporation"
+ ou = "MSIX Packaging"
+ friendly_name = "Mozilla Corporation MSIX Packaging Test Certificate"
+ # Password is needed when generating the cert, but
+ # "makeappx" explicitly does _not_ support passing it
+ # so it ends up getting removed when we create the pfx
+ password = "temp"
+
+ cache_dir = mozpath.join(get_state_dir(), "cache", "mach-msix")
+ ca_crt_path = mozpath.join(cache_dir, "MozillaMSIXCA.cer")
+ ca_key_path = mozpath.join(cache_dir, "MozillaMSIXCA.key")
+ csr_path = mozpath.join(cache_dir, "MozillaMSIX.csr")
+ crt_path = mozpath.join(cache_dir, "MozillaMSIX.cer")
+ key_path = mozpath.join(cache_dir, "MozillaMSIX.key")
+ pfx_path = mozpath.join(
+ cache_dir,
+ "{}.pfx".format(friendly_name).replace(" ", "_").lower(),
+ )
+ pfx_path = mozpath.abspath(pfx_path)
+ ensureParentDir(pfx_path)
+
+ if force or not os.path.isfile(pfx_path):
+ log(
+ logging.INFO,
+ "msix",
+ {"pfx_path": pfx_path},
+ "Creating new self signed certificate at: {}".format(pfx_path),
+ )
+
+ # Ultimately, we only end up using the CA certificate
+ # and the pfx (aka pkcs12) bundle containing the signing key
+ # and certificate. The other things we create along the way
+ # are not used for subsequent signing for testing.
+ # To get those, we have to do a few things:
+ # 1) Create a new CA key and certificate
+ # 2) Create a new signing key
+ # 3) Create a CSR with that signing key
+ # 4) Create the certificate with the CA key+cert from the CSR
+ # 5) Convert the signing key and certificate to a pfx bundle
+ args = [
+ "req",
+ "-x509",
+ "-days",
+ "7200",
+ "-sha256",
+ "-newkey",
+ "rsa:4096",
+ "-keyout",
+ ca_key_path,
+ "-out",
+ ca_crt_path,
+ "-outform",
+ "PEM",
+ "-subj",
+ f"/OU={ou} CA/CN={cn} CA",
+ "-passout",
+ f"pass:{password}",
+ ]
+ run_openssl(args)
+ args = [
+ "genrsa",
+ "-des3",
+ "-out",
+ key_path,
+ "-passout",
+ f"pass:{password}",
+ ]
+ run_openssl(args)
+ args = [
+ "req",
+ "-new",
+ "-key",
+ key_path,
+ "-out",
+ csr_path,
+ "-subj",
+ # We actually want these in the opposite order, to match what's
+ # included in the AppxManifest. Openssl ends up reversing these
+ # for some reason, so we put them in backwards here.
+ f"/OU={ou}/CN={cn}",
+ "-passin",
+ f"pass:{password}",
+ ]
+ run_openssl(args)
+ args = [
+ "x509",
+ "-req",
+ "-sha256",
+ "-days",
+ "7200",
+ "-in",
+ csr_path,
+ "-CA",
+ ca_crt_path,
+ "-CAcreateserial",
+ "-CAkey",
+ ca_key_path,
+ "-out",
+ crt_path,
+ "-outform",
+ "PEM",
+ "-passin",
+ f"pass:{password}",
+ ]
+ run_openssl(args)
+ args = [
+ "pkcs12",
+ "-export",
+ "-inkey",
+ key_path,
+ "-in",
+ crt_path,
+ "-name",
+ friendly_name,
+ "-passin",
+ f"pass:{password}",
+ # All three of these options (-keypbe, -certpbe, and -passout)
+ # are necessary to create a pfx bundle that won't even prompt
+ # for a password. If we miss one, we will still get a password
+ # prompt for the blank password.
+ "-keypbe",
+ "NONE",
+ "-certpbe",
+ "NONE",
+ "-passout",
+ "pass:",
+ "-out",
+ pfx_path,
+ ]
+ run_openssl(args)
+
+ args = [makeappx, "sign", "-p", output, "-c", pfx_path]
+ if not verbose:
+ subprocess.check_call(
+ args,
+ universal_newlines=True,
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL,
+ )
+ else:
+ # Suppress output unless we fail.
+ try:
+ subprocess.check_output(args, universal_newlines=True)
+ except subprocess.CalledProcessError as e:
+ sys.stderr.write(e.output)
+ raise
+
+ if verbose:
+ log(
+ logging.INFO,
+ "msix",
+ {
+ "ca_crt_path": ca_crt_path,
+ "ca_crt": mozpath.basename(ca_crt_path),
+ "output_path": output,
+ "output": mozpath.basename(output),
+ },
+ r"""\
+# Usage
+First, transfer the root certificate ({ca_crt_path}) and signed MSIX
+({output_path}) to a Windows machine.
+To trust this certificate ({ca_crt_path}), run the following in an elevated shell:
+powershell -c 'Import-Certificate -FilePath "{ca_crt}" -Cert Cert:\LocalMachine\Root\'
+To verify this MSIX signature exists and is trusted:
+powershell -c 'Get-AuthenticodeSignature -FilePath "{output}" | Format-List *'
+To install this MSIX:
+powershell -c 'Add-AppPackage -path "{output}"'
+To see details after installing:
+powershell -c 'Get-AppPackage -name Mozilla.MozillaFirefox(Beta,...)'
+ """.strip(),
+ )
+
+
+def sign_msix(output, force=False, log=None, verbose=False):
+ """Sign an MSIX with a locally generated self-signed certificate."""
+
+ if sys.platform.startswith("win"):
+ return _sign_msix_win(output, force, log, verbose)
+ else:
+ return _sign_msix_posix(output, force, log, verbose)
diff --git a/python/mozbuild/mozbuild/repackaging/pkg.py b/python/mozbuild/mozbuild/repackaging/pkg.py
new file mode 100644
index 0000000000..e7699ce5c4
--- /dev/null
+++ b/python/mozbuild/mozbuild/repackaging/pkg.py
@@ -0,0 +1,46 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this file,
+# You can obtain one at http://mozilla.org/MPL/2.0/.
+
+import shutil
+import tarfile
+from pathlib import Path
+
+import mozfile
+from mozpack.pkg import create_pkg
+
+from mozbuild.bootstrap import bootstrap_toolchain
+
+
+def repackage_pkg(infile, output):
+
+ if not tarfile.is_tarfile(infile):
+ raise Exception("Input file %s is not a valid tarfile." % infile)
+
+ xar_tool = bootstrap_toolchain("xar/xar")
+ if not xar_tool:
+ raise Exception("Could not find xar tool.")
+ mkbom_tool = bootstrap_toolchain("mkbom/mkbom")
+ if not mkbom_tool:
+ raise Exception("Could not find mkbom tool.")
+ # Note: CPIO isn't standard on all OS's
+ cpio_tool = shutil.which("cpio")
+ if not cpio_tool:
+ raise Exception("Could not find cpio.")
+
+ with mozfile.TemporaryDirectory() as tmpdir:
+ mozfile.extract_tarball(infile, tmpdir)
+
+ app_list = list(Path(tmpdir).glob("*.app"))
+ if len(app_list) != 1:
+ raise Exception(
+ "Input file should contain a single .app file. %s found."
+ % len(app_list)
+ )
+ create_pkg(
+ source_app=Path(app_list[0]),
+ output_pkg=Path(output),
+ mkbom_tool=Path(mkbom_tool),
+ xar_tool=Path(xar_tool),
+ cpio_tool=Path(cpio_tool),
+ )
diff --git a/python/mozbuild/mozbuild/repackaging/test/python.ini b/python/mozbuild/mozbuild/repackaging/test/python.ini
new file mode 100644
index 0000000000..f51fad30a3
--- /dev/null
+++ b/python/mozbuild/mozbuild/repackaging/test/python.ini
@@ -0,0 +1,4 @@
+[DEFAULT]
+subsuite = mozbuild
+
+[test_msix.py]
diff --git a/python/mozbuild/mozbuild/repackaging/test/test_msix.py b/python/mozbuild/mozbuild/repackaging/test/test_msix.py
new file mode 100644
index 0000000000..f6735dcc75
--- /dev/null
+++ b/python/mozbuild/mozbuild/repackaging/test/test_msix.py
@@ -0,0 +1,53 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+import unittest
+
+from mozunit import main
+
+from mozbuild.repackaging.msix import get_embedded_version
+
+
+class TestMSIX(unittest.TestCase):
+ def test_embedded_version(self):
+ """Test embedded version extraction."""
+
+ buildid = "YYYY0M0D0HMmSs"
+ for input, output in [
+ ("X.0a1", "X.YY0M.D0H.0"),
+ ("X.YbZ", "X.Y.Z.0"),
+ ("X.Yesr", "X.Y.0.0"),
+ ("X.Y.Zesr", "X.Y.Z.0"),
+ ("X.YrcZ", "X.Y.Z.0"),
+ ("X.Y", "X.Y.0.0"),
+ ("X.Y.Z", "X.Y.Z.0"),
+ ]:
+ version = get_embedded_version(input, buildid)
+ self.assertEqual(version, output)
+ # Some parts of the MSIX packaging ecosystem require the final digit
+ # in the dotted quad to be 0.
+ self.assertTrue(version.endswith(".0"))
+
+ buildid = "YYYYMmDdHhMmSs"
+ for input, output in [
+ ("X.0a1", "X.YYMm.DdHh.0"),
+ ]:
+ version = get_embedded_version(input, buildid)
+ self.assertEqual(version, output)
+ # Some parts of the MSIX packaging ecosystem require the final digit
+ # in the dotted quad to be 0.
+ self.assertTrue(version.endswith(".0"))
+
+ for input in [
+ "X.Ya1",
+ "X.0a2",
+ "X.Y.ZbW",
+ "X.Y.ZrcW",
+ ]:
+ with self.assertRaises(ValueError):
+ get_embedded_version(input, buildid)
+
+
+if __name__ == "__main__":
+ main()