dmg.py (8788B)
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 os 6 import platform 7 import shutil 8 import subprocess 9 from pathlib import Path 10 11 import mozfile 12 13 from mozbuild.dirutils import ensureParentDir 14 15 is_linux = platform.system() == "Linux" 16 is_osx = platform.system() == "Darwin" 17 18 19 def chmod(dir): 20 "Set permissions of DMG contents correctly" 21 subprocess.check_call(["chmod", "-R", "a+rX,a-st,u+w,go-w", dir]) 22 23 24 def rsync(source: Path, dest: Path): 25 "rsync the contents of directory source into directory dest" 26 # Ensure a trailing slash on directories so rsync copies the *contents* of source. 27 raw_source = str(source) 28 if source.is_dir(): 29 raw_source = str(source) + "/" 30 subprocess.check_call(["rsync", "-a", "--copy-unsafe-links", raw_source, dest]) 31 32 33 def set_folder_icon(dir: Path, tmpdir: Path, hfs_tool: Path = None): 34 "Set HFS attributes of dir to use a custom icon" 35 if is_linux: 36 hfs = tmpdir / "staged.hfs" 37 subprocess.check_call([hfs_tool, hfs, "attr", "/", "C"]) 38 elif is_osx: 39 subprocess.check_call(["SetFile", "-a", "C", dir]) 40 41 42 def generate_hfs_file( 43 stagedir: Path, tmpdir: Path, volume_name: str, mkfshfs_tool: Path 44 ): 45 """ 46 When cross compiling, we zero fill an hfs file, that we will turn into 47 a DMG. To do so we test the size of the staged dir, and add some slight 48 padding to that. 49 """ 50 hfs = tmpdir / "staged.hfs" 51 output = subprocess.check_output(["du", "-s", stagedir]) 52 size = int(output.split()[0]) / 1000 # Get in MB 53 size = int(size * 1.02) # Bump the used size slightly larger. 54 # Setup a proper file sized out with zero's 55 subprocess.check_call([ 56 "dd", 57 "if=/dev/zero", 58 f"of={hfs}", 59 "bs=1M", 60 f"count={size}", 61 ]) 62 subprocess.check_call([mkfshfs_tool, "-v", volume_name, hfs]) 63 64 65 def create_app_symlink(stagedir: Path, tmpdir: Path, hfs_tool: Path = None): 66 """ 67 Make a symlink to /Applications. The symlink name is a space 68 so we don't have to localize it. The Applications folder icon 69 will be shown in Finder, which should be clear enough for users. 70 """ 71 if is_linux: 72 hfs = os.path.join(tmpdir, "staged.hfs") 73 subprocess.check_call([hfs_tool, hfs, "symlink", "/ ", "/Applications"]) 74 elif is_osx: 75 os.symlink("/Applications", stagedir / " ") 76 77 78 def create_dmg_from_staged( 79 stagedir: Path, 80 output_dmg: Path, 81 tmpdir: Path, 82 volume_name: str, 83 hfs_tool: Path = None, 84 dmg_tool: Path = None, 85 mkfshfs_tool: Path = None, 86 attribution_sentinel: str = None, 87 compression: str = None, 88 ): 89 "Given a prepared directory stagedir, produce a DMG at output_dmg." 90 if compression is None: 91 # Easier to put the default here once, than in every place that takes default args 92 compression = "bzip2" 93 if compression not in ["bzip2", "lzma"]: 94 raise Exception("Don't know how to handle %s compression" % (compression,)) 95 96 if is_linux: 97 # The dmg tool doesn't create the destination directories, and silently 98 # returns success if the parent directory doesn't exist. 99 ensureParentDir(output_dmg) 100 hfs = os.path.join(tmpdir, "staged.hfs") 101 subprocess.check_call([hfs_tool, hfs, "addall", stagedir]) 102 103 dmg_cmd = [dmg_tool, "build", hfs, output_dmg] 104 if attribution_sentinel: 105 while len(attribution_sentinel) < 1024: 106 attribution_sentinel += "\t" 107 subprocess.check_call([ 108 hfs_tool, 109 hfs, 110 "setattr", 111 f"{volume_name}.app", 112 "com.apple.application-instance", 113 attribution_sentinel, 114 ]) 115 subprocess.check_call(["cp", hfs, str(Path(output_dmg).parent)]) 116 dmg_cmd.append(attribution_sentinel) 117 118 if compression == "lzma": 119 dmg_cmd.extend([ 120 "--compression", 121 "lzma", 122 "--level", 123 "5", 124 "--run-sectors", 125 "2048", 126 ]) 127 128 subprocess.check_call( 129 dmg_cmd, 130 # dmg is seriously chatty 131 stdout=subprocess.DEVNULL, 132 ) 133 elif is_osx: 134 format = "UDBZ" 135 if compression == "lzma": 136 format = "ULMO" 137 138 hybrid = tmpdir / "hybrid.dmg" 139 subprocess.check_call([ 140 "hdiutil", 141 "makehybrid", 142 "-hfs", 143 "-hfs-volume-name", 144 volume_name, 145 "-hfs-openfolder", 146 stagedir, 147 "-ov", 148 stagedir, 149 "-o", 150 hybrid, 151 ]) 152 subprocess.check_call([ 153 "hdiutil", 154 "convert", 155 "-format", 156 format, 157 "-imagekey", 158 "bzip2-level=9", 159 "-ov", 160 hybrid, 161 "-o", 162 output_dmg, 163 ]) 164 165 166 def create_dmg( 167 source_directory: Path, 168 output_dmg: Path, 169 volume_name: str, 170 extra_files: list[tuple], 171 dmg_tool: Path, 172 hfs_tool: Path, 173 mkfshfs_tool: Path, 174 attribution_sentinel: str = None, 175 compression: str = None, 176 ): 177 """ 178 Create a DMG disk image at the path output_dmg from source_directory. 179 180 Use volume_name as the disk image volume name, and 181 use extra_files as a list of tuples of (filename, relative path) to copy 182 into the disk image. 183 """ 184 if platform.system() not in ("Darwin", "Linux"): 185 raise Exception("Don't know how to build a DMG on '%s'" % platform.system()) 186 187 with mozfile.TemporaryDirectory() as tmp: 188 tmpdir = Path(tmp) 189 stagedir = tmpdir / "stage" 190 stagedir.mkdir() 191 192 # Copy the app bundle over using rsync 193 rsync(source_directory, stagedir) 194 # Copy extra files 195 for source, target in extra_files: 196 full_target = stagedir / target 197 full_target.parent.mkdir(parents=True, exist_ok=True) 198 shutil.copyfile(source, full_target) 199 if is_linux: 200 # Not needed in osx 201 generate_hfs_file(stagedir, tmpdir, volume_name, mkfshfs_tool) 202 create_app_symlink(stagedir, tmpdir, hfs_tool) 203 # Set the folder attributes to use a custom icon 204 set_folder_icon(stagedir, tmpdir, hfs_tool) 205 chmod(stagedir) 206 create_dmg_from_staged( 207 stagedir, 208 output_dmg, 209 tmpdir, 210 volume_name, 211 hfs_tool, 212 dmg_tool, 213 mkfshfs_tool, 214 attribution_sentinel, 215 compression, 216 ) 217 218 219 def extract_dmg_contents( 220 dmgfile: Path, 221 destdir: Path, 222 dmg_tool: Path = None, 223 hfs_tool: Path = None, 224 ): 225 if is_linux: 226 with mozfile.TemporaryDirectory() as tmpdir: 227 hfs_file = os.path.join(tmpdir, "firefox.hfs") 228 subprocess.check_call( 229 [dmg_tool, "extract", dmgfile, hfs_file], 230 # dmg is seriously chatty 231 stdout=subprocess.DEVNULL, 232 ) 233 subprocess.check_call([hfs_tool, hfs_file, "extractall", "/", destdir]) 234 else: 235 # TODO: find better way to resolve topsrcdir (checkout directory) 236 topsrcdir = Path(__file__).parent.parent.parent.parent.resolve() 237 unpack_diskimage = topsrcdir / "build/package/mac_osx/unpack-diskimage" 238 unpack_mountpoint = Path("/tmp/app-unpack") 239 subprocess.check_call([unpack_diskimage, dmgfile, unpack_mountpoint, destdir]) 240 241 242 def extract_dmg( 243 dmgfile: Path, 244 output: Path, 245 dmg_tool: Path = None, 246 hfs_tool: Path = None, 247 dsstore: Path = None, 248 icon: Path = None, 249 background: Path = None, 250 ): 251 if platform.system() not in ("Darwin", "Linux"): 252 raise Exception("Don't know how to extract a DMG on '%s'" % platform.system()) 253 254 with mozfile.TemporaryDirectory() as tmp: 255 tmpdir = Path(tmp) 256 extract_dmg_contents(dmgfile, tmpdir, dmg_tool, hfs_tool) 257 applications_symlink = tmpdir / " " 258 if applications_symlink.is_symlink(): 259 # Rsync will fail on the presence of this symlink 260 applications_symlink.unlink() 261 rsync(tmpdir, output) 262 263 if dsstore: 264 dsstore.parent.mkdir(parents=True, exist_ok=True) 265 rsync(tmpdir / ".DS_Store", dsstore) 266 if background: 267 background.parent.mkdir(parents=True, exist_ok=True) 268 rsync(tmpdir / ".background" / background.name, background) 269 if icon: 270 icon.parent.mkdir(parents=True, exist_ok=True) 271 rsync(tmpdir / ".VolumeIcon.icns", icon)