Files
RobandClaude Opus 5.5 cb059ac20d Geführtes Update: "Anwendung ersetzen" im Update-Hinweis
Der Update-Hinweis bietet neben "Update Ordner öffnen" nun den Button
"Anwendung ersetzen". Er kopiert das ZIP vom SMB-Laufwerk lokal, prüft es,
entpackt es in einen eigenen Ordner neben der aktuellen Installation,
übernimmt input/, output/ und eigene Templates und startet die neue EXE.
Die alte Installation bleibt als Rückfallebene erhalten; eine angepasste
settings.ini wird als settings.vorherige-version.ini aufbewahrt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 12:55:12 +01:00

138 lines
5.8 KiB
Python

"""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,
)