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>
This commit is contained in:
1 parent
5e2fab7fd3
commit
cb059ac20d
7 files changed
+315
-5
No files matched your search
@@ -0,0 +1,137 @@
|
||||
"""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,
|
||||
)
|
||||
Reference in new issue
Block a user