diff --git a/.gitignore b/.gitignore index 6fe90bf..0bd092e 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,6 @@ swift/speech-helper swift/llm-helper swift/test_foundation swift/test_foundation.swift +dist/ +.DS_Store + diff --git a/MACOS_APP_PACKAGING.md b/MACOS_APP_PACKAGING.md new file mode 100644 index 0000000..5f8f2aa --- /dev/null +++ b/MACOS_APP_PACKAGING.md @@ -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" + + + + + CFBundleExecutable + VoiceAgent + CFBundleIdentifier + com.voiceagent.mac + CFBundleName + VoiceAgent + CFBundleDisplayName + VoiceAgent + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0.0 + CFBundleVersion + 1.0.0 + LSMinimumSystemVersion + 14.0 + NSMicrophoneUsageDescription + VoiceAgent requires access to your microphone to listen to your voice commands. + NSSpeechRecognitionUsageDescription + VoiceAgent uses on-device speech recognition to process your spoken input. + NSHighResolutionCapable + + + +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`** | diff --git a/app_main.py b/app_main.py new file mode 100644 index 0000000..832423f --- /dev/null +++ b/app_main.py @@ -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) diff --git a/apple_llm.py b/apple_llm.py index a790d96..a9b3fb1 100644 --- a/apple_llm.py +++ b/apple_llm.py @@ -13,6 +13,7 @@ import json import os import shutil import subprocess +import sys from pathlib import Path from loguru import logger @@ -41,8 +42,25 @@ _NOISE_TRANSCRIPTS = { "[silence]", } -HERE = Path(__file__).parent -LLM_HELPER_PATH = HERE / "swift" / "llm-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 + +LLM_HELPER_PATH = get_helper_path("llm-helper") def probe_apple_llm() -> tuple[bool, str]: diff --git a/build_app.sh b/build_app.sh new file mode 100644 index 0000000..7543069 --- /dev/null +++ b/build_app.sh @@ -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" + + + + + CFBundleExecutable + VoiceAgent + CFBundleIdentifier + com.voiceagent.mac + CFBundleName + VoiceAgent + CFBundleDisplayName + VoiceAgent + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0.0 + CFBundleVersion + 1.0.0 + LSMinimumSystemVersion + 14.0 + NSMicrophoneUsageDescription + VoiceAgent requires access to your microphone to listen to your voice commands. + NSSpeechRecognitionUsageDescription + VoiceAgent uses on-device speech recognition to process your spoken input. + NSHighResolutionCapable + + + +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 "==========================================================" diff --git a/setup_app.py b/setup_app.py new file mode 100644 index 0000000..a7bc6ea --- /dev/null +++ b/setup_app.py @@ -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"], +) diff --git a/speech_analyzer_stt.py b/speech_analyzer_stt.py index cc7b5f8..901576e 100644 --- a/speech_analyzer_stt.py +++ b/speech_analyzer_stt.py @@ -33,6 +33,7 @@ import asyncio import json import os import subprocess +import sys import tempfile from collections.abc import AsyncGenerator from pathlib import Path @@ -45,7 +46,25 @@ from pipecat.services.stt_service import SegmentedSTTService from pipecat.transcriptions.language import Language 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. _FIRST_RUN_TIMEOUT = 300.0 diff --git a/swift/VoiceAgentLauncher.swift b/swift/VoiceAgentLauncher.swift new file mode 100644 index 0000000..91e39ce --- /dev/null +++ b/swift/VoiceAgentLauncher.swift @@ -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)") + } + } +}