├── README.md ├── app_screenshot.png ├── build.py ├── entitlements.plist ├── icon.icns ├── icon.iconset ├── icon_512x512.png └── icon_512x512@2x.png ├── main.py ├── main.spec ├── requirements.txt └── testapp_provision.provisionprofile /README.md: -------------------------------------------------------------------------------- 1 | # Guide: Uploading a Python app to the Mac App Store (October 2022) 2 | 3 | ## Table of Contents 4 | 5 | - [Introduction](#introduction) 6 | - [General Prerequisites](#general-prerequisites) 7 | - [Libraries and Tools](#libraries-and-tools) 8 | - [Running the App Locally](#running-the-app-locally) 9 | - [Packaging the App](#packaging-the-app) 10 | - [Pyinstaller .spec file](#pyinstaller-spec-file) 11 | - [Entitlements .plist file](#entitlements-plist-file) 12 | - [Running Pyinstaller and Codesigning](#running-pyinstaller-and-codesigning) 13 | 14 | 15 | ## Introduction 16 | 17 | **Note**: if you think that you've already got a good understanding of the process of building/codesigning, you can skip to 18 | the [Running Pyinstaller and Codesigning](#running-pyinstaller-and-codesigning) section, where you can simply run 19 | my build script to package your app for the Mac App Store. 20 | 21 | This guide will walk you through the process of uploading a Python app to the Mac App Store. It is based on my 22 | personal experience of struggling to upload an app successfully. One might think that uploading a Python app to the 23 | Mac App Store would be easy. After all, Python is one of the most popular languages in the world, and macOS is one of 24 | the most popular operating systems. However, complications with app sandboxing and the app signing process make 25 | such an endeavour difficult. It is crucial to specify the correct build parameters and entitlements in order for 26 | the app to run properly and be accepted by the Mac App Store. 27 | 28 | Many of the guides on the internet that explain how to do this are outdated, and the process of deploying a Python app 29 | has changed significantly since they were written. This guide is intended to be roughly up-to-date and accurate as of 30 | October 2022, and uses modern tools and libraries (e.g. Python 3.9, PyInstaller, PySide6). 31 | 32 | The goal of this guide is to allow you to, at the very least, upload a Python app to the Mac App Store using 33 | Transporter. However, just because an app is successfully uploaded through Transporter does not mean it will be accepted 34 | by Apple. This guide will not cover the process of getting your app accepted by Apple, which is its own beast. 35 | 36 | Finally, the app we will upload to the App Store will be a simple Python app that shows "Hello World" in a UI window. 37 | This app is meant to be a building block for you to use to build your own app, and as such, I've kept it incredibly 38 | barebones and simple. 39 | 40 | 41 | ## General Prerequisites 42 | 43 | * You must have a Mac Developer account. This is a paid account, and you must pay an annual fee to keep it active. 44 | * You should have an app registered in the App Store Connect profile. You can find a link to this here: 45 | https://appstoreconnect.apple.com/apps. If you don't have an app registered, you can create one by clicking the 46 | "Add App" button in the top right corner of the page, and setting up the app. 47 | * Once you have an app registered in the App Store Connect profile, you should have a bundle ID. This is a unique 48 | identifier for your app, and it is used to identify your app in the App Store. You can find your bundle ID by 49 | clicking on your app in the App Store Connect profile, and then clicking on the "General" tab. The bundle ID is 50 | listed under the "App Information" section. We will use this bundle ID later in the guide. 51 | 52 | 53 | ## Libraries and Tools 54 | 55 | * MacOS (obviously) - Please note, I am using my personal M1 Mac (macOS Monterey) which uses a chip with the ARM64 56 | architecture. If you are using an Intel-based Mac, you might have to slightly change a Pyinstaller spec file to use the 57 | Intel architecture instead of ARM64. I have not tested this, so I cannot guarantee that it will work, but it should be 58 | possible. 59 | * XCode Command Line Tools - You can install them by running `xcode-select --install` in a 60 | terminal 61 | * Python 3.9 - I am using Python compiled to run on ARM64, since I am using an M1 Mac, but you may be able to use 62 | Python compiled for Intel's x86_64 architecture. Again, I have not tested this, so be aware of complications there. 63 | * Pyside6 - You can install it by running `pip install -r requirements.txt` in a terminal in this project's 64 | directory. 65 | * PyInstaller - You can install it by running `pip install -r requirements.txt` in a terminal in this project's 66 | directory. 67 | 68 | 69 | ## Running the app locally 70 | 71 | The `main.py` file contains a simple Python app that shows "Hello World" in a UI window. Clone this repository and 72 | make sure to install the project requirements using `pip install -r requirements.txt` in a terminal. Then, you can run 73 | the app by running `python main.py`, and you should see a window pop up with "Hello World" in it. 74 | 75 | It should look something like this: 76 | 77 | ![](app_screenshot.png) 78 | 79 | ## Packaging the app 80 | 81 | ### Pyinstaller .spec file 82 | 83 | The first step in packaging the app is to create a Pyinstaller .spec file. This file contains information about how 84 | Pyinstaller should package the app. You can find the .spec file (called `main.spec`) in this repository. 85 | 86 | Getting the contents of this file right was quite tricky, and I had to do a lot of trial and error to get it to work. 87 | You should, for the most part, be able to use it as a template for your own app. However, there are a few things you 88 | should know about this file: 89 | 90 | * The bundle identifier is something you should change to match your app's bundle identifier. You can set it by setting 91 | the value in the plist dictionary section of the .spec file, under the key `CFBundleIdentifier` 92 | * The added files section is where you should add any files that your app needs to run 93 | * The app category is something you should change to match your app's category. The category value is under the key 94 | `LSApplicationCategoryType` in the plist dictionary section of the .spec file. 95 | * If you deviate from the default .spec file I've created, be very careful about what you change. I've found that 96 | certain keys/values will cause the app to crash if they are not set correctly. For example, if you change the 97 | `console=False` to `console=True`, the app will crash if you attempt to open it after you codesign it. 98 | 99 | 100 | ### Entitlements .plist file 101 | 102 | The next step is to create an entitlements .plist file. This file contains information about the app's entitlements. 103 | Entitlements are basically permissions that the app needs to run. You can find the .plist file (called 104 | `entitlements.plist`) in this repository. You should be able to use this file as is, for the most part. 105 | 106 | 107 | ### Provisioning Profile 108 | 109 | You will also need to create a provisioning profile. This is a file that contains information that uniquely ties 110 | developers and devices to an authorized Development Team and enables a device to be used for testing, and it is used to 111 | sign the app. You can create a provisioning profile by going to the "Certificates, 112 | Identifiers, & Profiles" section of the Apple Developer website: 113 | https://developer.apple.com/account/resources/profiles/list. Then, click on "Profiles" in the left 114 | sidebar, and click the "+" button. Then, select "Mac App Store" as the type of provisioning 115 | profile, and select your app's bundle ID. Then, click "Continue", and then "Generate". This will create a provisioning 116 | profile for your app. You can download it by clicking the "Download" button in the top right corner of the page. 117 | 118 | 119 | ### Creating an icon 120 | 121 | The next step is to create an icon for your app. You can use any image editor to create an icon. However, the icon 122 | must be in the .icns format. You can convert an image to the .icns format by using the `iconutil` command. For more 123 | details about how to do this, see this gist: https://gist.github.com/jamieweavis/b4c394607641e1280d447deed5fc85fc 124 | 125 | 126 | ### Running Pyinstaller and Codesigning 127 | 128 | I've created a python script called `build.py` that will run Pyinstaller and codesign the app. The .spec file currently 129 | assumes that the icon is called `icon.icns` and the entrypoint is called `main.py`. It also assumes there are no 130 | added files, but for a real app, you will probably need to add some files. 131 | You can change these values in the .spec file if you want to use different names, but I may add command line arguments 132 | to the `build.py` script to make this easier in the future. 133 | 134 | You will need two certificates to run this script: the 3rd party Mac Developer Application certificate, and the 3rd 135 | party Mac Developer Installer certificate. You can find these certificates by going to the "Certificates, Identifiers, 136 | & Profiles" section located here: https://developer.apple.com/account/resources/certificates/list. If you haven't 137 | created these certificates yet, you can probably find tutorials online on how to do so (and I may include more details 138 | on this in the future). This process may involve creating a Certificate Signing Request (CSR) locally and then uploading 139 | it to the Apple Developer website. 140 | 141 | Once you have the certificates, you can run the `build.py` script in this repository. A sample run might look like this: 142 | 143 | ```bash 144 | $ python build.py \ 145 | --app_name "Testapp" \ 146 | --version "0.0.1" \ 147 | --spec_file "main.spec" \ 148 | --entitlements "entitlements.plist" \ 149 | --provisioning_profile "testapp_provision.provisionprofile" \ 150 | --app_certificate "3rd Party Mac Developer Application: John Smith (L42TK32G7A)" \ 151 | --installer_certificate "3rd Party Mac Developer Installer: John Smith (L42TK32G7A)" \ 152 | --output_dir "dist" 153 | ``` 154 | 155 | ## More details to follow... -------------------------------------------------------------------------------- /app_screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyavramov/python_app_mac_app_store/7f77c8bd3803a4d5580bb49797586609e322b31f/app_screenshot.png -------------------------------------------------------------------------------- /build.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import logging 3 | 4 | import os 5 | import shutil 6 | import subprocess 7 | 8 | 9 | logger = logging.getLogger() 10 | 11 | logging.basicConfig( 12 | format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", 13 | level=logging.INFO, 14 | ) 15 | 16 | 17 | def cmdline_params() -> argparse.Namespace: 18 | """Parses the command line arguments""" 19 | parser = argparse.ArgumentParser() 20 | 21 | parser.add_argument("--entitlements", required=True) 22 | parser.add_argument("--app_certificate", required=True) 23 | parser.add_argument("--installer_certificate", required=True) 24 | parser.add_argument("--app_name", required=True) 25 | parser.add_argument("--spec_file", required=True) 26 | parser.add_argument("--version", required=True) 27 | parser.add_argument("--provisioning_profile", required=True) 28 | parser.add_argument("--output_dir", required=True) 29 | 30 | return parser.parse_args() 31 | 32 | 33 | def remove_build_and_output_dir(output_dir: str) -> None: 34 | """Removes the build and dist folders""" 35 | logger.info("Removing build and dist folders...") 36 | if os.path.exists("build"): 37 | shutil.rmtree("build") 38 | 39 | if os.path.exists(output_dir): 40 | if input("Remove old output directory? (y/n): ").lower() == "y": 41 | shutil.rmtree(output_dir) 42 | 43 | 44 | def run_pyinstaller_build(params: argparse.Namespace) -> None: 45 | """Removes the build and dist folders and then runs pyinstaller build command""" 46 | remove_build_and_output_dir(params.output_dir) 47 | ensure_output_dir_exists(params.output_dir) 48 | 49 | logger.info("Running pyinstaller build...") 50 | subprocess.run( 51 | [ 52 | "pyinstaller", 53 | "--clean", 54 | "--noconfirm", 55 | "--distpath", 56 | params.output_dir, 57 | "--workpath", 58 | "build", 59 | params.spec_file, 60 | ] 61 | ) 62 | 63 | 64 | def codesign_app_deep(entitlements: str, app_certificate: str, output_dir: str, app_name: str) -> None: 65 | """Runs the codesign command with the deep option""" 66 | app_path = os.path.join(output_dir, f"{app_name}.app") 67 | subprocess.run( 68 | [ 69 | "codesign", 70 | "--force", 71 | "--timestamp", 72 | "--deep", 73 | "--verbose", 74 | "--options", 75 | "runtime", 76 | "--entitlements", 77 | entitlements, 78 | "--sign", 79 | app_certificate, 80 | app_path, 81 | ], 82 | ) 83 | 84 | 85 | def codesign_verify(output_dir: str, app_name: str) -> None: 86 | """Runs the codesign verify command""" 87 | app_path = os.path.join(output_dir, f"{app_name}.app") 88 | subprocess.run( 89 | ["codesign", "--verify", "--verbose", app_path], 90 | ) 91 | 92 | 93 | def ensure_output_dir_exists(output_dir: str) -> None: 94 | """Ensures the output directory exists""" 95 | if not os.path.exists(output_dir): 96 | os.makedirs(output_dir) 97 | 98 | 99 | def run_productbuild_command(params: argparse.Namespace) -> None: 100 | """Runs the productbuild command""" 101 | logger.info("Running productbuild...") 102 | 103 | output_path = os.path.join(params.output_dir, f"{params.app_name}.pkg") 104 | app_path = os.path.join(params.output_dir, f"{params.app_name}.app") 105 | 106 | subprocess.run( 107 | [ 108 | "productbuild", 109 | "--component", 110 | app_path, 111 | "/Applications", 112 | "--sign", 113 | params.installer_certificate, 114 | "--version", 115 | params.version, 116 | output_path, 117 | ] 118 | ) 119 | 120 | 121 | def copy_provisioning_file_to_app(provisioning_file: str, output_dir: str, app_name: str) -> None: 122 | """Copies the provisioning file to the app bundle""" 123 | logger.info("Copying provisioning file to app bundle...") 124 | app_path = os.path.join(output_dir, f"{app_name}.app") 125 | shutil.copyfile(provisioning_file, os.path.join(app_path, "Contents", "embedded.provisionprofile")) 126 | 127 | 128 | def run_signing_commands(params: argparse.Namespace) -> None: 129 | """Copies the provisioning file to the app bundle and 130 | then runs the codesign commands""" 131 | copy_provisioning_file_to_app(params.provisioning_profile, params.output_dir, params.app_name) 132 | codesign_app_deep(params.entitlements, params.app_certificate, params.output_dir, params.app_name) 133 | codesign_verify(params.output_dir, params.app_name) 134 | 135 | 136 | def check_cli_tool_exists(tool_name: str) -> None: 137 | """Checks that a cli tool is installed""" 138 | try: 139 | subprocess.run([tool_name], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) 140 | except FileNotFoundError: 141 | logger.error(f"{tool_name} is not installed. Please install it and try again.") 142 | exit(1) 143 | 144 | 145 | def check_build_tools_are_installed() -> None: 146 | """Checks that codesign, productbuild, and pyinstaller are installed""" 147 | check_cli_tool_exists("codesign") 148 | check_cli_tool_exists("productbuild") 149 | check_cli_tool_exists("pyinstaller") 150 | 151 | 152 | def main() -> None: 153 | params = cmdline_params() 154 | check_build_tools_are_installed() 155 | 156 | run_pyinstaller_build(params) 157 | run_signing_commands(params) 158 | run_productbuild_command(params) 159 | 160 | 161 | if __name__ == "__main__": 162 | main() 163 | -------------------------------------------------------------------------------- /entitlements.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | com.apple.security.cs.allow-jit 7 | 8 | com.apple.security.cs.allow-unsigned-executable-memory 9 | 10 | com.apple.security.cs.disable-library-validation 11 | 12 | com.apple.security.app-sandbox 13 | 14 | 15 | -------------------------------------------------------------------------------- /icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyavramov/python_app_mac_app_store/7f77c8bd3803a4d5580bb49797586609e322b31f/icon.icns -------------------------------------------------------------------------------- /icon.iconset/icon_512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyavramov/python_app_mac_app_store/7f77c8bd3803a4d5580bb49797586609e322b31f/icon.iconset/icon_512x512.png -------------------------------------------------------------------------------- /icon.iconset/icon_512x512@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nyavramov/python_app_mac_app_store/7f77c8bd3803a4d5580bb49797586609e322b31f/icon.iconset/icon_512x512@2x.png -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | from PySide6.QtWidgets import QApplication, QLabel, QMainWindow 4 | 5 | 6 | class MainWindow(QMainWindow): 7 | def __init__(self, *args, **kwargs): 8 | super(MainWindow, self).__init__(*args, **kwargs) 9 | self.setCentralWidget(QLabel("Hello, world!")) 10 | 11 | 12 | app = QApplication(sys.argv) 13 | window = MainWindow() 14 | window.show() 15 | app.exec_() 16 | -------------------------------------------------------------------------------- /main.spec: -------------------------------------------------------------------------------- 1 | # -*- mode: python ; coding: utf-8 -*- 2 | 3 | 4 | block_cipher = None 5 | 6 | added_files = [ 7 | ] 8 | 9 | a = Analysis( 10 | ['main.py'], 11 | pathex=[], 12 | binaries=[], 13 | datas=added_files, 14 | hiddenimports=[], 15 | hookspath=[], 16 | hooksconfig={}, 17 | runtime_hooks=[], 18 | excludes=[], 19 | win_no_prefer_redirects=False, 20 | win_private_assemblies=False, 21 | cipher=block_cipher, 22 | noarchive=False 23 | ) 24 | 25 | pyz = PYZ( 26 | a.pure, 27 | a.zipped_data, 28 | cipher=block_cipher 29 | ) 30 | 31 | exe = EXE( 32 | pyz, 33 | a.scripts, 34 | [], 35 | exclude_binaries=True, 36 | name='test_app', 37 | debug=False, 38 | bootloader_ignore_signals=False, 39 | strip=False, 40 | upx=True, 41 | upx_exclude=[], 42 | runtime_tmpdir=None, 43 | console=False, 44 | disable_windowed_traceback=False, 45 | target_arch='universal2', 46 | codesign_identity=None, 47 | entitlements_file=None, 48 | icon='icon.icns' 49 | ) 50 | 51 | 52 | coll = COLLECT( 53 | exe, 54 | a.binaries, 55 | a.zipfiles, 56 | a.datas, 57 | strip=False, 58 | upx=True, 59 | upx_exclude=[], 60 | name='app' 61 | ) 62 | 63 | app = BUNDLE( 64 | coll, 65 | name='Testapp.app', 66 | icon='icon.icns', 67 | bundle_identifier='com.fake_company.test-app.pkg', 68 | info_plist={ 69 | 'NSPrincipalClass': 'NSApplication', 70 | 'NSAppleScriptEnabled': False, 71 | 'LSBackgroundOnly': False, 72 | 'NSRequiresAquaSystemAppearance': 'No', 73 | 'CFBundlePackageType': 'APPL', 74 | 'CFBundleSupportedPlatforms': ['MacOSX'], 75 | 'CFBundleIdentifier': 'com.fake_company.test-app.pkg', 76 | 'CFBundleVersion': '0.0.1', 77 | 'LSMinimumSystemVersion': '10.15', 78 | 'LSApplicationCategoryType': 'public.app-category.utilities', 79 | 'ITSAppUsesNonExemptEncryption': False, 80 | } 81 | ) -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | pyinstaller==5.4.1 2 | pyside6==6.3.1 -------------------------------------------------------------------------------- /testapp_provision.provisionprofile: -------------------------------------------------------------------------------- 1 | A fake provision profile that you should replace with a legitimate one. --------------------------------------------------------------------------------