#!/usr/bin/env python3 """ build.py – Compilation + installation de TranscribeStation Produit un binaire onefile PyInstaller et installe les raccourcis système. Usage : python build.py # build + install (plateforme courante) python build.py --linux # build Linux python build.py --windows # build Windows (Wine ou .bat natif) python build.py --both # Linux + Windows python build.py --install-only # installe sans recompiler python build.py --clean # nettoyer build/ dist/ *.spec icon.* Raccourcis créés : Linux → ~/.local/share/applications/TranscribeStation.desktop ~/.local/share/icons/hicolor/256x256/apps/TranscribeStation.png ~/Bureau/TranscribeStation.desktop (si dossier existe) Windows → %APPDATA%\\Microsoft\\Windows\\Start Menu\\Programs\\TranscribeStation.lnk %USERPROFILE%\\Desktop\\TranscribeStation.lnk """ import sys import os import shutil import platform import subprocess import argparse import textwrap import urllib.request import zipfile import tempfile from pathlib import Path # ─── Configuration ──────────────────────────────────────────────────────────── APP_NAME = "TranscribeStation" APP_VERSION = "1.2.0" APP_AUTHOR = "H3Campus" APP_URL = "https://github.com/h3campus/transcribe-station" APP_COMMENT = "Gestionnaire de transcription audio – JT-Tools by Johnny" APP_COMMENT_WIN = "Gestionnaire de transcription audio - JT-Tools by Johnny" APP_CATS = "Audio;AudioVideo;Office;" MAIN_SCRIPT = "transcribe_station.py" ICON_ICO = Path("icon.ico") ICON_PNG = Path("icon.png") BUILD_DIR = Path("build") DIST_DIR = Path("dist") HIDDEN_IMPORTS = [ "PySide6.QtMultimedia", "PySide6.QtCore", "PySide6.QtGui", "PySide6.QtWidgets", "soundfile", "numpy", "hid", "cffi", "_cffi_backend", ] # --collect-all hid : inclut la DLL native hidapi.dll dans le bundle Windows COLLECT_ALL = ["hid"] ADD_DATA: list[str] = [] # hidapi.dll natif Windows (absent du package pip hid 1.x qui est un pur wrapper ctypes) HIDAPI_VERSION = "0.14.0" HIDAPI_DLL_URL = ( f"https://github.com/libusb/hidapi/releases/download/" f"hidapi-{HIDAPI_VERSION}/hidapi-win.zip" ) # ─── Couleurs terminal ───────────────────────────────────────────────────────── R='\033[0;31m'; G='\033[0;32m'; Y='\033[1;33m' C='\033[0;36m'; B='\033[1m'; N='\033[0m' def ok(m): print(f"{G}[OK]{N} {m}") def info(m):print(f"{C}[..]{N} {m}") def warn(m):print(f"{Y}[!!]{N} {m}") def err(m): print(f"{R}[XX]{N} {m}", file=sys.stderr) def step(m):print(f"\n{B}{m}{N}") # ─── hidapi.dll ─────────────────────────────────────────────────────────────── def _fetch_hidapi_dll() -> Path | None: """Télécharge hidapi.dll (x64) depuis les releases GitHub, mise en cache dans build/.""" dll_cache = BUILD_DIR / "hidapi.dll" if dll_cache.exists(): ok(f"hidapi.dll en cache : {dll_cache}") return dll_cache info(f"Téléchargement hidapi.dll v{HIDAPI_VERSION} depuis GitHub...") BUILD_DIR.mkdir(parents=True, exist_ok=True) try: with tempfile.NamedTemporaryFile(suffix=".zip", delete=False) as tmp: tmp_path = tmp.name urllib.request.urlretrieve(HIDAPI_DLL_URL, tmp_path) with zipfile.ZipFile(tmp_path) as zf: candidates = [n for n in zf.namelist() if n.lower().endswith("hidapi.dll") and "x64" in n.lower()] if not candidates: candidates = [n for n in zf.namelist() if n.lower().endswith("hidapi.dll")] if candidates: with zf.open(candidates[0]) as src, open(dll_cache, "wb") as dst: dst.write(src.read()) ok(f"hidapi.dll extrait ({candidates[0]}) → {dll_cache}") return dll_cache warn("hidapi.dll introuvable dans l'archive") except Exception as exc: warn(f"Échec téléchargement hidapi.dll : {exc}") finally: try: os.unlink(tmp_path) except Exception: pass return None def _install_hidapi_dll_onedir(internal_dir: Path) -> None: """Copie hidapi.dll dans _internal/ d'un bundle onedir.""" if not internal_dir.exists(): warn(f"Dossier _internal introuvable : {internal_dir}") return dll = _fetch_hidapi_dll() if dll: dest = internal_dir / "hidapi.dll" shutil.copy2(dll, dest) ok(f"hidapi.dll installé → {dest}") else: warn("hidapi.dll non disponible — le pédalier ne fonctionnera pas sans cette DLL.") # ─── Helpers ────────────────────────────────────────────────────────────────── def run(cmd: list, check=True) -> int: info(" ".join(str(c) for c in cmd)) r = subprocess.run(cmd, check=False) if check and r.returncode != 0: err(f"Échec (code {r.returncode})") sys.exit(r.returncode) return r.returncode def pip_install(pkgs: list, python=sys.executable): run([python, "-m", "pip", "install", "--quiet"] + pkgs) def validate_wine_pyside(wine: str, wine_python: str) -> bool: probe = subprocess.run( [ wine, wine_python, "-c", ( "from pathlib import Path; " "import importlib.metadata as md; " "import PySide6; " "qt_dir = Path(PySide6.__file__).resolve().parent; " "missing = [name for name in ('Qt6Core.dll', 'Qt6Multimedia.dll') if not (qt_dir / name).exists()]; " "print('PYSIDE6_VERSION=' + md.version('PySide6')); " "print('QT_DIR=' + str(qt_dir).replace(chr(92), '/')); " "print('MISSING_DLLS=' + ','.join(missing)); " "from PySide6 import QtCore; " "print('QTCORE_OK=1')" ), ], check=False, capture_output=True, text=True, ) output = (probe.stdout or "") + (probe.stderr or "") for line in output.splitlines(): if line.strip(): info(f"Wine Qt probe : {line}") if probe.returncode != 0: missing = "" for line in output.splitlines(): if line.startswith("MISSING_DLLS="): missing = line.split("=", 1)[1].strip() break err("PySide6 est installé dans Wine, mais QtCore ne se charge pas.") if missing: err(f"DLL Qt manquantes dans le wheel Windows : {missing}") err("Le binaire Windows généré via Wine serait invalide.") err("Utilisez le script build_windows.bat sur Windows natif.") return False return True # ─── Génération d'icônes ────────────────────────────────────────────────────── def _draw_icon(draw, sz): """Dessine le logo waveform TranscribeStation.""" draw.rounded_rectangle( [0, 0, sz - 1, sz - 1], radius=int(sz * 0.18), fill=(59, 110, 220, 255), ) bars = [0.25, 0.45, 0.70, 0.90, 0.65, 1.0, 0.55, 0.80, 0.40, 0.20] n, mg = len(bars), sz * 0.13 bw = (sz - 2 * mg) / (n * 1.7) gap = (sz - 2 * mg) / n mid = sz / 2 for i, h in enumerate(bars): x = mg + i * gap + (gap - bw) / 2 bh = h * sz * 0.36 draw.rounded_rectangle( [x, mid - bh, x + bw, mid + bh], radius=bw / 2, fill=(255, 255, 255, 215), ) def generate_icons() -> bool: """Génère icon.png (256px) et icon.ico (multi-tailles). Retourne True si OK.""" try: from PIL import Image, ImageDraw except ImportError: warn("Pillow absent → icônes ignorées (pip install Pillow)") return False sizes_ico = [16, 32, 48, 64, 128, 256] images = [] for sz in sizes_ico: img = Image.new("RGBA", (sz, sz), (0, 0, 0, 0)) _draw_icon(ImageDraw.Draw(img), sz) images.append(img) # PNG 256 px pour Linux XDG images[-1].save(str(ICON_PNG)) ok(f"Icône PNG : {ICON_PNG}") # ICO multi-tailles pour Windows images[0].save( str(ICON_ICO), format="ICO", sizes=[(s, s) for s in sizes_ico], append_images=images[1:], ) ok(f"Icône ICO : {ICON_ICO}") return True # ─── Raccourci GNOME / Linux ────────────────────────────────────────────────── def install_linux_shortcut(binary: Path): step("📌 Installation du raccourci GNOME...") binary = binary.resolve() # 1. Icône → XDG hicolor xdg_icon_dir = Path.home() / ".local/share/icons/hicolor/256x256/apps" xdg_icon_dir.mkdir(parents=True, exist_ok=True) xdg_icon = xdg_icon_dir / f"{APP_NAME}.png" if ICON_PNG.exists(): shutil.copy(ICON_PNG, xdg_icon) ok(f"Icône installée : {xdg_icon}") else: warn("icon.png absent — raccourci sans icône") # 2. Fichier .desktop → applications apps_dir = Path.home() / ".local/share/applications" apps_dir.mkdir(parents=True, exist_ok=True) desktop_path = apps_dir / f"{APP_NAME}.desktop" desktop_content = textwrap.dedent(f"""\ [Desktop Entry] Version=1.0 Type=Application Name={APP_NAME} GenericName=Transcription Audio Comment={APP_COMMENT} Exec={binary} Icon={APP_NAME} Terminal=false Categories={APP_CATS} StartupNotify=true StartupWMClass={APP_NAME} Keywords=transcription;audio;dictée;pédalier; """) desktop_path.write_text(desktop_content, encoding="utf-8") desktop_path.chmod(0o755) ok(f".desktop installé : {desktop_path}") # 3. Raccourci Bureau (Desktop) for bureau in ["Bureau", "Desktop", "Escritorio"]: bureau_dir = Path.home() / bureau if bureau_dir.is_dir(): bureau_link = bureau_dir / f"{APP_NAME}.desktop" shutil.copy(desktop_path, bureau_link) bureau_link.chmod(0o755) ok(f"Raccourci bureau : {bureau_link}") break # 4. Mettre à jour la base des .desktop for cmd in [ ["update-desktop-database", str(apps_dir)], ["gtk-update-icon-cache", "-f", "-t", str(Path.home() / ".local/share/icons/hicolor")], ["xdg-desktop-menu", "forceupdate"], ]: if shutil.which(cmd[0]): run(cmd, check=False) ok("Raccourci GNOME installé. Visible au prochain rechargement de session.") def uninstall_linux_shortcut(): step("🗑 Désinstallation raccourci GNOME...") targets = [ Path.home() / f".local/share/applications/{APP_NAME}.desktop", Path.home() / f".local/share/icons/hicolor/256x256/apps/{APP_NAME}.png", ] for bureau in ["Bureau", "Desktop", "Escritorio"]: targets.append(Path.home() / bureau / f"{APP_NAME}.desktop") for t in targets: if t.exists(): t.unlink() ok(f"Supprimé : {t}") # ─── Raccourci Windows (depuis Windows natif) ───────────────────────────────── def install_windows_shortcut(binary: Path): """Crée Menu Démarrer + Bureau via PowerShell (à appeler depuis Windows).""" step("📌 Installation du raccourci Windows...") binary = binary.resolve() # param() en premiere ligne : ExePath est passe par le bat via -ExePath "..." # Ne PAS hardcoder $ExePath dans le corps du script (serait ecrase par la ligne param) ps_script = textwrap.dedent(f""" param([string]$ExePath) $AppName = "{APP_NAME}" $Comment = "{APP_COMMENT_WIN}" function New-Shortcut([string]$dest) {{ $wsh = New-Object -ComObject WScript.Shell $lnk = $wsh.CreateShortcut($dest) $lnk.TargetPath = $ExePath $lnk.IconLocation = "$ExePath,0" $lnk.Description = $Comment $lnk.WorkingDirectory = [System.IO.Path]::GetDirectoryName($ExePath) $lnk.Save() Write-Host "[OK] Raccourci : $dest" }} $startMenu = "$env:APPDATA\\Microsoft\\Windows\\Start Menu\\Programs" New-Shortcut "$startMenu\\$AppName.lnk" $desktop = [Environment]::GetFolderPath("Desktop") New-Shortcut "$desktop\\$AppName.lnk" Write-Host "" Write-Host "[OK] Shortcuts installed successfully." """).strip() ps_file = DIST_DIR / "windows" / "install_shortcut.ps1" ps_file.parent.mkdir(parents=True, exist_ok=True) ps_file.write_text(ps_script, encoding="utf-8-sig") ok(f"Script PowerShell généré : {ps_file}") if platform.system() == "Windows": info("Exécution de PowerShell...") run(["powershell", "-ExecutionPolicy", "Bypass", "-File", str(ps_file)], check=False) else: warn("Cross-compilation : exécutez ce script sur la machine Windows cible :") warn(f" powershell -ExecutionPolicy Bypass -File {ps_file}") # ─── Build Linux ────────────────────────────────────────────────────────────── def build_linux(install: bool = True): step("🐧 Build Linux") pip_install(["pyinstaller", "Pillow"]) generate_icons() run([sys.executable, "-m", "PyInstaller", "--onefile", "--clean", "--noconfirm", f"--name={APP_NAME}", "--distpath=dist/linux", "--workpath=build/linux", "--specpath=build/linux", "--log-level=WARN", *(f"--hidden-import={h}" for h in HIDDEN_IMPORTS), *(f"--collect-all={m}" for m in COLLECT_ALL), *(f"--add-data={d}" for d in ADD_DATA), f"--icon={ICON_ICO}" if ICON_ICO.exists() else "", MAIN_SCRIPT, ]) binary = Path(f"dist/linux/{APP_NAME}") if binary.exists(): binary.chmod(0o755) size = binary.stat().st_size // (1024 * 1024) ok(f"Binaire Linux : {binary} ({size} Mo)") if install: install_linux_shortcut(binary) else: err("Binaire introuvable dans dist/linux/") # ─── Build Windows ──────────────────────────────────────────────────────────── def build_windows(install: bool = True): step("🪟 Build Windows") # ── Sur machine Windows native ──────────────────────────────────────────── if platform.system() == "Windows": pip_install(["pyinstaller", "Pillow"]) generate_icons() icon_arg = f"--icon={ICON_ICO.resolve()}" if ICON_ICO.exists() else "" main_script = str(Path(MAIN_SCRIPT).resolve()) run([sys.executable, "-m", "PyInstaller", "--onedir", "--clean", "--noconfirm", "--windowed", f"--name={APP_NAME}", "--distpath=dist/windows", "--workpath=build/windows", "--specpath=build/windows", "--log-level=WARN", *(f"--hidden-import={h}" for h in HIDDEN_IMPORTS), *(f"--collect-all={m}" for m in COLLECT_ALL), icon_arg, main_script, ]) binary = Path(f"dist/windows/{APP_NAME}/{APP_NAME}.exe") if binary.exists(): ok(f"Binaire Windows : {binary}") _install_hidapi_dll_onedir(Path(f"dist/windows/{APP_NAME}/_internal")) if install: install_windows_shortcut(binary) else: err("Binaire introuvable.") return # ── Cross-compilation depuis Linux via Wine ──────────────────────────────── wine = shutil.which("wine") or shutil.which("wine64") wine_python = None if wine: for candidate in [ Path.home() / ".wine/drive_c/Python312/python.exe", Path.home() / ".wine/drive_c/Python311/python.exe", Path.home() / ".wine/drive_c/Python310/python.exe", ]: if candidate.exists(): wine_python = candidate break if wine and wine_python: info(f"Wine Python : {wine_python}") pip_install(["pyinstaller", "PySide6", "numpy", "soundfile", "hid", "Pillow"], python=str(wine_python)) generate_icons() if not validate_wine_pyside(wine, str(wine_python)): sys.exit(1) # Récupérer hidapi.dll pour l'embarquer dans le onefile via --add-binary hidapi_dll = _fetch_hidapi_dll() add_binary_args = [f"--add-binary={hidapi_dll}:."] if hidapi_dll else [] if not hidapi_dll: warn("hidapi.dll non disponible — le pédalier ne fonctionnera pas dans le .exe Wine") run([wine, str(wine_python), "-m", "PyInstaller", "--onefile", "--clean", "--noconfirm", "--windowed", f"--name={APP_NAME}", "--distpath=dist/windows", "--workpath=build/windows", "--specpath=build/windows", "--log-level=WARN", *(f"--hidden-import={h}" for h in HIDDEN_IMPORTS), *(f"--collect-all={m}" for m in COLLECT_ALL), *add_binary_args, f"--icon={ICON_ICO}" if ICON_ICO.exists() else "", MAIN_SCRIPT, ]) binary = Path(f"dist/windows/{APP_NAME}.exe") if binary.exists(): size = binary.stat().st_size // (1024 * 1024) ok(f"Binaire Windows : {binary} ({size} Mo)") install_windows_shortcut(Path(f"dist/windows/{APP_NAME}.exe")) else: warn("Wine ou Python Windows introuvable → génération du script natif Windows") _write_windows_bat(install=install) def _write_windows_bat(install: bool = True): """Genere build_windows.bat + _build_icons.py dans dist/ pour execution native sur Windows.""" out_dir = Path("dist") / "windows" out_dir.mkdir(parents=True, exist_ok=True) bat_path = out_dir / "build_windows.bat" icons_path = out_dir / "_build_icons.py" # ── Script Python icones dans un fichier separe (evite l'interpretation batch) ── icon_py = ( "from PIL import Image, ImageDraw\n" "sizes = [16, 32, 48, 64, 128, 256]\n" "imgs = []\n" "for sz in sizes:\n" " img = Image.new('RGBA', (sz, sz), (0, 0, 0, 0))\n" " d = ImageDraw.Draw(img)\n" " d.rounded_rectangle([0, 0, sz-1, sz-1], radius=int(sz*.18), fill=(59, 110, 220, 255))\n" " bars = [.25, .45, .7, .9, .65, 1., .55, .8, .4, .2]\n" " n, mg = len(bars), sz*.13\n" " bw = (sz - 2*mg) / (n * 1.7)\n" " gap = (sz - 2*mg) / n\n" " mid = sz / 2\n" " for i, h in enumerate(bars):\n" " x = mg + i*gap + (gap - bw) / 2\n" " bh = h * sz * .36\n" " d.rounded_rectangle([x, mid-bh, x+bw, mid+bh], radius=bw/2, fill=(255, 255, 255, 215))\n" " imgs.append(img)\n" "imgs[-1].save('icon.png')\n" "imgs[0].save('icon.ico', format='ICO', sizes=[(s,s) for s in sizes], append_images=imgs[1:])\n" "print('[OK] Icones generees')\n" ) icons_path.write_text(icon_py, encoding="utf-8") ok(f"Script icones genere : {icons_path}") # ── Arguments hidden-import + collect-all PyInstaller ── hi_lines = " ^\n ".join(f"--hidden-import {h}" for h in HIDDEN_IMPORTS) ca_lines = " ^\n ".join(f"--collect-all {m}" for m in COLLECT_ALL) shortcut_block = "" if install: shortcut_block = ( "echo [5/5] Installation des raccourcis...\n" "REM Raccourci vers binaire LOCAL (WScript.Shell refuse les chemins reseau/partages)\n" f'set "LOCAL_EXE=%LOCAL_DIST%\\{APP_NAME}\\{APP_NAME}.exe"\n' f'if exist "%PROJECT_ROOT%dist\\windows\\install_shortcut.ps1" (\n' f' powershell -NoProfile -ExecutionPolicy Bypass ^\n' f' -File "%PROJECT_ROOT%dist\\windows\\install_shortcut.ps1" ^\n' f' -ExePath "%LOCAL_EXE%"\n' f') else (\n' f' echo [!!] Script PowerShell introuvable\n' f')\n' ) # Venv court pour contourner MAX_PATH avec PySide6 (Microsoft Store Python) # C:\ts\.venv\Lib\site-packages\ = 30 chars vs ~140 chars pour AppData\Packages\... VENV = "C:\\ts\\.venv" # ASCII uniquement dans le bat bat = ( "@echo off\n" "setlocal EnableDelayedExpansion\n" f"REM build_windows.bat -- {APP_NAME} v{APP_VERSION}\n" "REM A executer sur une machine Windows avec Python 3.12+\n" "echo.\n" f"echo *** Build {APP_NAME} v{APP_VERSION} pour Windows ***\n" "echo.\n" "\n" "REM -- Localisation du projet\n" "set \"SCRIPT_DIR=%~dp0\"\n" "for %%I in (\"%SCRIPT_DIR%\\..\") do set \"PROJECT_ROOT=%%~fI\\\"\n" f"if exist \"%PROJECT_ROOT%{MAIN_SCRIPT}\" goto ROOT_FOUND\n" "for %%I in (\"%SCRIPT_DIR%\\..\\..\") do set \"PROJECT_ROOT=%%~fI\\\"\n" f"if exist \"%PROJECT_ROOT%{MAIN_SCRIPT}\" goto ROOT_FOUND\n" f"echo [XX] {MAIN_SCRIPT} introuvable.\n" "pause & exit /b 1\n" "\n" ":ROOT_FOUND\n" "echo [OK] Dossier projet : %PROJECT_ROOT%\n" "echo.\n" "\n" "REM -- Localisation d'un vrai Python (evite le stub Windows Store)\n" "set \"REAL_PYTHON=\"\n" "\n" "REM Essai 1 : py launcher (installe avec Python officiel)\n" "where py >nul 2>&1\n" "if not errorlevel 1 (\n" " py -3 -c \"import sys; print(sys.executable)\" >nul 2>&1\n" " if not errorlevel 1 (\n" " for /f \"delims=\" %%P in ('py -3 -c \"import sys; print(sys.executable)\"') do set \"REAL_PYTHON=%%P\"\n" " )\n" ")\n" "\n" "REM Essai 2 : emplacements standard Python 3.x\n" "if not defined REAL_PYTHON (\n" " for %%V in (313 312 311 310 39) do (\n" " if not defined REAL_PYTHON (\n" " if exist \"C:\\Python%%V\\python.exe\" set \"REAL_PYTHON=C:\\Python%%V\\python.exe\"\n" " )\n" " )\n" ")\n" "REM Essai 3 : AppData\\Local\\Programs\\Python\n" "if not defined REAL_PYTHON (\n" " for %%V in (313 312 311 310 39) do (\n" " if not defined REAL_PYTHON (\n" " set \"_CANDIDATE=%LOCALAPPDATA%\\Programs\\Python\\Python%%V\\python.exe\"\n" " if exist \"!_CANDIDATE!\" set \"REAL_PYTHON=!_CANDIDATE!\"\n" " )\n" " )\n" ")\n" "\n" "if not defined REAL_PYTHON (\n" " echo [XX] Python 3 introuvable. Installez Python depuis https://www.python.org/downloads/\n" " echo [XX] Assurez-vous de cocher \"Add python.exe to PATH\" lors de l'installation.\n" " pause & exit /b 1\n" ")\n" "echo [OK] Python trouve : %REAL_PYTHON%\n" "\n" "REM -- Venv a chemin court pour eviter MAX_PATH avec PySide6\n" f"set \"VENV={VENV}\"\n" "set \"VPYTHON=%VENV%\\Scripts\\python.exe\"\n" "set \"VPIP=%VENV%\\Scripts\\pip.exe\"\n" "echo [1/5] Preparation venv dans %VENV%...\n" "if exist \"%VENV%\" (\n" " \"%VPYTHON%\" -c \"import sys\" >nul 2>&1\n" " if errorlevel 1 (\n" " echo [!!] Venv existant invalide - recreation...\n" " rmdir /S /Q \"%VENV%\"\n" " )\n" ")\n" "if not exist \"%VPYTHON%\" (\n" " mkdir C:\\ts 2>nul\n" " \"%REAL_PYTHON%\" -m venv \"%VENV%\"\n" " if errorlevel 1 ( echo ERREUR venv & pause & exit /b 1 )\n" ")\n" "echo [OK] Venv pret.\n" "\n" "echo [2/5] Installation des dependances Python...\n" "\"%VPIP%\" install --quiet --upgrade pip\n" "\"%VPIP%\" install --quiet pyinstaller PySide6 numpy soundfile hid Pillow\n" "if errorlevel 1 ( echo ERREUR pip & pause & exit /b 1 )\n" "\"%VPYTHON%\" -c \"from PySide6.QtMultimedia import QMediaPlayer; print('[OK] PySide6 + QtMultimedia OK')\"\n" "if errorlevel 1 ( echo ERREUR PySide6.QtMultimedia & pause & exit /b 1 )\n" "\n" "echo [3/5] Generation des icones...\n" "copy /Y \"%SCRIPT_DIR%_build_icons.py\" \"%PROJECT_ROOT%_build_icons.py\" > nul\n" "\"%VPYTHON%\" \"%PROJECT_ROOT%_build_icons.py\"\n" "if errorlevel 1 ( echo ERREUR icones & pause & exit /b 1 )\n" "del \"%PROJECT_ROOT%_build_icons.py\" > nul 2>&1\n" "\n" "REM -- Build dans C:\\ts (chemin local court) pour eviter les erreurs reseau/MAX_PATH\n" "set \"LOCAL_DIST=C:\\ts\\dist\"\n" "set \"LOCAL_BUILD=C:\\ts\\build\"\n" "\n" "echo [4/5] Compilation PyInstaller (sortie locale C:\\ts\\dist)...\n" "\"%VENV%\\Scripts\\pyinstaller.exe\" --onedir --windowed --clean --noconfirm ^\n" f" --name {APP_NAME} ^\n" " --distpath \"%LOCAL_DIST%\" ^\n" " --workpath \"%LOCAL_BUILD%\" ^\n" " --specpath \"%LOCAL_BUILD%\" ^\n" " --icon \"%PROJECT_ROOT%icon.ico\" ^\n" f" {hi_lines} ^\n" f" {ca_lines} ^\n" f" \"%PROJECT_ROOT%{MAIN_SCRIPT}\"\n" "if errorlevel 1 ( echo ERREUR PyInstaller & pause & exit /b 1 )\n" "\n" "REM -- Ajout de hidapi.dll (absent du package pip hid)\n" f"set \"HIDAPI_URL=https://github.com/libusb/hidapi/releases/download/hidapi-{HIDAPI_VERSION}/hidapi-win.zip\"\n" f"set \"HIDAPI_ZIP=C:\\ts\\hidapi-win.zip\"\n" f"set \"HIDAPI_DLL=%LOCAL_DIST%\\{APP_NAME}\\_internal\\hidapi.dll\"\n" "if not exist \"%HIDAPI_DLL%\" (\n" " echo Telechargement hidapi.dll...\n" " powershell -NoProfile -Command \"Invoke-WebRequest -Uri '%HIDAPI_URL%' -OutFile '%HIDAPI_ZIP%'\"\n" " if not errorlevel 1 (\n" " powershell -NoProfile -Command \"" f"Expand-Archive -Force '%HIDAPI_ZIP%' 'C:\\\\ts\\\\hidapi_tmp'; " f"$dll = Get-ChildItem 'C:\\\\ts\\\\hidapi_tmp' -Recurse -Filter hidapi.dll | " f"Where-Object {{ $_.FullName -match 'x64' }} | Select-Object -First 1; " f"if ($dll) {{ Copy-Item $dll.FullName '%HIDAPI_DLL%'; Write-Host '[OK] hidapi.dll installe' }} " f"else {{ Write-Host '[!!] hidapi.dll x64 introuvable dans archive' }}; " f"Remove-Item 'C:\\\\ts\\\\hidapi_tmp' -Recurse -Force\"\n" " ) else (\n" " echo [!!] Echec telechargement hidapi.dll - le pedalier ne fonctionnera pas.\n" " )\n" ") else (\n" " echo [OK] hidapi.dll deja present.\n" ")\n" "\n" "echo Copie du binaire vers le dossier projet...\n" f"if exist \"%PROJECT_ROOT%dist\\windows\\{APP_NAME}\" rmdir /S /Q \"%PROJECT_ROOT%dist\\windows\\{APP_NAME}\"\n" f"xcopy /E /I /Y \"%LOCAL_DIST%\\{APP_NAME}\" \"%PROJECT_ROOT%dist\\windows\\{APP_NAME}\\\"\n" "if errorlevel 1 ( echo ERREUR copie xcopy & pause & exit /b 1 )\n" f"echo [OK] Binaire copie dans %PROJECT_ROOT%dist\\windows\\{APP_NAME}\\\n" "\n" + shortcut_block + "\n" "copy /Y \"%PROJECT_ROOT%icon.ico\" \"C:\\ts\\icon.ico\" > nul\n" "\n" "echo [6/6] Creation de l'installeur (Inno Setup)...\n" "set \"ISCC_EXE=\"\n" "if exist \"C:\\Program Files (x86)\\Inno Setup 6\\ISCC.exe\" " "set \"ISCC_EXE=C:\\Program Files (x86)\\Inno Setup 6\\ISCC.exe\"\n" "if exist \"C:\\Program Files\\Inno Setup 6\\ISCC.exe\" " "set \"ISCC_EXE=C:\\Program Files\\Inno Setup 6\\ISCC.exe\"\n" "if not defined ISCC_EXE (\n" " echo [!!] Inno Setup 6 non installe - installeur ignore.\n" " echo [!!] Telecharger gratuitement : https://jrsoftware.org/isdl.php\n" " echo [!!] Puis relancez ce script pour generer l'installeur.\n" " goto BUILD_DONE\n" ")\n" f"mkdir C:\\ts\\installer 2>nul\n" f"echo Compilation Inno Setup...\n" f"\"%ISCC_EXE%\" \"%SCRIPT_DIR%{APP_NAME}.iss\"\n" "if errorlevel 1 ( echo [!!] Erreur Inno Setup & goto BUILD_DONE )\n" f"echo [OK] Installeur : C:\\ts\\installer\\{APP_NAME}_Setup_v{APP_VERSION}.exe\n" "\n" ":BUILD_DONE\n" "echo.\n" f"echo *** Build termine ! ***\n" f"echo Binaire : %PROJECT_ROOT%dist\\windows\\{APP_NAME}\\{APP_NAME}.exe\n" f"echo Installeur : C:\\ts\\installer\\{APP_NAME}_Setup_v{APP_VERSION}.exe (si Inno Setup installe)\n" "echo.\n" "pause\n" ) bat_crlf = bat.replace("\n", "\r\n") bat_path.write_bytes(bat_crlf.encode("ascii", errors="replace")) ok(f"Script Windows genere : {bat_path}") ok(f"Script icones genere : {icons_path}") generate_inno_script() warn(f"Sur Windows : lancez dist\\windows\\build_windows.bat depuis la racine du projet") warn(f"Inno Setup requis pour l'installeur : https://jrsoftware.org/isdl.php") # ─── Installeur Inno Setup ──────────────────────────────────────────────────── def generate_inno_script(): """ Génère TranscribeStation.iss pour Inno Setup 6. Sortie : dist/windows/TranscribeStation.iss (CRLF + BOM UTF-8) Compilation : ISCC.exe TranscribeStation.iss Résultat : C:\\ts\\installer\\TranscribeStation_Setup_v{APP_VERSION}.exe """ # On utilise une string ordinaire pour éviter les conflits entre # les accolades Inno Setup {app} et le f-string Python. # N = APP_NAME, V = APP_VERSION, A = APP_AUTHOR, U = APP_URL N, V, A, U = APP_NAME, APP_VERSION, APP_AUTHOR, APP_URL iss = ( "[Setup]\n" f"AppName={N}\n" f"AppVersion={V}\n" f"AppVerName={N} {V}\n" f"AppPublisher={A}\n" f"AppPublisherURL={U}\n" f"AppSupportURL={U}\n" f"DefaultDirName={{localappdata}}\\Programs\\{N}\n" f"DefaultGroupName={N}\n" "AllowNoIcons=yes\n" "OutputDir=C:\\ts\\installer\n" f"OutputBaseFilename={N}_Setup_v{V}\n" "SetupIconFile=C:\\ts\\icon.ico\n" "Compression=lzma2/ultra64\n" "SolidCompression=yes\n" "WizardStyle=modern\n" "WizardSizePercent=120\n" "PrivilegesRequired=lowest\n" "ArchitecturesAllowed=x64compatible\n" "ArchitecturesInstallIn64BitMode=x64compatible\n" f"UninstallDisplayIcon={{app}}\\{N}.exe\n" f"UninstallDisplayName={N} {V}\n" "CloseApplications=yes\n" "\n" "[Languages]\n" 'Name: "french"; MessagesFile: "compiler:Languages\\French.isl"\n' "\n" "[Tasks]\n" 'Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; ' 'GroupDescription: "{cm:AdditionalIcons}"\n' "\n" "[Files]\n" f'Source: "C:\\ts\\dist\\{N}\\*"; ' 'DestDir: "{app}"; ' "Flags: ignoreversion recursesubdirs createallsubdirs\n" "\n" "[Icons]\n" f'Name: "{{group}}\\{N}"; Filename: "{{app}}\\{N}.exe"\n' f'Name: "{{userdesktop}}\\{N}"; Filename: "{{app}}\\{N}.exe"; Tasks: desktopicon\n' "\n" "[Run]\n" f'Filename: "{{app}}\\{N}.exe"; ' f'Description: "Lancer {N}"; ' "Flags: nowait postinstall skipifsilent\n" "\n" "[UninstallDelete]\n" 'Type: filesandordirs; Name: "{app}"\n' "\n" "[Code]\n" "// Verifie que l'architecture est x64\n" "function InitializeSetup(): Boolean;\n" "begin\n" " Result := True;\n" " if not Is64BitInstallMode then begin\n" f" MsgBox('{N} necessite Windows 64 bits.', mbError, MB_OK);\n" " Result := False;\n" " end;\n" "end;\n" ) out = DIST_DIR / "windows" / f"{APP_NAME}.iss" out.parent.mkdir(parents=True, exist_ok=True) # BOM UTF-8 + CRLF obligatoires pour Inno Setup iss_crlf = iss.replace("\n", "\r\n") out.write_bytes(b"\xef\xbb\xbf" + iss_crlf.encode("utf-8")) ok(f"Script Inno Setup généré : {out}") return out # ─── Nettoyage ──────────────────────────────────────────────────────────────── def clean(): step("🧹 Nettoyage...") for d in [BUILD_DIR, DIST_DIR]: if d.exists(): shutil.rmtree(d); ok(f"Supprimé : {d}/") for f in Path(".").glob("*.spec"): f.unlink(); ok(f"Supprimé : {f}") for icon in [ICON_ICO, ICON_PNG]: if icon.exists(): icon.unlink(); ok(f"Supprimé : {icon}") ok("Nettoyage terminé.") # ─── Point d'entrée ─────────────────────────────────────────────────────────── def main(): parser = argparse.ArgumentParser( description=f"Build + install {APP_NAME}", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=textwrap.dedent(""" Exemples : python build.py build + raccourcis (auto-detect OS) python build.py --linux build Linux + raccourci GNOME python build.py --windows build Windows + raccourci Menu Démarrer python build.py --both les deux python build.py --no-install build sans créer de raccourcis python build.py --uninstall supprimer le raccourci GNOME python build.py --clean supprimer artefacts build/dist/ """), ) parser.add_argument("--linux", action="store_true") parser.add_argument("--windows", action="store_true") parser.add_argument("--both", action="store_true") parser.add_argument("--no-install", action="store_true", help="Ne pas installer de raccourci") parser.add_argument("--install-only", action="store_true", help="Installer raccourci sans recompiler") parser.add_argument("--uninstall", action="store_true", help="Désinstaller raccourci GNOME") parser.add_argument("--clean", action="store_true") args = parser.parse_args() os.chdir(Path(__file__).parent) do_install = not args.no_install if args.clean: clean(); return if args.uninstall: uninstall_linux_shortcut(); return if args.install_only: binary = Path(f"dist/linux/{APP_NAME}") if binary.exists(): generate_icons() install_linux_shortcut(binary) else: err(f"Binaire introuvable : {binary}") err("Lancez d'abord : python build.py --linux") return if args.both: build_linux(install=do_install) build_windows(install=do_install) return if args.windows: build_windows(install=do_install); return if args.linux or platform.system() == "Linux": build_linux(install=do_install) elif platform.system() == "Windows": build_windows(install=do_install) else: err(f"Plateforme inconnue : {platform.system()}") err("Utilisez --linux ou --windows") if __name__ == "__main__": main()