feat(splash): splash natif PyInstaller — couvre la décompression onefile

L'exe --onefile décompresse ~720 Mo dans %TEMP% au lancement. Sur Windows,
cela prend 15-30 s AVANT que Python ne démarre. Pendant ce temps :
- Aucune fenêtre visible (même le splash tkinter existant n'était pas encore
  exécuté, car il faut d'abord l'import de Python).
- L'utilisateur clique parfois plusieurs fois, croit que l'app est plantée.

Solution : Splash natif PyInstaller (Splash() dans le .spec). L'image est
affichée PAR LE BOOTLOADER de l'exe, AVANT même le démarrage Python. Le
texte sous l'image est actualisable via pyi_splash.update_text(), puis
fermé via pyi_splash.close() une fois le splash tkinter visible.

Changements :
- assets/splash.png (480x240) : titre + sous-titre + indication de durée
- anonymisation_onefile.spec : Splash() + splash/splash.binaries dans EXE()
- launcher.py : import pyi_splash (fallback silencieux en mode dev), helpers
  _splash_update / _splash_close, fermeture du splash natif dès que le
  splash tkinter est à l'écran (évite superposition).
- .gitignore : exception !assets/** pour versionner l'image du splash
  (règle générale *.png exclut tout le reste).

Effet utilisateur attendu : fenêtre visible IMMÉDIATEMENT au double-clic,
avec message "Démarrage en cours — merci de patienter…". Suppression du
trou noir de 15-30 s.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-15 15:28:45 +02:00
parent e2e2a7c8e3
commit 0a377bc001
4 changed files with 67 additions and 2 deletions

3
.gitignore vendored
View File

@@ -40,6 +40,9 @@ models/
*.jpg *.jpg
*.jpeg *.jpeg
*.gif *.gif
# Exception : assets embarqués dans l'exe (splash, icônes…) doivent être versionnés
!assets/**
!assets
*.mp3 *.mp3
*.wav *.wav
*.mp4 *.mp4

View File

@@ -45,8 +45,27 @@ a = Analysis(
noarchive=False, noarchive=False,
) )
pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher) pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)
# Splash natif PyInstaller : image affichée AU LANCEMENT DE L'EXE,
# avant même que Python démarre. Couvre les ~15-30 s de décompression
# du bundle --onefile dans %TEMP% qui laissaient l'écran vide auparavant.
# Le launcher ferme le splash via pyi_splash.close() une fois la GUI prête.
splash = Splash(
os.path.join(app_dir, 'assets', 'splash.png'),
binaries=a.binaries,
datas=a.datas,
text_pos=(10, 215),
text_size=10,
text_color='white',
minify_script=True,
always_on_top=False,
)
exe = EXE( exe = EXE(
pyz, a.scripts, a.binaries, a.zipfiles, a.datas, [], pyz, a.scripts,
splash, # image affichée immédiatement
splash.binaries, # bootloader splash
a.binaries, a.zipfiles, a.datas, [],
name='Anonymisation', name='Anonymisation',
debug=False, debug=False,
strip=False, strip=False,

BIN
assets/splash.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

View File

@@ -9,6 +9,35 @@ from pathlib import Path
import threading import threading
import logging import logging
# pyi_splash : module injecté par PyInstaller quand --splash est utilisé.
# Permet d'actualiser / fermer le splash natif affiché au démarrage de l'exe
# pendant la décompression --onefile (~15-30 s sur Windows). En mode dev
# (pas frozen), le module n'existe pas → fallback silencieux.
try:
import pyi_splash # type: ignore
_HAS_PYI_SPLASH = True
except Exception:
pyi_splash = None
_HAS_PYI_SPLASH = False
def _splash_update(text: str) -> None:
"""Met à jour le texte affiché sous le splash natif PyInstaller (si actif)."""
if _HAS_PYI_SPLASH:
try:
pyi_splash.update_text(text)
except Exception:
pass
def _splash_close() -> None:
"""Ferme le splash natif PyInstaller (si actif)."""
if _HAS_PYI_SPLASH:
try:
pyi_splash.close()
except Exception:
pass
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Single-instance guard (lock file in user's temp directory) # Single-instance guard (lock file in user's temp directory)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -80,10 +109,16 @@ def launch_gui():
Le chargement des gazetteers (INSEE 200k+ noms, FINESS 100k+ établissements, Le chargement des gazetteers (INSEE 200k+ noms, FINESS 100k+ établissements,
BDPM 7k+ médicaments) prend 1030 s. Sans feedback visuel, l'utilisateur BDPM 7k+ médicaments) prend 1030 s. Sans feedback visuel, l'utilisateur
croit que l'application est plantée. Le splash permet d'indiquer l'avancée. croit que l'application est plantée. Deux splashes collaborent :
- Splash natif PyInstaller (image) : visible AVANT le démarrage Python,
pendant la décompression --onefile dans %TEMP%.
- Splash tkinter ci-dessous : progressbar + messages pendant l'import
des modèles et la construction de la GUI.
""" """
log.info("Launching GUI...") log.info("Launching GUI...")
_splash_update("Chargement de l'interface…")
splash = tk.Tk() splash = tk.Tk()
splash.title("Anonymisation") splash.title("Anonymisation")
splash.geometry("440x200") splash.geometry("440x200")
@@ -122,6 +157,11 @@ def launch_gui():
threading.Thread(target=_do_import, daemon=True).start() threading.Thread(target=_do_import, daemon=True).start()
# Fermer le splash natif PyInstaller dès que le splash tkinter est à l'écran
# (sinon les deux se superposent pendant tout l'import).
splash.update() # force le rendu initial
_splash_close()
def _poll(): def _poll():
# Met à jour le message selon l'étape atteinte par le thread d'import # Met à jour le message selon l'étape atteinte par le thread d'import
if result.get("step") == "gui" and not result["done"]: if result.get("step") == "gui" and not result["done"]:
@@ -358,8 +398,11 @@ def main():
try: try:
if check_models_ready(): if check_models_ready():
_splash_update("Modèles déjà installés — chargement…")
launch_gui() launch_gui()
else: else:
_splash_update("Premier lancement — configuration initiale")
_splash_close() # laisse place à la SetupWindow qui a sa propre UI
setup = SetupWindow() setup = SetupWindow()
setup.run() setup.run()
except Exception as e: except Exception as e: