From d02ad8fb659eb750ec0504a29c8aa908489971f9 Mon Sep 17 00:00:00 2001 From: diego-ferrand Date: Fri, 6 Nov 2026 11:40:41 -0300 Subject: [PATCH] Ship the macOS build as a signed one-folder app. --- build.py | 192 ++++++++++++++++++++++------------ packaging/macos.spec.template | 64 ++++++++++++ 2 files changed, 188 insertions(+), 68 deletions(-) create mode 100644 packaging/macos.spec.template diff --git a/build.py b/build.py index ae05432..aca3089 100644 --- a/build.py +++ b/build.py @@ -2,7 +2,9 @@ """Build script for creating PyInstaller binary.""" import os import platform +import plistlib import shutil +import string import subprocess import tomllib from datetime import date @@ -19,14 +21,18 @@ def clean_build(): shutil.rmtree(build_dir) -def build_version_file(): +def read_project_metadata() -> dict: pyproject = Path(__file__).parent / "pyproject.toml" with open(pyproject, "rb") as f: data = tomllib.load(f) + return data["project"] + - version = data["project"]["version"] - name = data["project"]["name"] - description = data["project"]["description"] +def build_version_file(): + project = read_project_metadata() + version = project["version"] + name = project["name"] + description = project["description"] nums = tuple(int(x) for x in version.split(".")) + (0,) * (4 - len(version.split("."))) @@ -88,8 +94,17 @@ def get_icon_file(system: str) -> str: return 'app.icns' if system == 'macos' else 'app.ico' -def run_pyinstaller(name: str, icon: str): - hiddenimports = [ +APP_EXECUTABLE = "perfecto-mcp" +COLLECT_DIR_NAME = "perfecto-mcp" + + +def bundle_mode(system: str) -> str: + """macOS ships a PyInstaller folder so launch skips the one-file unpack.""" + return "onedir" if system == "macos" else "onefile" + + +def hiddenimports() -> list[str]: + return [ "tools.ai_scriptless", "tools.ai_scriptless.commands", "tools.ai_scriptless.elements", @@ -99,8 +114,21 @@ def run_pyinstaller(name: str, icon: str): "tools.ai_scriptless.step_path", "tools.ai_scriptless.tree", "tools.ai_scriptless.variables", + "opentelemetry.sdk.trace", + "opentelemetry.sdk.trace.export", + "opentelemetry.sdk.resources", + "opentelemetry.sdk.metrics", + "opentelemetry.sdk.metrics.export", + "opentelemetry.exporter.otlp.proto.grpc.trace_exporter", + "opentelemetry.exporter.otlp.proto.grpc.metric_exporter", + "opentelemetry.exporter.otlp.proto.http.trace_exporter", + "opentelemetry.exporter.otlp.proto.http.metric_exporter", + "opentelemetry.propagate", ] - PyInstaller.__main__.run([ + + +def pyinstaller_args(name: str, icon: str) -> list[str]: + return [ 'main.py', '--onefile', '--version-file=version_info.txt', @@ -110,23 +138,45 @@ def run_pyinstaller(name: str, icon: str): f'--icon={icon}', '--clean', '--noconfirm', - *[f'--hidden-import={module}' for module in hiddenimports], - '--hidden-import=opentelemetry.sdk.trace', - '--hidden-import=opentelemetry.sdk.trace.export', - '--hidden-import=opentelemetry.sdk.resources', - '--hidden-import=opentelemetry.sdk.metrics', - '--hidden-import=opentelemetry.sdk.metrics.export', - '--hidden-import=opentelemetry.exporter.otlp.proto.grpc.trace_exporter', - '--hidden-import=opentelemetry.exporter.otlp.proto.grpc.metric_exporter', - '--hidden-import=opentelemetry.exporter.otlp.proto.http.trace_exporter', - '--hidden-import=opentelemetry.exporter.otlp.proto.http.metric_exporter', - '--hidden-import=opentelemetry.propagate', + *[f'--hidden-import={module}' for module in hiddenimports()], '--collect-submodules=opentelemetry', '--collect-all=grpc', # Streamable HTTP transport: uvicorn resolves its loop/protocol # implementations by name at runtime, so PyInstaller cannot see them. '--collect-submodules=uvicorn', - ]) + ] + + +MACOS_SPEC_TEMPLATE = Path(__file__).parent / "packaging" / "macos.spec.template" + + +def macos_spec(arch: str, icon: str, version: str) -> str: + """Render the macOS onedir spec from packaging/macos.spec.template.""" + template = string.Template(MACOS_SPEC_TEMPLATE.read_text(encoding="utf-8")) + return template.substitute( + hiddenimports=", ".join(repr(name) for name in hiddenimports()), + executable_name=repr(APP_EXECUTABLE), + icon=repr(icon), + collect_name=repr(COLLECT_DIR_NAME), + app_name=repr(f"perfecto-mcp-{arch}.app"), + version=repr(version), + ) + + +def run_pyinstaller(name: str, icon: str): + PyInstaller.__main__.run(pyinstaller_args(name, icon)) + + +def run_macos_bundle(arch: str, icon: str, dist_dir: Path): + version = read_project_metadata()["version"] + spec_path = Path("perfecto-mcp-macos.spec") + spec_path.write_text(macos_spec(arch, icon, version), encoding="utf-8") + try: + PyInstaller.__main__.run([str(spec_path), "--clean", "--noconfirm"]) + finally: + spec_path.unlink(missing_ok=True) + clean_build() + finalize_macos_app(arch, dist_dir) def build(): @@ -136,27 +186,15 @@ def build(): arch = normalize_architecture(platform.machine().lower()) name = get_binary_name(system, arch) icon = get_icon_file(system) + dist_dir = Path("dist") - run_pyinstaller(name, icon) - clean_build() - - if system == "macos": - create_app_bundle(name, arch, dist_dir=Path("dist")) - elif system == "linux": - create_sha256_checksum(name, dist_dir=Path("dist")) - - -def create_app_directory_structure(app_path: Path) -> Path: - macos_path = app_path / "Contents" / "MacOS" - macos_path.mkdir(parents=True, exist_ok=True) - return macos_path - - -def copy_binary_to_app(binary_path: Path, target_path: Path): - if not binary_path.exists(): - raise FileNotFoundError(f"Binary not found: {binary_path}") - shutil.copy2(binary_path, target_path) - os.chmod(target_path, 0o755) + if bundle_mode(system) == "onedir": + run_macos_bundle(arch, icon, dist_dir) + else: + run_pyinstaller(name, icon) + clean_build() + if system == "linux": + create_sha256_checksum(name, dist_dir=dist_dir) def create_launcher_script(launcher_path: Path): @@ -177,44 +215,62 @@ def create_launcher_script(launcher_path: Path): os.chmod(launcher_path, 0o755) -def create_info_plist(plist_path: Path): - info_plist_content = """ - - - - CFBundleExecutable - launcher.sh +def configure_launcher_plist(plist_path: Path) -> None: + """Point the bundle at launcher.sh so a double-click opens Terminal.""" + with plist_path.open("rb") as fh: + info = plistlib.load(fh) + info["CFBundleExecutable"] = "launcher.sh" + info["CFBundleName"] = "Perfecto MCP" + info["CFBundleDisplayName"] = "Perfecto MCP" + info["CFBundleIdentifier"] = "com.perfecto.mcp" + info.pop("LSBackgroundOnly", None) + with plist_path.open("wb") as fh: + plistlib.dump(info, fh) + + +def codesign_command(app_path: Path, identity: str | None = None) -> list[str]: + """Sign the .app and every nested Mach-O produced by the onedir bundle. + + ``--deep`` is required. Signing only the outer bundle leaves the libraries + in ``Contents/Frameworks`` unsigned. Set ``MACOS_CODESIGN_IDENTITY`` to a + Developer ID; otherwise the bundle is ad-hoc signed. + """ + if identity is None: + identity = os.environ.get("MACOS_CODESIGN_IDENTITY", "-").strip() or "-" + return [ + "codesign", + "--force", + "--deep", + "--sign", + identity, + str(app_path), + ] - CFBundleIdentifier - com.perfecto.mcp - CFBundleName - Perfecto MCP +def sign_app_bundle(app_path: Path) -> None: + subprocess.run(codesign_command(app_path), check=True) + subprocess.run( + ["codesign", "--verify", "--deep", "--strict", "--verbose=2", str(app_path)], + check=True, + ) - CFBundlePackageType - APPL - - -""" - with open(plist_path, "w", encoding="utf-8") as f: - f.write(info_plist_content) - -def create_app_bundle(binary_name: str, arch: str, dist_dir: Path): +def finalize_macos_app(arch: str, dist_dir: Path) -> None: app_name = f"perfecto-mcp-{arch}.app" app_path = dist_dir / app_name - contents_path = app_path / "Contents" - - macos_path = create_app_directory_structure(app_path) - - binary_path = dist_dir / binary_name - copy_binary_to_app(binary_path, macos_path / "perfecto-mcp") + macos_path = app_path / "Contents" / "MacOS" + executable = macos_path / APP_EXECUTABLE + if not executable.is_file(): + raise FileNotFoundError(f"Bundle executable not found: {executable}") create_launcher_script(macos_path / "launcher.sh") - create_info_plist(contents_path / "Info.plist") + configure_launcher_plist(app_path / "Contents" / "Info.plist") + + collect_dir = dist_dir / COLLECT_DIR_NAME + if collect_dir.exists(): + shutil.rmtree(collect_dir) - binary_path.unlink() + sign_app_bundle(app_path) print(f"Created {app_name} in {dist_dir}") diff --git a/packaging/macos.spec.template b/packaging/macos.spec.template new file mode 100644 index 0000000..01a68d3 --- /dev/null +++ b/packaging/macos.spec.template @@ -0,0 +1,64 @@ +# -*- mode: python ; coding: utf-8 -*- +# Placeholders ($hiddenimports, $executable_name, $icon, $collect_name, +# $app_name, $version) are Python expressions substituted by build.py. +from PyInstaller.utils.hooks import collect_all, collect_submodules + +datas = [('pyproject.toml', '.'), ('resources', 'resources')] +binaries = [] +hiddenimports = [$hiddenimports] +hiddenimports += collect_submodules('opentelemetry') +hiddenimports += collect_submodules('uvicorn') +tmp_ret = collect_all('grpc') +datas += tmp_ret[0] +binaries += tmp_ret[1] +hiddenimports += tmp_ret[2] + +a = Analysis( + ['main.py'], + pathex=[], + binaries=binaries, + datas=datas, + hiddenimports=hiddenimports, + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=[], + noarchive=False, + optimize=0, +) +pyz = PYZ(a.pure) + +exe = EXE( + pyz, + a.scripts, + [], + exclude_binaries=True, + name=$executable_name, + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + console=True, + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, + icon=[$icon], +) +coll = COLLECT( + exe, + a.binaries, + a.datas, + strip=False, + upx=True, + upx_exclude=[], + name=$collect_name, +) +app = BUNDLE( + coll, + name=$app_name, + icon=$icon, + bundle_identifier='com.perfecto.mcp', + version=$version, +)