Welcome to mirror list, hosted at ThFree Co, Russian Federation.

git.blender.org/blender.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
Diffstat (limited to 'build_files/buildbot/worker_bundle_dmg.py')
-rwxr-xr-xbuild_files/buildbot/worker_bundle_dmg.py542
1 files changed, 542 insertions, 0 deletions
diff --git a/build_files/buildbot/worker_bundle_dmg.py b/build_files/buildbot/worker_bundle_dmg.py
new file mode 100755
index 00000000000..cd3da85e12a
--- /dev/null
+++ b/build_files/buildbot/worker_bundle_dmg.py
@@ -0,0 +1,542 @@
+#!/usr/bin/env python3
+
+# ##### BEGIN GPL LICENSE BLOCK #####
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software Foundation,
+# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+#
+# ##### END GPL LICENSE BLOCK #####
+
+import argparse
+import re
+import shutil
+import subprocess
+import sys
+import time
+
+from pathlib import Path
+from tempfile import TemporaryDirectory, NamedTemporaryFile
+from typing import List
+
+BUILDBOT_DIRECTORY = Path(__file__).absolute().parent
+CODESIGN_SCRIPT = BUILDBOT_DIRECTORY / 'worker_codesign.py'
+BLENDER_GIT_ROOT_DIRECTORY = BUILDBOT_DIRECTORY.parent.parent
+DARWIN_DIRECTORY = BLENDER_GIT_ROOT_DIRECTORY / 'release' / 'darwin'
+
+
+# Extra size which is added on top of actual files size when estimating size
+# of destination DNG.
+EXTRA_DMG_SIZE_IN_BYTES = 800 * 1024 * 1024
+
+################################################################################
+# Common utilities
+
+
+def get_directory_size(root_directory: Path) -> int:
+ """
+ Get size of directory on disk
+ """
+
+ total_size = 0
+ for file in root_directory.glob('**/*'):
+ total_size += file.lstat().st_size
+ return total_size
+
+
+################################################################################
+# DMG bundling specific logic
+
+def create_argument_parser():
+ parser = argparse.ArgumentParser()
+ parser.add_argument(
+ 'source_dir',
+ type=Path,
+ help='Source directory which points to either existing .app bundle'
+ 'or to a directory with .app bundles.')
+ parser.add_argument(
+ '--background-image',
+ type=Path,
+ help="Optional background picture which will be set on the DMG."
+ "If not provided default Blender's one is used.")
+ parser.add_argument(
+ '--volume-name',
+ type=str,
+ help='Optional name of a volume which will be used for DMG.')
+ parser.add_argument(
+ '--dmg',
+ type=Path,
+ help='Optional argument which points to a final DMG file name.')
+ parser.add_argument(
+ '--applescript',
+ type=Path,
+ help="Optional path to applescript to set up folder looks of DMG."
+ "If not provided default Blender's one is used.")
+ return parser
+
+
+def collect_app_bundles(source_dir: Path) -> List[Path]:
+ """
+ Collect all app bundles which are to be put into DMG
+
+ If the source directory points to FOO.app it will be the only app bundle
+ packed.
+
+ Otherwise all .app bundles from given directory are placed to a single
+ DMG.
+ """
+
+ if source_dir.name.endswith('.app'):
+ return [source_dir]
+
+ app_bundles = []
+ for filename in source_dir.glob('*'):
+ if not filename.is_dir():
+ continue
+ if not filename.name.endswith('.app'):
+ continue
+
+ app_bundles.append(filename)
+
+ return app_bundles
+
+
+def collect_and_log_app_bundles(source_dir: Path) -> List[Path]:
+ app_bundles = collect_app_bundles(source_dir)
+
+ if not app_bundles:
+ print('No app bundles found for packing')
+ return
+
+ print(f'Found {len(app_bundles)} to pack:')
+ for app_bundle in app_bundles:
+ print(f'- {app_bundle}')
+
+ return app_bundles
+
+
+def estimate_dmg_size(app_bundles: List[Path]) -> int:
+ """
+ Estimate size of DMG to hold requested app bundles
+
+ The size is based on actual size of all files in all bundles plus some
+ space to compensate for different size-on-disk plus some space to hold
+ codesign signatures.
+
+ Is better to be on a high side since the empty space is compressed, but
+ lack of space might cause silent failures later on.
+ """
+
+ app_bundles_size = 0
+ for app_bundle in app_bundles:
+ app_bundles_size += get_directory_size(app_bundle)
+
+ return app_bundles_size + EXTRA_DMG_SIZE_IN_BYTES
+
+
+def copy_app_bundles_to_directory(app_bundles: List[Path],
+ directory: Path) -> None:
+ """
+ Copy all bundles to a given directory
+
+ This directory is what the DMG will be created from.
+ """
+ for app_bundle in app_bundles:
+ print(f'Copying {app_bundle.name}...')
+ shutil.copytree(app_bundle, directory / app_bundle.name)
+
+
+def get_main_app_bundle(app_bundles: List[Path]) -> Path:
+ """
+ Get application bundle main for the installation
+ """
+ return app_bundles[0]
+
+
+def create_dmg_image(app_bundles: List[Path],
+ dmg_filepath: Path,
+ volume_name: str) -> None:
+ """
+ Create DMG disk image and put app bundles in it
+
+ No DMG configuration or codesigning is happening here.
+ """
+
+ if dmg_filepath.exists():
+ print(f'Removing existing writable DMG {dmg_filepath}...')
+ dmg_filepath.unlink()
+
+ print('Preparing directory with app bundles for the DMG...')
+ with TemporaryDirectory(prefix='blender-dmg-content-') as content_dir_str:
+ # Copy all bundles to a clean directory.
+ content_dir = Path(content_dir_str)
+ copy_app_bundles_to_directory(app_bundles, content_dir)
+
+ # Estimate size of the DMG.
+ dmg_size = estimate_dmg_size(app_bundles)
+ print(f'Estimated DMG size: {dmg_size:,} bytes.')
+
+ # Create the DMG.
+ print(f'Creating writable DMG {dmg_filepath}')
+ command = ('hdiutil',
+ 'create',
+ '-size', str(dmg_size),
+ '-fs', 'HFS+',
+ '-srcfolder', content_dir,
+ '-volname', volume_name,
+ '-format', 'UDRW',
+ dmg_filepath)
+ subprocess.run(command)
+
+
+def get_writable_dmg_filepath(dmg_filepath: Path):
+ """
+ Get file path for writable DMG image
+ """
+ parent = dmg_filepath.parent
+ return parent / (dmg_filepath.stem + '-temp.dmg')
+
+
+def mount_readwrite_dmg(dmg_filepath: Path) -> None:
+ """
+ Mount writable DMG
+
+ Mounting point would be /Volumes/<volume name>
+ """
+
+ print(f'Mounting read-write DMG ${dmg_filepath}')
+ command = ('hdiutil',
+ 'attach', '-readwrite',
+ '-noverify',
+ '-noautoopen',
+ dmg_filepath)
+ subprocess.run(command)
+
+
+def get_mount_directory_for_volume_name(volume_name: str) -> Path:
+ """
+ Get directory under which the volume will be mounted
+ """
+
+ return Path('/Volumes') / volume_name
+
+
+def eject_volume(volume_name: str) -> None:
+ """
+ Eject given volume, if mounted
+ """
+ mount_directory = get_mount_directory_for_volume_name(volume_name)
+ if not mount_directory.exists():
+ return
+ mount_directory_str = str(mount_directory)
+
+ print(f'Ejecting volume {volume_name}')
+
+ # Figure out which device to eject.
+ mount_output = subprocess.check_output(['mount']).decode()
+ device = ''
+ for line in mount_output.splitlines():
+ if f'on {mount_directory_str} (' not in line:
+ continue
+ tokens = line.split(' ', 3)
+ if len(tokens) < 3:
+ continue
+ if tokens[1] != 'on':
+ continue
+ if device:
+ raise Exception(
+ f'Multiple devices found for mounting point {mount_directory}')
+ device = tokens[0]
+
+ if not device:
+ raise Exception(
+ f'No device found for mounting point {mount_directory}')
+
+ print(f'{mount_directory} is mounted as device {device}, ejecting...')
+ subprocess.run(['diskutil', 'eject', device])
+
+
+def copy_background_if_needed(background_image_filepath: Path,
+ mount_directory: Path) -> None:
+ """
+ Copy background to the DMG
+
+ If the background image is not specified it will not be copied.
+ """
+
+ if not background_image_filepath:
+ print('No background image provided.')
+ return
+
+ print(f'Copying background image {background_image_filepath}')
+
+ destination_dir = mount_directory / '.background'
+ destination_dir.mkdir(exist_ok=True)
+
+ destination_filepath = destination_dir / background_image_filepath.name
+ shutil.copy(background_image_filepath, destination_filepath)
+
+
+def create_applications_link(mount_directory: Path) -> None:
+ """
+ Create link to /Applications in the given location
+ """
+
+ print('Creating link to /Applications')
+
+ command = ('ln', '-s', '/Applications', mount_directory / ' ')
+ subprocess.run(command)
+
+
+def run_applescript(applescript: Path,
+ volume_name: str,
+ app_bundles: List[Path],
+ background_image_filepath: Path) -> None:
+ """
+ Run given applescript to adjust look and feel of the DMG
+ """
+
+ main_app_bundle = get_main_app_bundle(app_bundles)
+
+ with NamedTemporaryFile(
+ mode='w', suffix='.applescript') as temp_applescript:
+ print('Adjusting applescript for volume name...')
+ # Adjust script to the specific volume name.
+ with open(applescript, mode='r') as input:
+ for line in input.readlines():
+ stripped_line = line.strip()
+ if stripped_line.startswith('tell disk'):
+ line = re.sub('tell disk ".*"',
+ f'tell disk "{volume_name}"',
+ line)
+ elif stripped_line.startswith('set background picture'):
+ if not background_image_filepath:
+ continue
+ else:
+ background_image_short = \
+ '.background:' + background_image_filepath.name
+ line = re.sub('to file ".*"',
+ f'to file "{background_image_short}"',
+ line)
+ line = line.replace('blender.app', main_app_bundle.name)
+ temp_applescript.write(line)
+
+ temp_applescript.flush()
+
+ print('Running applescript...')
+ command = ('osascript', temp_applescript.name)
+ subprocess.run(command)
+
+ print('Waiting for applescript...')
+
+ # NOTE: This is copied from bundle.sh. The exact reason for sleep is
+ # still remained a mystery.
+ time.sleep(5)
+
+
+def codesign(subject: Path):
+ """
+ Codesign file or directory
+
+ NOTE: For DMG it will also notarize.
+ """
+
+ command = (CODESIGN_SCRIPT, subject)
+ subprocess.run(command)
+
+
+def codesign_app_bundles_in_dmg(mount_directory: str) -> None:
+ """
+ Code sign all binaries and bundles in the mounted directory
+ """
+
+ print(f'Codesigning all app bundles in {mount_directory}')
+ codesign(mount_directory)
+
+
+def codesign_and_notarize_dmg(dmg_filepath: Path) -> None:
+ """
+ Run codesign and notarization pipeline on the DMG
+ """
+
+ print(f'Codesigning and notarizing DMG {dmg_filepath}')
+ codesign(dmg_filepath)
+
+
+def compress_dmg(writable_dmg_filepath: Path,
+ final_dmg_filepath: Path) -> None:
+ """
+ Compress temporary read-write DMG
+ """
+ command = ('hdiutil', 'convert',
+ writable_dmg_filepath,
+ '-format', 'UDZO',
+ '-o', final_dmg_filepath)
+
+ if final_dmg_filepath.exists():
+ print(f'Removing old compressed DMG {final_dmg_filepath}')
+ final_dmg_filepath.unlink()
+
+ print('Compressing disk image...')
+ subprocess.run(command)
+
+
+def create_final_dmg(app_bundles: List[Path],
+ dmg_filepath: Path,
+ background_image_filepath: Path,
+ volume_name: str,
+ applescript: Path) -> None:
+ """
+ Create DMG with all app bundles
+
+ Will take care configuring background, signing all binaries and app bundles
+ and notarizing the DMG.
+ """
+
+ print('Running all routines to create final DMG')
+
+ writable_dmg_filepath = get_writable_dmg_filepath(dmg_filepath)
+ mount_directory = get_mount_directory_for_volume_name(volume_name)
+
+ # Make sure volume is not mounted.
+ # If it is mounted it will prevent removing old DMG files and could make
+ # it so app bundles are copied to the wrong place.
+ eject_volume(volume_name)
+
+ create_dmg_image(app_bundles, writable_dmg_filepath, volume_name)
+
+ mount_readwrite_dmg(writable_dmg_filepath)
+
+ # Run codesign first, prior to copying amything else.
+ #
+ # This allows to recurs into the content of bundles without worrying about
+ # possible interfereice of Application symlink.
+ codesign_app_bundles_in_dmg(mount_directory)
+
+ copy_background_if_needed(background_image_filepath, mount_directory)
+ create_applications_link(mount_directory)
+ run_applescript(applescript, volume_name, app_bundles,
+ background_image_filepath)
+
+ print('Ejecting read-write DMG image...')
+ eject_volume(volume_name)
+
+ compress_dmg(writable_dmg_filepath, dmg_filepath)
+ writable_dmg_filepath.unlink()
+
+ codesign_and_notarize_dmg(dmg_filepath)
+
+
+def ensure_dmg_extension(filepath: Path) -> Path:
+ """
+ Make sure given file have .dmg extension
+ """
+
+ if filepath.suffix != '.dmg':
+ return filepath.with_suffix(f'{filepath.suffix}.dmg')
+ return filepath
+
+
+def get_dmg_filepath(requested_name: Path, app_bundles: List[Path]) -> Path:
+ """
+ Get full file path for the final DMG image
+
+ Will use the provided one when possible, otherwise will deduct it from
+ app bundles.
+
+ If the name is deducted, the DMG is stored in the current directory.
+ """
+
+ if requested_name:
+ return ensure_dmg_extension(requested_name.absolute())
+
+ # TODO(sergey): This is not necessarily the main one.
+ main_bundle = app_bundles[0]
+ # Strip .app from the name
+ return Path(main_bundle.name[:-4] + '.dmg').absolute()
+
+
+def get_background_image(requested_background_image: Path) -> Path:
+ """
+ Get effective filepath for the background image
+ """
+
+ if requested_background_image:
+ return requested_background_image.absolute()
+
+ return DARWIN_DIRECTORY / 'background.tif'
+
+
+def get_applescript(requested_applescript: Path) -> Path:
+ """
+ Get effective filepath for the applescript
+ """
+
+ if requested_applescript:
+ return requested_applescript.absolute()
+
+ return DARWIN_DIRECTORY / 'blender.applescript'
+
+
+def get_volume_name_from_dmg_filepath(dmg_filepath: Path) -> str:
+ """
+ Deduct volume name from the DMG path
+
+ Will use first part of the DMG file name prior to dash.
+ """
+
+ tokens = dmg_filepath.stem.split('-')
+ words = tokens[0].split()
+
+ return ' '.join(word.capitalize() for word in words)
+
+
+def get_volume_name(requested_volume_name: str,
+ dmg_filepath: Path) -> str:
+ """
+ Get effective name for DMG volume
+ """
+
+ if requested_volume_name:
+ return requested_volume_name
+
+ return get_volume_name_from_dmg_filepath(dmg_filepath)
+
+
+def main():
+ parser = create_argument_parser()
+ args = parser.parse_args()
+
+ # Get normalized input parameters.
+ source_dir = args.source_dir.absolute()
+ background_image_filepath = get_background_image(args.background_image)
+ applescript = get_applescript(args.applescript)
+
+ app_bundles = collect_and_log_app_bundles(source_dir)
+ if not app_bundles:
+ return
+
+ dmg_filepath = get_dmg_filepath(args.dmg, app_bundles)
+ volume_name = get_volume_name(args.volume_name, dmg_filepath)
+
+ print(f'Will produce DMG "{dmg_filepath.name}" (without quotes)')
+
+ create_final_dmg(app_bundles,
+ dmg_filepath,
+ background_image_filepath,
+ volume_name,
+ applescript)
+
+
+if __name__ == "__main__":
+ main()