"""Richtet eine neue Programmversion neben der aktuellen Installation ein. Ablauf von "Anwendung ersetzen": 1. ZIP vom SMB-Laufwerk lokal zwischenspeichern und prüfen. 2. In einen Zwischenordner neben der aktuellen Installation entpacken. 3. input/ und output/ sowie eigene Template-PDFs übernehmen. 4. Zwischenordner in "Tätigkeitsnachweis-Generator vX.Y.Z" umbenennen. 5. Neue EXE starten; sie beendet die alte Instanz nach Rückfrage selbst. Die alte Installation bleibt unverändert als Rückfallebene erhalten. """ import os import shutil import subprocess import sys import tempfile import zipfile from dataclasses import dataclass from pathlib import Path, PurePosixPath EXE_NAME = "Tätigkeitsnachweis-Generator.exe" USER_DATA_DIRS = ("input", "output") SETTINGS_FILE = Path("config") / "settings.ini" class UpdateError(Exception): """Fehler mit einer Meldung, die direkt angezeigt werden kann.""" @dataclass class UpdateResult: target_dir: Path already_installed: bool = False previous_settings: Path | None = None def can_replace_application() -> bool: """Nur die gebaute Windows-EXE kann sich selbst ersetzen.""" return sys.platform == "win32" and bool(getattr(sys, "frozen", False)) def _check_members(package: zipfile.ZipFile) -> None: names = package.namelist() for name in names: path = PurePosixPath(name.replace("\\", "/")) if path.is_absolute() or ".." in path.parts or ":" in name: raise UpdateError(f"Das Update-Paket enthält einen ungültigen Pfad: {name}") if EXE_NAME not in names: raise UpdateError(f"Das Update-Paket enthält keine {EXE_NAME}.") if package.testzip() is not None: raise UpdateError("Das Update-Paket ist beschädigt. Bitte später erneut versuchen.") def _copy_user_data(current_dir: Path, staging_dir: Path) -> None: for folder in USER_DATA_DIRS: source = current_dir / folder if source.is_dir(): shutil.copytree(source, staging_dir / folder, dirs_exist_ok=True) # Eigene Templates übernehmen, ohne die mitgelieferten zu überschreiben. templates = current_dir / "template" if templates.is_dir(): (staging_dir / "template").mkdir(exist_ok=True) for path in templates.iterdir(): destination = staging_dir / "template" / path.name if path.is_file() and not destination.exists(): shutil.copy2(path, destination) def _keep_previous_settings(current_dir: Path, staging_dir: Path) -> Path | None: """Neue settings.ini gilt; abweichende alte Einstellungen bleiben daneben erhalten.""" old, new = current_dir / SETTINGS_FILE, staging_dir / SETTINGS_FILE if not old.is_file() or (new.is_file() and old.read_bytes() == new.read_bytes()): return None new.parent.mkdir(parents=True, exist_ok=True) backup = new.with_name("settings.vorherige-version.ini") shutil.copy2(old, backup) return backup def install_update(zip_path: Path, current_dir: Path) -> UpdateResult: """Entpackt zip_path neben current_dir und übernimmt die Nutzerdaten.""" current_dir = current_dir.resolve() target_dir = current_dir.parent / zip_path.stem if target_dir == current_dir: raise UpdateError("Diese Version ist bereits die laufende Installation.") if (target_dir / EXE_NAME).is_file(): return UpdateResult(target_dir, already_installed=True) if target_dir.exists(): raise UpdateError( f"Der Ordner {target_dir} existiert bereits, enthält aber kein Programm. " "Bitte den Ordner entfernen oder umbenennen und erneut versuchen.") staging_dir = current_dir.parent / f".{zip_path.stem}.update" try: shutil.rmtree(staging_dir, ignore_errors=True) with tempfile.TemporaryDirectory(prefix="taetigkeitsnachweis-update-") as temporary: # Erst lokal kopieren: ein VPN-Abbruch trifft so nur die Kopie. local_zip = Path(temporary) / zip_path.name try: shutil.copyfile(zip_path, local_zip) except OSError as exc: raise UpdateError( "Das Update konnte nicht vom Netzlaufwerk geladen werden. " "Bitte VPN-Verbindung und Laufwerk J: prüfen.") from exc try: with zipfile.ZipFile(local_zip) as package: _check_members(package) package.extractall(staging_dir) except zipfile.BadZipFile as exc: raise UpdateError("Das Update-Paket ist keine gültige ZIP-Datei.") from exc _copy_user_data(current_dir, staging_dir) previous_settings = _keep_previous_settings(current_dir, staging_dir) staging_dir.rename(target_dir) except UpdateError: shutil.rmtree(staging_dir, ignore_errors=True) raise except OSError as exc: shutil.rmtree(staging_dir, ignore_errors=True) raise UpdateError( f"Die neue Version konnte nicht eingerichtet werden ({exc}). " f"Bitte Schreibrechte für {current_dir.parent} prüfen.") from exc if previous_settings is not None: previous_settings = target_dir / SETTINGS_FILE.parent / previous_settings.name return UpdateResult(target_dir, previous_settings=previous_settings) def start_application(target_dir: Path) -> None: """Startet die neue EXE in einem eigenen Konsolenfenster wie per Doppelklick.""" env = {key: value for key, value in os.environ.items() if key != "APP_BASE_DIR" and not key.startswith(("STREAMLIT_", "_MEIPASS", "_PYI_"))} # Sonst übernimmt die neue EXE Pfade aus dem Bundle der laufenden alten EXE. env["PYINSTALLER_RESET_ENVIRONMENT"] = "1" subprocess.Popen( [str(target_dir / EXE_NAME)], cwd=target_dir, env=env, close_fds=True, creationflags=subprocess.CREATE_NEW_CONSOLE | subprocess.CREATE_NEW_PROCESS_GROUP, )