From 3470473e4b29f9775526c8205fb990551fdc355c Mon Sep 17 00:00:00 2001 From: Maximilian Hils Date: Fri, 3 Nov 2023 12:21:55 +0100 Subject: [PATCH] build macOS app bundle (#6447) --- .github/workflows/main.yml | 30 ++++- CHANGELOG.md | 5 + release/README.md | 10 +- release/build.py | 114 +++++++++++++++--- release/installbuilder/mitmproxy.xml | 2 +- release/selftest.py | 4 + release/specs/.mitmproxy-wrapper | 3 + release/specs/icon.icns | Bin 0 -> 716278 bytes .../specs/{windows-dir.spec => onedir.spec} | 23 +++- 9 files changed, 159 insertions(+), 32 deletions(-) mode change 100644 => 100755 release/build.py create mode 100644 release/specs/.mitmproxy-wrapper create mode 100644 release/specs/icon.icns rename release/specs/{windows-dir.spec => onedir.spec} (57%) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 416692cd4..e2b9bd6a7 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -81,7 +81,7 @@ jobs: fail-fast: false matrix: include: - - image: macos-11 + - image: macos-12 platform: macos - image: windows-2019 platform: windows @@ -104,12 +104,36 @@ jobs: path: release/installbuilder/setup key: installbuilder - run: pip install .[dev] # pyinstaller 5.9 does not like pyproject.toml + editable installs. - - run: python -u release/build.py standalone-binaries + + # macOS x64. Due to GHA limitations, we are currently building the Apple Silicon app bundle outside of CI. + - if: matrix.platform == 'macos' && github.repository == 'mitmproxy/mitmproxy' + && (startsWith(github.ref, 'refs/heads/') || startsWith(github.ref, 'refs/tags/')) + id: keychain + uses: apple-actions/import-codesign-certs@5565bb656f60c98c8fc515f3444dd8db73545dc2 + with: + keychain: ${{ runner.temp }}/temp + p12-file-base64: ${{ secrets.APPLE_CERTIFICATE }} + p12-password: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + - if: matrix.platform == 'macos' && github.repository == 'mitmproxy/mitmproxy' + && (startsWith(github.ref, 'refs/heads/') || startsWith(github.ref, 'refs/tags/')) + run: | + python -u release/build.py macos-app \ + --keychain "${{ runner.temp }}/temp.keychain" \ + --team-id "S8XHQB96PW" \ + --apple-id "${{ secrets.APPLE_ID }}" \ + --password "${{ secrets.APPLE_APP_PASSWORD }}" + + # Linux - if: matrix.platform == 'linux' - run: python -u release/build.py --dirty wheel + run: python -u release/build.py standalone-binaries wheel + + # Windows + - if: matrix.platform == 'windows' + run: python -u release/build.py standalone-binaries - if: matrix.platform == 'windows' && github.repository == 'mitmproxy/mitmproxy' && (github.ref == 'refs/heads/citest' || startsWith(github.ref, 'refs/tags/')) run: python -u release/build.py --dirty installbuilder-installer msix-installer + - uses: actions/upload-artifact@v3 with: # artifacts must have different names, see https://github.com/actions/upload-artifact/issues/24 diff --git a/CHANGELOG.md b/CHANGELOG.md index d7fb70035..a57e86ef3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,11 @@ ([#6389](https://github.com/mitmproxy/mitmproxy/pull/6389), @mhils) * Add a contentview for DNS-over-HTTPS. ([#6389](https://github.com/mitmproxy/mitmproxy/pull/6389), @mhils) +* Replaced standalone mitmproxy binaries on macOS with an app bundle + that contains the mitmproxy/mitmweb/mitmdump CLI tools. + This change was necessary to support macOS code signing requirements. + Homebrew remains the recommended installation method. + ([#6447](https://github.com/mitmproxy/mitmproxy/pull/6447), @mhils) * Fix certificate generation to work with strict mode OpenSSL 3.x clients ([#6410](https://github.com/mitmproxy/mitmproxy/pull/6410), @mmaxim) * Fix path() documentation that the return value might include the query string diff --git a/release/README.md b/release/README.md index 289a6797b..cb0d8ce01 100644 --- a/release/README.md +++ b/release/README.md @@ -4,8 +4,9 @@ 2. Invoke the [release workflow](https://github.com/mitmproxy/mitmproxy/actions/workflows/release.yml) from the GitHub UI. 3. The spawned workflow runs will require manual confirmation on GitHub which you need to approve twice: https://github.com/mitmproxy/mitmproxy/actions -4. Once everything has been deployed, update the website. -5. Verify that the front-page download links for all platforms are working. +4. Build the macOS ARM binaries outside of CI and upload them to the download server: `./build.py macos-app`. +5. Once everything has been deployed, update the website. +6. Verify that the front-page download links for all platforms are working. ### GitHub Releases @@ -40,10 +41,7 @@ ### Homebrew -- The Homebrew maintainers are typically very fast and detect our new relese - within a day. -- If you feel the need, you can run this from a macOS machine: - `brew bump-formula-pr --url https://github.com/mitmproxy/mitmproxy/archive/.tar.gz mitmproxy` +TODO: This is not current and needs to be replaced with Cask instructions. ### Website diff --git a/release/build.py b/release/build.py old mode 100644 new mode 100755 index 36d3b6e0f..9d6bdf6da --- a/release/build.py +++ b/release/build.py @@ -9,10 +9,10 @@ import shutil import subprocess import tarfile import urllib.request +import warnings import zipfile from datetime import datetime from pathlib import Path -from typing import Literal import click import cryptography.fernet @@ -88,16 +88,18 @@ def version() -> str: ) -def operating_system() -> Literal["windows", "linux", "macos", "unknown"]: - pf = platform.system() - if pf == "Windows": - return "windows" - elif pf == "Linux": - return "linux" - elif pf == "Darwin": - return "macos" - else: - return "unknown" +def operating_system() -> str: + match (platform.system(), platform.machine()): + case ("Windows", _): + return "windows" + case ("Linux", _): + return "linux" + case ("Darwin", "x86_64"): + return "macos-x86_64" + case ("Darwin", "arm64"): + return "macos-arm64" + warnings.warn("Unexpected platform.") + return f"{platform.system()}-{platform.machine()}" def _pyinstaller(specfile: str) -> None: @@ -109,7 +111,7 @@ def _pyinstaller(specfile: str) -> None: "--workpath", TEMP_DIR / "pyinstaller/temp", "--distpath", - TEMP_DIR / "pyinstaller/dist", + TEMP_DIR / "pyinstaller/out", specfile, ], cwd=here / "specs", @@ -118,14 +120,14 @@ def _pyinstaller(specfile: str) -> None: @cli.command() def standalone_binaries(): - """All platforms: Build the standalone binaries generated with PyInstaller""" + """Windows and Linux: Build the standalone binaries generated with PyInstaller""" with archive(DIST_DIR / f"mitmproxy-{version()}-{operating_system()}") as f: _pyinstaller("standalone.spec") - _test_binaries(TEMP_DIR / "pyinstaller/dist") + _test_binaries(TEMP_DIR / "pyinstaller/out") for tool in ["mitmproxy", "mitmdump", "mitmweb"]: - executable = TEMP_DIR / "pyinstaller/dist" / tool + executable = TEMP_DIR / "pyinstaller/out" / tool if platform.system() == "Windows": executable = executable.with_suffix(".exe") @@ -133,11 +135,83 @@ def standalone_binaries(): print(f"Packed {f.name!r}.") -def _ensure_pyinstaller_onedir(): - if not (TEMP_DIR / "pyinstaller/dist/onedir").exists(): - _pyinstaller("windows-dir.spec") +@cli.command() +@click.option("--keychain") +@click.option("--team-id") +@click.option("--apple-id") +@click.option("--password") +def macos_app( + keychain: str | None, + team_id: str | None, + apple_id: str | None, + password: str | None, +) -> None: + """ + macOS: Build into mitmproxy.app. - _test_binaries(TEMP_DIR / "pyinstaller/dist/onedir") + If you do not specify options, notarization is skipped. + """ + + _pyinstaller("onedir.spec") + _test_binaries(TEMP_DIR / "pyinstaller/out/mitmproxy.app/Contents/MacOS") + + if keychain: + assert isinstance(team_id, str) + assert isinstance(apple_id, str) + assert isinstance(password, str) + # Notarize the app bundle. + subprocess.check_call( + [ + "xcrun", + "notarytool", + "store-credentials", + "AC_PASSWORD", + *(["--keychain", keychain]), + *(["--team-id", team_id]), + *(["--apple-id", apple_id]), + *(["--password", password]), + ] + ) + subprocess.check_call( + [ + "ditto", + "-c", + "-k", + "--keepParent", + TEMP_DIR / "pyinstaller/out/mitmproxy.app", + TEMP_DIR / "notarize.zip", + ] + ) + subprocess.check_call( + [ + "xcrun", + "notarytool", + "submit", + TEMP_DIR / "notarize.zip", + *(["--keychain", keychain]), + *(["--keychain-profile", "AC_PASSWORD"]), + "--wait", + ] + ) + # 2023: it's not possible to staple to unix executables. + # subprocess.check_call([ + # "xcrun", + # "stapler", + # "staple", + # TEMP_DIR / "pyinstaller/out/mitmproxy.app", + # ]) + else: + warnings.warn("Notarization skipped.") + + with archive(DIST_DIR / f"mitmproxy-{version()}-{operating_system()}") as f: + f.add(str(TEMP_DIR / "pyinstaller/out/mitmproxy.app"), "mitmproxy.app") + print(f"Packed {f.name!r}.") + + +def _ensure_pyinstaller_onedir(): + if not (TEMP_DIR / "pyinstaller/out/onedir").exists(): + _pyinstaller("onedir.spec") + _test_binaries(TEMP_DIR / "pyinstaller/out/onedir") def _test_binaries(binary_directory: Path) -> None: @@ -162,7 +236,7 @@ def msix_installer(): _ensure_pyinstaller_onedir() shutil.copytree( - TEMP_DIR / "pyinstaller/dist/onedir", + TEMP_DIR / "pyinstaller/out/onedir", TEMP_DIR / "msix", dirs_exist_ok=True, ) diff --git a/release/installbuilder/mitmproxy.xml b/release/installbuilder/mitmproxy.xml index aeff2c957..43b3ae38c 100644 --- a/release/installbuilder/mitmproxy.xml +++ b/release/installbuilder/mitmproxy.xml @@ -31,7 +31,7 @@ 1 - ../build/pyinstaller/dist/onedir/* + ../build/pyinstaller/out/onedir/* run.ps1 diff --git a/release/selftest.py b/release/selftest.py index 01eae5ca0..b601772f1 100644 --- a/release/selftest.py +++ b/release/selftest.py @@ -15,6 +15,10 @@ from mitmproxy import ctx def load(_): # force a random port ctx.options.listen_port = 0 + try: + ctx.options.web_open_browser = False + except KeyError: + pass def running(): diff --git a/release/specs/.mitmproxy-wrapper b/release/specs/.mitmproxy-wrapper new file mode 100644 index 000000000..e6115a1b8 --- /dev/null +++ b/release/specs/.mitmproxy-wrapper @@ -0,0 +1,3 @@ +#!/bin/bash +dir=$(cd "$( dirname "${0}")" && pwd ) +open -a Terminal "${dir}/mitmproxy" diff --git a/release/specs/icon.icns b/release/specs/icon.icns new file mode 100644 index 0000000000000000000000000000000000000000..96b4f1420609664d4ebc20b33fc4b549588426f6 GIT binary patch literal 716278 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