From b85a75857e35a605d287ffb40767eb80a9230250 Mon Sep 17 00:00:00 2001 From: Kerim Atilgan Date: Tue, 3 Nov 2026 15:18:22 +0100 Subject: [PATCH] =?UTF-8?q?Updater:=20Pr=C3=BCfung=20beim=20Start=20und=20?= =?UTF-8?q?unter=20Hilfe,=20Setup=20herunterladen,=20DB=20sichern,=20still?= =?UTF-8?q?=20installieren,=20Neustart?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- README.md | 5 +++ build/installer.iss | 8 ++++ provtool/gui/app.py | 96 ++++++++++++++++++++++++++++++++++++++++- provtool/gui/dialogs.py | 6 ++- provtool/updater.py | 82 +++++++++++++++++++++++++++++++++++ tests/test_kern.py | 65 ++++++++++++++++++++++++++++ 6 files changed, 260 insertions(+), 2 deletions(-) create mode 100644 provtool/updater.py diff --git a/README.md b/README.md index 2240abc..a208fdd 100644 --- a/README.md +++ b/README.md @@ -96,6 +96,11 @@ Kopfzeile mit Menüs und Suche, links Abrechnungen und Händler, oben Kennzahlen (nicht zugeordnet, von Hand geändert, doppelt, bereits abgerechnet). Oben rechts „Dunkel“/„Hell“ schaltet das dunkle Design um. Das Windows-95-Design gibt es weiter unter *Datei ▸ Einstellungen ▸ Darstellung*. +**Updates:** Das Programm schaut beim Start auf Gitea nach einer neueren Version (abschaltbar unter +*Datei ▸ Einstellungen*, von Hand unter *Hilfe ▸ Nach Updates suchen*). Die per Setup installierte +Windows-Version lädt das neue Setup, sichert die Datenbank (`vor-update-.db` im Einstellungsordner), +installiert still und startet neu. Die einzelne .exe und Linux öffnen stattdessen die Download-Seite. + **Linux:** `Provisionsmanager95-linux-x86_64-.tar.gz` entpacken und `Provisionsmanager95` starten (ab Debian 12 / Ubuntu 24.04). Daten liegen unter `~/.config/Provisionsmanager95`. diff --git a/build/installer.iss b/build/installer.iss index e1d4b9d..76406e3 100644 --- a/build/installer.iss +++ b/build/installer.iss @@ -43,3 +43,11 @@ Name: "{autodesktop}\Provisions-Manager"; Filename: "{app}\Provisionsmanager.exe [Run] Filename: "{app}\Provisionsmanager.exe"; Description: "{cm:LaunchProgram,Provisions-Manager}"; Flags: nowait postinstall skipifsilent +; Update aus dem Programm (/VERYSILENT /NEUSTART=1): danach wieder starten +Filename: "{app}\Provisionsmanager.exe"; Flags: nowait; Check: NachUpdateStarten + +[Code] +function NachUpdateStarten: Boolean; +begin + Result := ExpandConstant('{param:NEUSTART|0}') = '1'; +end; diff --git a/provtool/gui/app.py b/provtool/gui/app.py index 12c467f..3b158a6 100644 --- a/provtool/gui/app.py +++ b/provtool/gui/app.py @@ -5,14 +5,17 @@ import os import re import subprocess import sys +import tempfile +import threading import tkinter as tk import traceback +import webbrowser from collections import defaultdict from datetime import date from pathlib import Path from tkinter import filedialog -from provtool import config +from provtool import __version__, config, updater from provtool import export as exp from provtool import regeln from provtool.db import EIGEN, UNZUGEORDNET, AbrechnungErledigt, Datenbank @@ -235,6 +238,7 @@ class App: hi.add_command(label="Kurzanleitung", accelerator="F1", command=self.hilfe) hi.add_command(label="Rundgang durch das Programm", command=self.rundgang) hi.add_command(label="Tipp des Tages...", command=self.tipp_des_tages) + hi.add_command(label="Nach Updates suchen...", command=self.updates_pruefen) hi.add_separator() hi.add_command(label="Einrichtungs-Assistent...", command=self.assistent) hi.add_separator() @@ -1247,9 +1251,95 @@ class App: self.db_wechseln(res["pfad"]) except Exception as exc: w.fehler(self.root, "Einstellungen", f"Datenbank konnte nicht geöffnet werden:\n{exc}") + self.settings["update_pruefen"] = res["update"] + config.save(self.settings) if res["design"] != w.DESIGN: self.design_wechseln(res["design"]) + # ================================================================= Updates + @staticmethod + def _installiert() -> bool: + """Läuft die per Setup installierte Windows-Version? Nur die kann sich selbst aktualisieren.""" + return (bool(getattr(sys, "frozen", False)) and sys.platform.startswith("win") + and Path(sys.executable).name.lower() == "provisionsmanager.exe") + + def _im_hintergrund(self, arbeit, fertig): + """arbeit() im Thread (nur Netzwerk/Dateien, kein Tk), danach fertig(ergebnis) im Tk-Thread.""" + erg: dict = {} + + def lauf(): + try: + erg["wert"] = arbeit() + except Exception as exc: + erg["fehler"] = exc + threading.Thread(target=lauf, daemon=True).start() + + def warten(): + if self._beendet or self._schliesst: + return + if not erg: + self.root.after(200, warten) + else: + fertig(erg) + self.root.after(200, warten) + + def updates_pruefen(self, still: bool = False): + """Auf Gitea nach einer neueren Version suchen. still = automatisch beim Start: nur melden, wenn es + eine gibt (keine Meldung bei Fehler oder wenn aktuell).""" + if getattr(self, "_update_laeuft", False): + return + self._update_laeuft = True + + def fertig(erg): + self._update_laeuft = False + self._update_melden(erg, still) + self._im_hintergrund(updater.neueste, fertig) + + def _update_melden(self, erg: dict, still: bool): + if "fehler" in erg: + if not still: + w.warnung(self.root, "Updates", f"Konnte nicht nach Updates suchen:\n{erg['fehler']}") + return + info = erg["wert"] + if not updater.ist_neuer(info["version"], __version__): + if not still: + w.info(self.root, "Updates", f"Sie haben die neueste Version ({__version__}).") + return + if still and modal.offen(): # z. B. Tipp des Tages offen – später noch einmal + self.root.after(3000, lambda: self._update_melden(erg, still)) + return + text = f"Version {info['version']} ist verfügbar (installiert: {__version__})." + if self._installiert() and info["setup_url"]: + if w.frage(self.root, "Update verfügbar", + text + "\n\nJetzt herunterladen und installieren? Die Datenbank wird vorher gesichert, das " + "Programm beendet sich kurz und startet danach von selbst wieder.", + ja="Jetzt installieren", nein="Später"): + self._update_installieren(info) + elif w.frage(self.root, "Update verfügbar", text + "\n\nDownload-Seite öffnen?", + ja="Seite öffnen", nein="Später"): + webbrowser.open(info["seite"]) + + def _update_installieren(self, info: dict): + self.status.set("info", f" Lade Version {info['version']} herunter ...") + ordner = Path(tempfile.gettempdir()) / "Provisionsmanager-Update" + + def fertig(erg): + if "fehler" in erg: + self.status.set("info", "") + w.fehler(self.root, "Update", f"Download fehlgeschlagen:\n{erg['fehler']}") + return + try: + self.db.backup(config.app_dir() / f"vor-update-{__version__}.db") + except Exception as exc: + if not w.frage(self.root, "Update", f"Die Datenbank konnte nicht gesichert werden:\n{exc}\n\n" + "Trotzdem installieren?", ja="Installieren", nein="Abbrechen", default_ja=False): + return + # NEUSTART=1: das Setup startet das Programm danach wieder (installer.iss) + subprocess.Popen([str(erg["wert"]), "/VERYSILENT", "/SUPPRESSMSGBOXES", "/NORESTART", + "/CLOSEAPPLICATIONS", "/NEUSTART=1"], close_fds=True) + self.beenden() + self._im_hintergrund(lambda: updater.herunterladen(info, ordner), fertig) + def design_umschalten(self): self.design_wechseln("modern" if w.DUNKEL else "dunkel") @@ -1450,12 +1540,16 @@ def main(): except Exception: pass dateien = [a for a in sys.argv[1:] if Path(a).exists()] + erster = True while True: root = tk.Tk(className="Provisionsmanager95") app = App(root) if dateien: root.after(200, lambda: app.importieren(dateien)) dateien = [] + if erster and app.settings.get("update_pruefen", True): # nur im echten Start, nicht in Tests + root.after(3000, lambda: app.updates_pruefen(still=True)) + erster = False root.mainloop() if not app.neustart: # sonst: Design wurde umgeschaltet – Fenster neu aufbauen break diff --git a/provtool/gui/dialogs.py b/provtool/gui/dialogs.py index ff2bd2d..c808987 100644 --- a/provtool/gui/dialogs.py +++ b/provtool/gui/dialogs.py @@ -1035,6 +1035,10 @@ class EinstellungenDialog(w.Dialog): tk.Radiobutton(g2, text="Klassisch (Windows 95, Legacy)", variable=self.v_design, value="win95").pack( anchor="w") tk.Label(g2, text="Das Fenster wird nach OK mit dem neuen Design neu aufgebaut.", fg=w.SHADOW).pack(anchor="w") + g3 = w.gruppe(self, "Updates") + g3.pack(fill="x", padx=10, pady=(0, 10)) + self.v_update = tk.BooleanVar(value=settings.get("update_pruefen", True)) + tk.Checkbutton(g3, text="Beim Start nach neuen Versionen suchen", variable=self.v_update).pack(anchor="w") self.knopfleiste([("OK", self._ok), ("Abbrechen", self.abbrechen)]).pack(pady=(0, 10)) def _waehlen(self): @@ -1045,7 +1049,7 @@ class EinstellungenDialog(w.Dialog): self.v.set(p) def _ok(self): - self.result = dict(pfad=self.v.get().strip(), design=self.v_design.get()) + self.result = dict(pfad=self.v.get().strip(), design=self.v_design.get(), update=self.v_update.get()) self.destroy() diff --git a/provtool/updater.py b/provtool/updater.py new file mode 100644 index 0000000..79c3d17 --- /dev/null +++ b/provtool/updater.py @@ -0,0 +1,82 @@ +"""Updates: neuestes Release auf Gitea finden und das Windows-Setup herunterladen. + +Nur Netzwerk und Dateien – kein Tk (läuft im Hintergrund-Thread). Das Installieren startet die App (gui/app.py). +""" +from __future__ import annotations + +import json +import re +import urllib.request +from pathlib import Path +from urllib.parse import urlparse + +HOST = "git.handy-insel.de" +API = f"https://{HOST}/api/v1/repos/kerimatilgan/provisionsmanager95/releases/latest" +SETUP = re.compile(r"Provisionsmanager-Setup-[\d.]+\.exe") +MAX_BYTES = 200 * 1024 * 1024 + + +class UpdateFehler(Exception): + pass + + +def version_tupel(v: str) -> tuple[int, ...]: + """'v1.10.0' -> (1, 10, 0); Unlesbares -> ()""" + m = re.fullmatch(r"v?(\d+(?:\.\d+)*)", (v or "").strip()) + return tuple(int(x) for x in m[1].split(".")) if m else () + + +def ist_neuer(neu: str, alt: str) -> bool: + return version_tupel(neu) > version_tupel(alt) and bool(version_tupel(alt)) + + +def _eigene_url(url: str) -> bool: + """Nur über HTTPS vom eigenen Gitea laden (die Antwort des Servers ist hier die Vertrauensgrenze).""" + u = urlparse(url) + return u.scheme == "https" and u.hostname == HOST + + +def neueste(timeout: float = 8) -> dict: + """Neuestes Release: {"version", "seite", "setup_url", "groesse"} (setup_url None, wenn kein Setup dabei).""" + with urllib.request.urlopen(API, timeout=timeout) as r: + daten = json.loads(r.read(1024 * 1024).decode("utf-8")) + setup = next((a for a in daten.get("assets", []) if SETUP.fullmatch(a.get("name", ""))), None) + url = setup.get("browser_download_url") if setup else None + if url and not _eigene_url(url): + url = None + seite = daten.get("html_url") or "" + return {"version": str(daten.get("tag_name", "")).lstrip("v"), + "seite": seite if _eigene_url(seite) else f"https://{HOST}/kerimatilgan/provisionsmanager95/releases", + "setup_url": url, "groesse": int(setup.get("size") or 0) if setup else 0} + + +def herunterladen(info: dict, ordner: Path, timeout: float = 60) -> Path: + """Setup nach `ordner` laden und prüfen (Größe wie angekündigt, Windows-Programm). Gibt den Pfad zurück.""" + url = info.get("setup_url") + if not url or not _eigene_url(url): + raise UpdateFehler("Für diese Version gibt es kein Setup zum Herunterladen.") + ordner.mkdir(parents=True, exist_ok=True) + ziel = ordner / Path(urlparse(url).path).name + if not SETUP.fullmatch(ziel.name): + raise UpdateFehler(f"Unerwarteter Dateiname: {ziel.name}") + teil = ziel.with_suffix(".teil") + n = 0 + try: + with urllib.request.urlopen(url, timeout=timeout) as r, teil.open("wb") as f: + if not _eigene_url(r.geturl()): # Weiterleitung auf einen fremden Server + raise UpdateFehler("Download wurde auf einen fremden Server umgeleitet.") + while block := r.read(1 << 16): + n += len(block) + if n > MAX_BYTES: + raise UpdateFehler("Download ist unerwartet groß.") + f.write(block) + except BaseException: + teil.unlink(missing_ok=True) + raise + with teil.open("rb") as f: + kopf = f.read(2) + if kopf != b"MZ" or (info.get("groesse") and n != info["groesse"]): + teil.unlink(missing_ok=True) + raise UpdateFehler("Download ist unvollständig oder beschädigt – bitte später erneut versuchen.") + teil.replace(ziel) + return ziel diff --git a/tests/test_kern.py b/tests/test_kern.py index 5301424..8fdd990 100644 --- a/tests/test_kern.py +++ b/tests/test_kern.py @@ -696,3 +696,68 @@ def test_einstellungen_atomar_und_defekt(tmp_path, monkeypatch): daten = config.load() assert daten["db_pfad"] is None and daten["defekt"].endswith("einstellungen.defekt.json") assert (tmp_path / "einstellungen.defekt.json").read_text(encoding="utf-8") == "{ kaputt" + + +# --------------------------------------------------------------------------- Updates + +class _Antwort: + """Ersatz für urlopen(): liefert Bytes, kennt seine (End-)URL.""" + + def __init__(self, daten: bytes, url: str): + import io + self._f, self._url = io.BytesIO(daten), url + + def read(self, n=-1): + return self._f.read(n) + + def geturl(self): + return self._url + + def __enter__(self): + return self + + def __exit__(self, *a): + return False + + +def test_update_version_vergleich(): + from provtool import updater + assert updater.ist_neuer("1.10.0", "1.9.2") and updater.ist_neuer("v1.8.0", "1.7.0") + assert not updater.ist_neuer("1.7.0", "1.7.0") and not updater.ist_neuer("1.6.9", "1.7.0") + assert not updater.ist_neuer("kaputt", "1.7.0") and not updater.ist_neuer("1.8.0", "") + + +def test_update_neueste_nimmt_nur_eigenes_setup(monkeypatch): + import json + from provtool import updater + basis = "https://git.handy-insel.de/kerimatilgan/provisionsmanager95/releases/download/v1.8.0/" + release = {"tag_name": "v1.8.0", "html_url": "https://git.handy-insel.de/x/releases/tag/v1.8.0", "assets": [ + {"name": "Provisionsmanager95.exe", "browser_download_url": basis + "Provisionsmanager95.exe", "size": 5}, + {"name": "Provisionsmanager-Setup-1.8.0.exe", "browser_download_url": basis + "Provisionsmanager-Setup-1.8.0.exe", + "size": 7}]} + monkeypatch.setattr(updater.urllib.request, "urlopen", + lambda url, timeout=0: _Antwort(json.dumps(release).encode(), url)) + info = updater.neueste() + assert info == {"version": "1.8.0", "seite": release["html_url"], + "setup_url": basis + "Provisionsmanager-Setup-1.8.0.exe", "groesse": 7} + release["assets"][1]["browser_download_url"] = "https://boese.example/Provisionsmanager-Setup-1.8.0.exe" + assert updater.neueste()["setup_url"] is None, "Setup von fremdem Server darf nicht geladen werden" + + +def test_update_download_wird_geprueft(monkeypatch, tmp_path): + from provtool import updater + url = "https://git.handy-insel.de/r/releases/download/v1.8.0/Provisionsmanager-Setup-1.8.0.exe" + inhalt = {"daten": b"MZ" + b"x" * 98, "url": url} + monkeypatch.setattr(updater.urllib.request, "urlopen", lambda u, timeout=0: _Antwort(inhalt["daten"], inhalt["url"])) + setup = updater.herunterladen({"setup_url": url, "groesse": 100}, tmp_path) + assert setup.name == "Provisionsmanager-Setup-1.8.0.exe" and setup.read_bytes().startswith(b"MZ") + for daten, ende, groesse in ((b"MZ" + b"x" * 10, url, 100), # unvollständig + (b"PK" + b"x" * 98, url, 100), # kein Windows-Programm + (b"MZ" + b"x" * 98, "https://boese.example/a.exe", 100)): # umgeleitet + inhalt.update(daten=daten, url=ende) + setup.unlink(missing_ok=True) + with pytest.raises(updater.UpdateFehler): + updater.herunterladen({"setup_url": url, "groesse": groesse}, tmp_path) + assert not list(tmp_path.iterdir()), "halber Download bleibt liegen" + with pytest.raises(updater.UpdateFehler): + updater.herunterladen({"setup_url": "http://git.handy-insel.de/a/Provisionsmanager-Setup-1.8.0.exe"}, tmp_path)