diff --git a/build_macos.py b/build_macos.py index d565a76..9d855fb 100644 --- a/build_macos.py +++ b/build_macos.py @@ -239,6 +239,7 @@ def run_pyinstaller(): if app_path.exists(): log(f"Build completata: {app_path}") patch_info_plist(app_path) + 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()) @@ -296,6 +297,85 @@ def patch_info_plist(app_path): _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. @@ -340,9 +420,19 @@ def adhoc_codesign(app_path): 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", _BUNDLE_ID, + "--identifier", ident, "--timestamp=none", str(p)], capture_output=True, text=True, @@ -350,6 +440,22 @@ def adhoc_codesign(app_path): 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( diff --git a/core/config.py b/core/config.py index a60ab8e..75b02a4 100644 --- a/core/config.py +++ b/core/config.py @@ -5,7 +5,7 @@ import os import sys from pathlib import Path -VERSION = "v1.7.12" +VERSION = "v1.7.13" APP_NAME = "MusicTools" diff --git a/core/paths.py b/core/paths.py index e379d2d..2463e52 100644 --- a/core/paths.py +++ b/core/paths.py @@ -33,11 +33,18 @@ def _bundle_dirs() -> list[Path]: # Directory dell'eseguibile exe_dir = Path(sys.executable).parent dirs.append(exe_dir) - # macOS: Contents/Frameworks/ (PyInstaller onedir .app) + # macOS: Contents/Frameworks/ (PyInstaller onedir .app) + sub-bundle + # ffmpeg.app / ffprobe.app dove sono incapsulati per TCC. if not _IS_WINDOWS: frameworks = exe_dir.parent / "Frameworks" if frameworks.exists(): dirs.append(frameworks) + # Mini-bundle .app per i subprocess (vedi build_macos.py + # wrap_subprocess_in_bundle). + for sub in ("ffmpeg.app", "ffprobe.app"): + sub_macos = frameworks / sub / "Contents" / "MacOS" + if sub_macos.exists(): + dirs.append(sub_macos) return dirs @@ -96,55 +103,45 @@ def find_ytdlp() -> str: raise FileNotFoundError(msg) -def find_ffmpeg_dir() -> Optional[str]: - """Trova la directory contenente ffmpeg e ffprobe. - - Ordine di ricerca: - 1. Bundle PyInstaller - 2. Percorsi noti per OS - 3. Qualsiasi posizione nel PATH - """ - ffmpeg_name = _exe("ffmpeg") - - # 1. Bundle +def _find_binary(name: str) -> Optional[str]: + """Trova un binario (ffmpeg o ffprobe) nelle dir del bundle o nel sistema.""" + exe_name = _exe(name) + # 1. Bundle (incluso eventuali sub-bundle .app su macOS) for d in _bundle_dirs(): - if (d / ffmpeg_name).exists(): - return str(d) - - # 2. Percorsi noti + candidate = d / exe_name + if candidate.exists(): + return str(candidate) + # 2. Percorsi noti per OS if _IS_WINDOWS: - # Posizioni comuni su Windows for search in ( Path(os.environ.get("LOCALAPPDATA", "")) / "Programs" / "ffmpeg" / "bin", Path("C:/ffmpeg/bin"), Path(os.environ.get("ProgramFiles", "")) / "ffmpeg" / "bin", ): - if (search / "ffmpeg.exe").exists(): - return str(search) + cand = search / exe_name + if cand.exists(): + return str(cand) else: for search_path in ("/opt/homebrew/bin", "/usr/local/bin"): - if (Path(search_path) / "ffmpeg").exists(): - return search_path - + cand = Path(search_path) / exe_name + if cand.exists(): + return str(cand) # 3. PATH generico - ffmpeg = shutil.which("ffmpeg") - if ffmpeg: - return str(Path(ffmpeg).parent) + found = shutil.which(name) + return found if found else None - return None + +def find_ffmpeg_dir() -> Optional[str]: + """Ritorna la directory contenente ffmpeg (utile per PATH env).""" + ff = _find_binary("ffmpeg") + return str(Path(ff).parent) if ff else None def find_ffmpeg() -> Optional[str]: """Ritorna il path completo di ffmpeg.""" - d = find_ffmpeg_dir() - if d: - return str(Path(d) / _exe("ffmpeg")) - return None + return _find_binary("ffmpeg") def find_ffprobe() -> Optional[str]: """Ritorna il path completo di ffprobe.""" - d = find_ffmpeg_dir() - if d: - return str(Path(d) / _exe("ffprobe")) - return None + return _find_binary("ffprobe")