# -*- mode: python ; coding: utf-8 -*- """PyInstaller spec for Spendif.ai native desktop app. Build: cd sw_artifacts uv run --extra desktop pyinstaller desktop.spec --noconfirm --clean Produces: macOS → dist/SpendifAi.app Windows → dist/SpendifAi/SpendifAi.exe """ import sys from pathlib import Path from PyInstaller.utils.hooks import collect_all, collect_data_files # --------------------------------------------------------------------------- # Streamlit assets (templates, static files, etc.) # --------------------------------------------------------------------------- st_datas, st_binaries, st_hiddenimports = collect_all("streamlit") # Plotly needs its own data files plotly_datas = collect_data_files("plotly") # llama-cpp-python: imported lazily inside core.llm_backends, so PyInstaller # cannot discover it via static analysis. Pull in the python package AND the # shipped shared libs (libllama, libggml*, libmtmd, …) which ctypes loads at # runtime from `/lib/`. Without these the bundled app crashes on # first model load with `Shared library with base name 'llama' not found`. llama_datas, llama_binaries, llama_hiddenimports = collect_all("llama_cpp") # Voice prototype: whisper.cpp and sherpa-onnx are imported lazily inside # services.voice_service, so static analysis cannot find them either. voice_datas, voice_binaries, voice_hiddenimports = [], [], [] for _pkg in ("pywhispercpp", "sherpa_onnx"): try: _d, _b, _h = collect_all(_pkg) except Exception: continue voice_datas += _d voice_binaries += _b voice_hiddenimports += _h + [_pkg] + (["_pywhispercpp"] if _pkg == "pywhispercpp" else []) # The accelerator plugins, which nothing links to. # # Built with GGML_BACKEND_DL=ON, each backend is a plugin the library loads at # runtime rather than a link-time dependency. That is what lets one package # carry the processor and Vulkan together instead of shipping a second wheel. # The cost is exactly here: PyInstaller finds libraries by following link-time # dependencies, so it copies libggml, libggml-base and libggml-cpu, which # libllama needs, and leaves libggml-vulkan behind, which nobody names. # # Found on 2026-09-25 by the check in the build job, on the first run: the # wheel had the plugin, the bundle did not. Without that check the Linux # package would have shipped with no Vulkan and run on the processor without # saying so. try: import llama_cpp as _llama_cpp _llama_lib = Path(_llama_cpp.__file__).parent / "lib" # As data, not as a binary, and the difference is the point. A binary goes # through dependency analysis, which rewrites it and can drop it when # something it names cannot be resolved on the build machine. A plugin # loaded by path at runtime wants to arrive exactly as it was built, with # its own dependency on the system's Vulkan loader resolved on the machine # that has a driver, which is the user's and not ours. # The names follow ggml's own loader: libggml-*.so on Linux and macOS, # ggml-*.dll on Windows, with no "lib" prefix. Matching only the first # would leave the Windows bundle without Vulkan, quietly. _carried = sorted(set(_llama_lib.glob("libggml-*")) | set(_llama_lib.glob("ggml-*"))) for _plugin in _carried: llama_datas.append((str(_plugin), "llama_cpp/lib")) print(f"desktop.spec: carrying {len(_carried)} ggml files from {_llama_lib}: " f"{', '.join(p.name for p in _carried)}") except Exception as _exc: # noqa: BLE001 - a spec that cannot look is a spec # that builds a package quietly missing its accelerators, so say it loudly. print(f"desktop.spec: WARNING could not collect the ggml plugins ({_exc})") # --------------------------------------------------------------------------- # Application packages to bundle alongside the frozen launcher # --------------------------------------------------------------------------- # nsi/ is deliberately absent. It is the generation input for # core/static_rules.json, and core/static_rules.json is what runs # (core/nsi_lookup.py reads it). Listing nsi/ here shipped 16 MB into # locally built artifacts and nothing into CI ones, because the directory # is gitignored: same artifact name, different contents per builder. APP_PACKAGES = [ "app.py", "api", "chat_bot", "config", "core", "db", "desktop", "prompts", "reports", "services", "support", "ui", ] app_datas = [] for pkg in APP_PACKAGES: src = Path(pkg) if src.is_file(): app_datas.append((str(src), ".")) elif src.is_dir(): app_datas.append((str(src), pkg)) # Splash HTML app_datas.append(("desktop/splash.html", "desktop")) # .env.example as fallback if Path(".env.example").exists(): app_datas.append((".env.example", ".")) # VERSION file — single source of truth for the app version _app_version = ( Path("VERSION").read_text(encoding="utf-8").strip() if Path("VERSION").exists() else "0.0.0" ) if Path("VERSION").exists(): app_datas.append(("VERSION", ".")) # --------------------------------------------------------------------------- # Hidden imports that PyInstaller cannot auto-detect # --------------------------------------------------------------------------- hidden_imports = [ # App modules "api.main", "config", "core.orchestrator", "core.classifier", "core.categorizer", "core.normalizer", "core.sanitizer", "core.model_manager", "core.llm_backends", "core.schemas", "core.models", "db.models", "db.repository", "services.import_service", "services.settings_service", "support.logging", # Third-party "uvicorn.logging", "uvicorn.protocols.http", "uvicorn.protocols.http.auto", "uvicorn.protocols.websockets", "uvicorn.protocols.websockets.auto", "uvicorn.lifespan", "uvicorn.lifespan.on", "pydantic", "sqlalchemy.dialects.sqlite", "plotly", "openpyxl", "xlrd", # imported by pandas only when it opens a .xls "chardet", "yaml", "dotenv", "webview", "llama_cpp", "desktop.signal_watch", # imported by the launcher before app_dir is on sys.path ] + st_hiddenimports + llama_hiddenimports + voice_hiddenimports # --------------------------------------------------------------------------- # Icon (platform-dependent) # --------------------------------------------------------------------------- icon_path = None if sys.platform == "darwin": icns = Path("packaging/macos/spendifai.icns") if icns.exists(): icon_path = str(icns) elif sys.platform == "win32": ico = Path("packaging/windows/spendifai.ico") if ico.exists(): icon_path = str(ico) # --------------------------------------------------------------------------- # Analysis # --------------------------------------------------------------------------- a = Analysis( ["desktop/launcher.py"], pathex=["."], binaries=st_binaries + llama_binaries + voice_binaries, datas=app_datas + st_datas + plotly_datas + llama_datas + voice_datas, hiddenimports=hidden_imports, hookspath=[], hooksconfig={}, runtime_hooks=[], excludes=[ # Exclude heavy optional deps not needed in desktop mode "azure", "azure.ai", "azure.identity", "matplotlib", "IPython", "notebook", "tkinter", ], noarchive=False, ) # PyInstaller delega lo strip alla toolchain GNU, che e' fatta per ELF: sui # binari PE di Windows produce file che il caricatore non accetta piu'. La # documentazione ufficiale lo dichiara "not recommended for Windows", e il # 2026-09-22 e' costato un pacchetto MSIX che si installava e non partiva: # python312.dll rovinato, LoadLibrary "Invalid access to memory location", # nessun log perche' Python non arrivava a girare. # # Su Linux lo strip rovina le librerie delle wheel manylinux: auditwheel le ha # gia' riscritte con patchelf, e lo strip GNU ne sposta i segmenti. Il # 2026-09-25 il .deb partiva su debian:12 e moriva importando numpy: # "libscipy_openblas64_...so: ELF load command address/offset not # page-aligned". Resta attivo solo su macOS. STRIP = sys.platform == "darwin" pyz = PYZ(a.pure) exe = EXE( pyz, a.scripts, [], exclude_binaries=True, # --onedir mode name="SpendifAi", debug=False, bootloader_ignore_signals=False, strip=STRIP, upx=False, # console=True esisteva per non perdere i traceback: senza console, su # macOS stdout e stderr finivano in /dev/null e un errore prima della # finestra era invisibile. Quella ragione e' venuta meno, perche' il # launcher redirige entrambi su file e da oggi ci manda anche l'output del # processo Streamlit, che e' dove succede il lavoro vero. # # Il prezzo di lasciarla accesa lo paga Windows: una finestra di comandi # nera resta aperta dietro l'app per tutta la sessione. # # Resta scoperto un caso solo: un errore del bootloader di PyInstaller, # prima che Python parta. Li' non c'e' file di log perche' non c'e' ancora # nessuno a scriverlo. console=False, icon=icon_path, ) coll = COLLECT( exe, a.binaries, a.datas, strip=STRIP, upx=False, name="SpendifAi", ) # macOS .app bundle if sys.platform == "darwin": app = BUNDLE( coll, name="SpendifAi.app", icon=icon_path, bundle_identifier="ai.spendif.desktop", info_plist={ "CFBundleName": "Spendif.ai", "CFBundleDisplayName": "Spendif.ai", "CFBundleShortVersionString": _app_version, "CFBundleVersion": _app_version, "LSMinimumSystemVersion": "12.0", "NSHighResolutionCapable": True, # EXPERIMENTAL voice round trip (spec 32, 3.2): without this key # macOS denies the microphone, or ends the app, at the first ask. "NSMicrophoneUsageDescription": "Spendif.ai listens to your answer to confirm an expense. " "The audio is transcribed on this computer and never leaves it.", }, )