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