Files
javis_bot/jarvis_desktop.spec
javis-bot c4abf63f38
Some checks failed
Release / semantic-release (push) Successful in 59s
tests / Unit tests (Linux, Python 3.11) (push) Successful in 13m45s
Release / build-linux (push) Failing after 7m47s
Release / build-windows (push) Has been cancelled
Release / build-macos (arm64, macos-latest) (push) Has been cancelled
Release / build-macos (x64, macos-15-intel) (push) Has been cancelled
Release / release-main (push) Has been cancelled
Release / release-develop (push) Has been cancelled
Add Discord-native hybrid front-end for Jarvis (bot + bridge)
Transform isair/jarvis into a Discord-controlled voice assistant running on
the Ubuntu VNC desktop, keeping the mature ~39k-line Python brain intact.

- bot/ (Node + bun, discord.js): /자비스 slash commands (ephemeral),
  voice channel join + voice receive/playback, pluggable VNC screen broadcast
  (selfbot live / noVNC / screenshot)
- bridge/ (Python, Flask): wraps jarvis STT + run_reply_engine + Piper TTS
  behind a thin localhost HTTP API
- .env.example, scripts/ (start_bridge/start_bot/dev), README rewrite,
  docs/language-comparison.md and docs/vnc-xfce-setup.md

Language decision: hybrid (Python brain + Node/bun Discord layer) because
Discord blocks bot video; native screen broadcast only works via a Node
selfbot library.
2026-06-09 14:51:05 +09:00

571 lines
19 KiB
Python

# -*- 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',
)