495 lines
15 KiB
Python
495 lines
15 KiB
Python
# SPDX-License-Identifier: GPL-2.0-or-later
|
|
# SPDX-FileCopyrightText: 2011-2024 Blender Authors
|
|
# <pep8 compliant>
|
|
|
|
import os
|
|
import re
|
|
import time
|
|
import subprocess
|
|
import platform
|
|
import pathlib
|
|
import tempfile
|
|
import typing
|
|
|
|
import worker.utils
|
|
|
|
# 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: pathlib.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 collect_app_bundles(source_dir: pathlib.Path) -> typing.List[pathlib.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: pathlib.Path) -> typing.List[pathlib.Path]:
|
|
app_bundles = collect_app_bundles(source_dir)
|
|
|
|
if not app_bundles:
|
|
worker.utils.info("No app bundles found for packing")
|
|
return []
|
|
|
|
worker.utils.info(f"Found {len(app_bundles)} to pack:")
|
|
for app_bundle in app_bundles:
|
|
worker.utils.info(f"- {app_bundle}")
|
|
|
|
return app_bundles
|
|
|
|
|
|
def estimate_dmg_size(app_bundles: typing.List[pathlib.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(
|
|
app_bundles: typing.List[pathlib.Path], dir_path: pathlib.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:
|
|
destination_dir_path = dir_path / app_bundle.name
|
|
|
|
worker.utils.info(f"Copying app bundle [{app_bundle}] to [{dir_path}]")
|
|
|
|
worker.utils.copy_dir(app_bundle, destination_dir_path)
|
|
|
|
# Only chmod if we can;t get cmake install to do it - james
|
|
# for r, d, f in os.walk(destination_dir_path):
|
|
# worker.utils.info(f'chmoding [{r}] -> 0o755')
|
|
# os.chmod(r, 0o755)
|
|
|
|
|
|
def get_main_app_bundle(app_bundles: typing.List[pathlib.Path]) -> pathlib.Path:
|
|
"""
|
|
Get application bundle main for the installation
|
|
"""
|
|
return app_bundles[0]
|
|
|
|
|
|
def create_dmg_image(
|
|
app_bundles: typing.List[pathlib.Path],
|
|
dmg_file_path: pathlib.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_file_path.exists():
|
|
worker.utils.info(f"Removing existing writable DMG {dmg_file_path}...")
|
|
worker.utils.remove_file(dmg_file_path)
|
|
|
|
temp_content_path = tempfile.TemporaryDirectory(prefix="blender-dmg-content-")
|
|
worker.utils.info(
|
|
f"Preparing directory with app bundles for the DMG [{temp_content_path}]"
|
|
)
|
|
with temp_content_path as content_dir_str:
|
|
# Copy all bundles to a clean directory.
|
|
content_dir_path = pathlib.Path(content_dir_str)
|
|
# worker.utils.info(f'content_dir_path={content_dir_path}')
|
|
copy_app_bundles(app_bundles, content_dir_path)
|
|
|
|
# Estimate size of the DMG.
|
|
dmg_size = estimate_dmg_size(app_bundles)
|
|
worker.utils.info(f"Estimated DMG size: [{dmg_size:,}] bytes.")
|
|
|
|
# Create the DMG.
|
|
worker.utils.info(f"Creating writable DMG [{dmg_file_path}]")
|
|
command = (
|
|
"hdiutil",
|
|
"create",
|
|
"-size",
|
|
str(dmg_size),
|
|
"-fs",
|
|
"HFS+",
|
|
"-srcfolder",
|
|
content_dir_path,
|
|
"-volname",
|
|
volume_name,
|
|
"-format",
|
|
"UDRW",
|
|
"-mode",
|
|
"755",
|
|
dmg_file_path,
|
|
)
|
|
|
|
worker.utils.call(command)
|
|
|
|
|
|
def get_writable_dmg_file_path(dmg_file_path: pathlib.Path) -> pathlib.Path:
|
|
"""
|
|
Get file path for writable DMG image
|
|
"""
|
|
parent = dmg_file_path.parent
|
|
return parent / (dmg_file_path.stem + "-temp.dmg")
|
|
|
|
|
|
def mount_readwrite_dmg(dmg_file_path: pathlib.Path) -> None:
|
|
"""
|
|
Mount writable DMG
|
|
|
|
Mounting point would be /Volumes/<volume name>
|
|
"""
|
|
|
|
worker.utils.info(f"Mounting read-write DMG ${dmg_file_path}")
|
|
cmd: worker.utils.CmdSequence = [
|
|
"hdiutil",
|
|
"attach",
|
|
"-readwrite",
|
|
"-noverify",
|
|
"-noautoopen",
|
|
dmg_file_path,
|
|
]
|
|
worker.utils.call(cmd)
|
|
|
|
|
|
def get_mount_directory_for_volume_name(volume_name: str) -> pathlib.Path:
|
|
"""
|
|
Get directory under which the volume will be mounted
|
|
"""
|
|
|
|
return pathlib.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)
|
|
|
|
worker.utils.info(f"Ejecting volume [{volume_name}]")
|
|
|
|
# First try through Finder, as sometimes diskutil fails for unknown reasons.
|
|
command = [
|
|
"osascript",
|
|
"-e",
|
|
f"""tell application "Finder" to eject (every disk whose name is "{volume_name}")""",
|
|
]
|
|
worker.utils.call(command)
|
|
if not mount_directory.exists():
|
|
return
|
|
|
|
# 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}]")
|
|
|
|
worker.utils.info(
|
|
f"[{mount_directory}] is mounted as device [{device}], ejecting..."
|
|
)
|
|
command = ["diskutil", "eject", device]
|
|
worker.utils.call(command)
|
|
|
|
|
|
def copy_background_if_needed(
|
|
background_image_file_path: pathlib.Path, mount_directory: pathlib.Path
|
|
) -> None:
|
|
"""
|
|
Copy background to the DMG
|
|
|
|
If the background image is not specified it will not be copied.
|
|
"""
|
|
|
|
if not background_image_file_path:
|
|
worker.utils.info("No background image provided.")
|
|
return
|
|
|
|
destination_dir = mount_directory / ".background"
|
|
destination_dir.mkdir(exist_ok=True)
|
|
|
|
destination_file_path = destination_dir / background_image_file_path.name
|
|
|
|
worker.utils.info(
|
|
f"Copying background image [{background_image_file_path}] to [{destination_file_path}]"
|
|
)
|
|
worker.utils.copy_file(background_image_file_path, destination_file_path)
|
|
|
|
|
|
def create_applications_link(mount_directory: pathlib.Path) -> None:
|
|
"""
|
|
Create link to /Applications in the given location
|
|
"""
|
|
worker.utils.info(f"Creating link to /Applications -> {mount_directory}")
|
|
target_path = mount_directory / " "
|
|
cmd: worker.utils.CmdSequence = ["ln", "-s", "/Applications", target_path]
|
|
worker.utils.call(cmd)
|
|
|
|
|
|
def run_applescript_file_path(
|
|
applescript_file_path: pathlib.Path,
|
|
volume_name: str,
|
|
app_bundles: typing.List[pathlib.Path],
|
|
background_image_file_path: pathlib.Path,
|
|
) -> None:
|
|
"""
|
|
Run given applescript to adjust look and feel of the DMG
|
|
"""
|
|
main_app_bundle = get_main_app_bundle(app_bundles)
|
|
|
|
architecture = platform.machine().lower()
|
|
# needs_run_applescript = (architecture != "x86_64")
|
|
needs_run_applescript = True
|
|
|
|
if not needs_run_applescript:
|
|
worker.utils.info(
|
|
f"Having issues with apple script on [{architecture}], skipping !"
|
|
)
|
|
return
|
|
|
|
temp_script_file_path = tempfile.NamedTemporaryFile(mode="w", suffix=".applescript")
|
|
with temp_script_file_path as temp_applescript_file:
|
|
worker.utils.info(
|
|
f"Adjusting applescript [{temp_script_file_path.name}] for volume name [{volume_name}]"
|
|
)
|
|
# Adjust script to the specific volume name.
|
|
with open(applescript_file_path, mode="r") as input_file:
|
|
worker.utils.info("Start script update")
|
|
for line in input_file.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_file_path:
|
|
continue
|
|
else:
|
|
background_image_short = (
|
|
f".background:{background_image_file_path.name}"
|
|
)
|
|
line = re.sub(
|
|
'to file ".*"', f'to file "{background_image_short}"', line
|
|
)
|
|
line = line.replace("blender.app", main_app_bundle.name)
|
|
stripped_line = line.rstrip("\r\n")
|
|
worker.utils.info(f"line={stripped_line}")
|
|
temp_applescript_file.write(line)
|
|
|
|
temp_applescript_file.flush()
|
|
worker.utils.info("End script update")
|
|
|
|
# This does not help issues when running applescript
|
|
worker.utils.info("Updating permissions")
|
|
os.chmod(temp_script_file_path.name, 0o755)
|
|
|
|
# Setting flags to this applescript will fail execution, not permitted
|
|
# command = ['chflags', "uchg", temp_script_file_path.name]
|
|
# worker.utils.call(command)
|
|
|
|
command = ["osascript", "-s", "o", temp_script_file_path.name]
|
|
worker.utils.call(command)
|
|
|
|
# NOTE: This is copied from bundle.sh. The exact reason for sleep is
|
|
# still remained a mystery.
|
|
worker.utils.info("Waiting for applescript...")
|
|
time.sleep(5)
|
|
|
|
|
|
def compress_dmg(
|
|
writable_dmg_file_path: pathlib.Path, final_dmg_file_path: pathlib.Path
|
|
) -> None:
|
|
"""
|
|
Compress temporary read-write DMG
|
|
"""
|
|
cmd: worker.utils.CmdSequence = [
|
|
"hdiutil",
|
|
"convert",
|
|
writable_dmg_file_path,
|
|
"-format",
|
|
"UDZO",
|
|
"-o",
|
|
final_dmg_file_path,
|
|
]
|
|
|
|
if final_dmg_file_path.exists():
|
|
worker.utils.info(f"Removing old compressed DMG [{final_dmg_file_path}]")
|
|
worker.utils.remove_file(final_dmg_file_path)
|
|
|
|
worker.utils.info("Compressing disk image...")
|
|
worker.utils.call(cmd)
|
|
|
|
|
|
def create_final_dmg(
|
|
app_bundles: typing.List[pathlib.Path],
|
|
dmg_file_path: pathlib.Path,
|
|
background_image_file_path: pathlib.Path,
|
|
volume_name: str,
|
|
applescript_file_path: pathlib.Path,
|
|
) -> None:
|
|
"""
|
|
Create DMG with all app bundles
|
|
|
|
Will take care configuring background
|
|
"""
|
|
|
|
worker.utils.info("Running all routines to create final DMG")
|
|
|
|
writable_dmg_file_path = get_writable_dmg_file_path(dmg_file_path)
|
|
worker.utils.info(f"Mouting volume [{volume_name}]")
|
|
mount_directory = get_mount_directory_for_volume_name(volume_name)
|
|
worker.utils.info(f"Mount at [{mount_directory}]")
|
|
|
|
# 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)
|
|
|
|
worker.utils.info(f"Creating image [{writable_dmg_file_path}] to [{volume_name}]")
|
|
create_dmg_image(app_bundles, writable_dmg_file_path, volume_name)
|
|
|
|
worker.utils.info(f"Mount r/w mode [{writable_dmg_file_path}]")
|
|
mount_readwrite_dmg(writable_dmg_file_path)
|
|
|
|
copy_background_if_needed(background_image_file_path, mount_directory)
|
|
create_applications_link(mount_directory)
|
|
|
|
run_applescript_file_path(
|
|
applescript_file_path, volume_name, app_bundles, background_image_file_path
|
|
)
|
|
|
|
eject_volume(volume_name)
|
|
|
|
compress_dmg(writable_dmg_file_path, dmg_file_path)
|
|
worker.utils.remove_file(writable_dmg_file_path)
|
|
|
|
|
|
def ensure_dmg_extension(filepath: pathlib.Path) -> pathlib.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_file_path(
|
|
requested_file_path: pathlib.Path, app_bundles: typing.List[pathlib.Path]
|
|
) -> pathlib.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_file_path:
|
|
return ensure_dmg_extension(requested_file_path.absolute())
|
|
|
|
# TODO(sergey): This is not necessarily the main one.
|
|
main_bundle = app_bundles[0]
|
|
# Strip .app from the name
|
|
return pathlib.Path(main_bundle.name[:-4] + ".dmg").absolute()
|
|
|
|
|
|
def get_volume_name_from_dmg_file_path(dmg_file_path: pathlib.Path) -> str:
|
|
"""
|
|
Deduct volume name from the DMG path
|
|
|
|
Will use first part of the DMG file name prior to dash.
|
|
"""
|
|
|
|
tokens = dmg_file_path.stem.split("-")
|
|
words = tokens[0].split()
|
|
|
|
return " ".join(word.capitalize() for word in words)
|
|
|
|
|
|
def bundle(
|
|
source_dir: pathlib.Path,
|
|
dmg_file_path: pathlib.Path,
|
|
applescript_file_path: pathlib.Path,
|
|
background_image_file_path: pathlib.Path,
|
|
) -> None:
|
|
app_bundles = collect_and_log_app_bundles(source_dir)
|
|
for app_bundle in app_bundles:
|
|
worker.utils.info(f"App bundle path [{app_bundle}]")
|
|
|
|
dmg_file_path = get_dmg_file_path(dmg_file_path, app_bundles)
|
|
volume_name = get_volume_name_from_dmg_file_path(dmg_file_path)
|
|
|
|
worker.utils.info(f"Will produce DMG [{dmg_file_path.name}]")
|
|
|
|
create_final_dmg(
|
|
app_bundles,
|
|
dmg_file_path,
|
|
background_image_file_path,
|
|
volume_name,
|
|
applescript_file_path,
|
|
)
|