pkg.py (10011B)
1 # This Source Code Form is subject to the terms of the Mozilla Public 2 # License, v. 2.0. If a copy of the MPL was not distributed with this 3 # file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 5 import concurrent.futures 6 import lzma 7 import plistlib 8 import struct 9 import subprocess 10 from pathlib import Path 11 from string import Template 12 from urllib.parse import quote 13 14 import mozfile 15 16 from mozbuild.util import cpu_count 17 18 TEMPLATE_DIRECTORY = Path(__file__).parent / "apple_pkg" 19 PBZX_CHUNK_SIZE = 16 * 1024 * 1024 # 16MB chunks 20 21 22 def get_apple_template(name: str) -> Template: 23 """ 24 Given <name>, open file at <TEMPLATE_DIRECTORY>/<name>, read contents and 25 return as a Template 26 27 Args: 28 name: str, Filename for the template 29 30 Returns: 31 Template, loaded from file 32 """ 33 tmpl_path = TEMPLATE_DIRECTORY / name 34 if not tmpl_path.is_file(): 35 raise Exception(f"Could not find template: {tmpl_path}") 36 with tmpl_path.open("r") as tmpl: 37 contents = tmpl.read() 38 return Template(contents) 39 40 41 def save_text_file(content: str, destination: Path): 42 """ 43 Saves a text file to <destination> with provided <content> 44 Note: Overwrites contents 45 46 Args: 47 content: str, The desired contents of the file 48 destination: Path, The file path 49 """ 50 with destination.open("w") as out_fd: 51 out_fd.write(content) 52 print(f"Created text file at {destination}") 53 print(f"Created text file size: {destination.stat().st_size} bytes") 54 55 56 def get_app_info_plist(app_path: Path) -> dict: 57 """ 58 Retrieve most information from Info.plist file of an app. 59 The Info.plist file should be located in ?.app/Contents/Info.plist 60 61 Note: Ignores properties that are not <string> type 62 63 Args: 64 app_path: Path, the .app file/directory path 65 66 Returns: 67 dict, the dictionary of properties found in Info.plist 68 """ 69 info_plist = app_path / "Contents/Info.plist" 70 if not info_plist.is_file(): 71 raise Exception(f"Could not find Info.plist in {info_plist}") 72 73 print(f"Reading app Info.plist from: {info_plist}") 74 75 with info_plist.open("rb") as plist_fd: 76 data = plistlib.load(plist_fd) 77 78 return data 79 80 81 def create_payload(destination: Path, root_path: Path, cpio_tool: str): 82 """ 83 Creates a payload at <destination> based on <root_path> 84 85 Args: 86 destination: Path, the destination Path 87 root_path: Path, the root directory Path 88 cpio_tool: str, 89 """ 90 # Files to be cpio'd are root folder + contents 91 file_list = ["./"] + get_relative_glob_list(root_path, "**/*") 92 93 with mozfile.TemporaryDirectory() as tmp_dir: 94 tmp_payload_path = Path(tmp_dir) / "Payload" 95 print(f"Creating Payload with cpio from {root_path} to {tmp_payload_path}") 96 print(f"Found {len(file_list)} files") 97 with tmp_payload_path.open("wb") as tmp_payload: 98 process = subprocess.run( 99 [ 100 cpio_tool, 101 "-o", # copy-out mode 102 "--format", 103 "odc", # old POSIX .1 portable format 104 "--owner", 105 "0:80", # clean ownership 106 ], 107 check=False, 108 stdout=tmp_payload, 109 stderr=subprocess.PIPE, 110 input="\n".join(file_list) + "\n", 111 encoding="ascii", 112 cwd=root_path, 113 ) 114 # cpio outputs number of blocks to stderr 115 print(f"[CPIO]: {process.stderr}") 116 if process.returncode: 117 raise Exception(f"CPIO error {process.returncode}") 118 119 tmp_payload_size = tmp_payload_path.stat().st_size 120 print(f"Uncompressed Payload size: {tmp_payload_size // 1024}kb") 121 122 def compress_chunk(chunk): 123 compressed_chunk = lzma.compress(chunk) 124 return len(chunk), compressed_chunk 125 126 def chunker(fileobj, chunk_size): 127 while True: 128 chunk = fileobj.read(chunk_size) 129 if not chunk: 130 break 131 yield chunk 132 133 with tmp_payload_path.open("rb") as f_in, destination.open( 134 "wb" 135 ) as f_out, concurrent.futures.ThreadPoolExecutor( 136 max_workers=cpu_count() 137 ) as executor: 138 f_out.write(b"pbzx") 139 f_out.write(struct.pack(">Q", PBZX_CHUNK_SIZE)) 140 chunks = chunker(f_in, PBZX_CHUNK_SIZE) 141 for uncompressed_size, compressed_chunk in executor.map( 142 compress_chunk, chunks 143 ): 144 f_out.write(struct.pack(">Q", uncompressed_size)) 145 if len(compressed_chunk) < uncompressed_size: 146 f_out.write(struct.pack(">Q", len(compressed_chunk))) 147 f_out.write(compressed_chunk) 148 else: 149 # Considering how unlikely this is, we prefer to just decompress 150 # here than to keep the original uncompressed chunk around 151 f_out.write(struct.pack(">Q", uncompressed_size)) 152 f_out.write(lzma.decompress(compressed_chunk)) 153 154 print(f"Compressed Payload file to {destination}") 155 print(f"Compressed Payload size: {destination.stat().st_size // 1024}kb") 156 157 158 def create_bom(bom_path: Path, root_path: Path, mkbom_tool: Path): 159 """ 160 Creates a Bill Of Materials file at <bom_path> based on <root_path> 161 162 Args: 163 bom_path: Path, destination Path for the BOM file 164 root_path: Path, root directory Path 165 mkbom_tool: Path, mkbom tool Path 166 """ 167 print(f"Creating BOM file from {root_path} to {bom_path}") 168 subprocess.check_call([ 169 mkbom_tool, 170 "-u", 171 "0", 172 "-g", 173 "80", 174 str(root_path), 175 str(bom_path), 176 ]) 177 print(f"Created BOM File size: {bom_path.stat().st_size // 1024}kb") 178 179 180 def get_relative_glob_list(source: Path, glob: str) -> list[str]: 181 """ 182 Given a source path, return a list of relative path based on glob 183 184 Args: 185 source: Path, source directory Path 186 glob: str, unix style glob 187 188 Returns: 189 list[str], paths found in source directory 190 """ 191 return [f"./{c.relative_to(source)}" for c in source.glob(glob)] 192 193 194 def xar_package_folder(source_path: Path, destination: Path, xar_tool: Path): 195 """ 196 Create a pkg from <source_path> to <destination> 197 The command is issued with <source_path> as cwd 198 199 Args: 200 source_path: Path, source absolute Path 201 destination: Path, destination absolute Path 202 xar_tool: Path, xar tool Path 203 """ 204 if not source_path.is_absolute() or not destination.is_absolute(): 205 raise Exception("Source and destination should be absolute.") 206 207 print(f"Creating pkg from {source_path} to {destination}") 208 # Create a list of ./<file> - noting xar takes care of <file>/** 209 file_list = get_relative_glob_list(source_path, "*") 210 211 subprocess.check_call( 212 [ 213 xar_tool, 214 "--compression", 215 "none", 216 "-vcf", 217 destination, 218 *file_list, 219 ], 220 cwd=source_path, 221 ) 222 print(f"Created PKG file to {destination}") 223 print(f"Created PKG size: {destination.stat().st_size // 1024}kb") 224 225 226 def create_pkg( 227 source_app: Path, 228 output_pkg: Path, 229 mkbom_tool: Path, 230 xar_tool: Path, 231 cpio_tool: Path, 232 ): 233 """ 234 Create a mac PKG installer from <source_app> to <output_pkg> 235 236 Args: 237 source_app: Path, source .app file/directory Path 238 output_pkg: Path, destination .pkg file 239 mkbom_tool: Path, mkbom tool Path 240 xar_tool: Path, xar tool Path 241 cpio: Path, cpio tool Path 242 """ 243 244 app_name = source_app.name.rsplit(".", maxsplit=1)[0] 245 246 with mozfile.TemporaryDirectory() as tmpdir: 247 root_path = Path(tmpdir) / "darwin/root" 248 flat_path = Path(tmpdir) / "darwin/flat" 249 250 # Create required directories 251 # TODO: Investigate Resources folder contents for other lproj? 252 (flat_path / "Resources/en.lproj").mkdir(parents=True, exist_ok=True) 253 (flat_path / f"{app_name}.pkg").mkdir(parents=True, exist_ok=True) 254 root_path.mkdir(parents=True, exist_ok=True) 255 256 # Copy files over 257 subprocess.check_call([ 258 "cp", 259 "-R", 260 str(source_app), 261 str(root_path), 262 ]) 263 264 # Count all files (innards + itself) 265 file_count = len(list(source_app.glob("**/*"))) + 1 266 print(f"Calculated source files count: {file_count}") 267 # Get package contents size 268 package_size = sum(f.stat().st_size for f in source_app.glob("**/*")) // 1024 269 print(f"Calculated source package size: {package_size}kb") 270 271 app_info = get_app_info_plist(source_app) 272 app_info["numberOfFiles"] = file_count 273 app_info["installKBytes"] = package_size 274 app_info["app_name"] = app_name 275 app_info["app_name_url_encoded"] = quote(app_name) 276 277 # This seems arbitrary, there might be another way of doing it, 278 # but Info.plist doesn't provide the simple version we need 279 major_version = app_info["CFBundleShortVersionString"].split(".")[0] 280 app_info["simple_version"] = f"{major_version}.0.0" 281 282 pkg_info_tmpl = get_apple_template("PackageInfo.template") 283 pkg_info = pkg_info_tmpl.substitute(app_info) 284 save_text_file(pkg_info, flat_path / f"{app_name}.pkg/PackageInfo") 285 286 distribution_tmp = get_apple_template("Distribution.template") 287 distribution = distribution_tmp.substitute(app_info) 288 save_text_file(distribution, flat_path / "Distribution") 289 290 payload_path = flat_path / f"{app_name}.pkg/Payload" 291 create_payload(payload_path, root_path, cpio_tool) 292 293 bom_path = flat_path / f"{app_name}.pkg/Bom" 294 create_bom(bom_path, root_path, mkbom_tool) 295 296 xar_package_folder(flat_path, output_pkg, xar_tool)