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
+3
View File
@@ -6,3 +6,6 @@ swift/speech-helper
swift/llm-helper swift/llm-helper
swift/test_foundation swift/test_foundation
swift/test_foundation.swift swift/test_foundation.swift
dist/
.DS_Store
+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`** |
+18
View File
@@ -0,0 +1,18 @@
#!/usr/bin/env python3
"""Main entrypoint for VoiceAgent macOS App bundle."""
import asyncio
import os
import sys
# Ensure CA certificates are configured for macOS SSL
os.environ["SSL_CERT_FILE"] = "/etc/ssl/cert.pem"
os.environ["REQUESTS_CA_BUNDLE"] = "/etc/ssl/cert.pem"
import bot
if __name__ == "__main__":
try:
sys.exit(asyncio.run(bot.main()))
except KeyboardInterrupt:
sys.exit(0)
+20 -2
View File
@@ -13,6 +13,7 @@ import json
import os import os
import shutil import shutil
import subprocess import subprocess
import sys
from pathlib import Path from pathlib import Path
from loguru import logger from loguru import logger
@@ -41,8 +42,25 @@ _NOISE_TRANSCRIPTS = {
"[silence]", "[silence]",
} }
HERE = Path(__file__).parent def get_helper_path(binary_name: str) -> Path:
LLM_HELPER_PATH = HERE / "swift" / "llm-helper" if getattr(sys, "frozen", False) and hasattr(sys, "_MEIPASS"):
p1 = Path(sys._MEIPASS) / binary_name
if p1.exists():
return p1
p2 = Path(sys._MEIPASS) / "swift" / binary_name
if p2.exists():
return p2
if getattr(sys, "frozen", False):
res_dir = Path(sys.executable).parent.parent / "Resources"
p1 = res_dir / binary_name
if p1.exists():
return p1
p2 = res_dir / "swift" / binary_name
if p2.exists():
return p2
return Path(__file__).parent / "swift" / binary_name
LLM_HELPER_PATH = get_helper_path("llm-helper")
def probe_apple_llm() -> tuple[bool, str]: def probe_apple_llm() -> tuple[bool, str]:
+87
View File
@@ -0,0 +1,87 @@
#!/bin/bash
# build_app.sh - Build standalone macOS VoiceAgent.app bundle
set -euo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$HERE"
echo "=== 1. Building Swift Helper & Launcher Binaries ==="
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"
echo "=== 2. Creating macOS App Bundle Structure ==="
rm -rf "$APP_BUNDLE"
mkdir -p "$MACOS_DIR"
mkdir -p "$RESOURCES_DIR/swift"
mkdir -p "$SRC_DIR"
echo "=== 3. Compiling Native App Bundle Launcher ==="
swiftc -O -parse-as-library "$HERE/swift/VoiceAgentLauncher.swift" -o "$MACOS_DIR/VoiceAgent"
chmod +x "$MACOS_DIR/VoiceAgent"
echo "=== 4. Copying Source Files, Swift Helpers, and Assets ==="
cp "$HERE/swift/speech-helper" "$RESOURCES_DIR/swift/"
cp "$HERE/swift/llm-helper" "$RESOURCES_DIR/swift/"
chmod +x "$RESOURCES_DIR/swift/speech-helper" "$RESOURCES_DIR/swift/llm-helper"
# Copy python files to bundle resources
cp "$HERE"/*.py "$SRC_DIR/" 2>/dev/null || true
if [ -f "$HERE/vocabulary.example.txt" ]; then
cp "$HERE/vocabulary.example.txt" "$RESOURCES_DIR/"
fi
if [ -f "$HERE/corrections.example.txt" ]; then
cp "$HERE/corrections.example.txt" "$RESOURCES_DIR/"
fi
echo "=== 5. Generating Info.plist with Entitlements ==="
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
echo "=== 6. Code-signing App Bundle ==="
codesign -s - --deep --force "$APP_BUNDLE"
echo "=== 7. Installing to /Applications ==="
rm -rf /Applications/VoiceAgent.app
cp -R "$APP_BUNDLE" /Applications/
codesign -s - --deep --force /Applications/VoiceAgent.app
echo "=========================================================="
echo "Successfully built and installed VoiceAgent.app to:"
echo "1. /Applications/VoiceAgent.app"
echo "2. $APP_BUNDLE"
echo "=========================================================="
+52
View File
@@ -0,0 +1,52 @@
"""py2app setup configuration for building VoiceAgent.app."""
from setuptools import setup
APP = ["app_main.py"]
DATA_FILES = [
("swift", ["swift/speech-helper", "swift/llm-helper"]),
]
OPTIONS = {
"argv_emulation": False,
"iconfile": None,
"plist": {
"CFBundleName": "VoiceAgent",
"CFBundleDisplayName": "VoiceAgent",
"CFBundleIdentifier": "com.voiceagent.mac",
"CFBundleVersion": "1.0.0",
"CFBundleShortVersionString": "1.0.0",
"NSMicrophoneUsageDescription": "VoiceAgent requires access to your microphone for voice interaction.",
"NSSpeechRecognitionUsageDescription": "VoiceAgent uses on-device speech recognition to process your spoken input.",
"NSHumanReadableCopyright": "Copyright © 2026 Adolfo Reyna. All rights reserved.",
"LSMinimumSystemVersion": "14.0",
"NSHighResolutionCapable": True,
},
"includes": [
"bot",
"brain",
"voice_manager",
"claude_llm",
"apple_llm",
"apple_stt",
"apple_tts",
"speech_analyzer_stt",
"echo_guard",
"global_hotkey",
"push_to_talk",
"journal",
"memory_tools",
"transcript_repair",
"vocabulary",
"spoken_text",
"sounddevice_transport",
],
}
setup(
app=APP,
name="VoiceAgent",
data_files=DATA_FILES,
options={"py2app": OPTIONS},
setup_requires=["py2app"],
)
+20 -1
View File
@@ -33,6 +33,7 @@ import asyncio
import json import json
import os import os
import subprocess import subprocess
import sys
import tempfile import tempfile
from collections.abc import AsyncGenerator from collections.abc import AsyncGenerator
from pathlib import Path from pathlib import Path
@@ -45,7 +46,25 @@ from pipecat.services.stt_service import SegmentedSTTService
from pipecat.transcriptions.language import Language from pipecat.transcriptions.language import Language
from pipecat.utils.time import time_now_iso8601 from pipecat.utils.time import time_now_iso8601
HELPER = Path(__file__).parent / "swift" / "speech-helper" def get_helper_path(binary_name: str) -> Path:
if getattr(sys, "frozen", False) and hasattr(sys, "_MEIPASS"):
p1 = Path(sys._MEIPASS) / binary_name
if p1.exists():
return p1
p2 = Path(sys._MEIPASS) / "swift" / binary_name
if p2.exists():
return p2
if getattr(sys, "frozen", False):
res_dir = Path(sys.executable).parent.parent / "Resources"
p1 = res_dir / binary_name
if p1.exists():
return p1
p2 = res_dir / "swift" / binary_name
if p2.exists():
return p2
return Path(__file__).parent / "swift" / binary_name
HELPER = get_helper_path("speech-helper")
# Generous: the helper may be downloading the on-device model on first use. # Generous: the helper may be downloading the on-device model on first use.
_FIRST_RUN_TIMEOUT = 300.0 _FIRST_RUN_TIMEOUT = 300.0
+47
View File
@@ -0,0 +1,47 @@
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)")
}
}
}