376 lines
11 KiB
Python
Executable File
376 lines
11 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import os
|
|
import platform
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import tarfile
|
|
import urllib.request
|
|
import warnings
|
|
import zipfile
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
|
|
import click
|
|
import cryptography.fernet
|
|
|
|
here = Path(__file__).absolute().parent
|
|
|
|
TEMP_DIR = here / "build"
|
|
DIST_DIR = here / "dist"
|
|
|
|
|
|
@click.group(chain=True)
|
|
@click.option("--dirty", is_flag=True)
|
|
def cli(dirty):
|
|
if dirty:
|
|
print("Keeping temporary files.")
|
|
else:
|
|
print("Cleaning up temporary files...")
|
|
if TEMP_DIR.exists():
|
|
shutil.rmtree(TEMP_DIR)
|
|
if DIST_DIR.exists():
|
|
shutil.rmtree(DIST_DIR)
|
|
|
|
TEMP_DIR.mkdir()
|
|
DIST_DIR.mkdir()
|
|
|
|
|
|
@cli.command()
|
|
def wheel():
|
|
"""Build the wheel for PyPI."""
|
|
print("Building wheel...")
|
|
subprocess.check_call(
|
|
[
|
|
"python",
|
|
"-m",
|
|
"build",
|
|
"--outdir",
|
|
DIST_DIR,
|
|
]
|
|
)
|
|
if os.environ.get("GITHUB_REF", "").startswith("refs/tags/"):
|
|
ver = version() # assert for tags that the version matches the tag.
|
|
else:
|
|
ver = "*"
|
|
(whl,) = DIST_DIR.glob(f"mitmproxy-{ver}-py3-none-any.whl")
|
|
print(f"Found wheel package: {whl}")
|
|
subprocess.check_call(["tox", "-e", "wheeltest", "--", whl])
|
|
|
|
|
|
class ZipFile2(zipfile.ZipFile):
|
|
# ZipFile and tarfile have slightly different APIs. Let's fix that.
|
|
def add(self, name: str, arcname: str) -> None:
|
|
return self.write(name, arcname)
|
|
|
|
def __enter__(self) -> ZipFile2:
|
|
return self
|
|
|
|
@property
|
|
def name(self) -> str:
|
|
assert self.filename
|
|
return self.filename
|
|
|
|
|
|
def archive(path: Path) -> tarfile.TarFile | ZipFile2:
|
|
if platform.system() == "Windows":
|
|
return ZipFile2(path.with_name(f"{path.name}.zip"), "w")
|
|
else:
|
|
return tarfile.open(path.with_name(f"{path.name}.tar.gz"), "w:gz")
|
|
|
|
|
|
def version() -> str:
|
|
return os.environ.get("GITHUB_REF_NAME", "").replace("/", "-") or os.environ.get(
|
|
"BUILD_VERSION", "dev"
|
|
)
|
|
|
|
|
|
def operating_system() -> str:
|
|
match platform.system():
|
|
case "Windows":
|
|
system = "windows"
|
|
case "Linux":
|
|
system = "linux"
|
|
case "Darwin":
|
|
system = "macos"
|
|
case other:
|
|
warnings.warn("Unexpected system.")
|
|
system = other
|
|
match platform.machine():
|
|
case "AMD64" | "x86_64":
|
|
machine = "x86_64"
|
|
case "arm64":
|
|
machine = "arm64"
|
|
case other:
|
|
warnings.warn("Unexpected platform.")
|
|
machine = other
|
|
return f"{system}-{machine}"
|
|
|
|
|
|
def _pyinstaller(specfile: str) -> None:
|
|
print(f"Invoking PyInstaller with {specfile}...")
|
|
subprocess.check_call(
|
|
[
|
|
"pyinstaller",
|
|
"--clean",
|
|
"--workpath",
|
|
TEMP_DIR / "pyinstaller/temp",
|
|
"--distpath",
|
|
TEMP_DIR / "pyinstaller/out",
|
|
specfile,
|
|
],
|
|
cwd=here / "specs",
|
|
)
|
|
|
|
|
|
@cli.command()
|
|
def standalone_binaries():
|
|
"""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/out")
|
|
|
|
for tool in ["mitmproxy", "mitmdump", "mitmweb"]:
|
|
executable = TEMP_DIR / "pyinstaller/out" / tool
|
|
if platform.system() == "Windows":
|
|
executable = executable.with_suffix(".exe")
|
|
|
|
f.add(str(executable), str(executable.name))
|
|
print(f"Packed {f.name!r}.")
|
|
|
|
|
|
@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.
|
|
|
|
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:
|
|
for tool in ["mitmproxy", "mitmdump", "mitmweb"]:
|
|
executable = binary_directory / tool
|
|
if platform.system() == "Windows":
|
|
executable = executable.with_suffix(".exe")
|
|
|
|
print(f"> {tool} --version")
|
|
subprocess.check_call([executable, "--version"])
|
|
|
|
if tool == "mitmproxy":
|
|
continue # requires a TTY, which we don't have here.
|
|
|
|
print(f"> {tool} -s selftest.py")
|
|
subprocess.check_call([executable, "-s", here / "selftest.py"])
|
|
|
|
|
|
@cli.command()
|
|
def msix_installer():
|
|
"""Windows: Build the MSIX installer for the Windows Store."""
|
|
_ensure_pyinstaller_onedir()
|
|
|
|
shutil.copytree(
|
|
TEMP_DIR / "pyinstaller/out/onedir",
|
|
TEMP_DIR / "msix",
|
|
dirs_exist_ok=True,
|
|
)
|
|
shutil.copytree(here / "windows-installer", TEMP_DIR / "msix", dirs_exist_ok=True)
|
|
|
|
manifest = TEMP_DIR / "msix/AppxManifest.xml"
|
|
app_version = version()
|
|
if not re.match(r"\d+\.\d+\.\d+", app_version):
|
|
app_version = (
|
|
datetime.now()
|
|
.strftime("%y%m.%d.%H%M")
|
|
.replace(".0", ".")
|
|
.replace(".0", ".")
|
|
.replace(".0", ".")
|
|
)
|
|
manifest.write_text(manifest.read_text().replace("1.2.3", app_version))
|
|
|
|
makeappx_exe = (
|
|
Path(os.environ["ProgramFiles(x86)"])
|
|
/ "Windows Kits/10/App Certification Kit/makeappx.exe"
|
|
)
|
|
subprocess.check_call(
|
|
[
|
|
makeappx_exe,
|
|
"pack",
|
|
"/d",
|
|
TEMP_DIR / "msix",
|
|
"/p",
|
|
DIST_DIR / f"mitmproxy-{version()}-installer.msix",
|
|
],
|
|
)
|
|
assert (DIST_DIR / f"mitmproxy-{version()}-installer.msix").exists()
|
|
|
|
|
|
@cli.command()
|
|
def installbuilder_installer():
|
|
"""Windows: Build the InstallBuilder installer."""
|
|
_ensure_pyinstaller_onedir()
|
|
|
|
IB_VERSION = "23.4.0"
|
|
IB_SETUP_SHA256 = "e4ff212ed962f9e0030d918b8a6e4d6dd8a9adc8bf8bc1833459351ee649eff3"
|
|
IB_DIR = here / "installbuilder"
|
|
IB_SETUP = IB_DIR / "setup" / f"{IB_VERSION}-installer.exe"
|
|
IB_CLI = Path(
|
|
rf"C:\Program Files\InstallBuilder Enterprise {IB_VERSION}\bin\builder-cli.exe"
|
|
)
|
|
IB_LICENSE = IB_DIR / "license.xml"
|
|
|
|
if not IB_LICENSE.exists():
|
|
print("Decrypt InstallBuilder license...")
|
|
f = cryptography.fernet.Fernet(os.environ["CI_BUILD_KEY"].encode())
|
|
with (
|
|
open(IB_LICENSE.with_suffix(".xml.enc"), "rb") as infile,
|
|
open(IB_LICENSE, "wb") as outfile,
|
|
):
|
|
outfile.write(f.decrypt(infile.read()))
|
|
|
|
if not IB_CLI.exists():
|
|
if not IB_SETUP.exists():
|
|
url = (
|
|
f"https://github.com/mitmproxy/installbuilder-mirror/releases/download/"
|
|
f"{IB_VERSION}/installbuilder-enterprise-{IB_VERSION}-windows-x64-installer.exe"
|
|
)
|
|
print(f"Downloading InstallBuilder from {url}...")
|
|
|
|
def report(block, blocksize, total):
|
|
done = block * blocksize
|
|
if round(100 * done / total) != round(100 * (done - blocksize) / total):
|
|
print(f"Downloading... {round(100 * done / total)}%")
|
|
|
|
tmp = IB_SETUP.with_suffix(".tmp")
|
|
urllib.request.urlretrieve(
|
|
url,
|
|
tmp,
|
|
reporthook=report,
|
|
)
|
|
tmp.rename(IB_SETUP)
|
|
|
|
ib_setup_hash = hashlib.sha256()
|
|
with IB_SETUP.open("rb") as fp:
|
|
while True:
|
|
data = fp.read(65_536)
|
|
if not data:
|
|
break
|
|
ib_setup_hash.update(data)
|
|
if ib_setup_hash.hexdigest() != IB_SETUP_SHA256: # pragma: no cover
|
|
raise RuntimeError(
|
|
f"InstallBuilder hashes don't match: {ib_setup_hash.hexdigest()}"
|
|
)
|
|
|
|
print("Install InstallBuilder...")
|
|
subprocess.run(
|
|
[IB_SETUP, "--mode", "unattended", "--unattendedmodeui", "none"], check=True
|
|
)
|
|
assert IB_CLI.is_file()
|
|
|
|
print("Run InstallBuilder...")
|
|
subprocess.check_call(
|
|
[
|
|
IB_CLI,
|
|
"build",
|
|
str(IB_DIR / "mitmproxy.xml"),
|
|
"windows-x64",
|
|
"--license",
|
|
str(IB_LICENSE),
|
|
"--setvars",
|
|
f"project.version={version()}",
|
|
"--verbose",
|
|
],
|
|
cwd=IB_DIR,
|
|
)
|
|
installer = DIST_DIR / f"mitmproxy-{version()}-windows-x64-installer.exe"
|
|
assert installer.exists()
|
|
|
|
# unify filenames
|
|
installer = installer.rename(
|
|
installer.with_name(installer.name.replace("x64", "x86_64"))
|
|
)
|
|
|
|
print("Run installer...")
|
|
subprocess.run(
|
|
[installer, "--mode", "unattended", "--unattendedmodeui", "none"], check=True
|
|
)
|
|
_test_binaries(Path(r"C:\Program Files\mitmproxy\bin"))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
cli()
|