Files
musicdownload/build_macos.py
T
luzadev b74a0a53ec v1.7.11: fix definitivo BlackHole — ad-hoc codesign del bundle
Causa root: MusicTools.app ha il permesso TCC microfono, ma quando
lancia ffmpeg come subprocess macOS tratta ffmpeg come binario non
firmato distinto -> AVFoundation apre il device senza errore ma
restituisce solo silenzio.

Fix in build_macos.py: dopo PyInstaller + Info.plist, applico
`codesign --force --deep --sign -` ad-hoc su tutto il bundle.
La firma e' "free" (no Developer ID) ma rende l'app un singolo
team coerente per TCC, quindi il permesso del main si propaga
anche a ffmpeg/ffprobe/yt-dlp.
2026-06-09 21:29:30 +02:00

381 lines
13 KiB
Python

#!/usr/bin/env python3
"""
Build script per creare MusicTools.app su macOS.
Uso:
python3 build_macos.py
Cosa fa:
1. Scarica il binario standalone di yt-dlp per macOS
2. Raccoglie ffmpeg + ffprobe + tutte le loro dylib da Homebrew
3. Esegue PyInstaller per creare MusicTools.app
Requisiti:
pip3 install pyinstaller
brew install ffmpeg
"""
import os
import platform
import re
import shutil
import stat
import subprocess
import sys
import urllib.request
from pathlib import Path
ROOT = Path(__file__).resolve().parent
BUILD_DIR = ROOT / "build"
DIST_DIR = ROOT / "dist"
BUNDLE_DIR = ROOT / "bundle_bin" # binari da includere nell'app
def log(msg):
print(f"\n>>> {msg}")
# =========================================================================
# 1. Scarica yt-dlp standalone
# =========================================================================
def download_ytdlp():
"""Scarica il binario standalone di yt-dlp per macOS."""
arch = platform.machine() # arm64 o x86_64
if arch == "arm64":
url = "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_macos"
else:
url = "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_macos_legacy"
dest = BUNDLE_DIR / "yt-dlp"
if dest.exists():
log(f"yt-dlp gia presente: {dest}")
return dest
log(f"Scarico yt-dlp da {url} ...")
BUNDLE_DIR.mkdir(parents=True, exist_ok=True)
# Usa curl perche urllib di Python 3.8 ha problemi SSL su macOS
subprocess.run(
["curl", "-L", "-o", str(dest), url],
check=True,
)
dest.chmod(dest.stat().st_mode | stat.S_IEXEC)
log(f"yt-dlp scaricato: {dest}")
return dest
# =========================================================================
# 2. Raccogli ffmpeg/ffprobe + dylib
# =========================================================================
def find_brew_binary(name):
"""Trova il percorso reale di un binario brew."""
path = shutil.which(name, path="/opt/homebrew/bin:/usr/local/bin")
if not path:
path = shutil.which(name)
if not path:
print(f"ERRORE: {name} non trovato. Installa con: brew install {name}")
sys.exit(1)
return str(Path(path).resolve())
def collect_dylibs(binary_path, collected=None):
"""Raccoglie ricorsivamente tutte le dylib linkate da un binario."""
if collected is None:
collected = set()
result = subprocess.run(
["otool", "-L", binary_path],
capture_output=True, text=True,
)
for line in result.stdout.splitlines()[1:]: # skip prima riga (nome binario)
line = line.strip()
match = re.match(r"(.+\.dylib)\s+\(", line)
if not match:
continue
dylib = match.group(1).strip()
# Includi solo librerie homebrew (non di sistema)
if dylib.startswith("/opt/homebrew/") or dylib.startswith("/usr/local/"):
real = str(Path(dylib).resolve())
if real not in collected and os.path.exists(real):
collected.add(real)
# Raccogli ricorsivamente
collect_dylibs(real, collected)
return collected
def bundle_ffmpeg():
"""Copia ffmpeg, ffprobe e tutte le loro dylib in bundle_bin/."""
BUNDLE_DIR.mkdir(parents=True, exist_ok=True)
ffmpeg_path = find_brew_binary("ffmpeg")
ffprobe_path = find_brew_binary("ffprobe")
log("Raccolgo librerie dinamiche di ffmpeg/ffprobe...")
all_dylibs = set()
collect_dylibs(ffmpeg_path, all_dylibs)
collect_dylibs(ffprobe_path, all_dylibs)
log(f"Trovate {len(all_dylibs)} dylib")
# Copia binari
for src in (ffmpeg_path, ffprobe_path):
dst = BUNDLE_DIR / Path(src).name
shutil.copy2(src, dst)
dst.chmod(dst.stat().st_mode | stat.S_IEXEC)
log(f" Copiato: {Path(src).name}")
# Copia dylib
lib_dir = BUNDLE_DIR / "lib"
lib_dir.mkdir(exist_ok=True)
for dylib in sorted(all_dylibs):
dst = lib_dir / Path(dylib).name
shutil.copy2(dylib, dst)
log(f" Copiate {len(all_dylibs)} librerie in bundle_bin/lib/")
# Fix rpath nei binari: cambia i riferimenti /opt/homebrew/... a @executable_path/lib/
log("Fix rpath nei binari...")
for binary_name in ("ffmpeg", "ffprobe"):
binary = BUNDLE_DIR / binary_name
_fix_rpaths(binary, all_dylibs)
# Fix rpath nelle dylib stesse
for dylib in sorted(all_dylibs):
dst = lib_dir / Path(dylib).name
_fix_rpaths(dst, all_dylibs, is_dylib=True)
log("ffmpeg/ffprobe bundled con successo")
def _fix_rpaths(binary, all_dylibs, is_dylib=False):
"""Sostituisce i path assoluti delle dylib con path relativi."""
prefix = "@executable_path/lib/" if not is_dylib else "@loader_path/"
# Se e una dylib, fix anche il suo install name
if is_dylib:
subprocess.run(
["install_name_tool", "-id",
f"@loader_path/{binary.name}", str(binary)],
capture_output=True,
)
for dylib in all_dylibs:
dylib_name = Path(dylib).name
subprocess.run(
["install_name_tool", "-change",
dylib, f"{prefix}{dylib_name}", str(binary)],
capture_output=True,
)
# Prova anche con path non-resolved (symlink)
for brew_prefix in ("/opt/homebrew", "/usr/local"):
for alt in (f"{brew_prefix}/opt/", f"{brew_prefix}/lib/", f"{brew_prefix}/Cellar/"):
result = subprocess.run(
["otool", "-L", str(binary)],
capture_output=True, text=True,
)
for line in result.stdout.splitlines():
line = line.strip()
if dylib_name in line and (alt in line):
old_path = re.match(r"(.+\.dylib)", line)
if old_path:
subprocess.run(
["install_name_tool", "-change",
old_path.group(1).strip(),
f"{prefix}{dylib_name}", str(binary)],
capture_output=True,
)
# =========================================================================
# 3. PyInstaller
# =========================================================================
def run_pyinstaller():
"""Esegue PyInstaller per creare l'app."""
log("Eseguo PyInstaller...")
# Genera lista --add-binary per i binari bundled
add_binaries = []
for f in BUNDLE_DIR.iterdir():
if f.is_file() and not f.name.startswith("."):
add_binaries.extend(["--add-binary", f"{f}:."])
# Dylibs in lib/
lib_dir = BUNDLE_DIR / "lib"
if lib_dir.exists():
for f in lib_dir.iterdir():
if f.is_file():
add_binaries.extend(["--add-binary", f"{f}:lib"])
icon_path = ROOT / "assets" / "icon.icns"
cmd = [
sys.executable, "-m", "PyInstaller",
"--name", "MusicTools",
"--windowed",
"--onedir",
"--noconfirm",
]
if icon_path.exists():
cmd.extend(["--icon", str(icon_path)])
cmd.extend([
# Web UI assets (HTML/CSS/JS) — usati da pywebview
"--add-data", f"{ROOT / 'webui'}:webui",
# Hidden imports per pywebview (WebKit su macOS)
"--hidden-import", "webview",
"--hidden-import", "webview.platforms.cocoa",
"--hidden-import", "requests",
"--collect-all", "mutagen",
# Binari bundled (yt-dlp, ffmpeg, ffprobe + dylibs)
*add_binaries,
# Entry point
str(ROOT / "main.py"),
])
log(f"Comando: {' '.join(cmd[:10])}...")
subprocess.run(cmd, check=True, cwd=str(ROOT))
app_path = DIST_DIR / "MusicTools.app"
if app_path.exists():
log(f"Build completata: {app_path}")
patch_info_plist(app_path)
adhoc_codesign(app_path)
# Mostra dimensione
size = sum(f.stat().st_size for f in app_path.rglob("*") if f.is_file())
log(f"Dimensione: {size / 1024 / 1024:.0f} MB")
else:
log("ATTENZIONE: .app non trovata, controlla l'output di PyInstaller")
# Potrebbe essere in dist/MusicTools/ (non .app)
alt = DIST_DIR / "MusicTools"
if alt.exists():
log(f"Trovata directory: {alt}")
def patch_info_plist(app_path):
"""Aggiunge le chiavi TCC (Transparency, Consent and Control) richieste
da macOS nel bundle .app, altrimenti macOS nega silenziosamente l'accesso
al microfono e l'app NON compare in Impostazioni > Privacy e sicurezza.
Senza NSMicrophoneUsageDescription, qualsiasi tentativo di ffmpeg di
aprire un dispositivo audio (incluso BlackHole) viene rifiutato dal
sistema e non viene mostrato nessun prompt all'utente.
"""
info_plist = app_path / "Contents" / "Info.plist"
if not info_plist.exists():
log("ATTENZIONE: Info.plist non trovato, skip patch TCC")
return
pb = "/usr/libexec/PlistBuddy"
# Helper: aggiungi una chiave; se gia presente sovrascrivila.
def add_or_set(key, value, ptype="string"):
# Tenta Add; se esiste, fa Set.
r = subprocess.run(
[pb, "-c", f'Add :{key} {ptype} "{value}"', str(info_plist)],
capture_output=True, text=True,
)
if r.returncode != 0 and "already exists" in (r.stderr + r.stdout).lower():
subprocess.run(
[pb, "-c", f'Set :{key} "{value}"', str(info_plist)],
check=True,
)
log("Patching Info.plist con le chiavi TCC...")
add_or_set(
"NSMicrophoneUsageDescription",
"MusicTools usa il microfono per registrare l'audio del sistema "
"(ad esempio tramite BlackHole o un altro dispositivo loopback).",
)
# Sicurezza extra: dichiarare bundle identifier stabile aiuta il
# database TCC a riconoscere coerentemente l'app tra le sessioni.
add_or_set("CFBundleIdentifier", "com.djluza.musictools")
add_or_set("LSApplicationCategoryType", "public.app-category.music")
log("Info.plist aggiornato.")
def adhoc_codesign(app_path):
"""Ad-hoc codesign dell'intero bundle .app.
Senza una firma coerente, macOS tratta ffmpeg (subprocess bundled)
come binario separato dal main MusicTools per TCC: ffmpeg apre il
dispositivo audio senza errore ma riceve solo silenzio perche' il
sistema non gli concede il permesso microfono ereditato.
`codesign --force --deep --sign -` applica una firma ad-hoc (gratis,
senza Developer ID) a tutti i binari nested. Per macOS questo rende
l'app un singolo "team" coerente, e il permesso TCC concesso al
main si propaga anche a ffmpeg/ffprobe/yt-dlp.
"""
log("Ad-hoc codesign del bundle (per TCC microfono ereditato)...")
try:
# Rimuovi vecchie firme che potrebbero confondere codesign --deep
subprocess.run(
["xattr", "-cr", str(app_path)],
capture_output=True,
)
subprocess.run(
["codesign", "--remove-signature", "--deep", str(app_path)],
capture_output=True,
)
result = subprocess.run(
["codesign", "--force", "--deep", "--sign", "-",
"--timestamp=none", str(app_path)],
capture_output=True, text=True,
)
if result.returncode != 0:
log(f"ATTENZIONE: codesign fallito: {result.stderr.strip()}")
else:
log("Codesign ad-hoc completato.")
# Verifica
verify = subprocess.run(
["codesign", "--verify", "--deep", "--strict", str(app_path)],
capture_output=True, text=True,
)
if verify.returncode == 0:
log("Verifica firma OK.")
else:
log(f"NOTA: verifica firma con warning: {verify.stderr.strip()}")
except FileNotFoundError:
log("ATTENZIONE: 'codesign' non trovato nel PATH, skip ad-hoc signing")
# =========================================================================
# Main
# =========================================================================
def main():
print("=" * 50)
print(" MusicTools — Build macOS")
print("=" * 50)
# Verifica pyinstaller
try:
import PyInstaller
log(f"PyInstaller {PyInstaller.__version__}")
except ImportError:
log("Installo PyInstaller...")
subprocess.run([sys.executable, "-m", "pip", "install", "pyinstaller"],
check=True)
# Pulisci bundle precedente
if BUNDLE_DIR.exists():
shutil.rmtree(BUNDLE_DIR)
download_ytdlp()
bundle_ffmpeg()
run_pyinstaller()
print("\n" + "=" * 50)
print(" DONE!")
print("=" * 50)
app = DIST_DIR / "MusicTools.app"
if app.exists():
print(f"\n L'app e in: {app}")
print(f" Per testarla: open '{app}'")
print(f"\n Per distribuirla, comprimi la cartella:")
print(f" zip -r MusicTools.zip dist/MusicTools.app")
print()
if __name__ == "__main__":
main()