Compare commits

...
4 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
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
luzadev 415175ec4d v1.7.10: fix registrazione BlackHole muta (device by name)
QuickTime registrava da BlackHole, MusicTools no: il bug era che
list_input_devices passava al record l'index AVFoundation, ma
l'index puo' rinumerarsi tra l'elenco e l'apertura (iPhone in
continuita', Multi-Output Device, hot-plug). Quando ffmpeg apriva
:<index> col numero sbagliato, registrava silenzio dal device
"vero" mappato a quel numero.

Fix: in _list_macos uso il NOME del device come id (es.
"BlackHole 2ch"). ffmpeg AVFoundation accetta sia :<index> sia
:<name> ed il nome e' stabile. Aggiunto campo 'index' solo per
log/diagnostica. Nessun cambio Windows (gia' faceva by-name).
2026-06-09 20:58:51 +02:00
5 changed files with 243 additions and 38 deletions

No files matched your search

+6 -1
View File
@@ -229,7 +229,12 @@ class Api:
),
"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:
res = license_mod.consume_quota(feature)
except license_mod.LicenseNetworkError as e:
+191
View File
@@ -239,6 +239,8 @@ 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())
log(f"Dimensione: {size / 1024 / 1024:.0f} MB")
@@ -292,6 +294,195 @@ def patch_info_plist(app_path):
log("Info.plist aggiornato.")
_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 (
'<?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:
# 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
# =========================================================================
+1 -1
View File
@@ -5,7 +5,7 @@ import os
import sys
from pathlib import Path
VERSION = "v1.7.9"
VERSION = "v1.7.13"
APP_NAME = "MusicTools"
+31 -34
View File
@@ -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")
+14 -2
View File
@@ -73,7 +73,14 @@ def _is_virtual(name: str) -> bool:
def _list_macos(ffmpeg: str) -> list[dict]:
"""Parsa l'output di `ffmpeg -f avfoundation -list_devices true -i ""`.
L'output va su stderr."""
L'output va su stderr.
IMPORTANTE: come 'id' usiamo il NOME del device (non l'index numerico).
AVFoundation rinumera silenziosamente i device quando cambia il set di
hardware collegato (es. iPhone in continuita', Multi-Output Device, ecc).
Il nome e' invece stabile; ffmpeg AVFoundation accetta sia ':<index>'
sia ':<name>' come specifica del device input.
"""
try:
proc = subprocess.run(
[ffmpeg, "-hide_banner", "-f", "avfoundation",
@@ -105,7 +112,12 @@ def _list_macos(ffmpeg: str) -> list[dict]:
if not m:
continue
idx, name = m.group(1), m.group(2).strip()
devices.append({"id": idx, "name": name, "is_virtual": _is_virtual(name)})
devices.append({
"id": name, # nome stabile (vedi docstring)
"index": idx, # solo informativo per debug
"name": name,
"is_virtual": _is_virtual(name),
})
return devices