#!/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. Usa il NIGHTLY channel invece dello stable perche' YouTube rompe le API ogni 1-2 settimane e la stable resta indietro di 3-4 settimane tipicamente. La nightly esce quasi ogni giorno con i fix. Sempre re-download: garantisce che ogni build imbarca yt-dlp fresco. """ arch = platform.machine() # arm64 o x86_64 if arch == "arm64": url = "https://github.com/yt-dlp/yt-dlp-nightly-builds/releases/latest/download/yt-dlp_macos" else: url = "https://github.com/yt-dlp/yt-dlp-nightly-builds/releases/latest/download/yt-dlp_macos_legacy" dest = BUNDLE_DIR / "yt-dlp" BUNDLE_DIR.mkdir(parents=True, exist_ok=True) log(f"Scarico yt-dlp nightly da {url} ...") # 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 # ========================================================================= # 1b. Scarica fpcalc (Chromaprint) — usato dal tab Dedup # ========================================================================= def download_fpcalc(): """Scarica il binario universal Chromaprint fpcalc per macOS.""" dest = BUNDLE_DIR / "fpcalc" if dest.exists(): log(f"fpcalc gia presente: {dest}") return dest url = ("https://github.com/acoustid/chromaprint/releases/download/" "v1.5.1/chromaprint-fpcalc-1.5.1-macos-universal.tar.gz") log(f"Scarico fpcalc da {url} ...") BUNDLE_DIR.mkdir(parents=True, exist_ok=True) tmp_tar = BUNDLE_DIR / "_fpcalc.tar.gz" subprocess.run(["curl", "-L", "-o", str(tmp_tar), url], check=True) # Estrae ovunque nella cartella, poi trova fpcalc e lo sposta al posto tmp_extract = BUNDLE_DIR / "_fpcalc_extract" if tmp_extract.exists(): shutil.rmtree(tmp_extract) tmp_extract.mkdir() subprocess.run(["tar", "-xzf", str(tmp_tar), "-C", str(tmp_extract)], check=True) # Trova fpcalc nel folder estratto found = None for p in tmp_extract.rglob("fpcalc"): if p.is_file(): found = p break if not found: shutil.rmtree(tmp_extract, ignore_errors=True) tmp_tar.unlink(missing_ok=True) raise RuntimeError("fpcalc non trovato nell'archivio") shutil.copy2(found, dest) dest.chmod(dest.stat().st_mode | stat.S_IEXEC) shutil.rmtree(tmp_extract, ignore_errors=True) tmp_tar.unlink(missing_ok=True) log(f"fpcalc 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", # certifi ha cacert.pem necessario per SSL/HTTPS "--collect-data", "certifi", # 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) # NOTA: dal v1.7.14 il microfono viene letto direttamente da # PyObjC/AVCaptureSession nel processo Python (che ha il TCC del # bundle), non da ffmpeg subprocess. ffmpeg fa solo la conversione # CAF -> MP3 (no microfono = no TCC). Per questo non serve piu' # incapsularlo in un sub-bundle .app con NSMicrophoneUsageDescription. # wrap_subprocess_in_bundle(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 nell'Info.plist del bundle .app. NSMicrophoneUsageDescription e' obbligatoria: senza, macOS uccide l'app con SIGABRT quando AVFoundation tenta di aprire il microfono. Usiamo plistlib invece di PlistBuddy perche' i valori con apostrofi (es. "l'audio") rompono il parser shell-based di PlistBuddy. """ import plistlib info_plist = app_path / "Contents" / "Info.plist" if not info_plist.exists(): log("ATTENZIONE: Info.plist non trovato, skip patch TCC") return log("Patching Info.plist con le chiavi TCC...") with open(info_plist, "rb") as f: data = plistlib.load(f) data["NSMicrophoneUsageDescription"] = ( "MusicTools usa il microfono per registrare l'audio del sistema " "(ad esempio tramite BlackHole o un altro dispositivo loopback)." ) # Bundle identifier stabile aiuta TCC a riconoscere l'app tra le sessioni data["CFBundleIdentifier"] = "com.djluza.musictools" data["LSApplicationCategoryType"] = "public.app-category.music" with open(info_plist, "wb") as f: plistlib.dump(data, f) log(f"Info.plist aggiornato. Chiavi: {sorted(data.keys())[-5:]}") # Verifica with open(info_plist, "rb") as f: verify = plistlib.load(f) if "NSMicrophoneUsageDescription" not in verify: log("ERRORE CRITICO: NSMicrophoneUsageDescription NON e' stata scritta!") else: log("Verifica OK: NSMicrophoneUsageDescription presente.") _BUNDLE_ID = "com.djluza.musictools" def _subprocess_info_plist(executable, bundle_id): """Info.plist per un mini-bundle che incapsula un binario subprocess. NSMicrophoneUsageDescription qui dentro e' la chiave: senza di essa, macOS uccide il subprocess con SIGABRT appena tenta di aprire un device audio, anche se il main MusicTools ha il proprio TCC concesso. """ return ( '\n' '\n' '\n' '\n' f' CFBundleExecutable{executable}\n' f' CFBundleIdentifier{bundle_id}\n' f' CFBundleName{executable}\n' ' CFBundlePackageTypeAPPL\n' ' CFBundleShortVersionString1.0\n' ' CFBundleVersion1\n' ' LSBackgroundOnly\n' ' LSUIElement\n' ' NSMicrophoneUsageDescription\n' ' MusicTools usa il microfono per registrare l\'audio del ' 'sistema (BlackHole o altri dispositivi loopback).\n' '\n' '\n' ) def wrap_subprocess_in_bundle(app_path): """Sposta ffmpeg e ffprobe in mini-bundle .app dentro Frameworks/. macOS 14+ ENFORCE il fatto che ogni binario che apre device privacy-sensitive (microfono, camera, ...) debba avere un proprio Info.plist accessibile con NSMicrophoneUsageDescription. Quando ffmpeg viene lanciato come binario standalone (anche se figlio di MusicTools), non c'e' Info.plist associato -> SIGABRT. Soluzione: incapsulare ffmpeg in MusicTools.app/Contents/Frameworks/ ffmpeg.app/Contents/MacOS/ffmpeg, con Info.plist nel suo bundle. Stesso per ffprobe. """ log("Wrapping ffmpeg e ffprobe in sub-bundle .app...") fw = app_path / "Contents" / "Frameworks" if not fw.exists(): log("ATTENZIONE: Contents/Frameworks non trovato, skip wrapping.") return for name in ("ffmpeg", "ffprobe"): src = fw / name if not src.exists() or not src.is_file(): log(f" skip {name}: non trovato in Frameworks/") continue wrap = fw / f"{name}.app" if wrap.exists(): shutil.rmtree(wrap) macos = wrap / "Contents" / "MacOS" macos.mkdir(parents=True, exist_ok=True) dest = macos / name shutil.move(str(src), str(dest)) dest.chmod(0o755) # Aggiungi un nuovo rpath che punta a Contents/Frameworks/ (dove # stanno le dylib del bundle MusicTools). # @executable_path = ffmpeg.app/Contents/MacOS/ # ../../../ = MusicTools.app/Contents/Frameworks/ subprocess.run( ["install_name_tool", "-add_rpath", "@executable_path/../../../", str(dest)], capture_output=True, ) # Crea Info.plist con NSMicrophoneUsageDescription. info = wrap / "Contents" / "Info.plist" bundle_id = f"com.djluza.musictools.{name}" info.write_text(_subprocess_info_plist(name, bundle_id)) log(f" wrap: {name} -> {dest.relative_to(app_path)}") def adhoc_codesign(app_path): """Ad-hoc codesign dell'intero bundle .app con identifier coerente. macOS tratta ogni binario Mach-O firmato come una "app" distinta per le decisioni TCC (microfono, camera, ecc.). Con `codesign --deep` standard, i nested binary (ffmpeg, ffprobe, yt-dlp) ricevono ognuno un identifier auto-generato del tipo `-`, diverso da quello del bundle principale -> TCC NEGA al subprocess. Soluzione: firmiamo PRIMA ogni binario interno con `--identifier com.djluza.musictools`, poi il bundle .app intero. Cosi' TCC vede tutti i Mach-O come parte dello stesso "team" e propaga il permesso del main ai subprocess. """ log("Ad-hoc codesign del bundle (identifier coerente per TCC)...") try: # 1) Pulisci attributi estesi (rimuove quarantine residui dal build) subprocess.run(["xattr", "-cr", str(app_path)], capture_output=True) # 2) Trova tutti i Mach-O nested (binari ed eventuali .dylib). # Li firmiamo uno a uno con identifier coerente, dal piu' profondo # al piu' esterno (richiesta da codesign). nested_machos = [] for p in sorted(app_path.rglob("*"), key=lambda x: -len(x.parts)): if not p.is_file(): continue # Skip risorse non eseguibili if p.suffix in (".plist", ".nib", ".png", ".icns", ".svg", ".html", ".css", ".js", ".json", ".md", ".txt"): continue try: with open(p, "rb") as f: magic = f.read(4) except Exception: continue # Mach-O magic numbers (32/64-bit, big/little endian, fat) if magic in (b"\xcf\xfa\xed\xfe", b"\xce\xfa\xed\xfe", b"\xfe\xed\xfa\xce", b"\xfe\xed\xfa\xcf", b"\xca\xfe\xba\xbe", b"\xbe\xba\xfe\xca"): nested_machos.append(p) log(f"Trovati {len(nested_machos)} binari Mach-O da firmare.") for p in nested_machos: # Per ffmpeg/ffprobe dentro al sub-bundle .app, usa l'identifier # del SUB-bundle (TCC li tratta come app separate con Info.plist # proprio). Per gli altri binari nested (dylib, framework), usa # l'identifier del bundle principale. ident = _BUNDLE_ID parts = p.relative_to(app_path).parts for sub in ("ffmpeg.app", "ffprobe.app"): if sub in parts: ident = f"{_BUNDLE_ID}.{sub.replace('.app', '')}" break r = subprocess.run( ["codesign", "--force", "--sign", "-", "--identifier", ident, "--timestamp=none", str(p)], capture_output=True, text=True, ) if r.returncode != 0: log(f" warn: firma {p.name} fallita: {r.stderr.strip()}") # Firma anche i sub-bundle .app come bundle (con il loro Info.plist). for sub in ("ffmpeg.app", "ffprobe.app"): sub_path = app_path / "Contents" / "Frameworks" / sub if not sub_path.exists(): continue sub_ident = f"{_BUNDLE_ID}.{sub.replace('.app', '')}" r = subprocess.run( ["codesign", "--force", "--sign", "-", "--identifier", sub_ident, "--timestamp=none", str(sub_path)], capture_output=True, text=True, ) if r.returncode != 0: log(f" warn: firma {sub} fallita: {r.stderr.strip()}") # 3) Firma del bundle .app principale (l'identifier corretto viene # letto dall'Info.plist - CFBundleIdentifier che gia' settiamo). r = subprocess.run( ["codesign", "--force", "--sign", "-", "--identifier", _BUNDLE_ID, "--timestamp=none", str(app_path)], capture_output=True, text=True, ) if r.returncode != 0: log(f"ATTENZIONE: firma bundle fallita: {r.stderr.strip()}") else: log("Codesign ad-hoc completato.") # 4) Verifica finale 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: {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() download_fpcalc() 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()