1# -*- coding: utf-8 -*- 2from __future__ import unicode_literals 3 4import os 5import pkg_resources 6import platform 7import re 8import shutil 9import stat 10import subprocess 11import sys 12import tempfile 13import tokenize 14import json 15import time 16 17try: 18 {}.iteritems 19 iteritems = lambda x: x.iteritems() 20 iterkeys = lambda x: x.iterkeys() 21except AttributeError: 22 iteritems = lambda x: x.items() 23 iterkeys = lambda x: x.keys() 24try: 25 unicode 26except NameError: 27 unicode = str 28 29if sys.version_info < (3, 4): 30 import biplist 31 def plist_from_bytes(data): 32 return biplist.readPlistFromString(data) 33 def plist_bytes(data): 34 return biplist.Data(data) 35else: 36 import plistlib 37 def plist_from_bytes(data): 38 return plistlib.loads(data) 39 def plist_bytes(data): 40 return data 41 42from mac_alias import * 43from ds_store import * 44 45from . import colors 46from . import licensing 47 48try: 49 from . import badge 50except ImportError: 51 badge = None 52 53_hexcolor_re = re.compile(r'#[0-9a-f]{3}(?:[0-9a-f]{3})?') 54 55# The first element in the platform.mac_ver() tuple is a string containing the 56# macOS version (e.g., '10.15.6'). Parse into an integer tuple. 57MACOS_VERSION = tuple(int(v) for v in platform.mac_ver()[0].split('.')) 58 59class DMGError(Exception): 60 pass 61 62def hdiutil(cmd, *args, **kwargs): 63 plist = kwargs.get('plist', True) 64 all_args = ['/usr/bin/hdiutil', cmd] 65 all_args.extend(args) 66 if plist: 67 all_args.append('-plist') 68 p = subprocess.Popen(all_args, stdout=subprocess.PIPE, close_fds=True) 69 output, errors = p.communicate() 70 if plist: 71 results = plist_from_bytes(output) 72 else: 73 results = output 74 retcode = p.wait() 75 return retcode, results 76 77# On Python 2 we can just execfile() it, but Python 3 deprecated that 78def load_settings(filename, settings): 79 if sys.version_info[0] == 2: 80 execfile(filename, settings, settings) 81 else: 82 encoding = 'utf-8' 83 with open(filename, 'rb') as fp: 84 try: 85 encoding = tokenize.detect_encoding(fp.readline)[0] 86 except SyntaxError: 87 pass 88 89 with open(filename, 'r', encoding=encoding) as fp: 90 exec(compile(fp.read(), filename, 'exec'), settings, settings) 91 92def load_json(filename, settings): 93 """Read an appdmg .json spec. Uses the defaults for appdmg, rather than 94 the usual defaults for dmgbuild. """ 95 96 with open(filename, 'r') as fp: 97 json_data = json.load(fp) 98 99 if 'title' not in json_data: 100 raise ValueError('missing \'title\' in JSON settings file') 101 if 'contents' not in json_data: 102 raise ValueError('missing \'contents\' in JSON settings file') 103 104 settings['volume_name'] = json_data['title'] 105 settings['icon'] = json_data.get('icon', None) 106 settings['badge_icon'] = json_data.get('badge-icon', None) 107 bk = json_data.get('background', None) 108 if bk is None: 109 bk = json_data.get('background-color', None) 110 if bk is not None: 111 settings['background'] = bk 112 settings['icon_size'] = json_data.get('icon-size', 80) 113 wnd = json_data.get('window', { 'position': (100, 100), 114 'size': (640, 480) }) 115 pos = wnd.get('position', { 'x': 100, 'y': 100 }) 116 siz = wnd.get('size', { 'width': 640, 'height': 480 }) 117 settings['window_rect'] = ((pos.get('x', 100), pos.get('y', 100)), 118 (siz.get('width', 640), siz.get('height', 480))) 119 settings['format'] = json_data.get('format', 'UDZO') 120 settings['compression_level'] = json_data.get('compression-level', None) 121 settings['license'] = json_data.get('license', None) 122 files = [] 123 hide = [] 124 hide_extensions = [] 125 symlinks = {} 126 icon_locations = {} 127 for fileinfo in json_data.get('contents', []): 128 if 'path' not in fileinfo: 129 raise ValueError('missing \'path\' in contents in JSON settings file') 130 if 'x' not in fileinfo: 131 raise ValueError('missing \'x\' in contents in JSON settings file') 132 if 'y' not in fileinfo: 133 raise ValueError('missing \'y\' in contents in JSON settings file') 134 135 kind = fileinfo.get('type', 'file') 136 path = fileinfo['path'] 137 name = fileinfo.get('name', os.path.basename(path.rstrip('/'))) 138 if kind == 'file': 139 files.append((path, name)) 140 elif kind == 'link': 141 symlinks[name] = path 142 elif kind == 'position': 143 pass 144 icon_locations[name] = (fileinfo['x'], fileinfo['y']) 145 hide_ext = fileinfo.get('hide_extension', False) 146 if hide_ext: 147 hide_extensions.append(name) 148 hidden = fileinfo.get('hidden', False) 149 if hidden: 150 hide.append(name) 151 152 settings['files'] = files 153 settings['hide_extensions'] = hide_extensions 154 settings['hide'] = hide 155 settings['symlinks'] = symlinks 156 settings['icon_locations'] = icon_locations 157 158def build_dmg(filename, volume_name, settings_file=None, settings={}, 159 defines={}, lookForHiDPI=True, detach_retries=5): 160 options = { 161 # Default settings 162 'filename': filename, 163 'volume_name': volume_name, 164 'format': 'UDBZ', 165 'compression_level': None, 166 'size': None, 167 'files': [], 168 'symlinks': {}, 169 'hide': [], 170 'hide_extensions': [], 171 'icon': None, 172 'badge_icon': None, 173 'background': None, 174 'show_status_bar': False, 175 'show_tab_view': False, 176 'show_toolbar': False, 177 'show_pathbar': False, 178 'show_sidebar': False, 179 'sidebar_width': 180, 180 'arrange_by': None, 181 'grid_offset': (0, 0), 182 'grid_spacing': 100.0, 183 'scroll_position': (0.0, 0.0), 184 'show_icon_preview': False, 185 'show_item_info': False, 186 'label_pos': 'bottom', 187 'text_size': 16.0, 188 'icon_size': 128.0, 189 'include_icon_view_settings': 'auto', 190 'include_list_view_settings': 'auto', 191 'list_icon_size': 16.0, 192 'list_text_size': 12.0, 193 'list_scroll_position': (0, 0), 194 'list_sort_by': 'name', 195 'list_use_relative_dates': True, 196 'list_calculate_all_sizes': False, 197 'list_columns': ('name', 'date-modified', 'size', 'kind', 'date-added'), 198 'list_column_widths': { 199 'name': 300, 200 'date-modified': 181, 201 'date-created': 181, 202 'date-added': 181, 203 'date-last-opened': 181, 204 'size': 97, 205 'kind': 115, 206 'label': 100, 207 'version': 75, 208 'comments': 300, 209 }, 210 'list_column_sort_directions': { 211 'name': 'ascending', 212 'date-modified': 'descending', 213 'date-created': 'descending', 214 'date-added': 'descending', 215 'date-last-opened': 'descending', 216 'size': 'descending', 217 'kind': 'ascending', 218 'label': 'ascending', 219 'version': 'ascending', 220 'comments': 'ascending', 221 }, 222 'window_rect': ((100, 100), (640, 280)), 223 'default_view': 'icon-view', 224 'icon_locations': {}, 225 'license': None, 226 'defines': defines 227 } 228 229 # Execute the settings file 230 if settings_file: 231 # We now support JSON settings files using appdmg's format 232 if settings_file.endswith('.json'): 233 load_json(settings_file, options) 234 else: 235 load_settings(settings_file, options) 236 237 # Add any overrides 238 options.update(settings) 239 240 # Set up the finder data 241 bounds = options['window_rect'] 242 243 bounds_string = '{{%s, %s}, {%s, %s}}' % (bounds[0][0], 244 bounds[0][1], 245 bounds[1][0], 246 bounds[1][1]) 247 bwsp = { 248 'ShowStatusBar': options['show_status_bar'], 249 'WindowBounds': bounds_string, 250 'ContainerShowSidebar': False, 251 'PreviewPaneVisibility': False, 252 'SidebarWidth': options['sidebar_width'], 253 'ShowTabView': options['show_tab_view'], 254 'ShowToolbar': options['show_toolbar'], 255 'ShowPathbar': options['show_pathbar'], 256 'ShowSidebar': options['show_sidebar'] 257 } 258 259 arrange_options = { 260 'name': 'name', 261 'date-modified': 'dateModified', 262 'date-created': 'dateCreated', 263 'date-added': 'dateAdded', 264 'date-last-opened': 'dateLastOpened', 265 'size': 'size', 266 'kind': 'kind', 267 'label': 'label', 268 } 269 270 icvp = { 271 'viewOptionsVersion': 1, 272 'backgroundType': 0, 273 'backgroundColorRed': 1.0, 274 'backgroundColorGreen': 1.0, 275 'backgroundColorBlue': 1.0, 276 'gridOffsetX': float(options['grid_offset'][0]), 277 'gridOffsetY': float(options['grid_offset'][1]), 278 'gridSpacing': float(options['grid_spacing']), 279 'arrangeBy': str(arrange_options.get(options['arrange_by'], 'none')), 280 'showIconPreview': options['show_icon_preview'] == True, 281 'showItemInfo': options['show_item_info'] == True, 282 'labelOnBottom': options['label_pos'] == 'bottom', 283 'textSize': float(options['text_size']), 284 'iconSize': float(options['icon_size']), 285 'scrollPositionX': float(options['scroll_position'][0]), 286 'scrollPositionY': float(options['scroll_position'][1]) 287 } 288 289 background = options['background'] 290 291 columns = { 292 'name': 'name', 293 'date-modified': 'dateModified', 294 'date-created': 'dateCreated', 295 'date-added': 'dateAdded', 296 'date-last-opened': 'dateLastOpened', 297 'size': 'size', 298 'kind': 'kind', 299 'label': 'label', 300 'version': 'version', 301 'comments': 'comments' 302 } 303 304 default_widths = { 305 'name': 300, 306 'date-modified': 181, 307 'date-created': 181, 308 'date-added': 181, 309 'date-last-opened': 181, 310 'size': 97, 311 'kind': 115, 312 'label': 100, 313 'version': 75, 314 'comments': 300, 315 } 316 317 default_sort_directions = { 318 'name': 'ascending', 319 'date-modified': 'descending', 320 'date-created': 'descending', 321 'date-added': 'descending', 322 'date-last-opened': 'descending', 323 'size': 'descending', 324 'kind': 'ascending', 325 'label': 'ascending', 326 'version': 'ascending', 327 'comments': 'ascending', 328 } 329 330 lsvp = { 331 'viewOptionsVersion': 1, 332 'sortColumn': columns.get(options['list_sort_by'], 'name'), 333 'textSize': float(options['list_text_size']), 334 'iconSize': float(options['list_icon_size']), 335 'showIconPreview': options['show_icon_preview'], 336 'scrollPositionX': options['list_scroll_position'][0], 337 'scrollPositionY': options['list_scroll_position'][1], 338 'useRelativeDates': options['list_use_relative_dates'], 339 'calculateAllSizes': options['list_calculate_all_sizes'], 340 } 341 342 lsvp['columns'] = {} 343 cndx = {} 344 345 for n, column in enumerate(options['list_columns']): 346 cndx[column] = n 347 width = options['list_column_widths'].get(column, 348 default_widths[column]) 349 asc = 'ascending' == options['list_column_sort_directions'].get(column, 350 default_sort_directions[column]) 351 352 lsvp['columns'][columns[column]] = { 353 'index': n, 354 'width': width, 355 'identifier': columns[column], 356 'visible': True, 357 'ascending': asc 358 } 359 360 n = len(options['list_columns']) 361 for k in iterkeys(columns): 362 if cndx.get(k, None) is None: 363 cndx[k] = n 364 width = default_widths[k] 365 asc = 'ascending' == default_sort_directions[k] 366 367 lsvp['columns'][columns[column]] = { 368 'index': n, 369 'width': width, 370 'identifier': columns[column], 371 'visible': False, 372 'ascending': asc 373 } 374 375 n += 1 376 377 default_view = options['default_view'] 378 views = { 379 'icon-view': b'icnv', 380 'column-view': b'clmv', 381 'list-view': b'Nlsv', 382 'coverflow': b'Flwv' 383 } 384 385 icvl = (b'type', views.get(default_view, 'icnv')) 386 387 include_icon_view_settings = default_view == 'icon-view' \ 388 or options['include_icon_view_settings'] not in \ 389 ('auto', 'no', 0, False, None) 390 include_list_view_settings = default_view in ('list-view', 'coverflow') \ 391 or options['include_list_view_settings'] not in \ 392 ('auto', 'no', 0, False, None) 393 394 filename = options['filename'] 395 volume_name = options['volume_name'] 396 397 # Construct a writeable image to start with 398 dirname, basename = os.path.split(os.path.realpath(filename)) 399 if not basename.endswith('.dmg'): 400 basename += '.dmg' 401 writableFile = tempfile.NamedTemporaryFile(dir=dirname, prefix='.temp', 402 suffix=basename) 403 404 total_size = options['size'] 405 if total_size == None: 406 # Start with a size of 128MB - this way we don't need to calculate the 407 # size of the background image, volume icon, and .DS_Store file (and 408 # 128 MB should be well sufficient for even the most outlandish image 409 # sizes, like an uncompressed 5K multi-resolution TIFF) 410 total_size = 128 * 1024 * 1024 411 412 def roundup(x, n): 413 return x if x % n == 0 else x + n - x % n 414 415 for path in options['files']: 416 if isinstance(path, tuple): 417 path = path[0] 418 419 if not os.path.islink(path) and os.path.isdir(path): 420 for dirpath, dirnames, filenames in os.walk(path): 421 for f in filenames: 422 fp = os.path.join(dirpath, f) 423 total_size += roundup(os.lstat(fp).st_size, 4096) 424 else: 425 total_size += roundup(os.lstat(path).st_size, 4096) 426 427 for name,target in iteritems(options['symlinks']): 428 total_size += 4096 429 430 total_size = str(max(total_size / 1000, 1024)) + 'K' 431 432 ret, output = hdiutil('create', 433 '-ov', 434 '-volname', volume_name, 435 '-fs', 'HFS+', 436 '-fsargs', '-c c=64,a=16,e=16', 437 '-size', total_size, 438 writableFile.name) 439 440 if ret: 441 raise DMGError('Unable to create disk image') 442 443 # IDME was deprecated in macOS 10.15/Catalina; as a result, use of -noidme 444 # started raising a warning. 445 if MACOS_VERSION >= (10, 15): 446 ret, output = hdiutil('attach', 447 '-nobrowse', 448 '-owners', 'off', 449 writableFile.name) 450 else: 451 ret, output = hdiutil('attach', 452 '-nobrowse', 453 '-owners', 'off', 454 '-noidme', 455 writableFile.name) 456 457 if ret: 458 raise DMGError('Unable to attach disk image') 459 460 try: 461 for info in output['system-entities']: 462 if info.get('mount-point', None): 463 device = info['dev-entry'] 464 mount_point = info['mount-point'] 465 466 icon = options['icon'] 467 if badge: 468 badge_icon = options['badge_icon'] 469 else: 470 badge_icon = None 471 icon_target_path = os.path.join(mount_point, '.VolumeIcon.icns') 472 if icon: 473 shutil.copyfile(icon, icon_target_path) 474 elif badge_icon: 475 badge.badge_disk_icon(badge_icon, icon_target_path) 476 477 if icon or badge_icon: 478 subprocess.call(['/usr/bin/SetFile', '-a', 'C', mount_point]) 479 480 background_bmk = None 481 482 if not isinstance(background, (str, unicode)): 483 pass 484 elif colors.isAColor(background): 485 c = colors.parseColor(background).to_rgb() 486 487 icvp['backgroundType'] = 1 488 icvp['backgroundColorRed'] = float(c.r) 489 icvp['backgroundColorGreen'] = float(c.g) 490 icvp['backgroundColorBlue'] = float(c.b) 491 else: 492 if os.path.isfile(background): 493 # look to see if there are HiDPI resources available 494 495 if lookForHiDPI is True: 496 name, extension = os.path.splitext(os.path.basename(background)) 497 orderedImages = [background] 498 imageDirectory = os.path.dirname(background) 499 if imageDirectory == '': 500 imageDirectory = '.' 501 for candidateName in os.listdir(imageDirectory): 502 hasScale = re.match( 503 r'^(?P<name>.+)@(?P<scale>\d+)x(?P<extension>\.\w+)$', 504 candidateName) 505 if hasScale and name == hasScale.group('name') and \ 506 extension == hasScale.group('extension'): 507 scale = int(hasScale.group('scale')) 508 if len(orderedImages) < scale: 509 orderedImages += [None] * (scale - len(orderedImages)) 510 orderedImages[scale - 1] = os.path.join(imageDirectory, candidateName) 511 512 if len(orderedImages) > 1: 513 # compile the grouped tiff 514 backgroundFile = tempfile.NamedTemporaryFile(suffix='.tiff') 515 background = backgroundFile.name 516 output = tempfile.TemporaryFile(mode='w+') 517 try: 518 subprocess.check_call( 519 ['/usr/bin/tiffutil', '-cathidpicheck'] + 520 list(filter(None, orderedImages)) + 521 ['-out', background], stdout=output, stderr=output) 522 except Exception as e: 523 output.seek(0) 524 raise ValueError( 525 'unable to compile combined HiDPI file "%s" got error: %s\noutput: %s' 526 % (background, str(e), output.read())) 527 528 _, kind = os.path.splitext(background) 529 path_in_image = os.path.join(mount_point, '.background' + kind) 530 shutil.copyfile(background, path_in_image) 531 elif pkg_resources.resource_exists('dmgbuild', 'resources/' + background + '.tiff'): 532 tiffdata = pkg_resources.resource_string( 533 'dmgbuild', 534 'resources/' + background + '.tiff') 535 path_in_image = os.path.join(mount_point, '.background.tiff') 536 537 with open(path_in_image, 'wb') as f: 538 f.write(tiffdata) 539 else: 540 raise ValueError('background file "%s" not found' % background) 541 542 alias = Alias.for_file(path_in_image) 543 background_bmk = Bookmark.for_file(path_in_image) 544 545 icvp['backgroundType'] = 2 546 icvp['backgroundImageAlias'] = plist_bytes(alias.to_bytes()) 547 548 for f in options['files']: 549 if isinstance(f, tuple): 550 f_in_image = os.path.join(mount_point, f[1]) 551 f = f[0] 552 else: 553 basename = os.path.basename(f.rstrip('/')) 554 f_in_image = os.path.join(mount_point, basename) 555 556 # use system ditto command to preserve code signing, etc. 557 subprocess.call(['/usr/bin/ditto', f, f_in_image]) 558 559 for name,target in iteritems(options['symlinks']): 560 name_in_image = os.path.join(mount_point, name) 561 os.symlink(target, name_in_image) 562 563 to_hide = [] 564 for name in options['hide_extensions']: 565 name_in_image = os.path.join(mount_point, name) 566 to_hide.append(name_in_image) 567 568 if to_hide: 569 subprocess.call(['/usr/bin/SetFile', '-a', 'E'] + to_hide) 570 571 to_hide = [] 572 for name in options['hide']: 573 name_in_image = os.path.join(mount_point, name) 574 to_hide.append(name_in_image) 575 576 if to_hide: 577 subprocess.call(['/usr/bin/SetFile', '-a', 'V'] + to_hide) 578 579 userfn = options.get('create_hook', None) 580 if callable(userfn): 581 userfn(mount_point, options) 582 583 image_dsstore = os.path.join(mount_point, '.DS_Store') 584 585 with DSStore.open(image_dsstore, 'w+') as d: 586 d['.']['vSrn'] = ('long', 1) 587 d['.']['bwsp'] = bwsp 588 if include_icon_view_settings: 589 d['.']['icvp'] = icvp 590 if background_bmk: 591 d['.']['pBBk'] = background_bmk 592 if include_list_view_settings: 593 d['.']['lsvp'] = lsvp 594 d['.']['icvl'] = icvl 595 596 for k,v in iteritems(options['icon_locations']): 597 d[k]['Iloc'] = v 598 599 # Delete .Trashes, if it gets created 600 shutil.rmtree(os.path.join(mount_point, '.Trashes'), True) 601 except: 602 # Always try to detach 603 hdiutil('detach', '-force', device, plist=False) 604 raise 605 606 for tries in range(detach_retries): 607 ret, output = hdiutil('detach', device, plist=False) 608 if not ret: 609 break 610 time.sleep(1) 611 612 if ret: 613 hdiutil('detach', '-force', device, plist=False) 614 raise DMGError('Unable to detach device cleanly') 615 616 # Shrink the output to the minimum possible size 617 ret, output = hdiutil('resize', 618 '-quiet', 619 '-sectors', 'min', 620 writableFile.name, 621 plist=False) 622 623 if ret: 624 raise DMGError('Unable to shrink') 625 626 key_prefix = {'UDZO': 'zlib', 'UDBZ': 'bzip2', 'ULFO': 'lzfse'} 627 compression_level = options['compression_level'] 628 if options['format'] in key_prefix and compression_level: 629 compression_args = [ 630 '-imagekey', 631 key_prefix[options['format']] + '-level=' + str(compression_level) 632 ] 633 else: 634 compression_args = [] 635 636 ret, output = hdiutil('convert', writableFile.name, 637 '-format', options['format'], 638 '-ov', 639 '-o', filename, *compression_args) 640 641 if ret: 642 raise DMGError('Unable to convert') 643 644 if options['license']: 645 ret, output = hdiutil('unflatten', '-quiet', filename, plist=False) 646 647 if ret: 648 raise DMGError('Unable to unflatten to add license') 649 650 licensing.add_license(filename, options['license']) 651 652 ret, output = hdiutil('flatten', '-quiet', filename, plist=False) 653 654 if ret: 655 raise DMGError('Unable to flatten after adding license') 656