desktop.spec
Rama main · réplica de lectura del repositorio original.
# -*- 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 `<llama_cpp>/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.",
},
)