feat: mode application de bureau + packaging Windows autonome

Permet de distribuer l'application en executable Windows double-clic,
sans Python/Node/terminal pour l'utilisateur final.

- `desktop.py` : demarre le serveur sur un port libre dans un thread et
  ouvre une fenetre native (pywebview). `PLESNA_DEBUG=1` active les
  DevTools. Commande `plesna-gerance desktop` (import paresseux).
- `paths.py` : resolution centralisee des chemins selon le contexte
  (dev, executable PyInstaller, surcharge par variables d'env). Les
  donnees vont dans un dossier utilisateur inscriptible (%APPDATA%),
  les ressources dans le bundle. connection.py/storage.py utilisent
  `get_data_dir()`, app.py `resource_path()`.
- app.py : type MIME `.mjs` force (modules ES / worker PDF.js sous
  Windows).
- Groupes de deps optionnels desktop / desktop-linux / build.
- packaging/ : spec PyInstaller, build_windows.ps1, installeur Inno
  Setup. Workflow GitHub build-windows.yml (runner Windows).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-19 17:21:27 +02:00
parent 7cebc0bb3d
commit a90d1c4f64
15 changed files with 912 additions and 29 deletions

View File

@@ -1,7 +1,7 @@
"""Application FastAPI pour l'extraction de comptes rendus de gérance."""
import mimetypes
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI
from fastapi.responses import FileResponse
@@ -9,6 +9,7 @@ from fastapi.staticfiles import StaticFiles
from .. import __version__
from ..database import init_db
from ..paths import resource_path
from .routes import (
analytics_router,
config_router,
@@ -20,6 +21,10 @@ from .routes import (
tags_router,
)
# Garantit le bon type MIME pour les modules ES (worker PDF.js notamment),
# indépendamment du registre système (Windows peut ne pas connaître .mjs).
mimetypes.add_type("text/javascript", ".mjs")
@asynccontextmanager
async def lifespan(app: FastAPI):
@@ -67,8 +72,8 @@ async def health() -> dict:
return {"status": "ok"}
# Determine the frontend dist path
FRONTEND_DIST = Path(__file__).parent.parent.parent.parent / "frontend" / "dist"
# Determine the frontend dist path (works both from source and when packaged)
FRONTEND_DIST = resource_path("frontend", "dist")
# Mount static files for production (if dist exists)

View File

@@ -153,11 +153,35 @@ def serve(host: str, port: int, reload: bool) -> None:
)
@main.command()
def desktop() -> None:
"""Lance l'application en fenetre native (application de bureau).
Demarre le serveur en arriere-plan et ouvre une fenetre dediee.
C'est le mode utilise par l'executable Windows empaquete.
Necessite la dependance optionnelle 'desktop' (pywebview):
uv sync --group desktop
"""
try:
import webview # noqa: F401
except ImportError as e:
raise click.ClickException(
"Dependance manquante pour le mode bureau (pywebview). "
"Installez-la avec : uv sync --group desktop"
) from e
from .desktop import run
run()
@main.command()
@click.option(
"--db-path",
type=click.Path(dir_okay=False, path_type=Path),
help="Chemin de la base de donnees (defaut: ~/.plesna_gerance/database.sqlite)",
help="Chemin de la base de donnees (defaut: dossier de donnees utilisateur)",
)
def init_db(db_path: Path | None) -> None:
"""Initialise la base de donnees SQLite.

View File

@@ -7,33 +7,23 @@ from pathlib import Path
from sqlalchemy import create_engine, text
from sqlalchemy.orm import Session, sessionmaker
from ..paths import get_data_dir
from .models import Base, Tag
# Default database path - relative to project root
def _get_project_root() -> Path:
"""Find project root by looking for pyproject.toml."""
current = Path(__file__).resolve()
for parent in current.parents:
if (parent / "pyproject.toml").exists():
return parent
# Fallback to home directory if not found
return Path.home() / ".plesna_gerance"
DEFAULT_DB_PATH = _get_project_root() / "data" / "database.sqlite"
# Global engine instance
_engine = None
_SessionLocal = None
def get_db_path() -> Path:
"""Get database path from environment or default."""
"""Get database path from environment or default.
Resolved lazily so a packaged build writes to the per-user data dir.
"""
env_path = os.environ.get("PLESNA_DB_PATH")
if env_path:
return Path(env_path)
return DEFAULT_DB_PATH
return get_data_dir() / "database.sqlite"
def _build_engine(db_path: Path):

View File

@@ -7,14 +7,7 @@ from datetime import date
from pathlib import Path
from typing import Any
def _get_project_root() -> Path:
"""Find project root by looking for pyproject.toml."""
current = Path(__file__).resolve()
for parent in current.parents:
if (parent / "pyproject.toml").exists():
return parent
return Path.home() / ".plesna_gerance"
from ..paths import get_data_dir
def get_storage_root() -> Path:
@@ -26,7 +19,7 @@ def get_storage_root() -> Path:
env_path = os.environ.get("PLESNA_STORAGE_PATH")
if env_path:
return Path(env_path)
return _get_project_root() / "data" / "documents"
return get_data_dir() / "documents"
def extract_street_letter(adresse: str | None) -> str:

View File

@@ -0,0 +1,87 @@
"""Lanceur application de bureau (fenêtre native via pywebview).
Démarre le serveur FastAPI en arrière-plan sur un port libre de la boucle
locale, attend qu'il réponde, puis ouvre une fenêtre native pointant dessus.
C'est le point d'entrée de l'exécutable empaqueté (PyInstaller).
``pywebview`` est importé paresseusement : le reste du paquet reste utilisable
sans cette dépendance (serveur headless, Docker, CI).
"""
import os
import socket
import threading
import httpx
import uvicorn
from .api import app
WINDOW_TITLE = "Plesna Gérance"
HOST = "127.0.0.1"
def _find_free_port() -> int:
"""Réserve un port TCP libre sur la boucle locale."""
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind((HOST, 0))
return s.getsockname()[1]
class _ThreadedServer(uvicorn.Server):
"""Serveur uvicorn lançable dans un thread (sans handlers de signaux)."""
def install_signal_handlers(self) -> None: # noqa: D102 - voir docstring classe
pass
def _wait_until_ready(base_url: str, timeout: float = 30.0) -> bool:
"""Attend que ``/api/health`` réponde, jusqu'à ``timeout`` secondes."""
import time
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
try:
resp = httpx.get(f"{base_url}/api/health", timeout=1.0)
if resp.status_code == 200:
return True
except httpx.HTTPError:
pass
time.sleep(0.2)
return False
def run() -> None:
"""Lance le serveur puis la fenêtre native. Bloque jusqu'à fermeture."""
port = _find_free_port()
base_url = f"http://{HOST}:{port}"
config = uvicorn.Config(app, host=HOST, port=port, log_level="warning")
server = _ThreadedServer(config)
thread = threading.Thread(target=server.run, daemon=True)
thread.start()
if not _wait_until_ready(base_url):
server.should_exit = True
raise RuntimeError(
"Le serveur n'a pas démarré à temps. "
"Consultez les journaux pour plus de détails."
)
# Import paresseux : pywebview n'est requis que pour le mode bureau.
import webview
# PLESNA_DEBUG=1 active l'inspecteur web (DevTools) dans la fenetre native :
# clic droit -> « Inspecter » (ou « Inspect element ») pour ouvrir la console.
debug = os.environ.get("PLESNA_DEBUG", "").lower() in ("1", "true", "yes")
webview.create_window(WINDOW_TITLE, base_url, width=1280, height=860)
try:
webview.start(debug=debug)
finally:
# Fermeture de la fenêtre -> arrêt propre du serveur.
server.should_exit = True
if __name__ == "__main__":
run()

View File

@@ -0,0 +1,70 @@
"""Résolution centralisée des chemins (données utilisateur et ressources).
Gère trois contextes d'exécution :
- **Développement** : lancé depuis les sources. Les données vivent dans
``<racine_projet>/data`` et les ressources (frontend buildé) dans
``<racine_projet>/frontend/dist``.
- **Exécutable empaqueté** (PyInstaller, ex. ``.exe`` Windows) : les données
doivent aller dans un dossier utilisateur inscriptible (``%APPDATA%`` sous
Windows) — l'exécutable lui-même est souvent en lecture seule
(``Program Files``). Les ressources sont extraites dans ``sys._MEIPASS``.
- **Surcharge explicite** via variables d'environnement (tests, Docker).
"""
import os
import sys
from pathlib import Path
APP_NAME = "PlesnaGerance"
def is_frozen() -> bool:
"""Vrai si on tourne depuis un exécutable PyInstaller."""
return getattr(sys, "frozen", False)
def get_bundle_dir() -> Path:
"""Répertoire racine des ressources embarquées (lecture seule).
- Empaqueté : ``sys._MEIPASS`` (dossier d'extraction PyInstaller), avec
repli sur le dossier de l'exécutable.
- Développement : racine du projet (``src/plesna_gerance/paths.py`` -> 3 crans).
"""
if is_frozen():
meipass = getattr(sys, "_MEIPASS", None)
if meipass:
return Path(meipass)
return Path(sys.executable).resolve().parent
return Path(__file__).resolve().parent.parent.parent
def resource_path(*parts: str) -> Path:
"""Chemin d'une ressource embarquée (ex. ``resource_path('frontend', 'dist')``)."""
return get_bundle_dir().joinpath(*parts)
def get_data_dir() -> Path:
"""Répertoire inscriptible des données utilisateur (DB, documents).
Priorité :
1. ``PLESNA_DATA_DIR`` si défini ;
2. dossier applicatif utilisateur si empaqueté ;
3. ``<racine_projet>/data`` en développement (comportement historique).
"""
env = os.environ.get("PLESNA_DATA_DIR")
if env:
return Path(env)
if is_frozen():
if sys.platform == "win32":
base = Path(os.environ.get("APPDATA") or Path.home())
elif sys.platform == "darwin":
base = Path.home() / "Library" / "Application Support"
else:
base = Path(
os.environ.get("XDG_DATA_HOME") or (Path.home() / ".local" / "share")
)
return base / APP_NAME
return get_bundle_dir() / "data"