Compare commits

..
2 Commits
Author SHA1 Message Date
luzadev 7dd793253b v1.7.13: fix definitivo TCC subprocess - sub-bundle .app per ffmpeg/ffprobe
Crash report ha dato la spiegazione precisa:
"Namespace TCC, Code 0 - This app has crashed because it attempted
to access privacy-sensitive data without a usage description.
The app's Info.plist must contain an NSMicrophoneUsageDescription key."

macOS 14+ richiede che ogni binario che apre device privacy-sensitive
abbia il suo PROPRIO Info.plist con NSMicrophoneUsageDescription, NON
basta che il main MusicTools lo abbia. Per binari standalone come
ffmpeg, non c'e' Info.plist associato -> SIGABRT immediato.

Fix: incapsulare ffmpeg e ffprobe in mini-bundle .app dentro
MusicTools.app/Contents/Frameworks/ffmpeg.app/Contents/MacOS/ffmpeg
con il proprio Info.plist contenente NSMicrophoneUsageDescription.
Aggiunto rpath @executable_path/../../../ per puntare alle dylib
di Contents/Frameworks/. Codesign di ogni sub-bundle con identifier
com.djluza.musictools.{ffmpeg,ffprobe}.

core/paths.py aggiornato per cercare i binari anche dentro i
sub-bundle .app oltre che in Contents/Frameworks/ legacy.

L'utente al primo lancio della registrazione vedra' un prompt
microfono per "ffmpeg" (in aggiunta a quello del main MusicTools).
2026-06-09 22:44:58 +02:00
luzadev 5771d631fd v1.7.12: codesign identifier coerente per TCC subprocess + skip quota su Annual
Bug: codesign --deep ad-hoc dava a ffmpeg un identifier auto-generato
(ffmpeg-<hash>) diverso da com.djluza.musictools del bundle, quindi
macOS trattava ffmpeg come app distinta per TCC -> permesso microfono
del main NON ereditato dal subprocess -> registrazione muta o errore.

Fix: in adhoc_codesign() ora firmiamo OGNI Mach-O nested con
--identifier com.djluza.musictools (stesso del bundle), poi firmiamo
il bundle .app. Cosi' TCC vede un unico "team" coerente e il
permesso microfono si propaga a ffmpeg/ffprobe/yt-dlp. Verificato
in dev mode: registrazione BlackHole funziona quando ffmpeg eredita
correttamente il TCC.

Anche fix collaterale: bridge._gate() salta la chiamata consume_quota
al server quando il piano corrente ha daily_limit=None (Annual). Per
i piani unlimited non c'e' motivo di chiamare il server -> niente
piu' errori online se il server e' temporaneamente irraggiungibile
durante una registrazione su un piano Annual.
2026-06-09 22:14:53 +02:00
4 changed files with 209 additions and 63 deletions

No files matched your search

