"""Implements the 'bdist_dmg' command (create macOS dmg and/or app bundle).""" from __future__ import annotations import os import shutil from typing import ClassVar from dmgbuild import build_dmg from setuptools import Command import cx_Freeze.icons from cx_Freeze import Executable from cx_Freeze.exception import OptionError __all__ = ["bdist_dmg"] class bdist_dmg(Command): """Create a Mac DMG disk image containing the Mac application bundle.""" description = ( "create a Mac DMG disk image containing the Mac application bundle" ) user_options: ClassVar[list[tuple[str, str | None, str]]] = [ ("volume-label=", None, "Volume label of the DMG disk image"), ( "applications-shortcut=", None, "Boolean for whether to include " "shortcut to Applications in the DMG disk image", ), ("silent", "s", "suppress all output except warnings"), ("format=", None, 'format of the disk image [default: "UDZO"]'), ( "filesystem=", None, 'filesystem of the disk image [default: "HFS+"]', ), ( "size=", None, "If defined, specifies the size of the filesystem within the " "image. If this is not defined, cx_Freeze (and then dmgbuild) " "will attempt to determine a reasonable size for the image. " "If you set this, you should set it large enough to hold the " "files you intend to copy into the image. The syntax is the " "same as for the -size argument to hdiutil, i.e. you can use " "the suffixes `b`, `k`, `m`, `g`, `t`, `p` and `e` for bytes, " "kilobytes, megabytes, gigabytes, terabytes, exabytes and " "petabytes respectively.", ), ( "background", "b", "A rgb color in the form #3344ff, svg named color like goldenrod, " "a path to an image, or the words 'builtin-arrow' [default: None]", ), ( "show-status-bar", None, "Show the status bar in the Finder window. Default is False.", ), ( "show-tab-view", None, "Show the tab view in the Finder window. Default is False.", ), ( "show-path-bar", None, "Show the path bar in the Finder window. Default is False.", ), ( "show-sidebar", None, "Show the sidebar in the Finder window. Default is False.", ), ( "sidebar-width", None, "Width of the sidebar in the Finder window. Default is None.", ), ( "window-rect", None, "Window rectangle in the form x, y, width, height. The position " "of the window in ((x, y), (w, h)) format, with y co-ordinates " "running from bottom to top. The Finder makes sure that the " "window will be on the user's display, so if you want your window " "at the top left of the display you could use (0, 100000) as the " "x, y co-ordinates. Unfortunately it doesn't appear to be " "possible to position the window relative to the top left or " "relative to the centre of the user's screen.", ), ( "icon-locations", None, "A dictionary specifying the co-ordinates of items in the root " "directory of the disk image, where the keys are filenames and " "the values are (x, y) tuples. e.g.: " 'icon-locations = { "Applications": (100, 100), ' '"README.txt": (200, 100) }', ), ( "default-view", None, "The default view of the Finder window. Possible values are " '"icon-view", "list-view", "column-view", "coverflow".', ), ( "show-icon-preview", None, "Show icon preview in the Finder window. Default is False.", ), ( "license", None, "Dictionary specifying license details with 'default-language', " "'licenses', and 'buttons'." "default-language: Language code (e.g., 'en_US') if no matching " "system language." "licenses: Map of language codes to license file paths " "(e.g., {'en_US': 'path/to/license_en.txt'})." "buttons: Map of language codes to UI strings " "([language, agree, disagree, print, save, instruction])." "Example: {'default-language': 'en_US', " "'licenses': {'en_US': 'path/to/license_en.txt'}, " "'buttons': {'en_US': ['English', 'Agree', 'Disagree', 'Print', " "'Save', 'Instruction text']}}", ), ] def initialize_options(self) -> None: self.silent = None self.volume_label = self.distribution.get_fullname() self.applications_shortcut = False self._symlinks = {} self._files = [] self.format = "UDZO" self.filesystem = "HFS+" self.size = None self.background = None self.show_status_bar = False self.show_tab_view = False self.show_path_bar = False self.show_sidebar = False self.sidebar_width = None self.window_rect = None self.hide = None self.hide_extensions = None self.icon_locations = None self.default_view = None self.show_icon_preview = False self.license = None # Non-exposed options self.include_icon_view_settings = "auto" self.include_list_view_settings = "auto" self.arrange_by = None self.grid_offset = None self.grid_spacing = None self.scroll_position = None self.label_pos = None self.text_size = None self.icon_size = None self.list_icon_size = None self.list_text_size = None self.list_scroll_position = None self.list_sort_by = None self.list_use_relative_dates = None self.list_calculate_all_sizes = None self.list_columns = None self.list_column_widths = None self.list_column_sort_directions = None def finalize_options(self) -> None: if not self.volume_label: msg = "volume-label must be set" raise OptionError(msg) if self.applications_shortcut: self._symlinks["Applications"] = "/Applications" if self.silent is None: self.silent = False self.finalize_dmgbuild_options() def finalize_dmgbuild_options(self) -> None: if self.background: self.background = self.background.strip() if self.background == "builtin-arrow" and ( self.icon_locations or self.window_rect ): msg = ( "background='builtin-arrow' cannot be used with " "icon_locations or window_rect" ) raise OptionError(msg) if not self.arrange_by: self.arrange_by = None if not self.grid_offset: self.grid_offset = (0, 0) if not self.grid_spacing: self.grid_spacing = 100 if not self.scroll_position: self.scroll_position = (0, 0) if not self.label_pos: self.label_pos = "bottom" if not self.text_size: self.text_size = 16 if not self.icon_size: self.icon_size = 128 def build_dmg(self) -> None: # Remove DMG if it already exists if os.path.exists(self.dmg_name): os.unlink(self.dmg_name) # Make dist folder self.dist_dir = os.path.join(self.build_dir, "dist") if os.path.exists(self.dist_dir): shutil.rmtree(self.dist_dir) self.mkpath(self.dist_dir) # Copy App Bundle dest_dir = os.path.join( self.dist_dir, os.path.basename(self.bundle_dir) ) if self.silent: shutil.copytree(self.bundle_dir, dest_dir, symlinks=True) else: self.copy_tree(self.bundle_dir, dest_dir, preserve_symlinks=True) # Add the App Bundle to the list of files self._files.append(self.bundle_dir) # set the app_name for the application bundle app_name = os.path.basename(self.bundle_dir) # Set the defaults if ( self.background == "builtin-arrow" and not self.icon_locations and not self.window_rect ): self.icon_locations = { "Applications": (500, 120), app_name: (140, 120), } self.window_rect = ((100, 100), (640, 380)) executables = self.distribution.executables # type: list[Executable] executable: Executable = executables[0] if len(executables) > 1: self.warn( "using the first executable as entrypoint: " f"{executable.target_name}" ) if executable.icon is None: icon_name = "setup.icns" icon_source_dir = os.path.dirname(cx_Freeze.icons.__file__) self.icon = os.path.join(icon_source_dir, icon_name) else: self.icon = os.path.abspath(executable.icon) with open("settings.py", "w") as f: def add_param(name, value) -> None: # if value is a string, add quotes if isinstance(value, (str)): f.write(f"{name} = '{value}'\n") else: f.write(f"{name} = {value}\n") # Some fields expect and allow None, others don't # so we need to check for None and not add them for # the fields that don't allow it # Disk Image Settings add_param("filename", self.dmg_name) add_param("volume_label", self.volume_label) add_param("format", self.format) add_param("filesystem", self.filesystem) add_param("size", self.size) # Content Settings add_param("files", self._files) add_param("symlinks", self._symlinks) if self.hide: add_param("hide", self.hide) if self.hide_extensions: add_param("hide_extensions", self.hide_extensions) # Only one of these can be set if self.icon_locations: add_param("icon_locations", self.icon_locations) if self.icon: add_param("icon", self.icon) # We don't need to set this, as we only support icns # add param ( "badge_icon", self.badge_icon) # Window Settings add_param("background", self.background) add_param("show_status_bar", self.show_status_bar) add_param("show_tab_view", self.show_tab_view) add_param("show_pathbar", self.show_path_bar) add_param("show_sidebar", self.show_sidebar) add_param("sidebar_width", self.sidebar_width) if self.window_rect: add_param("window_rect", self.window_rect) if self.default_view: add_param("default_view", self.default_view) add_param("show_icon_preview", self.show_icon_preview) add_param( "include_icon_view_settings", self.include_icon_view_settings ) add_param( "include_list_view_settings", self.include_list_view_settings ) # Icon View Settings\ add_param("arrange_by", self.arrange_by) add_param("grid_offset", self.grid_offset) add_param("grid_spacing", self.grid_spacing) add_param("scroll_position", self.scroll_position) add_param("label_pos", self.label_pos) if self.text_size: add_param("text_size", self.text_size) if self.icon_size: add_param("icon_size", self.icon_size) if self.icon_locations: add_param("icon_locations", self.icon_locations) # List View Settings if self.list_icon_size: add_param("list_icon_size", self.list_icon_size) if self.list_text_size: add_param("list_text_size", self.list_text_size) if self.list_scroll_position: add_param("list_scroll_position", self.list_scroll_position) add_param("list_sort_by", self.list_sort_by) add_param("list_use_relative_dates", self.list_use_relative_dates) add_param( "list_calculate_all_sizes", self.list_calculate_all_sizes ) if self.list_columns: add_param("list_columns", self.list_columns) if self.list_column_widths: add_param("list_column_widths", self.list_column_widths) if self.list_column_sort_directions: add_param( "list_column_sort_directions", self.list_column_sort_directions, ) # License Settings add_param("license", self.license) def log_handler(msg: dict[str, str]) -> None: if not self.silent: loggable = ",".join( f"{key}: {value}" for key, value in msg.items() ) self.announce(loggable) build_dmg( self.dmg_name, self.volume_label, "settings.py", callback=log_handler, ) def run(self) -> None: # Create the application bundle self.run_command("bdist_mac") # Find the location of the application bundle and the build dir self.bundle_dir = self.get_finalized_command("bdist_mac").bundle_dir self.build_dir = self.get_finalized_command("build_exe").build_base # Set the file name of the DMG to be built self.dmg_name = os.path.join( self.build_dir, self.volume_label + ".dmg" ) self.execute(self.build_dmg, ())