Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions scripts/build_installer.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import platform
import re
import shutil
import stat
import subprocess
import tempfile

Expand Down Expand Up @@ -71,6 +72,20 @@ def windows_installer(package: Path, output: Path, manifest: dict, compiler: str
return asset


def dmg_size_megabytes(layout: Path) -> int:
"""Budget logical payload bytes, filesystem blocks and HFS+ metadata."""
payload = entries = 0
for path in layout.rglob("*"):
info = path.lstat()
entries += 1
if stat.S_ISREG(info.st_mode):
payload += ((info.st_size + 4095) // 4096) * 4096
# Source-folder auto-sizing can underestimate sparse/compressed binaries.
# Do not follow Applications or other bundle symlinks into the build host.
capacity = payload + payload // 5 + entries * 4096 + 32 * 1024 * 1024
return (capacity + 1024 * 1024 - 1) // (1024 * 1024)


def macos_installer(package: Path, output: Path, manifest: dict, *, capture: bool = False) -> Path:
if platform.system() != "Darwin":
raise ValueError("DMG creation and verification must run on macOS.")
Expand All @@ -87,6 +102,7 @@ def macos_installer(package: Path, output: Path, manifest: dict, *, capture: boo
(layout / "Applications").symlink_to("/Applications")
shutil.copy2(Path(__file__).parents[1] / ("docs/capture-tool.md" if capture else "docs/install.md"), layout / "Install.txt")
subprocess.run(["hdiutil", "create", "-volname", name, "-srcfolder", str(layout),
"-fs", "HFS+", "-size", f"{dmg_size_megabytes(layout)}m",
"-format", "UDZO", "-ov", str(asset)], check=True)
subprocess.run(["hdiutil", "verify", str(asset)], check=True)
mounted.mkdir()
Expand Down
79 changes: 79 additions & 0 deletions tests/test_macos_installer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
from __future__ import annotations

from pathlib import Path
import shutil
import sys
import tempfile
import unittest
from unittest.mock import patch

sys.path.insert(0, str(Path(__file__).parents[1] / "scripts"))
import build_installer


class MacInstallerTests(unittest.TestCase):
def test_sparse_payload_is_sized_from_full_length_with_filesystem_headroom(self):
with tempfile.TemporaryDirectory() as temporary:
layout = Path(temporary)
binary = layout / "native-helper"
with binary.open("wb") as stream:
stream.truncate(64 * 1024 * 1024 + 1)
size = build_installer.dmg_size_megabytes(layout) * 1024 * 1024
self.assertGreaterEqual(size, binary.stat().st_size + 32 * 1024 * 1024)

@unittest.skipIf(sys.platform == "win32", "Mac layout uses Unix symlinks")
def test_applications_link_does_not_add_the_hosts_applications_to_the_volume(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
layout = root / "layout"
layout.mkdir()
outside = root / "host-applications"
outside.mkdir()
with (outside / "unrelated-app").open("wb") as stream:
stream.truncate(512 * 1024 * 1024)
before = build_installer.dmg_size_megabytes(layout)
(layout / "Applications").symlink_to(outside, target_is_directory=True)
self.assertLessEqual(build_installer.dmg_size_megabytes(layout), before + 1)

@unittest.skipIf(sys.platform == "win32", "Mac layout uses Unix symlinks")
def test_both_products_create_sized_images_and_verify_the_mounted_payload(self):
for capture in (False, True):
with self.subTest(capture=capture), tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
package, output = root / "package", root / "output"
output.mkdir()
name = "Focalet Capture" if capture else "Focalet"
app = package / f"{name}.app"
app.mkdir(parents=True)
(app / "binary").write_bytes(b"accepted signed payload")
calls = []
source = None

def run(arguments, *, check):
nonlocal source
self.assertTrue(check)
calls.append(arguments)
if arguments[0] == "ditto":
shutil.copytree(arguments[1], arguments[2], symlinks=True)
elif arguments[:2] == ["hdiutil", "create"]:
source = Path(arguments[arguments.index("-srcfolder") + 1])
self.assertEqual(arguments[arguments.index("-fs") + 1], "HFS+")
capacity = arguments[arguments.index("-size") + 1]
self.assertTrue(capacity.endswith("m"))
self.assertGreaterEqual(int(capacity[:-1]), 32)
Path(arguments[-1]).write_bytes(b"DMG fixture")
elif arguments[:2] == ["hdiutil", "attach"]:
mounted = Path(arguments[arguments.index("-mountpoint") + 1])
shutil.copytree(source, mounted, symlinks=True, dirs_exist_ok=True)

with patch.object(build_installer.platform, "system", return_value="Darwin"), \
patch.object(build_installer.subprocess, "run", side_effect=run):
asset = build_installer.macos_installer(package, output, {"architecture": "arm64"}, capture=capture)
self.assertTrue(asset.is_file())
self.assertTrue(any(call[:2] == ["hdiutil", "verify"] for call in calls))
self.assertTrue(any(call[:2] == ["codesign", "--verify"] for call in calls))
self.assertEqual(calls[-1][:2], ["hdiutil", "detach"])


if __name__ == "__main__":
unittest.main()
Loading