+6 -1
View File
@@ -229,7 +229,12 @@ class Api:
), ),
"plan": plan, "plan": plan,
} }
# 2) Quota gate server-side # 2) Quota gate server-side: skippato per i piani senza limite
# giornaliero (Annual) — evita una chiamata HTTP inutile e fa
# funzionare l'app anche offline per quei piani.
plan = license_mod.get_plan()
if plan.get("daily_limit") is None:
return None
try: try:
res = license_mod.consume_quota(feature) res = license_mod.consume_quota(feature)
except license_mod.LicenseNetworkError as e: except license_mod.LicenseNetworkError as e:
+171 -27
View File
@@ -239,6 +239,7 @@ def run_pyinstaller():
if app_path.exists(): if app_path.exists():
log(f"Build completata: {app_path}") log(f"Build completata: {app_path}")
patch_info_plist(app_path) patch_info_plist(app_path)
wrap_subprocess_in_bundle(app_path)
adhoc_codesign(app_path) adhoc_codesign(app_path)
# Mostra dimensione # Mostra dimensione
size = sum(f.stat().st_size for f in app_path.rglob("*") if f.is_file()) size = sum(f.stat().st_size for f in app_path.rglob("*") if f.is_file())
@@ -293,40 +294,183 @@ def patch_info_plist(app_path):
log("Info.plist aggiornato.") log("Info.plist aggiornato.")
def adhoc_codesign(app_path): _BUNDLE_ID = "com.djluza.musictools"
"""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, def _subprocess_info_plist(executable, bundle_id):
senza Developer ID) a tutti i binari nested. Per macOS questo rende """Info.plist per un mini-bundle che incapsula un binario subprocess.
l'app un singolo "team" coerente, e il permesso TCC concesso al
main si propaga anche a ffmpeg/ffprobe/yt-dlp. 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.
""" """
log("Ad-hoc codesign del bundle (per TCC microfono ereditato)...") return (
'<?xml version="1.0" encoding="UTF-8"?>\n'
'<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" '
'"http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n'
'<plist version="1.0">\n'
'<dict>\n'
f' <key>CFBundleExecutable</key><string>{executable}</string>\n'
f' <key>CFBundleIdentifier</key><string>{bundle_id}</string>\n'
f' <key>CFBundleName</key><string>{executable}</string>\n'
' <key>CFBundlePackageType</key><string>APPL</string>\n'
' <key>CFBundleShortVersionString</key><string>1.0</string>\n'
' <key>CFBundleVersion</key><string>1</string>\n'
' <key>LSBackgroundOnly</key><true/>\n'
' <key>LSUIElement</key><true/>\n'
' <key>NSMicrophoneUsageDescription</key>\n'
' <string>MusicTools usa il microfono per registrare l\'audio del '
'sistema (BlackHole o altri dispositivi loopback).</string>\n'
'</dict>\n'
'</plist>\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 `<nome>-<hash>`, 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: try:
# Rimuovi vecchie firme che potrebbero confondere codesign --deep # 1) Pulisci attributi estesi (rimuove quarantine residui dal build)
subprocess.run( subprocess.run(["xattr", "-cr", str(app_path)], capture_output=True)
["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
subprocess.run( # al piu' esterno (richiesta da codesign).
["codesign", "--remove-signature", "--deep", str(app_path)], nested_machos = []
capture_output=True, for p in sorted(app_path.rglob("*"), key=lambda x: -len(x.parts)):
) if not p.is_file():
result = subprocess.run( continue
["codesign", "--force", "--deep", "--sign", "-", # Skip risorse non eseguibili
"--timestamp=none", str(app_path)], 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, capture_output=True, text=True,
) )
if result.returncode != 0: if r.returncode != 0:
log(f"ATTENZIONE: codesign fallito: {result.stderr.strip()}") log(f"ATTENZIONE: firma bundle fallita: {r.stderr.strip()}")
else: else:
log("Codesign ad-hoc completato.") log("Codesign ad-hoc completato.")
# Verifica
# 4) Verifica finale
verify = subprocess.run( verify = subprocess.run(
["codesign", "--verify", "--deep", "--strict", str(app_path)], ["codesign", "--verify", "--deep", "--strict", str(app_path)],
capture_output=True, text=True, capture_output=True, text=True,
@@ -334,7 +478,7 @@ def adhoc_codesign(app_path):
if verify.returncode == 0: if verify.returncode == 0:
log("Verifica firma OK.") log("Verifica firma OK.")
else: else:
log(f"NOTA: verifica firma con warning: {verify.stderr.strip()}") log(f"NOTA verifica firma: {verify.stderr.strip()}")
except FileNotFoundError: except FileNotFoundError:
log("ATTENZIONE: 'codesign' non trovato nel PATH, skip ad-hoc signing") log("ATTENZIONE: 'codesign' non trovato nel PATH, skip ad-hoc signing")
+1 -1
View File
@@ -5,7 +5,7 @@ import os
import sys import sys
from pathlib import Path from pathlib import Path
VERSION = "v1.7.11" VERSION = "v1.7.13"
APP_NAME = "MusicTools" APP_NAME = "MusicTools"
+31 -34
View File
@@ -33,11 +33,18 @@ def _bundle_dirs() -> list[Path]:
# Directory dell'eseguibile # Directory dell'eseguibile
exe_dir = Path(sys.executable).parent exe_dir = Path(sys.executable).parent
dirs.append(exe_dir) 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: if not _IS_WINDOWS:
frameworks = exe_dir.parent / "Frameworks" frameworks = exe_dir.parent / "Frameworks"
if frameworks.exists(): if frameworks.exists():
dirs.append(frameworks) 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 return dirs
@@ -96,55 +103,45 @@ def find_ytdlp() -> str:
raise FileNotFoundError(msg) raise FileNotFoundError(msg)
def find_ffmpeg_dir() -> Optional[str]: def _find_binary(name: str) -> Optional[str]:
"""Trova la directory contenente ffmpeg e ffprobe. """Trova un binario (ffmpeg o ffprobe) nelle dir del bundle o nel sistema."""
exe_name = _exe(name)
Ordine di ricerca: # 1. Bundle (incluso eventuali sub-bundle .app su macOS)
1. Bundle PyInstaller
2. Percorsi noti per OS
3. Qualsiasi posizione nel PATH
"""
ffmpeg_name = _exe("ffmpeg")
# 1. Bundle
for d in _bundle_dirs(): for d in _bundle_dirs():
if (d / ffmpeg_name).exists(): candidate = d / exe_name
return str(d) if candidate.exists():
return str(candidate)
# 2. Percorsi noti # 2. Percorsi noti per OS
if _IS_WINDOWS: if _IS_WINDOWS:
# Posizioni comuni su Windows
for search in ( for search in (
Path(os.environ.get("LOCALAPPDATA", "")) / "Programs" / "ffmpeg" / "bin", Path(os.environ.get("LOCALAPPDATA", "")) / "Programs" / "ffmpeg" / "bin",
Path("C:/ffmpeg/bin"), Path("C:/ffmpeg/bin"),
Path(os.environ.get("ProgramFiles", "")) / "ffmpeg" / "bin", Path(os.environ.get("ProgramFiles", "")) / "ffmpeg" / "bin",
): ):
if (search / "ffmpeg.exe").exists(): cand = search / exe_name
return str(search) if cand.exists():
return str(cand)
else: else:
for search_path in ("/opt/homebrew/bin", "/usr/local/bin"): for search_path in ("/opt/homebrew/bin", "/usr/local/bin"):
if (Path(search_path) / "ffmpeg").exists(): cand = Path(search_path) / exe_name
return search_path if cand.exists():
return str(cand)
# 3. PATH generico # 3. PATH generico
ffmpeg = shutil.which("ffmpeg") found = shutil.which(name)
if ffmpeg: return found if found else None
return str(Path(ffmpeg).parent)
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]: def find_ffmpeg() -> Optional[str]:
"""Ritorna il path completo di ffmpeg.""" """Ritorna il path completo di ffmpeg."""
d = find_ffmpeg_dir() return _find_binary("ffmpeg")
if d:
return str(Path(d) / _exe("ffmpeg"))
return None
def find_ffprobe() -> Optional[str]: def find_ffprobe() -> Optional[str]:
"""Ritorna il path completo di ffprobe.""" """Ritorna il path completo di ffprobe."""
d = find_ffmpeg_dir() return _find_binary("ffprobe")
if d:
return str(Path(d) / _exe("ffprobe"))
return None