Skip to content

Commit

Permalink
MacOS Installers (#49)
Browse files Browse the repository at this point in the history
* add a first idea of creating a macos CLI installer with applecrate

most of the code is directly taken from the applecrate github repo.
needs testing, i currently have no idea if this works at all.
tests to be written once it actually works on macos...

* first idea for GUI bundling (comments only), fix test for unsupp OSes

* installer creation on macos finished

- applecrate is used to package CLIs (works)
- GUI: package `.app` by hand, then turn into dmg with dmgbuild

* add tests

* fix name of the installer dmg file

* fix tests

* update docs
  • Loading branch information
trappitsch authored Jun 6, 2024
1 parent acceb8c commit 64e5a8f
Show file tree
Hide file tree
Showing 8 changed files with 327 additions and 14 deletions.
11 changes: 10 additions & 1 deletion docs/.includes/installer_cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,4 +42,13 @@

=== "macOS"

CLI installers on macOS are currently not supported.
MacOS CLI tool installers are created using
[applecrate](https://github.com/RhetTbull/applecrate).
The installer is an executable in
`target/release/projectname-v1.2.3-macos.pkg`
that can be run by double-clicking it.

!!! bug
The uninstaller does currently not remove the virtual environment
that is created by PyApp, but only removes the executable.
This will be fixed in a future release.
16 changes: 15 additions & 1 deletion docs/.includes/installer_gui.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,4 +51,18 @@

=== "macOS"

GUI installers on macOS are currently not supported.
A MacOS GUI is created by manually first putting together
a minimal `.app` directory structure.
This directory contains the binary, the icon, and a `Info.plist` file.

A `.dmg` file is then created using
[dmgbuild](https://github.com/dmgbuild/dmgbuild).

!!! note
The building process of the `.dmg` file can currently not yet
be customized.
We are using some default settings, however,
hopefully in the future we can make this more customizable.

In order for this to work, you must have an `icon.icns` file
in the `assets` folder of your project directory.
4 changes: 3 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ dependencies = [
"rich-click>=1.7.3",
"rich>=13.7.0",
"colorama>=0.4.6",
"box-packager>=0.1.0",
"applecrate>=0.2.0; sys_platform=='darwin'",
"dmgbuild>=1.6.1; sys_platform=='darwin'",
]
requires-python = ">= 3.8"
license = { text = "MIT" }
Expand Down Expand Up @@ -48,6 +49,7 @@ dev-dependencies = [
"pytest-mock>=3.12.0",
"gitpython>=3.1.42",
"build>=1.2.1",
"applecrate>=0.2.0",
]

[tool.rye.scripts]
Expand Down
19 changes: 19 additions & 0 deletions requirements-dev.lock
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,11 @@
# features: []
# all-features: true
# with-sources: false
# generate-hashes: false

-e file:.
applecrate==0.2.0
# via box-packager
babel==2.14.0
# via mkdocs-material
bracex==2.4
Expand All @@ -18,6 +21,7 @@ certifi==2024.2.2
charset-normalizer==3.3.2
# via requests
click==8.1.7
# via applecrate
# via box-packager
# via mkdocs
# via mkdocs-click
Expand All @@ -27,6 +31,10 @@ colorama==0.4.6
# via mkdocs-material
coverage==7.4.3
# via pytest-cov
dmgbuild==1.6.1
# via box-packager
ds-store==1.3.1
# via dmgbuild
ghp-import==2.1.0
# via mkdocs
gitdb==4.0.11
Expand All @@ -37,15 +45,21 @@ idna==3.6
iniconfig==2.0.0
# via pytest
jinja2==3.1.3
# via applecrate
# via mkdocs
# via mkdocs-material
mac-alias==2.2.2
# via dmgbuild
# via ds-store
markdown==3.5.2
# via mkdocs
# via mkdocs-click
# via mkdocs-material
# via pymdown-extensions
markdown-it-py==3.0.0
# via rich
markdown2==2.4.13
# via applecrate
markupsafe==2.1.5
# via jinja2
# via mkdocs
Expand All @@ -66,13 +80,16 @@ mkdocs-material==9.5.13
mkdocs-material-extensions==1.3.1
# via mkdocs-material
packaging==23.2
# via applecrate
# via build
# via mkdocs
# via pytest
paginate==0.5.6
# via mkdocs-material
pathspec==0.12.1
# via mkdocs
pip==24.0
# via applecrate
platformdirs==4.2.0
# via mkdocs
pluggy==1.4.0
Expand Down Expand Up @@ -111,6 +128,8 @@ six==1.16.0
# via python-dateutil
smmap==5.0.1
# via gitdb
toml==0.10.2
# via applecrate
tomlkit==0.12.4
# via box-packager
typing-extensions==4.10.0
Expand Down
19 changes: 19 additions & 0 deletions requirements.lock
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,11 @@
# features: []
# all-features: true
# with-sources: false
# generate-hashes: false

-e file:.
applecrate==0.2.0
# via box-packager
babel==2.14.0
# via mkdocs-material
bracex==2.4
Expand All @@ -17,27 +20,38 @@ certifi==2024.2.2
charset-normalizer==3.3.2
# via requests
click==8.1.7
# via applecrate
# via box-packager
# via mkdocs
# via mkdocs-click
# via rich-click
colorama==0.4.6
# via box-packager
# via mkdocs-material
dmgbuild==1.6.1
# via box-packager
ds-store==1.3.1
# via dmgbuild
ghp-import==2.1.0
# via mkdocs
idna==3.6
# via requests
jinja2==3.1.3
# via applecrate
# via mkdocs
# via mkdocs-material
mac-alias==2.2.2
# via dmgbuild
# via ds-store
markdown==3.5.2
# via mkdocs
# via mkdocs-click
# via mkdocs-material
# via pymdown-extensions
markdown-it-py==3.0.0
# via rich
markdown2==2.4.13
# via applecrate
markupsafe==2.1.5
# via jinja2
# via mkdocs
Expand All @@ -58,11 +72,14 @@ mkdocs-material==9.5.13
mkdocs-material-extensions==1.3.1
# via mkdocs-material
packaging==23.2
# via applecrate
# via mkdocs
paginate==0.5.6
# via mkdocs-material
pathspec==0.12.1
# via mkdocs
pip==24.0
# via applecrate
platformdirs==4.2.0
# via mkdocs
pygments==2.17.2
Expand All @@ -89,6 +106,8 @@ rich-click==1.7.3
# via box-packager
six==1.16.0
# via python-dateutil
toml==0.10.2
# via applecrate
tomlkit==0.12.4
# via box-packager
typing-extensions==4.10.0
Expand Down
73 changes: 72 additions & 1 deletion src/box/installer.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import os
from pathlib import Path
import shutil
import subprocess
import sys

Expand All @@ -28,6 +29,7 @@ def __init__(self, verbose: bool = False):
if not verbose:
self.subp_kwargs["stdout"] = subprocess.DEVNULL
self.subp_kwargs["stderr"] = subprocess.DEVNULL
self._verbose = verbose

if sys.platform.startswith("linux"):
self._os = "Linux"
Expand Down Expand Up @@ -58,6 +60,10 @@ def create_installer(self):
self.windows_cli()
elif self._os == "Windows" and self._mode == "GUI":
self.windows_gui()
elif self._os == "macOS" and self._mode == "CLI":
self.macos_cli()
elif self._os == "macOS" and self._mode == "GUI":
self.macos_gui()
else:
self.unsupported_os_or_mode()

Expand Down Expand Up @@ -117,6 +123,71 @@ def linux_gui(self) -> None:
mode |= (mode & 0o444) >> 2
os.chmod(installer_file, mode)

def macos_cli(self):
"""Create a macOS CLI installer using applecrate."""
from applecrate import build_installer

name = self._config.name
version = self._config.version
installer_file = Path(RELEASE_DIR_NAME).joinpath(f"{name}-v{version}-macos.pkg")

kwargs = {}
if self._verbose:
kwargs["verbose"] = click.secho
build_installer(
app=name,
version=version,
install=[
(
self._release_file,
f"/usr/local/bin/{self._release_file.name}",
)
],
output=installer_file,
**kwargs,
)

self._installer_name = installer_file.name

def macos_gui(self):
"""Create a macOS GUI installer using applecrate."""
import dmgbuild

from box.installer_utils.mac_hlp import dmgbuild_settings, make_app

app_path = Path(RELEASE_DIR_NAME).joinpath(f"{self._config.name}.app")
dmg_path = Path(RELEASE_DIR_NAME).joinpath(
f"{self._config.name}-v{self._config.version}-macos.dmg"
)

# remove old app if it exists
if app_path.exists():
shutil.rmtree(app_path)

make_app(
Path(RELEASE_DIR_NAME),
self._config.name,
self._config.author,
self._config.version,
get_icon("icns"),
)

# create the dmg
settings = dmgbuild_settings(
Path(RELEASE_DIR_NAME), self._config.name, get_icon("icns")
)
with ut.set_dir(RELEASE_DIR_NAME):
dmgbuild.build_dmg(
filename=dmg_path.with_suffix("").name,
volume_name=f"{dmg_path.name}",
settings=settings,
)

# remove the app folder
shutil.rmtree(app_path)

self._installer_name = dmg_path.name

def unsupported_os_or_mode(self):
"""Print a message for unsupported OS or mode."""
fmt.warning(
Expand Down Expand Up @@ -229,7 +300,7 @@ def get_icon(suffix: str = None) -> Path:
- icon.jpg
- icon.jpeg
Note: Windows `.ico` files must be called out explicitly.
Note: Windows `.ico` files must be called out explicitly, same with MacOS `.icns` files.
:param suffix: The suffix of the icon file.
Expand Down
84 changes: 84 additions & 0 deletions src/box/installer_utils/mac_hlp.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
# Helper functions to create Mac Installers

from pathlib import Path
import shutil


def dmgbuild_settings(target_path: Path, name_pkg: str, icon: Path) -> dict:
"""Create the settings for building the dmg file.
:param target_path: Path to the target folder, i.e., where the app is and where the dmg will be created.
:param name_pkg: The name of the package as a string, same name as the app (but without the `.app`)!
:param icon: Path to the icon file.
"""
settings = {
"files": [str(target_path.joinpath(name_pkg).with_suffix(".app").absolute())],
"symlinks": {"Applications": "/Applications"},
"icon_locations": {f"{name_pkg}.app": (140, 120), "Applications": (500, 120)},
"background": "builtin-arrow",
}

return settings


def make_app(
target_path: Path, name_pkg: str, author: str, version: str, icon: Path
) -> None:
"""Create an apple executable `.app` file.
Creates the folder structure and `Info.plist` file required for an `.app` Apple App.
:param target_path: Path to the target folder, i.e., where the binary is and where the app will be created.
:param name_pkg: The name of the package as a string, same name as the binary!
:param version: Version of the package, as string, `X.Y.Z`.
:param icon: Path to the icon `.icns` file.
"""
app_path = target_path.joinpath(name_pkg).with_suffix(".app")
app_path.mkdir()

# create resource directory and copy icon into it
res_path = app_path.joinpath("Contents/Resources")
res_path.mkdir(parents=True)
shutil.copy(icon, res_path.joinpath(icon.name))

# create MacOS directory and copy binary into it
macos_path = app_path.joinpath("Contents/MacOS")
macos_path.mkdir(parents=True)
shutil.copy(target_path.joinpath(name_pkg), macos_path.joinpath(name_pkg))

# Create the Info.plist file
name_pkg_short = name_pkg if len(name_pkg) <= 16 else name_pkg[:16]
info_plist = rf"""<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleIdentifier</key>
<string>com.box-package.{name_pkg}</string>
<key>CFBundleExecutable</key>
<string>{name_pkg}</string>
<key>CFBundleIconFile</key>
<string>{icon.name}</string>
<key>CFBundleDisplayName</key>
<string>{name_pkg}</string>
<key>CFBundleName</key>
<string>{name_pkg_short}</string>
<key>CFBundleVersion</key>
<string>{version}</string>
<key>CFBundleShortVersionString</key>
<string>{version}</string>
<key>NSHumanReadableCopyright</key>
<string>{author}</string>
<key>CFBundleSignature</key>
<string>????</string>
</dict>
</plist>"""

info_plist_file = app_path.joinpath("Contents/Info.plist")
with open(info_plist_file, "w") as f:
f.write(info_plist)
Loading

0 comments on commit 64e5a8f

Please sign in to comment.