7.2 KiB
Packaging Python Applications into Native macOS Apps (.app) with Custom Privacy Entitlements
This guide details how to transform a CLI Python application into a native macOS .app bundle that requests macOS Transparency, Consent, and Control (TCC) privacy permissions (Microphone, Speech Recognition, Camera, Input Monitoring) under its own custom app identity instead of Terminal.app or python3.11.
1. Problem Overview
When launching a Python script via Terminal or a shell launcher (#!/bin/bash), macOS TCC attributes privacy permissions to either:
Terminal.app(if executed inside a terminal window), orpython3.11/python3.1(ifexec /path/to/pythonis called from a script).
To ensure macOS prompts display your custom application name (e.g., "VoiceAgent would like to access the microphone") and show up under System Settings → Privacy & Security → Microphone, you must follow Apple's native bundle layout and executable architecture.
2. Key Architectural Requirements
VoiceAgent.app/
└── Contents/
├── Info.plist # App Identity & Custom Privacy Descriptions
├── MacOS/
│ └── VoiceAgent # Compiled Mach-O Swift Launcher Binary
└── Resources/
├── src/ # Packaged Python Source Code
└── swift/ # Native Helper Binaries (speech-helper, llm-helper)
-
Standard macOS
.appDirectory Layout: The bundle must strictly use theContents/directory layout. -
Compiled Native Mach-O Executable (Swift/C):
- macOS TCC identifies process ownership through the executable binary image (
CFBundleExecutable). - Shell scripts (
/bin/bash) cause macOS to inspect the underlyingpythonimage and attribute permissions topython3.1. - Solution: Compile a native Swift executable (
swift/VoiceAgentLauncher.swift) into a Mach-O 64-bit arm64 binary (Contents/MacOS/VoiceAgent). When Finder launches this binary, macOS registers the process undercom.voiceagent.mac. Subprocesses spawned by this binary inherit the bundle's TCC identity.
- macOS TCC identifies process ownership through the executable binary image (
-
Privacy Entitlements in
Info.plist& Code Signing:Info.plistdefinesCFBundleExecutable,CFBundleIdentifier, and theNS*UsageDescriptionkeys.- The app bundle must be deeply code-signed (
codesign --deep).
3. Implementation Workflow
Step 1: Native Swift Launcher (swift/VoiceAgentLauncher.swift)
The Swift launcher sets environment variables, redirects logs, and spawns the Python process:
import Foundation
@main
struct VoiceAgentLauncher {
static func main() {
let fileManager = FileManager.default
let projDir = "/Users/adolforeyna/Projects/VoiceAgent1"
let bundleResPath = Bundle.main.resourcePath ?? ""
let bundledSrcPath = "\(bundleResPath)/src"
let workDir = fileManager.fileExists(atPath: projDir) ? projDir : bundledSrcPath
let pythonBin = "\(projDir)/.venv/bin/python"
let fallbackPython = "/usr/bin/python3"
let targetPython = fileManager.fileExists(atPath: pythonBin) ? pythonBin : fallbackPython
let targetScript = "\(workDir)/app_main.py"
setenv("SSL_CERT_FILE", "/etc/ssl/cert.pem", 1)
setenv("REQUESTS_CA_BUNDLE", "/etc/ssl/cert.pem", 1)
let logDir = fileManager.homeDirectoryForCurrentUser.appendingPathComponent("Library/Logs/VoiceAgent")
try? fileManager.createDirectory(at: logDir, withIntermediateDirectories: true)
let logFile = logDir.appendingPathComponent("voiceagent.log")
if !fileManager.fileExists(atPath: logFile.path) {
fileManager.createFile(atPath: logFile.path, contents: nil)
}
let process = Process()
process.executableURL = URL(fileURLWithPath: targetPython)
process.arguments = [targetScript]
process.currentDirectoryURL = URL(fileURLWithPath: workDir)
if let logHandle = try? FileHandle(forWritingTo: logFile) {
logHandle.seekToEndOfFile()
process.standardOutput = logHandle
process.standardError = logHandle
}
do {
try process.run()
process.waitUntilExit()
} catch {
print("Failed to run VoiceAgent: \(error)")
}
}
}
Step 2: Build & Package Script (build_app.sh)
Automate Swift binary compilation, bundle structure creation, Info.plist injection, installation to /Applications, and code signing:
#!/bin/bash
set -euo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$HERE"
# 1. Build Swift Helpers
bash swift/build.sh
APP_NAME="VoiceAgent"
DIST_DIR="$HERE/dist"
APP_BUNDLE="$DIST_DIR/$APP_NAME.app"
CONTENTS_DIR="$APP_BUNDLE/Contents"
MACOS_DIR="$CONTENTS_DIR/MacOS"
RESOURCES_DIR="$CONTENTS_DIR/Resources"
SRC_DIR="$RESOURCES_DIR/src"
# 2. Setup Bundle Directory Structure
rm -rf "$APP_BUNDLE"
mkdir -p "$MACOS_DIR" "$RESOURCES_DIR/swift" "$SRC_DIR"
# 3. Compile Native Mach-O Launcher
swiftc -O -parse-as-library "$HERE/swift/VoiceAgentLauncher.swift" -o "$MACOS_DIR/VoiceAgent"
chmod +x "$MACOS_DIR/VoiceAgent"
# 4. Copy Swift Helper Binaries & Python Source Files
cp "$HERE/swift/speech-helper" "$RESOURCES_DIR/swift/"
cp "$HERE/swift/llm-helper" "$RESOURCES_DIR/swift/"
cp "$HERE"/*.py "$SRC_DIR/" 2>/dev/null || true
# 5. Generate Info.plist
cat << 'EOF' > "$CONTENTS_DIR/Info.plist"
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleExecutable</key>
<string>VoiceAgent</string>
<key>CFBundleIdentifier</key>
<string>com.voiceagent.mac</string>
<key>CFBundleName</key>
<string>VoiceAgent</string>
<key>CFBundleDisplayName</key>
<string>VoiceAgent</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.0.0</string>
<key>CFBundleVersion</key>
<string>1.0.0</string>
<key>LSMinimumSystemVersion</key>
<string>14.0</string>
<key>NSMicrophoneUsageDescription</key>
<string>VoiceAgent requires access to your microphone to listen to your voice commands.</string>
<key>NSSpeechRecognitionUsageDescription</key>
<string>VoiceAgent uses on-device speech recognition to process your spoken input.</string>
<key>NSHighResolutionCapable</key>
<true/>
</dict>
</plist>
EOF
# 6. Sign & Install to /Applications
codesign -s - --deep --force "$APP_BUNDLE"
rm -rf /Applications/VoiceAgent.app
cp -R "$APP_BUNDLE" /Applications/
codesign -s - --deep --force /Applications/VoiceAgent.app
4. Verification Matrix
| Launch Mechanism | Process Ownership | Permission Prompt Identity | System Settings Category |
|---|---|---|---|
Terminal Script (python bot.py) |
Terminal.app |
"Terminal would like to access..." | Listed under Terminal |
Shell Script (#!/bin/bash exec) |
python3.11 |
"python3.1 would like to access..." | Listed under python3.1 |
Native Swift Launcher (VoiceAgent) |
com.voiceagent.mac |
"VoiceAgent would like to access..." | Listed under VoiceAgent |