852 lines
36 KiB
Python
852 lines
36 KiB
Python
#!/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()
|