├── .gitignore ├── LICENSE ├── README.rst ├── pyproject.toml └── src └── encrust ├── __init__.py ├── _architecture.py ├── _build.py ├── _dosetup.py ├── _signing.py ├── _spawnutil.py ├── _type.py ├── _zip.py ├── api.py ├── cli.py ├── py.typed └── required-python-entitlements.plist /.gitignore: -------------------------------------------------------------------------------- 1 | /dist 2 | /build 3 | 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright 4 | (c) 2023 Glyph 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | 24 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | Encrust 2 | ============================== 3 | 4 | Automate all the steps to add the flavorful, savory crust that macOS 5 | applications written in Python require to launch, which is to say: 6 | 7 | - universal2 binaries 8 | - code signing 9 | - notarization 10 | - archiving 11 | 12 | Run ``encrust configure`` for an explanation of how to set it up globally on 13 | your computer, then ``encrust release`` to run it for a particular project. 14 | 15 | You will also need an ``encrust_setup.py`` to configure it for your project. 16 | 17 | The documentation for how to use Encrust is, unfortunately, extremely thin, but 18 | there are working examples in the `PINPal 19 | `_ and 20 | `Pomodouroboros 21 | `_ 22 | projects. Please feel free to `file an issue 23 | `_ if you have questions! 24 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [tool.setuptools.package-data] 6 | "*" = [ 7 | "py.typed", 8 | "*.plist", 9 | ] 10 | 11 | [project] 12 | name = "encrust" 13 | description = "Sign, notarize, staple and archive Python mac apps." 14 | readme = "README.rst" 15 | version = "2025.4.4.2" 16 | dependencies = [ 17 | "twisted", 18 | "click", 19 | "delocate >= 0.12.0", 20 | "py2app", 21 | "wheel_filename", 22 | ] 23 | 24 | [project.optional-dependencies] 25 | mypy = [ 26 | "mypy", 27 | "mypy-zope", 28 | ] 29 | 30 | [project.scripts] 31 | encrust = "encrust.cli:main" 32 | 33 | [project.urls] 34 | Homepage = "https://github.com/glyph/Encrust" 35 | 36 | [tool.mypy] 37 | plugins = ["mypy_zope:plugin"] 38 | -------------------------------------------------------------------------------- /src/encrust/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/glyph/Encrust/7a39a67ed2233ffe1b60a0323b2105eb0fca4c2a/src/encrust/__init__.py -------------------------------------------------------------------------------- /src/encrust/_architecture.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from collections import defaultdict 4 | from dataclasses import dataclass 5 | from enum import Enum, auto 6 | from tempfile import NamedTemporaryFile 7 | from typing import AsyncIterable, Iterable 8 | 9 | from ._spawnutil import c, parallel 10 | from twisted.internet.defer import Deferred 11 | from twisted.python.filepath import FilePath 12 | from twisted.python.procutils import which 13 | from wheel_filename import ParsedWheelFilename, parse_wheel_filename 14 | 15 | 16 | class KnownArchitecture(Enum): 17 | """ 18 | One of the known binary architectures that a wheel can support on macOS; 19 | intel, arm, both, or pure-python. 20 | """ 21 | x86_64 = auto() 22 | arm64 = auto() 23 | universal2 = auto() 24 | purePython = auto() 25 | 26 | 27 | @dataclass(frozen=True) 28 | class PlatformSpecifics: 29 | """ 30 | A data structure representing the specific details of a macOS platform 31 | description for a python wheel; i.e. operating system version and binary 32 | architecture. 33 | """ 34 | 35 | os: str 36 | major: int 37 | minor: int 38 | architecture: KnownArchitecture 39 | 40 | 41 | def specifics(pwf: ParsedWheelFilename) -> Iterable[PlatformSpecifics]: 42 | """ 43 | Enumerate the specific macOS platforms supported by a given wheel based on 44 | its filename. 45 | """ 46 | for tag in pwf.platform_tags: 47 | splitted = tag.split("_", 3) 48 | print("split", splitted) 49 | if len(splitted) != 4: 50 | continue 51 | os, major, minor, arch = splitted 52 | try: 53 | parsedArch = KnownArchitecture[arch] 54 | except ValueError: 55 | continue 56 | yield PlatformSpecifics(os, int(major), int(minor), parsedArch) 57 | 58 | 59 | def wheelNameArchitecture(pwf: ParsedWheelFilename) -> KnownArchitecture: 60 | """ 61 | Determine the architecture from a wheel. 62 | """ 63 | if pwf.abi_tags == ["none"] and pwf.platform_tags == ["any"]: 64 | return KnownArchitecture.purePython 65 | allSpecifics = list(specifics(pwf)) 66 | if len(allSpecifics) != 1: 67 | raise ValueError( 68 | f"don't know how to handle multi-tag wheels {pwf!r} {allSpecifics!r}" 69 | ) 70 | return allSpecifics[0].architecture 71 | 72 | 73 | @dataclass 74 | class FusedPair: 75 | arm64: FilePath[str] | None = None 76 | x86_64: FilePath[str] | None = None 77 | universal2: FilePath[str] | None = None 78 | 79 | 80 | async def findSingleArchitectureBinaries( 81 | paths: Iterable[FilePath[str]], 82 | ) -> AsyncIterable[FilePath[str]]: 83 | """ 84 | Find any binaries under a given path that are single-architecture (i.e. 85 | those that will not run on an older Mac because they're fat binary). 86 | """ 87 | checkedSoFar = 0 88 | 89 | async def checkOne(path: FilePath[str]) -> tuple[FilePath[str], bool]: 90 | """ 91 | Check the given path for a single-architecture binary, returning True 92 | if it is one and False if not. 93 | """ 94 | nonlocal checkedSoFar 95 | if path.islink(): 96 | return path, False 97 | if not path.isfile(): 98 | return path, False 99 | checkedSoFar += 1 100 | # universal binaries begin "Mach-O universal binary with 2 architectures" 101 | if (checkedSoFar % 1000) == 0: 102 | print("?", end="", flush=True) 103 | isSingle = (await c.file("-b", path.path, quiet=True)).output.startswith( 104 | b"Mach-O 64-bit bundle" 105 | ) 106 | return path, isSingle 107 | 108 | async for eachPath, isSingleBinary in parallel( 109 | ( 110 | checkOne(subpath) 111 | for path in paths 112 | for subpath in path.walk( 113 | # if we are in a virtualenv, but our system site packages has 114 | # single-architecture binaries installed, we should not be 115 | # concerned with those, as they're not on our import path - 116 | # even though they *do* live below an entry on sys.path (the 117 | # stdlib). 118 | lambda sub: sub.basename() 119 | != "site-packages" 120 | ) 121 | ), 122 | 16, 123 | ): 124 | if isSingleBinary: 125 | yield eachPath 126 | 127 | 128 | def determineNeedsFusing( 129 | downloadDir: str, fusedDir: str 130 | ) -> Iterable[tuple[tuple[str, str], FusedPair]]: 131 | needsFusing: defaultdict[tuple[str, str], FusedPair] = defaultdict(FusedPair) 132 | 133 | for child in FilePath(downloadDir).children(): 134 | # every wheel in this list should either be architecture-independent, 135 | # universal2, *or* have *both* arm64 and x86_64 versions. 136 | pwf = parse_wheel_filename(child.basename()) 137 | arch = wheelNameArchitecture(pwf) 138 | fusedPath = FilePath(fusedDir).child(child.basename()) 139 | if arch == KnownArchitecture.purePython: 140 | child.moveTo(fusedPath) 141 | continue 142 | # OK we need to fuse a wheel 143 | fusor = needsFusing[(pwf.project, pwf.version)] 144 | if arch == KnownArchitecture.x86_64: 145 | fusor.x86_64 = child 146 | if arch == KnownArchitecture.arm64: 147 | fusor.arm64 = child 148 | if arch == KnownArchitecture.universal2: 149 | child.moveTo(fusedPath) 150 | fusor.universal2 = fusedPath 151 | return needsFusing.items() 152 | 153 | 154 | async def fuseOne( 155 | tmpDir: str, fusedDir: str, name: str, version: str, fusor: FusedPair 156 | ) -> None: 157 | if fusor.universal2 is not None: 158 | print(f"{name} has universal2; skipping") 159 | return 160 | 161 | left = fusor.arm64 162 | if left is None: 163 | raise RuntimeError(f"no arm64 architecture for {name}") 164 | right = fusor.x86_64 165 | if right is None: 166 | raise RuntimeError(f"no x86_64 architecture for {name}") 167 | await c["delocate-merge"]( 168 | "--verbose", f"--wheel-dir={fusedDir}", left.path, right.path 169 | ) 170 | 171 | 172 | async def fixArchitectures() -> None: 173 | """ 174 | Ensure that all wheels installed in the current virtual environment are 175 | universal2, not x86_64 or arm64. 176 | 177 | This probably only works on an arm64 (i.e., Apple Silicon) machine since it 178 | requires the ability to run C{pip} under both architectures. 179 | """ 180 | downloadDir = ".wheels/downloaded" 181 | tmpDir = ".wheels/tmp" 182 | fusedDir = ".wheels/fused" 183 | 184 | output = (await c.pip("freeze")).output.decode("utf-8") 185 | with NamedTemporaryFile(delete=False) as f: 186 | for line in output.split("\n"): 187 | if (":" not in line) and ("/" not in line) and (not line.startswith("-e")): 188 | f.write((line + "\n").encode("utf-8")) 189 | 190 | await c.mkdir("-p", downloadDir, fusedDir, tmpDir) 191 | for arch in ["arm64", "x86_64"]: 192 | await c.arch( 193 | f"-{arch}", 194 | which("pip")[0], 195 | "wheel", 196 | "-r", 197 | f.name, 198 | "-w", 199 | downloadDir, 200 | ) 201 | 202 | async for each in parallel( 203 | fuseOne(tmpDir, fusedDir, name, version, fusor) 204 | for ((name, version), fusor) in determineNeedsFusing(downloadDir, fusedDir) 205 | ): 206 | pass 207 | 208 | await c.pip( 209 | "install", 210 | "--no-index", 211 | "--find-links", 212 | fusedDir, 213 | "--force", 214 | "--requirement", 215 | f.name, 216 | ) 217 | 218 | 219 | start = Deferred.fromCoroutine 220 | 221 | 222 | async def validateArchitectures( 223 | paths: Iterable[FilePath[str]], report: bool = True 224 | ) -> bool: 225 | """ 226 | Ensure that there are no problematic single-architecture binaries in a 227 | given directory. 228 | """ 229 | success = True 230 | async for eachBinary in findSingleArchitectureBinaries(paths): 231 | if ( 232 | # exclude py2app prebuilt executable stubs 233 | ( 234 | eachBinary.basename() in {"main-x86_64", "main-arm64"} 235 | and eachBinary.parent().basename() == "prebuilt" 236 | ) 237 | or 238 | # exclude debugpy attach stubs 239 | ( 240 | eachBinary.basename() == "attach_x86_64.dylib" 241 | and eachBinary.parent().basename() == "pydevd_attach_to_process" 242 | ) 243 | or 244 | # exclude delocate's own tests 245 | ( 246 | eachBinary.parent().basename() == "data" 247 | and eachBinary.parent().parent().basename() == "tests" 248 | and eachBinary.parent().parent().parent().basename() == "delocate" 249 | ) 250 | ): 251 | continue 252 | if report: 253 | print() 254 | print(f"single-architecture binary: {eachBinary.path}") 255 | success = False 256 | return success 257 | -------------------------------------------------------------------------------- /src/encrust/_build.py: -------------------------------------------------------------------------------- 1 | """ 2 | Future work: 3 | 4 | - integrate cocoapods 5 | """ 6 | 7 | from __future__ import annotations 8 | 9 | import sys 10 | from dataclasses import dataclass 11 | from json import load 12 | from os.path import abspath, expanduser 13 | from typing import Iterable 14 | 15 | from twisted.python.filepath import FilePath 16 | from twisted.python.modules import getModule 17 | 18 | from ._architecture import fixArchitectures, validateArchitectures 19 | from ._signing import CodeSigner, notarize, signablePathsIn 20 | from ._spawnutil import c 21 | from ._zip import createZipFile 22 | 23 | 24 | @dataclass 25 | class AppSigner: 26 | notarizeProfile: str 27 | appleID: str 28 | teamID: str 29 | identityHash: str 30 | entitlementsPath: FilePath[str] = getModule(__name__).filePath.sibling( 31 | "required-python-entitlements.plist" 32 | ) 33 | 34 | 35 | @dataclass 36 | class AppBuilder: 37 | """ 38 | A builder for a particular application 39 | """ 40 | 41 | name: str 42 | version: str 43 | _signer: AppSigner | None = None 44 | 45 | async def signingConfiguration(self) -> AppSigner: 46 | """ 47 | Load the global signing configuration. 48 | """ 49 | if self._signer is None: 50 | with open(expanduser("~/.encrust.json")) as f: 51 | obj = load(f) 52 | self._signer = AppSigner( 53 | identityHash=obj["identity"], 54 | notarizeProfile=obj["profile"], 55 | appleID=obj["appleID"], 56 | teamID=obj["teamID"], 57 | ) 58 | return self._signer 59 | 60 | async def release(self) -> None: 61 | """ 62 | Execute the release end to end; build, sign, archive, notarize, staple. 63 | """ 64 | await self.fattenEnvironment() 65 | await self.build() 66 | archOK = await validateArchitectures([self.originalAppPath()], True) 67 | if not archOK: 68 | raise RuntimeError() 69 | await self.signApp() 70 | await self.notarizeApp() 71 | 72 | async def fattenEnvironment(self) -> None: 73 | """ 74 | Ensure the current virtualenv has all universal2 "fat" binaries. 75 | """ 76 | pathEntries = [FilePath(each) for each in sys.path if each] 77 | needsFattening = not await validateArchitectures(pathEntries) 78 | if not needsFattening: 79 | print("already ok") 80 | return 81 | await fixArchitectures() 82 | stillNeedsFattening = not await validateArchitectures(pathEntries, True) 83 | if stillNeedsFattening: 84 | raise RuntimeError( 85 | "single-architecture binaries still exist after fattening: {stillNeedsFattening}" 86 | ) 87 | print("all relevant binaries now universal2") 88 | 89 | def archivePath(self, variant: str) -> FilePath[str]: 90 | """ 91 | The path where we should archive our zip file. 92 | """ 93 | return FilePath("dist").child(f"{self.name}-{self.version}.{variant}.app.zip") 94 | 95 | async def archiveApp(self, variant: str) -> FilePath[str]: 96 | """ """ 97 | archivedAt = self.archivePath(variant) 98 | await createZipFile(archivedAt, self.originalAppPath()) 99 | return archivedAt 100 | 101 | async def build(self, *options: str) -> None: 102 | """ 103 | Invoke py2app to build a copy of the application, with the given py2app 104 | options. 105 | """ 106 | await c.python( 107 | "-m", 108 | "encrust._dosetup", 109 | "py2app", 110 | *options, 111 | workingDirectory=abspath("."), 112 | ) 113 | 114 | async def authenticateForSigning(self, password: str) -> None: 115 | """ 116 | Prompt the user to authenticate for code-signing and notarization. 117 | """ 118 | sign = await self.signingConfiguration() 119 | await c.xcrun( 120 | "notarytool", 121 | "store-credentials", 122 | sign.notarizeProfile, 123 | "--apple-id", 124 | sign.appleID, 125 | "--team-id", 126 | sign.teamID, 127 | "--password", 128 | password, 129 | ) 130 | 131 | def originalAppPath(self) -> FilePath[str]: 132 | """ 133 | A L{FilePath} pointing at the application (prior to notarization). 134 | """ 135 | return FilePath("./dist").child(self.name + ".app") 136 | 137 | def signablePaths(self) -> Iterable[FilePath]: 138 | return signablePathsIn(self.originalAppPath()) 139 | 140 | async def signApp(self) -> None: 141 | """ 142 | Find all binary files which need to be signed within the bundle and run 143 | C{codesign} to sign them. 144 | """ 145 | sign = await self.signingConfiguration() 146 | signer = CodeSigner( 147 | self.originalAppPath(), 148 | sign.identityHash, 149 | sign.entitlementsPath, 150 | ) 151 | await signer.sign() 152 | 153 | async def notarizeApp(self) -> None: 154 | """ 155 | Submit the built application to Apple for notarization and wait until we 156 | have seen a response. 157 | """ 158 | sign = await self.signingConfiguration() 159 | preReleasePath = await self.archiveApp("for-notarizing") 160 | await notarize( 161 | appleID=sign.appleID, 162 | teamID=sign.teamID, 163 | archivePath=preReleasePath, 164 | applicationPath=self.originalAppPath(), 165 | notarizeProfile=sign.notarizeProfile, 166 | ) 167 | await self.archiveApp("release") 168 | preReleasePath.remove() 169 | -------------------------------------------------------------------------------- /src/encrust/_dosetup.py: -------------------------------------------------------------------------------- 1 | """ 2 | A script to replace setuptools' `setup.py`, so we can invoke py2app directly. 3 | """ 4 | 5 | if __name__ == '__main__': 6 | import sys 7 | 8 | sys.path.append(".") 9 | sys.argv[0] = "encrust-setup.py" 10 | 11 | from encrust_setup import description # type:ignore[import-not-found] 12 | from setuptools import setup # type:ignore[import-untyped] 13 | 14 | setup(**description.setupOptions()) 15 | -------------------------------------------------------------------------------- /src/encrust/_signing.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass, field 4 | from typing import Iterable 5 | 6 | from twisted.internet.defer import Deferred 7 | from twisted.python.filepath import FilePath 8 | 9 | from ._spawnutil import c, parallel 10 | 11 | 12 | @dataclass 13 | class CodeSigner: 14 | bundle: FilePath[str] 15 | codesigningIdentity: str 16 | entitlements: FilePath[str] 17 | progress: dict[FilePath[str], Deferred[None]] = field(default_factory=dict) 18 | 19 | async def sign(self) -> None: 20 | active = 0 21 | async def signOneFile(fileToSign: FilePath[str]) -> None: 22 | """ 23 | Code sign a single file. 24 | """ 25 | nonlocal active 26 | fileStr = fileToSign.path 27 | entitlementsStr = self.entitlements.path 28 | print(f"code signing (|| {active}/{len(self.progress)}) {fileToSign}", flush=True) 29 | allChildren = [] 30 | for eachMaybeChild in self.progress: 31 | if fileToSign in eachMaybeChild.parents(): 32 | allChildren.append((eachMaybeChild, self.progress[eachMaybeChild])) 33 | self.progress[fileToSign] = Deferred() 34 | for pn, toAwait in allChildren: 35 | print(f"waiting for {pn.path!r}…") 36 | await toAwait 37 | print(f"done waiting for {pn.path!r}!") 38 | try: 39 | active += 1 40 | await c.codesign( 41 | "--sign", 42 | self.codesigningIdentity, 43 | "--entitlements", 44 | entitlementsStr, 45 | "--force", 46 | "--options", 47 | "runtime", 48 | fileStr, 49 | ) 50 | finally: 51 | self.progress.pop(fileToSign).callback(None) 52 | active -= 1 53 | print(f"finished signing (|| {active}/{len(self.progress)}) {fileToSign}", flush=True) 54 | 55 | async for signResult in parallel( 56 | (signOneFile(p) for p in signablePathsIn(self.bundle)) 57 | ): 58 | pass 59 | 60 | 61 | MACH_O_MAGIC = { 62 | b"\xca\xfe\xba\xbe", 63 | b"\xcf\xfa\xed\xfe", 64 | b"\xce\xfa\xed\xfe", 65 | b"\xbe\xba\xfe\xca", 66 | b"\xfe\xed\xfa\xcf", 67 | b"\xfe\xed\xfa\xce", 68 | } 69 | 70 | 71 | def hasMachOMagic(p: FilePath[str]) -> bool: 72 | with p.open("r") as f: 73 | magic = f.read(4) 74 | return magic in MACH_O_MAGIC 75 | 76 | 77 | def signablePathsIn(topPath: FilePath[str]) -> Iterable[FilePath[str]]: 78 | """ 79 | What files need to be individually code-signed within a given bundle? 80 | """ 81 | built = [] 82 | for p in topPath.walk(lambda subp: (not subp.islink() and subp.isdir())): 83 | if p.islink(): 84 | continue 85 | ext = p.splitext()[-1] 86 | if p.isfile(): 87 | if ext == "": 88 | if hasMachOMagic(p): 89 | built.append(p) 90 | if ext in {".so", ".dylib", ".a"}: 91 | built.append(p) 92 | if p.isdir(): 93 | if ext in {".framework", ".app", ".xpc"}: 94 | built.append(p) 95 | return reversed(built) 96 | 97 | 98 | async def notarize( 99 | *, 100 | archivePath: FilePath[str], 101 | applicationPath: FilePath[str], 102 | appleID: str, 103 | teamID: str, 104 | notarizeProfile: str, 105 | ) -> None: 106 | """ 107 | Submit the signed bundle for notarization, wait for success, then notarize 108 | it. 109 | """ 110 | await c.xcrun( 111 | "notarytool", 112 | "submit", 113 | archivePath.path, 114 | f"--apple-id={appleID}", 115 | f"--team-id={teamID}", 116 | f"--keychain-profile={notarizeProfile}", 117 | "--wait", 118 | ) 119 | await c.xcrun("stapler", "staple", applicationPath.path) 120 | -------------------------------------------------------------------------------- /src/encrust/_spawnutil.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from collections import deque 4 | from dataclasses import dataclass 5 | from os import environ 6 | from typing import ( 7 | AsyncIterable, 8 | Awaitable, 9 | Coroutine, 10 | Deque, 11 | Iterable, 12 | Mapping, 13 | Sequence, 14 | ) 15 | 16 | 17 | from twisted.internet.defer import Deferred, DeferredSemaphore 18 | from twisted.internet.error import ProcessDone, ProcessTerminated 19 | from twisted.internet.interfaces import IReactorProcess 20 | from twisted.internet.protocol import ProcessProtocol 21 | from twisted.python.failure import Failure 22 | from twisted.python.procutils import which 23 | 24 | from ._type import T, R 25 | 26 | 27 | @dataclass 28 | class ProcessResult: 29 | """ 30 | The result of running a process to completion. 31 | """ 32 | 33 | status: int 34 | output: bytes 35 | invocation: Invocation 36 | 37 | def check(self) -> None: 38 | """ 39 | make sure that this process didn't exit with error 40 | """ 41 | if self.status != 0: 42 | raise RuntimeError( 43 | f"process {self.invocation.executable} {self.invocation.argv} " 44 | f"exited with error {self.status}\n" 45 | f"{self.output.decode('utf-8', 'replace')}" 46 | ) 47 | 48 | 49 | @dataclass 50 | class InvocationProcessProtocol(ProcessProtocol): 51 | def __init__(self, invocation: Invocation, quiet: bool) -> None: 52 | super().__init__() 53 | self.invocation = invocation 54 | self.d = Deferred[int]() 55 | self.quiet = quiet 56 | self.output = b"" 57 | self.errors = b"" 58 | 59 | def show(self, data: bytes) -> None: 60 | if not self.quiet: 61 | print( 62 | f"{self.invocation.executable} {' '.join(self.invocation.argv)}:", 63 | data.decode("utf-8", "replace").rstrip("\n"), 64 | ) 65 | 66 | def outReceived(self, outData: bytes) -> None: 67 | self.output += outData 68 | self.show(outData) 69 | 70 | def errReceived(self, errData: bytes) -> None: 71 | self.errors += errData 72 | self.show(errData) 73 | 74 | def processEnded(self, reason: Failure) -> None: 75 | pd: ProcessDone | ProcessTerminated = reason.value 76 | self.d.callback(pd.exitCode) 77 | 78 | 79 | @dataclass 80 | class Invocation: 81 | """ 82 | A full command-line to be invoked. 83 | """ 84 | 85 | executable: str 86 | argv: Sequence[str] 87 | 88 | async def __call__( 89 | self, 90 | *, 91 | env: Mapping[str, str] = environ, 92 | quiet: bool = False, 93 | workingDirectory: str | None = None, 94 | ) -> ProcessResult: 95 | from twisted.internet import reactor 96 | 97 | ipp = InvocationProcessProtocol(self, quiet) 98 | IReactorProcess(reactor).spawnProcess( 99 | ipp, 100 | self.executable, 101 | [self.executable, *self.argv], 102 | environ, 103 | workingDirectory, 104 | ) 105 | value = await ipp.d 106 | if value != 0: 107 | raise RuntimeError( 108 | f"{self.executable} {self.argv} exited with error {value}" 109 | ) 110 | return ProcessResult(value, ipp.output, self) 111 | 112 | 113 | @dataclass 114 | class Command: 115 | """ 116 | A command is a reference to a potential executable on $PATH that can be 117 | run. 118 | """ 119 | 120 | name: str 121 | 122 | def __getitem__(self, argv: str | tuple[str, ...]) -> Invocation: 123 | """ """ 124 | return Invocation(which(self.name)[0], argv) 125 | 126 | async def __call__( 127 | self, 128 | *args: str, 129 | env: Mapping[str, str] = environ, 130 | quiet: bool = False, 131 | workingDirectory: str | None = None, 132 | ) -> ProcessResult: 133 | """ 134 | Immedately run. 135 | """ 136 | return await self[args](env=env, quiet=quiet, workingDirectory=workingDirectory) 137 | 138 | 139 | @dataclass 140 | class SyntaxSugar: 141 | """ 142 | Syntax sugar for running subprocesses. 143 | 144 | Use like:: 145 | 146 | await c.ls() 147 | await c["docker-compose"]("--help") 148 | 149 | """ 150 | 151 | def __getitem__(self, name) -> Command: 152 | """ """ 153 | return Command(name) 154 | 155 | def __getattr__(self, name) -> Command: 156 | """ """ 157 | return Command(name) 158 | 159 | 160 | # from twisted.internet import reactor 161 | c = SyntaxSugar() 162 | 163 | 164 | async def parallel( 165 | work: Iterable[Coroutine[Deferred[T], T, R]], parallelism: int = 18 166 | ) -> AsyncIterable[R]: 167 | """ 168 | Perform the given work with a limited level of parallelism. 169 | """ 170 | sem = DeferredSemaphore(parallelism) 171 | values: Deque[R] = deque() 172 | 173 | async def saveAndRelease(coro: Awaitable[R]) -> None: 174 | try: 175 | values.append(await coro) 176 | finally: 177 | sem.release() 178 | 179 | async def drain() -> AsyncIterable[R]: 180 | await sem.acquire() 181 | while values: 182 | yield values.popleft() 183 | 184 | for w in work: 185 | async for each in drain(): 186 | yield each 187 | Deferred.fromCoroutine(saveAndRelease(w)) 188 | 189 | for x in range(parallelism): 190 | async for each in drain(): 191 | yield each 192 | -------------------------------------------------------------------------------- /src/encrust/_type.py: -------------------------------------------------------------------------------- 1 | from typing import TypeVar 2 | 3 | T = TypeVar("T") 4 | R = TypeVar("R") 5 | -------------------------------------------------------------------------------- /src/encrust/_zip.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | 4 | from twisted.python.filepath import FilePath 5 | 6 | from ._spawnutil import c 7 | 8 | 9 | async def createZipFile(zipFile: FilePath, directoryToZip: FilePath) -> None: 10 | zipPath = zipFile.asTextMode().path 11 | dirPath = directoryToZip.asTextMode() 12 | await c.ditto( 13 | "-c", 14 | "-k", 15 | "--sequesterRsrc", 16 | "--keepParent", 17 | dirPath.basename(), 18 | zipPath, 19 | workingDirectory=dirPath.dirname(), 20 | ) 21 | -------------------------------------------------------------------------------- /src/encrust/api.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import sys 4 | from dataclasses import dataclass 5 | from os import environ 6 | from pathlib import Path 7 | from typing import Mapping, Sequence 8 | 9 | from ._spawnutil import c 10 | 11 | 12 | @dataclass(frozen=True) 13 | class SparkleFrameworkInfo: 14 | frameworkPath: Path 15 | downloadURL: str 16 | archivePath: Path 17 | 18 | @classmethod 19 | def fromVersion(cls, version: str) -> SparkleFrameworkInfo: 20 | home = environ["HOME"] 21 | qualname = f"Sparkle-{version}" 22 | archive = f"{qualname}.tar.xz" 23 | versionDir = Path(f"{home}/.local/firstparty/sparkle-project.org/{qualname}") 24 | return cls( 25 | frameworkPath=versionDir / "Sparkle.framework", 26 | downloadURL=f"https://github.com/sparkle-project/Sparkle/releases/download/{version}/{archive}", 27 | archivePath=versionDir / archive, 28 | ) 29 | 30 | async def download(self) -> None: 31 | """ 32 | Download the given version of the Sparkle framework and unpack it into 33 | a known location (asynchronously, by spawning subprocesses using a 34 | Twisted reactor). 35 | """ 36 | archiveDownloadPath = str(self.archivePath.parent) 37 | await c.mkdir("-p", archiveDownloadPath) 38 | await c.curl("-LO", self.downloadURL, workingDirectory=archiveDownloadPath) 39 | await c.tar("xf", self.archivePath.name, workingDirectory=archiveDownloadPath) 40 | 41 | 42 | @dataclass(frozen=True) 43 | class AppCastDeployment: 44 | """ 45 | Configuration values for deploying to an appcast hosted on a web server 46 | that we can rsync a local directory to. 47 | 48 | @note: hopefully obviously, rsync-to-a-directory is not the most robust 49 | deployment mechanism and we should probably have other ways of managing 50 | the input to the appcast. 51 | """ 52 | 53 | privateKeyAccount: str 54 | localUpdatesFolder: Path 55 | remoteHost: str 56 | remotePath: str 57 | 58 | async def deploy(self, fwinfo: SparkleFrameworkInfo) -> None: 59 | self.localUpdatesFolder.mkdir(parents=True, exist_ok=True) 60 | for release in Path("dist").glob("*.release.app.zip"): 61 | target = self.localUpdatesFolder / release.name 62 | if target.exists(): 63 | raise RuntimeError(f"version conflict for {release}") 64 | release.rename(target) 65 | await c[str(fwinfo.frameworkPath.parent / "bin" / "generate_appcast")]( 66 | "--account", self.privateKeyAccount, str(self.localUpdatesFolder) 67 | ) 68 | await c.rsync( 69 | "-avz", 70 | # NB: homebrew version required, since this is a new feature as of 71 | # 2020 and apple always insist on core utilities being decades out 72 | # of date 73 | "--mkpath", 74 | "--delete", 75 | str(self.localUpdatesFolder).rstrip("/") + "/", 76 | f"{self.remoteHost}:{self.remotePath.rstrip('/')}/", 77 | ) 78 | 79 | 80 | @dataclass 81 | class SparkleData: 82 | publicEDKey: str 83 | feedURL: str 84 | sparkleFramework: SparkleFrameworkInfo 85 | deployment: AppCastDeployment 86 | 87 | def plist(self) -> Mapping[str, str]: 88 | return {"SUPublicEDKey": self.publicEDKey, "SUFeedURL": self.feedURL} 89 | 90 | @classmethod 91 | def withConfig( 92 | cls, 93 | *, 94 | sparkleVersion: str, 95 | publicEDKey: str, 96 | feedURL: str, 97 | keychainAccount: str, 98 | localUpdatesFolder: Path, 99 | remoteHost: str, 100 | remotePath: str, 101 | ) -> SparkleData: 102 | self = cls( 103 | publicEDKey=publicEDKey, 104 | feedURL=feedURL, 105 | sparkleFramework=SparkleFrameworkInfo.fromVersion(sparkleVersion), 106 | deployment=AppCastDeployment( 107 | privateKeyAccount=keychainAccount, 108 | localUpdatesFolder=localUpdatesFolder, 109 | remoteHost=remoteHost, 110 | remotePath=remotePath, 111 | ), 112 | ) 113 | return self 114 | 115 | async def deploy(self) -> None: 116 | await self.deployment.deploy(self.sparkleFramework) 117 | 118 | 119 | def _prefix(p: Path, fx: str) -> Path: 120 | return p.parent / (fx + p.name) 121 | 122 | 123 | @dataclass(kw_only=True) 124 | class AppDescription: 125 | """ 126 | An L{AppDescription} is a high-level description of an application. 127 | """ 128 | 129 | bundleID: str 130 | bundleName: str 131 | icnsFile: Path 132 | mainPythonScript: Path 133 | 134 | dataFiles: Sequence[Path] = () 135 | otherFrameworks: Sequence[Path] = () 136 | dockIconAtStart: bool = True 137 | sparkleData: SparkleData | None = None 138 | 139 | def varyBundleForTesting(self) -> AppDescription: 140 | """ 141 | Add 'Ci' and 'Test' variants based on the environment variables: 142 | 143 | 1. C{CI_MODE}, which can be set for running in e.g. Github Actions 144 | continuous integration, and 145 | 146 | 2. C{TEST_MODE}, for generating an interactive version of the 147 | bundle intended to test specific functionality. 148 | 149 | If one of these variables is set, a few changes will be made to the 150 | build configuration: 151 | 152 | 1. The bundle's name will have either 'Ci' or 'Test' prepended to 153 | it. 154 | 155 | 2. the bundle's identifier will have 'test' or 'ci' prepended to 156 | its last dotted segment. 157 | 158 | 3. the bundle's script will be changed to have 'Ci' or 'Test' 159 | prepended (so a sibling script should exist with that name). 160 | 161 | 4. the bundle's icon file name will have 'Test' or 'Ci' prepended 162 | to it, similar to the script. 163 | 164 | 5. the bundle will not include metadata about the Sparkle 165 | framework, as it should not be updated or deployed publicly. 166 | """ 167 | 168 | def check_mode() -> str: 169 | match environ: 170 | case {"CI_MODE": b}: 171 | if b: 172 | return "Ci" 173 | case {"TEST_MODE": b}: 174 | if b: 175 | return "Test" 176 | return "" 177 | 178 | mode = check_mode() 179 | if mode == "": 180 | return self 181 | 182 | return AppDescription( 183 | bundleID=".".join( 184 | [ 185 | *(segments := self.bundleID.split("."))[:-1], 186 | mode + segments[-1], 187 | ] 188 | ), 189 | bundleName=mode + self.bundleName, 190 | icnsFile=_prefix(self.icnsFile, mode), 191 | mainPythonScript=_prefix(self.mainPythonScript, mode), 192 | dataFiles=self.dataFiles, 193 | otherFrameworks=self.otherFrameworks, 194 | dockIconAtStart=self.dockIconAtStart, 195 | sparkleData=None, 196 | ) 197 | 198 | def setupOptions(self) -> dict[str, object]: 199 | """ 200 | Create a collection of arguments to L{setuptools.setup}. 201 | """ 202 | # Import py2app for its side-effect of registering the setuptools 203 | # command. 204 | assert __import__("py2app") is not None 205 | sparklePlist: Mapping[str, str] = ( 206 | {} if self.sparkleData is None else self.sparkleData.plist() 207 | ) 208 | sparkleFrameworks = ( 209 | [] 210 | if self.sparkleData is None 211 | else [str(self.sparkleData.sparkleFramework.frameworkPath)] 212 | ) 213 | # resolving a virtualenv gives the actualenv 214 | pyVersionDir = Path(sys.executable).resolve().parent.parent 215 | 216 | # Tcl/Tk frameworks distributed with Python3.13+ need to be excluded. 217 | frameworksDir = pyVersionDir / "Frameworks" 218 | dylibExcludes: list[Path] = [] 219 | if frameworksDir.is_dir(): 220 | dylibExcludes.extend(frameworksDir.iterdir()) 221 | infoPList = { 222 | "LSUIElement": not self.dockIconAtStart, 223 | "CFBundleIdentifier": self.bundleID, 224 | "CFBundleName": self.bundleName, 225 | # py2app probably doesn't require this any more most of the 226 | # time, but it doesn't hurt. 227 | "NSRequiresAquaSystemAppearance": False, 228 | **sparklePlist, 229 | } 230 | return { 231 | "data_files": [str(f) for f in self.dataFiles], 232 | "options": { 233 | "py2app": { 234 | "plist": infoPList, 235 | "iconfile": str(self.icnsFile), 236 | "app": [str(self.mainPythonScript)], 237 | "frameworks": [ 238 | *sparkleFrameworks, 239 | *self.otherFrameworks, 240 | ], 241 | "excludes": [ 242 | # Excluding setuptools is a workaround for a problem in 243 | # py2app - 244 | # https://github.com/ronaldoussoren/py2app/issues/531 - 245 | # and despite a couple of spuriously declared 246 | # transitive dependencies on it 247 | # (https://github.com/zopefoundation/zope.interface/issues/339, 248 | # https://github.com/twisted/incremental/issues/141) we 249 | # don't actually need it 250 | "setuptools", 251 | ], 252 | # Workaround for 253 | # https://github.com/ronaldoussoren/py2app/issues/546 - 254 | # this needs validation to see if explicitly *including* 255 | # Tcl.framework and Tk.framework does result in them 256 | # getting signed properly, rather than blowing up in 257 | # py2app's codesign_adhoc. 258 | "dylib_excludes": [str(each) for each in dylibExcludes], 259 | } 260 | }, 261 | } 262 | -------------------------------------------------------------------------------- /src/encrust/cli.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from functools import wraps 3 | from getpass import getpass 4 | from os import environ 5 | from os.path import abspath 6 | from typing import Any, Callable, Concatenate, Coroutine, Generator, ParamSpec, TypeVar 7 | 8 | import click 9 | from twisted.internet.defer import Deferred 10 | from twisted.internet.task import react 11 | from twisted.python.failure import Failure 12 | 13 | from ._build import AppBuilder 14 | from ._spawnutil import c 15 | from .api import AppDescription 16 | 17 | P = ParamSpec("P") 18 | R = TypeVar("R") 19 | 20 | 21 | async def configuredBuilder() -> AppBuilder: 22 | """ 23 | Make an AppBuilder out of the local configuration. 24 | """ 25 | lines = await c.python( 26 | "-m", 27 | "encrust._dosetup", 28 | "--name", 29 | "--version", 30 | workingDirectory=abspath("."), 31 | ) 32 | name, version = lines.output.decode("utf-8").strip().split("\n") 33 | name = environ.get("ENCRUST_APP_NAME", name) 34 | version = environ.get("ENCRUST_APP_VERSION", version) 35 | return AppBuilder(name=name, version=version) 36 | 37 | 38 | def reactorized( 39 | c: Callable[ 40 | Concatenate[Any, P], 41 | Coroutine[Deferred[object], Any, object] 42 | | Generator[Deferred[object], Any, object], 43 | ], 44 | ) -> Callable[P, None]: 45 | """ 46 | Wrap an async twisted function for click. 47 | """ 48 | 49 | @wraps(c) 50 | def forclick(*a, **kw) -> None: 51 | def r(reactor: Any) -> Deferred[object]: 52 | async def ar(): 53 | try: 54 | await c(reactor, *a, **kw) 55 | except Exception: 56 | print(Failure().getTraceback()) 57 | 58 | return Deferred.fromCoroutine(ar()) 59 | 60 | react(r, []) 61 | 62 | return forclick 63 | 64 | 65 | @click.group() 66 | def main() -> None: 67 | """ 68 | Utility for building, signing, and notarizing macOS applications. 69 | """ 70 | 71 | 72 | @main.command() 73 | @reactorized 74 | async def signable(reactor: Any) -> None: 75 | """ 76 | (Debugging): print a list of every signable path in an already-built bundle. 77 | """ 78 | builder = await configuredBuilder() 79 | for p in builder.signablePaths(): 80 | print(p.path) 81 | 82 | 83 | @main.command() 84 | @reactorized 85 | async def sign(reactor: Any) -> None: 86 | """ 87 | (Debugging): Just locally codesign (and do not notarize) an already-built app. 88 | """ 89 | builder = await configuredBuilder() 90 | await builder.signApp() 91 | 92 | 93 | @main.command() 94 | @reactorized 95 | async def fatten(reactor: Any) -> None: 96 | """ 97 | Ensure that all locally installed shared objects are fat binaries (i.e. 98 | universal2 wheels). 99 | """ 100 | builder = await configuredBuilder() 101 | await builder.fattenEnvironment() 102 | 103 | 104 | @main.command() 105 | @reactorized 106 | async def build(reactor: Any) -> None: 107 | """ 108 | Build the application. 109 | """ 110 | builder = await configuredBuilder() 111 | await builder.build() 112 | await builder.signApp() 113 | 114 | 115 | @main.command() 116 | @reactorized 117 | async def devalias(reactor: Any) -> None: 118 | """ 119 | Build an app bundle that uses a symlink into the development copy of the 120 | source code, suitable only for local development, but a lot faster than 121 | rebuilding all the time. 122 | 123 | @see: U{py2app alias mode 124 | } 125 | """ 126 | builder = await configuredBuilder() 127 | await builder.build("--alias") 128 | 129 | 130 | @main.command() 131 | @reactorized 132 | async def release(reactor: Any) -> None: 133 | """ 134 | Build the application. 135 | """ 136 | builder = await configuredBuilder() 137 | await builder.release() 138 | 139 | 140 | def loadDescription() -> AppDescription: 141 | """ 142 | Load the description of the project from C{encrust_setup.py}. 143 | """ 144 | sys.path.append(".") 145 | import encrust_setup # type:ignore[import-not-found] 146 | 147 | desc = encrust_setup.description 148 | return desc 149 | 150 | 151 | @main.command() 152 | @reactorized 153 | async def getsparkle(reactor: Any) -> None: 154 | """ 155 | Download the Sparkle framework used by the current project. 156 | """ 157 | # TODO: should probably use something like Cocoapods to actually fetch 158 | # frameworks so that this generalizes a bit. But I would have to learn how 159 | # Cocoapods work for that. 160 | description = loadDescription() 161 | if description.sparkleData is None: 162 | print("Sparkle not specified, not downloading.") 163 | sys.exit(1) 164 | 165 | await description.sparkleData.sparkleFramework.download() 166 | 167 | 168 | @main.command() 169 | @reactorized 170 | async def appcastify(reactor: Any) -> None: 171 | """ 172 | Update, sign, and deploy the Sparkle appcast for the current application. 173 | 174 | Note that this currently must be manually done *after* `encrust release`, 175 | but we should probably integrate it into that process. 176 | """ 177 | description = loadDescription() 178 | if description.sparkleData is None: 179 | print("Sparkle not specified, not generating appcast.") 180 | sys.exit(1) 181 | await description.sparkleData.deploy() 182 | 183 | 184 | @main.command() 185 | @reactorized 186 | async def auth(reactor: Any) -> None: 187 | """ 188 | Authenticate to the notarization service with an app-specific password from 189 | https://appleid.apple.com/account/manage 190 | """ 191 | builder = await configuredBuilder() 192 | sign = await builder.signingConfiguration() 193 | newpw = getpass(f"Paste App-Specific Password for {sign.appleID} and hit enter: ") 194 | await builder.authenticateForSigning(newpw) 195 | print("Authenticated!") 196 | 197 | 198 | @main.command() 199 | @reactorized 200 | async def configure(reactor: Any) -> None: 201 | """ 202 | Configure this tool. 203 | """ 204 | print( 205 | """ 206 | TODO: this tool should walk you through configuration. 207 | 208 | For now: 209 | 0. First, set up a Python project built using `py2app`. 210 | a. make a virtualenv 211 | b. `pip install` your dependencies 212 | c. `pip install encrust` 213 | 214 | 1. enroll in the Apple Developer program at https://developer.apple.com/account 215 | 2. download Xcode.app from https://apps.apple.com/us/app/xcode/id497799835?mt=12 216 | 3. launch Xcode, 217 | a. open Preferences -> Accounts 218 | b. hit '+' to log in to the Apple ID you enrolled in 219 | the developer program with 220 | c. click "manage certificates" 221 | d. click "+" 222 | e. click "Developer ID Application" 223 | 4. run `security find-identity -v -p codesigning` 224 | 5. look for a "Developer ID Application" certificate in the list 225 | 6. edit ~/.encrust.json to contain an object like this: 226 | 227 | { 228 | "identity": /* the big hex ID from find-identity output */, 229 | "teamID": /* the thing in parentheses in find-identity output */, 230 | "appleID": /* the email address associated with your apple developer account */, 231 | "profile": /* an arbitrary string you've selected */ 232 | } 233 | 7. go to https://appleid.apple.com/account/manage and log in 234 | 8. click "App-Specific Passwords" 235 | 9. run `encrust auth` and paste the app password before closing the window 236 | 10. run `encrust release` 237 | 11. upload dist/-.release.app.zip somewhere on the web. 238 | """ 239 | ) 240 | -------------------------------------------------------------------------------- /src/encrust/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/glyph/Encrust/7a39a67ed2233ffe1b60a0323b2105eb0fca4c2a/src/encrust/py.typed -------------------------------------------------------------------------------- /src/encrust/required-python-entitlements.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.cs.allow-unsigned-executable-memory 6 | 7 | 8 | 9 | --------------------------------------------------------------------------------