import concurrent.futures
import lzma
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
from mozbuild.util import cpu_count
TEMPLATE_DIRECTORY = Path(__file__).parent / "apple_pkg"
PBZX_CHUNK_SIZE = 16 * 1024 * 1024
def get_apple_template(name: str) -> Template:
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):
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:
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):
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", "--format",
"odc", "--owner",
"0:80", ],
stdout=tmp_payload,
stderr=subprocess.PIPE,
input="\n".join(file_list) + "\n",
encoding="ascii",
cwd=root_path,
)
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=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:
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):
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]:
return [f"./{c.relative_to(source)}" for c in source.glob(glob)]
def xar_package_folder(source_path: Path, destination: 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}")
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,
):
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"
(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)
subprocess.check_call(
[
"cp",
"-R",
str(source_app),
str(root_path),
]
)
file_count = len(list(source_app.glob("**/*"))) + 1
print(f"Calculated source files count: {file_count}")
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)
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)