Ctrl K
ComunidadGuías…

desktop.spec

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
# -*- 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.",
        },
    )