summaryrefslogtreecommitdiffstats
path: root/python/mozbuild/mozpack/pkg.py
diff options
context:
space:
mode:
Diffstat (limited to 'python/mozbuild/mozpack/pkg.py')
-rw-r--r--python/mozbuild/mozpack/pkg.py299
1 files changed, 299 insertions, 0 deletions
diff --git a/python/mozbuild/mozpack/pkg.py b/python/mozbuild/mozpack/pkg.py
new file mode 100644
index 0000000000..75a63b9746
--- /dev/null
+++ b/python/mozbuild/mozpack/pkg.py
@@ -0,0 +1,299 @@
+# 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 concurrent.futures
+import lzma
+import os
+import plistlib
+import struct
+import subprocess
+from pathlib import Path
+from string import Template
+from typing import List
+from urllib.parse import quote
+
+import mozfile
+
+TEMPLATE_DIRECTORY = Path(__file__).parent / "apple_pkg"
+PBZX_CHUNK_SIZE = 16 * 1024 * 1024 # 16MB chunks
+
+
+def get_apple_template(name: str) -> Template:
+ """
+ Given <name>, open file at <TEMPLATE_DIRECTORY>/<name>, read contents and
+ return as a Template
+
+ Args:
+ name: str, Filename for the template
+
+ Returns:
+ Template, loaded from file
+ """
+ tmpl_path = TEMPLATE_DIRECTORY / name
+ if not tmpl_path.is_file():
+ raise Exception(f"Could not find template: {tmpl_path}")
+ with tmpl_path.open("r") as tmpl:
+ contents = tmpl.read()
+ return Template(contents)
+
+
+def save_text_file(content: str, destination: Path):
+ """
+ Saves a text file to <destination> with provided <content>
+ Note: Overwrites contents
+
+ Args:
+ content: str, The desired contents of the file
+ destination: Path, The file path
+ """
+ with destination.open("w") as out_fd:
+ out_fd.write(content)
+ print(f"Created text file at {destination}")
+ print(f"Created text file size: {destination.stat().st_size} bytes")
+
+
+def get_app_info_plist(app_path: Path) -> dict:
+ """
+ Retrieve most information from Info.plist file of an app.
+ The Info.plist file should be located in ?.app/Contents/Info.plist
+
+ Note: Ignores properties that are not <string> type
+
+ Args:
+ app_path: Path, the .app file/directory path
+
+ Returns:
+ dict, the dictionary of properties found in Info.plist
+ """
+ info_plist = app_path / "Contents/Info.plist"
+ if not info_plist.is_file():
+ raise Exception(f"Could not find Info.plist in {info_plist}")
+
+ print(f"Reading app Info.plist from: {info_plist}")
+
+ with info_plist.open("rb") as plist_fd:
+ data = plistlib.load(plist_fd)
+
+ return data
+
+
+def create_payload(destination: Path, root_path: Path, cpio_tool: str):
+ """
+ Creates a payload at <destination> based on <root_path>
+
+ Args:
+ destination: Path, the destination Path
+ root_path: Path, the root directory Path
+ cpio_tool: str,
+ """
+ # Files to be cpio'd are root folder + contents
+ file_list = ["./"] + get_relative_glob_list(root_path, "**/*")
+
+ with mozfile.TemporaryDirectory() as tmp_dir:
+ tmp_payload_path = Path(tmp_dir) / "Payload"
+ print(f"Creating Payload with cpio from {root_path} to {tmp_payload_path}")
+ print(f"Found {len(file_list)} files")
+ with tmp_payload_path.open("wb") as tmp_payload:
+ process = subprocess.run(
+ [
+ cpio_tool,
+ "-o", # copy-out mode
+ "--format",
+ "odc", # old POSIX .1 portable format
+ "--owner",
+ "0:80", # clean ownership
+ ],
+ stdout=tmp_payload,
+ stderr=subprocess.PIPE,
+ input="\n".join(file_list) + "\n",
+ encoding="ascii",
+ cwd=root_path,
+ )
+ # cpio outputs number of blocks to stderr
+ print(f"[CPIO]: {process.stderr}")
+ if process.returncode:
+ raise Exception(f"CPIO error {process.returncode}")
+
+ tmp_payload_size = tmp_payload_path.stat().st_size
+ print(f"Uncompressed Payload size: {tmp_payload_size // 1024}kb")
+
+ def compress_chunk(chunk):
+ compressed_chunk = lzma.compress(chunk)
+ return len(chunk), compressed_chunk
+
+ def chunker(fileobj, chunk_size):
+ while True:
+ chunk = fileobj.read(chunk_size)
+ if not chunk:
+ break
+ yield chunk
+
+ with tmp_payload_path.open("rb") as f_in, destination.open(
+ "wb"
+ ) as f_out, concurrent.futures.ThreadPoolExecutor(
+ max_workers=os.cpu_count()
+ ) as executor:
+ f_out.write(b"pbzx")
+ f_out.write(struct.pack(">Q", PBZX_CHUNK_SIZE))
+ chunks = chunker(f_in, PBZX_CHUNK_SIZE)
+ for uncompressed_size, compressed_chunk in executor.map(
+ compress_chunk, chunks
+ ):
+ f_out.write(struct.pack(">Q", uncompressed_size))
+ if len(compressed_chunk) < uncompressed_size:
+ f_out.write(struct.pack(">Q", len(compressed_chunk)))
+ f_out.write(compressed_chunk)
+ else:
+ # Considering how unlikely this is, we prefer to just decompress
+ # here than to keep the original uncompressed chunk around
+ f_out.write(struct.pack(">Q", uncompressed_size))
+ f_out.write(lzma.decompress(compressed_chunk))
+
+ print(f"Compressed Payload file to {destination}")
+ print(f"Compressed Payload size: {destination.stat().st_size // 1024}kb")
+
+
+def create_bom(bom_path: Path, root_path: Path, mkbom_tool: Path):
+ """
+ Creates a Bill Of Materials file at <bom_path> based on <root_path>
+
+ Args:
+ bom_path: Path, destination Path for the BOM file
+ root_path: Path, root directory Path
+ mkbom_tool: Path, mkbom tool Path
+ """
+ print(f"Creating BOM file from {root_path} to {bom_path}")
+ subprocess.check_call(
+ [
+ mkbom_tool,
+ "-u",
+ "0",
+ "-g",
+ "80",
+ str(root_path),
+ str(bom_path),
+ ]
+ )
+ print(f"Created BOM File size: {bom_path.stat().st_size // 1024}kb")
+
+
+def get_relative_glob_list(source: Path, glob: str) -> List[str]:
+ """
+ Given a source path, return a list of relative path based on glob
+
+ Args:
+ source: Path, source directory Path
+ glob: str, unix style glob
+
+ Returns:
+ list[str], paths found in source directory
+ """
+ return [f"./{c.relative_to(source)}" for c in source.glob(glob)]
+
+
+def xar_package_folder(source_path: Path, destination: Path, xar_tool: Path):
+ """
+ Create a pkg from <source_path> to <destination>
+ The command is issued with <source_path> as cwd
+
+ Args:
+ source_path: Path, source absolute Path
+ destination: Path, destination absolute Path
+ xar_tool: Path, xar tool Path
+ """
+ if not source_path.is_absolute() or not destination.is_absolute():
+ raise Exception("Source and destination should be absolute.")
+
+ print(f"Creating pkg from {source_path} to {destination}")
+ # Create a list of ./<file> - noting xar takes care of <file>/**
+ file_list = get_relative_glob_list(source_path, "*")
+
+ subprocess.check_call(
+ [
+ xar_tool,
+ "--compression",
+ "none",
+ "-vcf",
+ destination,
+ *file_list,
+ ],
+ cwd=source_path,
+ )
+ print(f"Created PKG file to {destination}")
+ print(f"Created PKG size: {destination.stat().st_size // 1024}kb")
+
+
+def create_pkg(
+ source_app: Path,
+ output_pkg: Path,
+ mkbom_tool: Path,
+ xar_tool: Path,
+ cpio_tool: Path,
+):
+ """
+ Create a mac PKG installer from <source_app> to <output_pkg>
+
+ Args:
+ source_app: Path, source .app file/directory Path
+ output_pkg: Path, destination .pkg file
+ mkbom_tool: Path, mkbom tool Path
+ xar_tool: Path, xar tool Path
+ cpio: Path, cpio tool Path
+ """
+
+ app_name = source_app.name.rsplit(".", maxsplit=1)[0]
+
+ with mozfile.TemporaryDirectory() as tmpdir:
+ root_path = Path(tmpdir) / "darwin/root"
+ flat_path = Path(tmpdir) / "darwin/flat"
+
+ # Create required directories
+ # TODO: Investigate Resources folder contents for other lproj?
+ (flat_path / "Resources/en.lproj").mkdir(parents=True, exist_ok=True)
+ (flat_path / f"{app_name}.pkg").mkdir(parents=True, exist_ok=True)
+ root_path.mkdir(parents=True, exist_ok=True)
+
+ # Copy files over
+ subprocess.check_call(
+ [
+ "cp",
+ "-R",
+ str(source_app),
+ str(root_path),
+ ]
+ )
+
+ # Count all files (innards + itself)
+ file_count = len(list(source_app.glob("**/*"))) + 1
+ print(f"Calculated source files count: {file_count}")
+ # Get package contents size
+ package_size = sum(f.stat().st_size for f in source_app.glob("**/*")) // 1024
+ print(f"Calculated source package size: {package_size}kb")
+
+ app_info = get_app_info_plist(source_app)
+ app_info["numberOfFiles"] = file_count
+ app_info["installKBytes"] = package_size
+ app_info["app_name"] = app_name
+ app_info["app_name_url_encoded"] = quote(app_name)
+
+ # This seems arbitrary, there might be another way of doing it,
+ # but Info.plist doesn't provide the simple version we need
+ major_version = app_info["CFBundleShortVersionString"].split(".")[0]
+ app_info["simple_version"] = f"{major_version}.0.0"
+
+ pkg_info_tmpl = get_apple_template("PackageInfo.template")
+ pkg_info = pkg_info_tmpl.substitute(app_info)
+ save_text_file(pkg_info, flat_path / f"{app_name}.pkg/PackageInfo")
+
+ distribution_tmp = get_apple_template("Distribution.template")
+ distribution = distribution_tmp.substitute(app_info)
+ save_text_file(distribution, flat_path / "Distribution")
+
+ payload_path = flat_path / f"{app_name}.pkg/Payload"
+ create_payload(payload_path, root_path, cpio_tool)
+
+ bom_path = flat_path / f"{app_name}.pkg/Bom"
+ create_bom(bom_path, root_path, mkbom_tool)
+
+ xar_package_folder(flat_path, output_pkg, xar_tool)