# -*- mode: python ; coding: utf-8 -*- """ PyInstaller spec file for Jarvis Desktop App Builds a standalone executable for Windows, macOS, and Linux """ import sys from pathlib import Path from PyInstaller.utils.hooks import collect_data_files, collect_submodules block_cipher = None # Get the project root directory project_root = Path('.').absolute() src_path = project_root / 'src' # Create qt.conf for macOS to help Qt find plugins correctly if sys.platform == 'darwin': qt_conf_path = project_root / 'qt.conf' qt_conf_path.write_text("""[Paths] Prefix = . Plugins = PyQt6/Qt6/plugins """) print(f"Created qt.conf at {qt_conf_path}") # Collect all necessary data files # Note: Let PyInstaller's built-in hooks handle sounddevice, ctranslate2, and Qt WebEngine # Manual collection can conflict with hooks and cause crashes datas = [ (str(src_path / 'desktop_app' / 'desktop_assets' / '*.png'), 'desktop_app/desktop_assets'), ] # Collect Piper TTS data files (espeak-ng-data is required for phonemization) try: import piper piper_path = Path(piper.__file__).parent # espeak-ng-data contains phoneme data needed for TTS espeak_data = piper_path / 'espeak-ng-data' if espeak_data.exists(): datas.append((str(espeak_data), 'piper/espeak-ng-data')) print(f"Bundling Piper espeak-ng-data from {espeak_data}") # tashkeel contains Arabic diacritization data tashkeel_data = piper_path / 'tashkeel' if tashkeel_data.exists(): datas.append((str(tashkeel_data), 'piper/tashkeel')) print(f"Bundling Piper tashkeel from {tashkeel_data}") except ImportError: print("Warning: piper not installed, TTS may not work in bundle") # Bundle tzdata on Windows so zoneinfo can resolve IANA zones (Windows has no # system zoneinfo database). macOS/Linux read /usr/share/zoneinfo at runtime # and do not need the pip package. if sys.platform == 'win32': try: datas += collect_data_files('tzdata') print("Bundling tzdata for zoneinfo support on Windows") except Exception as e: print(f"Warning: could not collect tzdata: {e}") # Add qt.conf for macOS if sys.platform == 'darwin': datas.append((str(project_root / 'qt.conf'), '.')) # Collect Qt plugins for system tray functionality try: import PyQt6 qt_path = Path(PyQt6.__file__).parent # Add Qt plugins for platform integration (needed for system tray on macOS) # Only add directories that actually exist (e.g., 'styles' may not exist on Linux) qt_plugin_dirs = [ ('platforms', 'PyQt6/Qt6/plugins/platforms'), ('styles', 'PyQt6/Qt6/plugins/styles'), ] for plugin_name, dest_path in qt_plugin_dirs: plugin_path = qt_path / 'Qt6' / 'plugins' / plugin_name if plugin_path.exists(): datas.append((str(plugin_path), dest_path)) else: print(f"Info: Qt plugin directory '{plugin_name}' not found, skipping") except Exception as e: print(f"Warning: Could not collect Qt plugins: {e}") # Note: Qt WebEngine resources are handled by PyInstaller's hook-PyQt6.QtWebEngineWidgets.py # Manual collection can conflict with the hook and cause crashes # Hidden imports that PyInstaller might miss hiddenimports = [ # Jarvis core modules 'jarvis', 'jarvis._version', 'jarvis.daemon', 'jarvis.config', 'jarvis.debug', 'jarvis.llm', 'jarvis.main', # Desktop app modules 'desktop_app', 'desktop_app.app', 'desktop_app.splash_screen', 'desktop_app.setup_wizard', 'desktop_app.updater', 'desktop_app.update_dialog', 'desktop_app.themes', 'desktop_app.face_widget', 'desktop_app.diary_dialog', 'desktop_app.memory_viewer', # Listening modules 'jarvis.listening', 'jarvis.listening.echo_detection', 'jarvis.listening.listener', 'jarvis.listening.state_manager', 'jarvis.listening.wake_detection', 'jarvis.listening.transcript_buffer', 'jarvis.listening.intent_judge', # Memory modules 'jarvis.memory', 'jarvis.memory.conversation', 'jarvis.memory.db', 'jarvis.memory.embeddings', # Output modules 'jarvis.output', 'jarvis.output.tts', 'jarvis.output.tune_player', # Piper TTS (local neural TTS) 'piper', 'piper.voice', 'piper.config', 'piper.download', 'piper.download_voices', 'piper.phonemize_espeak', 'piper.phoneme_ids', # ONNX Runtime (required by Piper for model inference) 'onnxruntime', 'onnxruntime.capi', 'onnxruntime.capi._pybind_state', # Profile modules 'jarvis.profile', 'jarvis.profile.profiles', # Reply modules 'jarvis.reply', 'jarvis.reply.engine', 'jarvis.reply.enrichment', # Tools modules 'jarvis.tools', 'jarvis.tools.base', 'jarvis.tools.registry', 'jarvis.tools.types', 'jarvis.tools.builtin', 'jarvis.tools.builtin.fetch_web_page', 'jarvis.tools.builtin.local_files', 'jarvis.tools.builtin.nutrition', 'jarvis.tools.builtin.nutrition.delete_meal', 'jarvis.tools.builtin.nutrition.fetch_meals', 'jarvis.tools.builtin.nutrition.log_meal', 'jarvis.tools.builtin.recall_conversation', 'jarvis.tools.builtin.refresh_mcp_tools', 'jarvis.tools.builtin.screenshot', 'jarvis.tools.builtin.web_search', 'jarvis.tools.external', 'jarvis.tools.external.mcp_client', # Utils modules 'jarvis.utils', 'jarvis.utils.fast_vector_store', 'jarvis.utils.fuzzy_search', 'jarvis.utils.location', 'jarvis.utils.redact', 'jarvis.utils.vector_store', # PyQt6 'PyQt6.QtCore', 'PyQt6.QtGui', 'PyQt6.QtWidgets', 'PyQt6.sip', # PyQt6 WebEngine (for embedded memory viewer) 'PyQt6.QtWebEngineWidgets', 'PyQt6.QtWebEngineCore', 'PyQt6.QtWebChannel', # Audio dependencies (critical for voice input) 'sounddevice', '_sounddevice_data', '_sounddevice_data.portaudio-binaries', 'webrtcvad', # Speech recognition (faster-whisper backend) 'faster_whisper', 'ctranslate2', 'huggingface_hub', 'huggingface_hub.file_download', 'huggingface_hub.hf_api', 'huggingface_hub.utils', 'tokenizers', # Third-party dependencies 'dotenv', 'psutil', 'requests', 'numpy', 'PIL', 'PIL.Image', 'rapidfuzz', 'rapidfuzz.fuzz', 'bs4', 'lxml', 'html2text', 'faiss', 'sqlite3', 'json', 'asyncio', 'threading', 'subprocess', 'geoip2', 'geoip2.database', 'miniupnpc', # zoneinfo support on Windows (macOS/Linux use /usr/share/zoneinfo) 'tzdata', 'zoneinfo', # Flask for memory viewer 'flask', 'flask.json', 'werkzeug', 'werkzeug.serving', 'werkzeug.routing', 'werkzeug.utils', 'werkzeug.datastructures', 'werkzeug.wrappers', 'werkzeug.exceptions', 'jinja2', 'markupsafe', 'itsdangerous', 'click', 'blinker', ] a = Analysis( ['src/desktop_app/app.py'], pathex=[str(src_path)], binaries=[], datas=datas, hiddenimports=hiddenimports, hookspath=[], hooksconfig={}, runtime_hooks=['src/desktop_app/rthook_onnxruntime.py'], excludes=[ # Exclude heavy packages to keep bundle size reasonable 'psycopg2', # Not used and causes OpenSSL conflicts 'torch', # PyTorch is 1.5-2GB - chatterbox TTS is optional 'torchaudio', 'torchvision', 'chatterbox', # Optional TTS engine (uses PyTorch) 'transformers', # Heavy ML library (not needed, faster_whisper uses ctranslate2) 'safetensors', 'accelerate', 'cv2', # OpenCV - not needed for core functionality 'opencv-python', 'matplotlib', # Not needed for core app 'notebook', 'jupyter', 'IPython', 'scipy', # Large, only used by optional features 'sklearn', 'scikit-learn', # Note: Keep huggingface_hub - needed by faster_whisper for model downloads ], win_no_prefer_redirects=False, win_private_assemblies=False, cipher=block_cipher, noarchive=False, ) # Filter out heavy binaries on all platforms to reduce bundle size # Note: Be careful not to exclude libs needed by numpy/faster-whisper excluded_binary_patterns = [ 'torch', 'libtorch', 'libcaffe2', # PyTorch (~1.5GB) 'torchaudio', 'torchvision', 'cv2', 'opencv', 'libopencv', # OpenCV (~500MB) 'sklearn', 'scikit', # scikit-learn 'transformers', # Heavy ML library 'chatterbox', 'matplotlib', # Note: Keep huggingface_hub (needed by faster_whisper for model downloads) # Note: Keep libopenblas (needed by numpy) and libfreetype (needed by av/ffmpeg) ] # Exclude VC++ runtime DLLs from the bundle entirely. Different packages # (PyQt6, conda, etc.) ship conflicting versions that cause access-violation # crashes in onnxruntime. Instead of trying to pick the "right" version we # rely on the system-installed Microsoft Visual C++ Redistributable which # users are asked to install (see README). Also exclude other system DLLs # that PyInstaller picks up from non-system locations (e.g. Oculus). excluded_system_dlls = { 'vcruntime140.dll', 'vcruntime140_1.dll', 'msvcp140.dll', 'msvcp140_1.dll', 'msvcp140_2.dll', 'ucrtbase.dll', # Universal CRT — must come from Windows System32 'dbghelp.dll', # Must come from Windows System32 } filtered_binaries = [] for binary in a.binaries: name = binary[0].lower() binary_path = str(binary[1]).lower() if len(binary) > 1 else '' # Check if this binary should be excluded should_exclude = False base_name = name.rsplit('\\', 1)[-1].rsplit('/', 1)[-1] # Exclude all VC runtime and system DLLs — use system-installed versions if base_name in excluded_system_dlls: print(f"Excluding system DLL (use VC++ Redistributable): {binary[0]}") should_exclude = True # Pattern-based exclusions (heavy libraries) if not should_exclude: for pattern in excluded_binary_patterns: if pattern in name or pattern in binary_path: print(f"Excluding heavy binary: {binary[0]}") should_exclude = True break if not should_exclude: filtered_binaries.append(binary) a.binaries = filtered_binaries # Note: VC++ runtime DLL handling on Windows is managed by PyInstaller 6.13.0+ # which has built-in pre-loading of system VC runtime DLLs # On macOS, ensure OpenSSL libraries are bundled properly if sys.platform == 'darwin': # Remove any psycopg2 binaries and OpenCV's bundled OpenSSL (should be excluded already, but be safe) filtered_binaries = [] for binary in a.binaries: name = binary[0] # Exclude psycopg2 entirely if 'psycopg2' in name.lower(): print(f"Excluding psycopg2: {name}") continue filtered_binaries.append(binary) # Find and bundle OpenSSL libraries from Python's dependencies # Python's SSL module needs these, and they should come from Python's installation python_executable = sys.executable python_lib_dir = Path(python_executable).parent.parent / 'lib' # Try to find OpenSSL in Python's lib directory or common locations openssl_candidates = [ # Check Python's lib directory (pyenv, virtualenv, etc.) python_lib_dir / 'libssl.3.dylib', python_lib_dir / 'libcrypto.3.dylib', # Check Homebrew locations (will bundle these into the app) Path('/opt/homebrew/opt/openssl@3/lib/libssl.3.dylib'), Path('/opt/homebrew/opt/openssl@3/lib/libcrypto.3.dylib'), Path('/opt/homebrew/lib/libssl.3.dylib'), Path('/opt/homebrew/lib/libcrypto.3.dylib'), # Check system locations Path('/usr/local/lib/libssl.3.dylib'), Path('/usr/local/lib/libcrypto.3.dylib'), ] openssl_libs = { 'libssl.3.dylib': None, 'libcrypto.3.dylib': None, } # Find existing OpenSSL libraries for candidate in openssl_candidates: lib_name = candidate.name if lib_name in openssl_libs and candidate.exists() and openssl_libs[lib_name] is None: openssl_libs[lib_name] = candidate print(f"Found OpenSSL library: {candidate}") # Remove any existing libssl/libcrypto entries first filtered_binaries = [b for b in filtered_binaries if not (b[0] == 'libssl.3.dylib' or b[0] == 'libcrypto.3.dylib')] # Add found OpenSSL libraries for lib_name, lib_path in openssl_libs.items(): if lib_path and lib_path.exists(): print(f"Bundling OpenSSL: {lib_path} as {lib_name}") filtered_binaries.append((lib_name, str(lib_path), 'BINARY')) else: print(f"Warning: OpenSSL library {lib_name} not found - SSL may not work!") a.binaries = filtered_binaries pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher) # Platform-specific configurations if sys.platform == 'darwin': # macOS: Create .app bundle exe = EXE( pyz, a.scripts, [], exclude_binaries=True, name='Jarvis', debug=False, bootloader_ignore_signals=False, strip=False, upx=True, console=False, # No console for production disable_windowed_traceback=False, argv_emulation=False, target_arch=None, codesign_identity=None, entitlements_file=None, icon=str(src_path / 'desktop_app' / 'desktop_assets' / 'icon_idle.png'), ) coll = COLLECT( exe, a.binaries, a.zipfiles, a.datas, strip=False, upx=True, upx_exclude=[], name='Jarvis', ) app = BUNDLE( coll, name='Jarvis.app', icon=str(src_path / 'desktop_app' / 'desktop_assets' / 'icon_idle.png'), bundle_identifier='com.jarvis.assistant', info_plist={ 'NSHighResolutionCapable': 'True', 'LSUIElement': '1', # Hide from dock 'NSMicrophoneUsageDescription': 'Jarvis needs microphone access to listen for voice commands.', 'NSScreenCaptureUsageDescription': 'Jarvis needs screen capture access to read text from your screen via OCR.', }, ) # Post-build: Ensure OpenSSL libraries are correct and remove conflicting ones import shutil frameworks_dir = Path('dist/Jarvis.app/Contents/Frameworks') # Remove OpenCV's bundled OpenSSL libraries (they conflict with Python's SSL) # Try both possible directory names for dylibs_dir_name in ['__dot__dylibs', '.dylibs']: cv2_dylibs_dir = frameworks_dir / 'cv2' / dylibs_dir_name if cv2_dylibs_dir.exists(): for lib_name in ['libssl.3.dylib', 'libcrypto.3.dylib']: cv2_lib = cv2_dylibs_dir / lib_name if cv2_lib.exists(): cv2_lib.unlink() print(f"Removed OpenCV bundled OpenSSL: {cv2_lib}") # Also check Resources directory resources_dir = Path('dist/Jarvis.app/Contents/Resources') cv2_resources_dylibs = resources_dir / 'cv2' / '.dylibs' if cv2_resources_dylibs.exists(): for lib_name in ['libssl.3.dylib', 'libcrypto.3.dylib']: cv2_lib = cv2_resources_dylibs / lib_name if cv2_lib.exists(): cv2_lib.unlink() print(f"Removed OpenCV bundled OpenSSL from Resources: {cv2_lib}") # Find OpenSSL libraries that were bundled (from the binaries we added) bundled_openssl = {} for binary in a.binaries: if binary[0] in ['libssl.3.dylib', 'libcrypto.3.dylib']: bundled_openssl[binary[0]] = Path(binary[1]) # Also check the source paths we used during build openssl_source_paths = { 'libssl.3.dylib': Path('/opt/homebrew/opt/openssl@3/lib/libssl.3.dylib'), 'libcrypto.3.dylib': Path('/opt/homebrew/opt/openssl@3/lib/libcrypto.3.dylib'), } # Fallback to homebrew lib if openssl@3 not found if not openssl_source_paths['libssl.3.dylib'].exists(): openssl_source_paths = { 'libssl.3.dylib': Path('/opt/homebrew/lib/libssl.3.dylib'), 'libcrypto.3.dylib': Path('/opt/homebrew/lib/libcrypto.3.dylib'), } # Fix any broken symlinks in Frameworks and ensure correct libraries are in place for lib_name in ['libssl.3.dylib', 'libcrypto.3.dylib']: lib_path = frameworks_dir / lib_name if lib_path.exists(): if lib_path.is_symlink(): # Check if symlink is broken try: lib_path.resolve(strict=True) # Symlink is valid, skip continue except (OSError, RuntimeError): # Broken symlink - remove it lib_path.unlink() print(f"Removed broken symlink: {lib_path}") else: # File exists and is not a symlink, check if it's valid if lib_path.stat().st_size > 0: # File looks valid, skip continue # Library doesn't exist or was removed - copy from source source_lib = None if lib_name in bundled_openssl and bundled_openssl[lib_name].exists(): source_lib = bundled_openssl[lib_name] elif lib_name in openssl_source_paths and openssl_source_paths[lib_name].exists(): source_lib = openssl_source_paths[lib_name] if source_lib and source_lib.exists(): shutil.copy2(source_lib, lib_path) print(f"Fixed OpenSSL library: {source_lib} -> {lib_path}") else: print(f"Warning: Could not find source for {lib_name}") elif sys.platform == 'win32': # Windows: Create onedir distribution (directory with EXE + DLLs alongside) # This avoids the VC++ runtime DLL conflicts that plague onefile mode and # enables packaging via Inno Setup installer. exe = EXE( pyz, a.scripts, [], exclude_binaries=True, name='Jarvis', debug=False, bootloader_ignore_signals=False, strip=False, upx=True, console=False, disable_windowed_traceback=False, argv_emulation=False, target_arch=None, codesign_identity=None, entitlements_file=None, icon=str(src_path / 'desktop_app' / 'desktop_assets' / 'icon_idle.ico'), ) coll = COLLECT( exe, a.binaries, a.zipfiles, a.datas, strip=False, upx=True, upx_exclude=[], name='Jarvis', ) else: # Linux: Create directory-based distribution (more reliable than one-file) exe = EXE( pyz, a.scripts, [], exclude_binaries=True, name='Jarvis', debug=False, bootloader_ignore_signals=False, strip=False, upx=False, console=False, disable_windowed_traceback=False, argv_emulation=False, target_arch=None, codesign_identity=None, entitlements_file=None, ) coll = COLLECT( exe, a.binaries, a.zipfiles, a.datas, strip=False, upx=False, upx_exclude=[], name='Jarvis', )