Add macOS app bundle packaging, native launcher, and MACOS_APP_PACKAGING guide

This commit is contained in:
Adolfo Reyna
2026-08-08 17:37:52 -04:00
parent 757261ebfd
commit 6e8a578e86
8 changed files with 431 additions and 3 deletions
+184
View File
@@ -0,0 +1,184 @@
# 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:
1. `Terminal.app` (if executed inside a terminal window), or
2. `python3.11` / `python3.1` (if `exec /path/to/python` is 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)
```
1. **Standard macOS `.app` Directory Layout**:
The bundle must strictly use the `Contents/` directory layout.
2. **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 underlying `python` image and attribute permissions to `python3.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 under `com.voiceagent.mac`. Subprocesses spawned by this binary inherit the bundle's TCC identity.
3. **Privacy Entitlements in `Info.plist` & Code Signing**:
- `Info.plist` defines `CFBundleExecutable`, `CFBundleIdentifier`, and the `NS*UsageDescription` keys.
- 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:
```swift
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:
```bash
#!/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`** |