Files
VoiceAgent/MACOS_APP_PACKAGING.md
T

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:

  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:

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