# launcher_src/sync_manager.py import json import shutil import subprocess import sys import time import zipfile from pathlib import Path from typing import List, Dict, Callable import os # --- MODIFICA --- Aggiunto import 'os' # Importiamo la nostra configurazione from . import config from .logger import get_logger # --- MODIFICA --- Nuova funzione helper per la copia granulare # In launcher_src/sync_manager.py def copytree_with_progress(src: Path, dst: Path, base_progress: int, total_jobs: int, progress_callback: Callable): """ Copia una directory in modo ricorsivo, fornendo un feedback ancora più granulare. """ src_name = src.name # --- NUOVA FASE DI CONTEGGIO CON FEEDBACK --- progress_callback(base_progress, f"Analisi di '{src_name}'...", "(conteggio file in corso)") try: # Crea una lista di tutti i file da copiare files_to_copy = [] for src_dir, _, files in os.walk(src): for file_ in files: files_to_copy.append(Path(src_dir) / file_) total_files = len(files_to_copy) except FileNotFoundError: print(f" - ERRORE: La cartella sorgente {src} non esiste.") return copied_files = 0 # Fase 2: Esecuzione if dst.exists(): shutil.rmtree(dst) dst.mkdir(parents=True, exist_ok=True) if total_files == 0: progress_callback(base_progress + int(1 / total_jobs * 100), f"Copia di '{src_name}'...", "Cartella vuota.") return # Ora iteriamo sulla lista di file che abbiamo già costruito for src_file in files_to_copy: relative_path = src_file.relative_to(src) dst_file = dst / relative_path # Assicura che la cartella di destinazione esista dst_file.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(src_file, dst_file) copied_files += 1 job_percentage = (copied_files / total_files) total_percentage = int(base_progress + (job_percentage / total_jobs * 100)) # Mostra solo il nome del file, non il percorso completo progress_callback(total_percentage, f"Copia di '{src_name}'...", relative_path.name) class SyncManager: """Gestisce la logica di sincronizzazione e avvio.""" # ... __init__, _read_metadata, check_for_updates rimangono INVARIATI ... def __init__(self): self.server_metadata = {} self.local_metadata = {} self._log = get_logger(self.__class__.__name__) def _read_metadata(self, path: Path) -> Dict: if not path.exists(): return {"hashes": {}} try: return json.loads(path.read_text(encoding="utf-8")) except (json.JSONDecodeError, IOError): return {"hashes": {}} # --- Integrità locale ------------------------------------------------- def _is_app_integrity_ok(self, app_root: Path) -> bool: """Controllo minimo di integrità della app locale. Richiede la presenza di alcuni file/sottocartelle chiave. Include MediaTrack integrato. """ required = [ app_root / "main.py", app_root / "start.py", app_root / "main_window.py", app_root / "config" / "config.yaml", # MediaTrack integration check app_root / "modules" / "mediatrack" / "run.py", ] for p in required: if not p.exists(): self._log.warning("Integrità APP non valida: manca %s", p) return False return True def _is_python_env_ok(self) -> bool: """Verifica che l'ambiente Python estratto esista (pythonw.exe).""" ok = config.LOCAL_PYTHON_EXECUTABLE.exists() if not ok: self._log.warning("Ambiente Python locale assente: %s", config.LOCAL_PYTHON_EXECUTABLE) return ok def check_for_updates(self) -> List[str]: self._log.info("Verifica aggiornamenti...") self.server_metadata = self._read_metadata(config.SERVER_METADATA_FILE) self.local_metadata = self._read_metadata(config.LOCAL_METADATA_FILE) server_hashes = self.server_metadata.get("hashes", {}) local_hashes = self.local_metadata.get("hashes", {}) metadata_ok = True if not server_hashes or all(v == "" for v in server_hashes.values()): metadata_ok = False self._log.warning( "Metadati server mancanti/illeggibili (%s). Procedo con controllo integrità e copia best-effort.", config.SERVER_METADATA_FILE, ) jobs_to_do = [] for artifact_id, relative_path in config.ARTIFACTS_TO_SYNC.items(): server_hash = server_hashes.get(artifact_id) local_hash = local_hashes.get(artifact_id) needs_update = False if artifact_id == "python_embeddable": # Verifica sia la presenza della cache zip sia dell'estrazione (pythonw.exe) cached_zip = config.LOCAL_LAUNCHER_CACHE_DIR / "python_embeddable.zip" if not cached_zip.exists() or not self._is_python_env_ok(): needs_update = True else: local_app_path = config.LOCAL_ROOT / relative_path if not local_app_path.exists() or not self._is_app_integrity_ok(local_app_path): needs_update = True if metadata_ok and server_hash != local_hash: needs_update = True if needs_update: self._log.info("Aggiornamento necessario per: %s", artifact_id) jobs_to_do.append(artifact_id) if not jobs_to_do: self._log.info("Nessun aggiornamento necessario.") return jobs_to_do # --- MODIFICA --- Funzione run_sync aggiornata def run_sync(self, jobs: List[str], progress_callback: Callable): self._log.info("Avvio sincronizzazione (%d job)", len(jobs)) total_jobs = len(jobs) for i, job_id in enumerate(jobs): # Questa è la percentuale di base all'inizio di ogni "macro-lavoro" base_percentage = int((i / total_jobs) * 100) # --- Gestione ZIP (app, local_db, python_embeddable) --- if job_id in ("python_embeddable", "app", "local_db"): progress_callback(base_percentage, f"Aggiornamento di '{job_id}'...", "") server_zip_path = config.SERVER_DISTRIBUTION_ROOT / config.ARTIFACTS_TO_SYNC[job_id] # Determina path locale per ZIP cache e directory estrazione if job_id == "python_embeddable": local_zip_path = config.LOCAL_LAUNCHER_CACHE_DIR / "python_embeddable.zip" local_extract_path = config.LOCAL_ROOT / "python_embeddable" elif job_id == "app": local_zip_path = config.LOCAL_LAUNCHER_CACHE_DIR / "app.zip" local_extract_path = config.LOCAL_ROOT / "app" elif job_id == "local_db": local_zip_path = config.LOCAL_LAUNCHER_CACHE_DIR / "local_db.zip" local_extract_path = config.LOCAL_ROOT / "local_db" if not server_zip_path.exists(): self._log.error("Pacchetto zip mancante sul server: %s", server_zip_path) continue try: progress_callback(base_percentage, f"Download di '{server_zip_path.name}'...", "") config.LOCAL_LAUNCHER_CACHE_DIR.mkdir(parents=True, exist_ok=True) shutil.copy2(server_zip_path, local_zip_path) progress_callback(base_percentage, f"Decompressione di '{job_id}'...", "(Attendere...)") if local_extract_path.exists(): shutil.rmtree(local_extract_path) local_extract_path.parent.mkdir(parents=True, exist_ok=True) with zipfile.ZipFile(local_zip_path, 'r') as zip_ref: # python_embeddable.zip ha i file dentro una cartella python_embeddable/ # quindi estraiamo nel parent; app.zip e local_db.zip hanno file alla root if job_id == "python_embeddable": zip_ref.extractall(local_extract_path.parent) else: zip_ref.extractall(local_extract_path) self._log.info("Artefatto '%s' aggiornato", job_id) except Exception as e: self._log.exception("Errore durante l'aggiornamento di '%s'", job_id) continue # --- Gestione Standard con la nuova logica --- server_path = config.SERVER_DISTRIBUTION_ROOT / config.ARTIFACTS_TO_SYNC[job_id] local_path = config.LOCAL_ROOT / config.ARTIFACTS_TO_SYNC[job_id] if not server_path.exists(): self._log.error("Sorgente inesistente sul server: %s", server_path) continue local_path.parent.mkdir(parents=True, exist_ok=True) if server_path.is_dir(): # --- MODIFICA CHIAVE: Usiamo la nostra nuova funzione di copia --- self._log.info("Copia directory: SRC=%s DST=%s", server_path, local_path) copytree_with_progress(server_path, local_path, base_percentage, total_jobs, progress_callback) else: progress_callback(base_percentage, f"Copia di '{server_path.name}'...", "") shutil.copy2(server_path, local_path) self._log.info("File copiato: %s", server_path.name) progress_callback(100, "Finalizzazione...", "") config.LOCAL_LAUNCHER_CACHE_DIR.mkdir(exist_ok=True) config.LOCAL_METADATA_FILE.write_text(json.dumps(self.server_metadata, indent=2)) self._log.info("Sincronizzazione completata. Metadati locali aggiornati.") # Opzione A: pulizia cartella 'database' locale legacy (non più usata) legacy_local_db_dir = config.LOCAL_ROOT / "database" if legacy_local_db_dir.exists(): try: shutil.rmtree(legacy_local_db_dir) self._log.info("Cartella locale legacy 'database' rimossa (Opzione A)") except Exception as e: self._log.warning("Impossibile rimuovere cartella legacy 'database': %s", e) def launch_application(self): """Avvia l'applicazione locale se presente, altrimenti solleva errori informativi.""" if not config.LOCAL_PYTHON_EXECUTABLE.exists(): self._log.error("pythonw.exe non trovato: %s", config.LOCAL_PYTHON_EXECUTABLE) raise FileNotFoundError( f"Eseguibile Python non trovato: {config.LOCAL_PYTHON_EXECUTABLE}" ) launch_script = config.LOCAL_ROOT / config.APP_LAUNCH_MODULE if not launch_script.exists(): self._log.error("Script di avvio non trovato: %s", launch_script) raise FileNotFoundError( f"File di avvio non trovato: {launch_script}" ) # Cancella il segnale precedente prima del lancio ready_file = config.LOCAL_ROOT / '.launcher_ready' ready_file.unlink(missing_ok=True) command = [str(config.LOCAL_PYTHON_EXECUTABLE), str(launch_script)] self._log.info("Avvio applicazione: %s", ' '.join(command)) try: subprocess.Popen(command, cwd=config.LOCAL_ROOT) except Exception as e: self._log.exception("Errore durante l'avvio dell'applicazione") raise RuntimeError(f"Impossibile avviare l'applicazione: {e}") def wait_for_ready(self, timeout: int = 30) -> bool: """Attende che l'app scriva il ready-file. Ritorna False se scade il timeout.""" ready_file = config.LOCAL_ROOT / '.launcher_ready' deadline = time.time() + timeout while time.time() < deadline: if ready_file.exists(): self._log.info("App pronta (ready-file ricevuto)") return True time.sleep(0.2) self._log.warning("Timeout attesa ready-file (%ds) — chiusura splash comunque", timeout) return False