From fec0e8449c799c9719bc30157e2312847fdd36d2 Mon Sep 17 00:00:00 2001 From: TheMockTv Date: Sun, 6 Sep 2026 16:42:35 +0200 Subject: [PATCH] Updater als Starter - die Nummer steht im Dateinamen Sein Bild (06.09.2026): "der updater schaut beim starten erst: ist auf dem git link eine neue version mit exe? wenn ja, erst laden, ersetzen und dann starten." "einmal pruefen auf vollstaendigkeit." "wir werden den namen mit nummer fuehren - wenn er merkt, eh warte mal, die heisst anders, dann neu machen" DER AUFBAU Der Updater IST der Starter. Er laeuft, BEVOR das Programm laeuft - damit gibt es das Windows-Problem "laufende EXE laesst sich nicht ersetzen" gar nicht. Und weil die installierte Datei die Nummer im Namen traegt (Rechnungstool-1.6.0.exe), wird ueberhaupt nichts ueberschrieben: eine neue Fassung ist eine neue Datei. Die vorige bleibt liegen und ist damit die Rueckfallebene, ohne dass jemand eine Sicherung anlegen muesste. Der Updater sieht am NAMEN, was da ist - er muss keiner Angabe glauben. version.py: die Nummer an EINER Stelle, im Code, in die EXE kompiliert. Sie entspricht dem Release-Tag ohne "v". Zusaetzlich fuehrt der Updater ein eigenes Buch (installiert.json); wer eine Datei umbenennt, verursacht hoechstens einen ueberfluessigen Download. BEIM BAUEN AUFGEFALLEN /releases/latest liefert NICHTS, weil alle Fassungen als Vorabversion gekennzeichnet sind - Forgejo laesst Vorabversionen dort aus. Der Updater nimmt deshalb die Liste und sucht selbst die hoechste Nummer. Waere das erst beim Nutzer aufgefallen, haette der Updater nie ein Update gefunden. Beide Repositories sind oeffentlich - kein Token noetig. Ein Token in einer verteilten EXE waere ohnehin keins. VOLLSTAENDIGKEIT Geladen wird in einen Zwischenordner. Erst wenn GROESSE und (falls das Release eine .sha256 anhaengt) PRUEFSUMME stimmen, kommt die Datei in den Installationsordner. Sonst wird verworfen und die vorhandene Fassung gestartet - lieber kein Update als ein kaputtes. PRUEFSTAND pruef_updater.py prueft vor allem, wann NICHT aktualisiert werden darf: gleiche Fassung, aeltere im Netz, kein passender Anhang, Entwurf, kein Netz, falsche Groesse, falsche Pruefsumme. Und den wichtigsten Punkt: eine halbe Datei erreicht den Installationsordner nie. Alle 18 Pruefstaende gruen. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01V4t48uxDok5rJXhC9bX1Ax --- pruef_updater.py | 204 ++++++++++++++++++++++++++ updater.py | 368 +++++++++++++++++++++++++++++++++++++++++++++++ version.py | 23 +++ 3 files changed, 595 insertions(+) create mode 100644 pruef_updater.py create mode 100644 updater.py create mode 100644 version.py diff --git a/pruef_updater.py b/pruef_updater.py new file mode 100644 index 0000000..e1bebef --- /dev/null +++ b/pruef_updater.py @@ -0,0 +1,204 @@ +# -*- coding: utf-8 -*- +"""Pruefstand: der Updater - vor allem die Faelle, in denen er NICHTS tun darf. + +Sein Bild (06.09.2026): + + "der updater schaut beim starten erst: ist auf dem git link eine neue + version mit exe? wenn ja, erst laden, ersetzen und dann starten." + "einmal pruefen auf vollstaendigkeit." + "wir werden den namen mit nummer fuehren - wenn er merkt, eh warte mal, + die heisst anders, dann neu machen" + +Ein Updater ist gefaehrlicher als das Programm, das er aktualisiert: Er +entscheidet ueber die Datei, die der Betrieb braucht. Deshalb wird hier weniger +geprueft, DASS er aktualisiert, als dass er es in den falschen Faellen NICHT +tut - und dass er nie etwas ueberschreibt. + +Ohne Netz: die Release-Liste wird eingesetzt, nicht geholt. +""" +import os +import shutil +import sys +import tempfile + +QUELLE = r"C:\claude\rechnungstool" +sys.path.insert(0, QUELLE) + +import updater as U # noqa: E402 + +fehler = [] + + +def pruefe(bedingung, text): + zeile = (" OK | " if bedingung else "FEHLER | ") + text + kod = sys.stdout.encoding or "utf-8" + print(zeile.encode(kod, "replace").decode(kod)) + if not bedingung: + fehler.append(text) + + +EXE = "Rechnungstool.exe" + +# ---- Versionen vergleichen --------------------------------------------- +pruefe(U.zerlegen("v1.6.0") == (1, 6, 0), "v1.6.0 wird zu (1, 6, 0)") +pruefe(U.zerlegen("") == (0,), "leere Angabe zaehlt als aelteste") +pruefe(U.zerlegen("kaputt") == (0,), "unlesbare Angabe zaehlt als aelteste") +pruefe(U.ist_neuer("v1.7.0", "1.6.9"), "1.7.0 ist neuer als 1.6.9 (nicht alphabetisch!)") +pruefe(not U.ist_neuer("v1.6.0", "1.6.0"), "Gleichstand ist NICHT neuer") +pruefe(U.ist_neuer("v1.10.0", "1.9.0"), "1.10.0 ist neuer als 1.9.0") + +# ---- Die Nummer im Dateinamen ------------------------------------------ +pruefe(U.dateiname(EXE, "v1.6.0") == "Rechnungstool-1.6.0.exe", + "aus Name und Tag wird Rechnungstool-1.6.0.exe") +pruefe(U.dateiname("Rechnungstool-1.5.0.exe", "v1.6.0") == "Rechnungstool-1.6.0.exe", + "eine schon vorhandene Nummer wird ersetzt, nicht angehaengt") +pruefe(U.basis_und_endung(EXE) == ("Rechnungstool", ".exe"), "Stamm und Endung sauber getrennt") + +# ---- Das passende Release finden --------------------------------------- +def release(tag, dateien, entwurf=False, vorab=True): + return {"tag_name": tag, "draft": entwurf, "prerelease": vorab, + "assets": [{"name": n, "size": g, + "browser_download_url": f"http://x/{tag}/{n}"} + for n, g in dateien]} + + +liste = [release("v1.5.0", [(EXE, 10)]), + release("v1.6.0", [(EXE, 20)]), + release("v1.5.5", [(EXE, 15)])] +treffer = U.passendes_release(liste, EXE) +pruefe(treffer and treffer[0]["tag_name"] == "v1.6.0", + "aus einer unsortierten Liste wird die neueste genommen") +pruefe(U.passendes_release([release("v9.9.9", [("anderes.exe", 5)])], EXE) is None, + "ein Release ohne passende Datei zaehlt nicht") +pruefe(U.passendes_release([release("v9.9.9", [(EXE, 5)], entwurf=True)], EXE) is None, + "ein Entwurf zaehlt nicht") +# Vorabversionen MUESSEN zaehlen - alle Fassungen sind so gekennzeichnet. +pruefe(U.passendes_release([release("v2.0.0", [(EXE, 30)], vorab=True)], EXE) is not None, + "Vorabversionen zaehlen mit (sonst faende der Updater nie etwas)") + +# ---- Vollstaendigkeit --------------------------------------------------- +tmp = tempfile.mkdtemp(prefix="upd_pruef_") +probe = os.path.join(tmp, "probe.bin") +with open(probe, "wb") as f: + f.write(b"A" * 1000) +summe = U.sha256(probe) + +gut, _ = U.vollstaendig(probe, 1000, summe) +pruefe(gut, "richtige Groesse und Pruefsumme werden angenommen") +gut, grund = U.vollstaendig(probe, 999, summe) +pruefe(not gut and "Groesse" in grund, f"falsche Groesse wird abgelehnt ({grund})") +gut, grund = U.vollstaendig(probe, 1000, "0" * 64) +pruefe(not gut and "Pruefsumme" in grund, f"falsche Pruefsumme wird abgelehnt ({grund})") +gut, _ = U.vollstaendig(probe, 1000, "") +pruefe(gut, "ohne Pruefsumme entscheidet die Groesse allein") +gut, _ = U.vollstaendig(os.path.join(tmp, "gibtsnicht"), 1000, "") +pruefe(not gut, "eine fehlende Datei ist nicht vollstaendig") + +# ---- Was liegt im Ordner? ---------------------------------------------- +ordner = os.path.join(tmp, "install") +os.makedirs(ordner) +for name, inhalt in (("Rechnungstool-1.5.0.exe", b"ALT"), + ("Rechnungstool-1.6.0.exe", b"AKTUELL"), + ("nichts-zu-tun.txt", b"x")): + with open(os.path.join(ordner, name), "wb") as f: + f.write(inhalt) + +fassung, pfad = U.neueste_installierte(ordner, EXE) +pruefe(fassung == "1.6.0", f"die neueste vorhandene Fassung wird am Namen erkannt ({fassung})") +pruefe(os.path.basename(pfad) == "Rechnungstool-1.6.0.exe", "und der Pfad stimmt") +pruefe(len(U.installierte(ordner, EXE)) == 2, "fremde Dateien im Ordner stoeren nicht") + +# ---- Der ganze Durchlauf, ohne Netz ------------------------------------- +meldungen = [] + + +def sagen(t): + meldungen.append(str(t)) + + +def alle_da(): + return sorted(os.path.basename(p) for _f, p in U.installierte(ordner, EXE)) + + +# 1. gleiche Fassung -> nichts laden, vorhandene starten +meldungen.clear() +start = U.durchlauf(ordner, "x/y", EXE, sagen=sagen, + holen_=lambda _r: [release("v1.6.0", [(EXE, 11)])]) +pruefe(os.path.basename(start) == "Rechnungstool-1.6.0.exe", + "gleiche Fassung: die vorhandene wird gestartet") +pruefe(alle_da() == ["Rechnungstool-1.5.0.exe", "Rechnungstool-1.6.0.exe"], + "und im Ordner hat sich nichts geaendert") + +# 2. aeltere Fassung im Netz -> nichts tun +start = U.durchlauf(ordner, "x/y", EXE, sagen=sagen, + holen_=lambda _r: [release("v1.5.0", [(EXE, 11)])]) +pruefe(os.path.basename(start) == "Rechnungstool-1.6.0.exe", + "aeltere Fassung im Netz: es wird nicht aktualisiert") + +# 3. kein Netz -> vorhandene starten und sagen, warum +meldungen.clear() + + +def wirft(_r): + raise OSError("kein Netz") + + +start = U.durchlauf(ordner, "x/y", EXE, sagen=sagen, holen_=wirft) +pruefe(os.path.basename(start) == "Rechnungstool-1.6.0.exe", + "ohne Netz wird die vorhandene Fassung gestartet") +pruefe(any("Kein Update" in m for m in meldungen), "und es wird gesagt, warum") + +# 4. neuere Fassung, aber Groesse passt nicht -> VERWERFEN +meldungen.clear() +falsch = release("v1.9.0", [(EXE, 999999)]) +falsch["assets"][0]["browser_download_url"] = "file:///" + probe.replace("\\", "/") +start = U.durchlauf(ordner, "x/y", EXE, sagen=sagen, holen_=lambda _r: [falsch]) +pruefe(any("VERWORFEN" in m for m in meldungen), "falsche Groesse: das Update wird verworfen") +pruefe(os.path.basename(start) == "Rechnungstool-1.6.0.exe", + "und die vorhandene Fassung wird gestartet") +pruefe(not os.path.exists(os.path.join(ordner, "Rechnungstool-1.9.0.exe")), + "die halbe Datei erreicht den Installationsordner NICHT - der wichtigste Punkt") + +# 5. neuere Fassung, alles stimmt -> als NEUE Datei dazu +meldungen.clear() +richtig = release("v1.9.0", [(EXE, 1000)]) +richtig["assets"][0]["browser_download_url"] = "file:///" + probe.replace("\\", "/") +start = U.durchlauf(ordner, "x/y", EXE, sagen=sagen, holen_=lambda _r: [richtig]) +pruefe(os.path.basename(start) == "Rechnungstool-1.9.0.exe", + "die neue Fassung wird gestartet") +pruefe(os.path.exists(os.path.join(ordner, "Rechnungstool-1.9.0.exe")), + "sie liegt als EIGENE Datei im Ordner") +pruefe(os.path.exists(os.path.join(ordner, "Rechnungstool-1.6.0.exe")), + "die vorige bleibt liegen - sie ist die Rueckfallebene") +pruefe(not os.path.exists(os.path.join(ordner, "Rechnungstool-1.5.0.exe")), + "die vorvorige wurde aufgeraeumt (es bleiben immer zwei)") + +buch = U.buch_lesen(ordner) +pruefe(buch.get("version") == "1.9.0", + f"der Updater fuehrt Buch ueber das, was er installiert hat ({buch})") + +# 6. gleich noch einmal -> nichts laden, es ist ja jetzt aktuell +meldungen.clear() +vorher = alle_da() +start = U.durchlauf(ordner, "x/y", EXE, sagen=sagen, holen_=lambda _r: [richtig]) +pruefe(any("Aktuell" in m for m in meldungen), + f"unmittelbar danach gilt es als aktuell ({meldungen})") +pruefe(alle_da() == vorher, "und es wird nichts noch einmal geladen") + +# 7. Buch verloren, Datei aber da -> nicht noch einmal laden +# Kommt vor, wenn jemand installiert.json loescht. Der Dateiname allein traegt +# die Entscheidung: die 1.9.0 liegt da, also wird sie nur gestartet. +meldungen.clear() +os.remove(os.path.join(ordner, U.BUCH)) +start = U.durchlauf(ordner, "x/y", EXE, sagen=sagen, holen_=lambda _r: [richtig]) +pruefe(os.path.basename(start) == "Rechnungstool-1.9.0.exe", + "auch ohne Buch wird die richtige Datei gestartet") +pruefe(not any("Wird geladen" in m for m in meldungen), + f"und nichts noch einmal geladen ({meldungen})") + +shutil.rmtree(tmp, ignore_errors=True) + +if fehler: + print(f"ROT | {len(fehler)} Punkt(e) nicht erfuellt") + sys.exit(1) +print("GRUEN | Der Updater aktualisiert - und laesst in den falschen Faellen die Finger davon") diff --git a/updater.py b/updater.py new file mode 100644 index 0000000..f06b06c --- /dev/null +++ b/updater.py @@ -0,0 +1,368 @@ +# -*- coding: utf-8 -*- +"""Der Updater - er ist der Starter. + +SEIN BILD (06.09.2026) +---------------------- + "der updater schaut beim starten erst: ist auf dem git link eine neue + version mit exe? wenn ja, erst laden, ersetzen und dann starten." + "einmal pruefen auf vollstaendigkeit." + "updater startet kurz als im ram und das main programm ist aus, sonst + kannst es nicht ersetzen." + +WARUM DAS DER RICHTIGE AUFBAU IST +--------------------------------- +Windows laesst eine laufende EXE nicht ueberschreiben. Ein Programm, das sich +selbst aktualisiert, muss deshalb Umwege gehen (Kopie im Temp, warten, bis der +eigene Prozess weg ist, tauschen). All das entfaellt, wenn der Updater das ist, +was der Nutzer startet: Er laeuft VOR dem Programm - die EXE ist gar nicht in +Benutzung, wenn sie ersetzt wird. + +DIE NUMMER STEHT IM DATEINAMEN (seine Idee, 06.09.2026) +------------------------------------------------------- + "wir werden den namen mit nummer fuehren - wenn er merkt, eh warte mal, die + heisst anders, dann neu machen" + +Die installierte Datei heisst `Rechnungstool-1.6.0.exe`. Daraus folgt dreierlei: + + * Es wird nie etwas UEBERSCHRIEBEN - eine neue Fassung ist eine neue Datei. + Das Sperrproblem von Windows gibt es damit nicht einmal theoretisch. + * Die alte Fassung bleibt liegen und ist die Rueckfallebene, ohne dass + jemand eine Sicherung anlegen muesste. + * Der Updater sieht AM NAMEN, was installiert ist. Er muss keiner Angabe + glauben, die auseinanderlaufen koennte. + +DIE REGEL, AN DER SOLCHE UPDATER SCHEITERN +------------------------------------------ +Sie schreiben einen halb geladenen Download in die Installation, und das +Programm startet nie wieder. Deshalb hier: in einen Zwischenordner laden, +GROESSE UND PRUEFSUMME gegen die Angaben des Releases halten, und erst dann in +den Installationsordner legen. + +AUFBAU +------ +Dieser Teil ist eine BIBLIOTHEK ohne Fenster und ohne Netzzwang: jede +Entscheidung ist eine eigene Funktion, die sich einzeln pruefen laesst +(pruef_updater.py). Das Startprogramm steht unten unter __main__. + +Diese Datei liegt in BEIDEN Programmen gleich. Wer sie aendert, kopiert sie +hinueber. +""" + +import hashlib +import json +import os +import re +import shutil +import subprocess +import sys +import tempfile +import urllib.request + +API = "https://git.pcore.de/api/v1/repos" + +# Wie die Buchfuehrung des Updaters heisst. Sie liegt neben der EXE und wird +# von IHM gefuehrt - nicht vom Programm. So kann nichts auseinanderlaufen: +# was hier steht, hat der Updater selbst installiert. +BUCH = "installiert.json" + + +# --------------------------------------------------------------------------- +# Versionen vergleichen +# --------------------------------------------------------------------------- +def zerlegen(fassung): + """'v1.6.0' oder '1.6.0' -> (1, 6, 0). Unlesbares wird zu (0,). + + Absichtlich nachsichtig: eine Nummer, die niemand versteht, darf kein + Update ausloesen - sie zaehlt als die aelteste denkbare. + """ + if not fassung: + return (0,) + teile = re.findall(r"\d+", str(fassung)) + return tuple(int(t) for t in teile) if teile else (0,) + + +def ist_neuer(dort, hier): + """Ist `dort` neuer als `hier`? Gleichstand ist NICHT neuer.""" + return zerlegen(dort) > zerlegen(hier) + + +# --------------------------------------------------------------------------- +# Das passende Release finden +# --------------------------------------------------------------------------- +def passendes_release(releases, exe_name): + """Das neueste Release, das eine Datei `exe_name` anhaengt. + + Vorabversionen zaehlen MIT - alle Fassungen dieser Programme sind als + Vorabversion gekennzeichnet, weil sie erprobt werden. Wer hier + `/releases/latest` benutzt, bekommt nichts: Forgejo laesst Vorabversionen + dort aus. Genau das ist beim Bauen aufgefallen. + + Entwuerfe (draft) zaehlen NICHT - die sind nicht veroeffentlicht. + """ + beste = None + for r in releases or []: + if r.get("draft"): + continue + anhang = next((a for a in r.get("assets") or [] + if a.get("name") == exe_name), None) + if not anhang: + continue + if beste is None or ist_neuer(r.get("tag_name"), beste[0].get("tag_name")): + beste = (r, anhang) + return beste + + +def summe_aus_release(release, exe_name): + """Die erwartete Pruefsumme aus dem Anhang `.sha256`, oder ''. + + Das Release haengt neben der EXE eine kleine Textdatei mit der Pruefsumme + an. Fehlt sie, wird nur die Groesse geprueft - dann sagt das der Updater + aber auch, statt so zu tun, als haette er geprueft. + """ + name = exe_name + ".sha256" + for a in release.get("assets") or []: + if a.get("name") == name: + return a.get("browser_download_url") or "" + return "" + + +# --------------------------------------------------------------------------- +# Laden und pruefen +# --------------------------------------------------------------------------- +def sha256(pfad): + h = hashlib.sha256() + with open(pfad, "rb") as f: + for brocken in iter(lambda: f.read(1 << 20), b""): + h.update(brocken) + return h.hexdigest() + + +def holen(url, ziel, melden=None): + """Datei laden. Gibt die Anzahl geladener Bytes zurueck.""" + with urllib.request.urlopen(url, timeout=60) as antwort, open(ziel, "wb") as raus: + gesamt = int(antwort.headers.get("Content-Length") or 0) + geladen = 0 + while True: + brocken = antwort.read(1 << 20) + if not brocken: + break + raus.write(brocken) + geladen += len(brocken) + if melden and gesamt: + melden(geladen, gesamt) + return geladen + + +def vollstaendig(pfad, erwartete_groesse, erwartete_summe=""): + """Ist die geladene Datei vollstaendig und unverfaelscht? + + Rueckgabe: (True, "") oder (False, Grund). Ein halb geladener Download darf + die Installation NIE erreichen - lieber gar kein Update als ein kaputtes. + """ + if not os.path.exists(pfad): + return False, "Die geladene Datei ist nicht da." + ist = os.path.getsize(pfad) + if erwartete_groesse and ist != erwartete_groesse: + return False, (f"Groesse stimmt nicht: {ist} Bytes geladen, " + f"{erwartete_groesse} erwartet.") + if erwartete_summe: + hat = sha256(pfad) + if hat.lower() != erwartete_summe.strip().lower(): + return False, "Pruefsumme stimmt nicht." + return True, "" + + +# --------------------------------------------------------------------------- +# Die Nummer steht im Dateinamen +# --------------------------------------------------------------------------- +def basis_und_endung(exe_name): + """'Rechnungstool.exe' -> ('Rechnungstool', '.exe').""" + stamm, endung = os.path.splitext(exe_name) + stamm = re.sub(r"-\d+(?:\.\d+)*$", "", stamm) # eine Nummer wieder abschneiden + return stamm, endung or ".exe" + + +def dateiname(exe_name, fassung): + """'Rechnungstool.exe' + 'v1.6.0' -> 'Rechnungstool-1.6.0.exe'.""" + stamm, endung = basis_und_endung(exe_name) + return f"{stamm}-{str(fassung).lstrip('v')}{endung}" + + +def installierte(ordner, exe_name): + """Was liegt im Ordner? Liste (Fassung, Pfad), neueste zuerst. + + Gelesen wird der DATEINAME - keine Angabe, der man glauben muesste. Eine + Datei ohne Nummer (aus der Zeit vor dieser Fassung) zaehlt als aelteste. + """ + stamm, endung = basis_und_endung(exe_name) + muster = re.compile(re.escape(stamm) + r"(?:-(\d+(?:\.\d+)*))?" + + re.escape(endung) + r"$", re.I) + gefunden = [] + try: + namen = os.listdir(ordner) + except OSError: + return [] + for name in namen: + treffer = muster.match(name) + if treffer: + gefunden.append((treffer.group(1) or "0", os.path.join(ordner, name))) + gefunden.sort(key=lambda x: zerlegen(x[0]), reverse=True) + return gefunden + + +def neueste_installierte(ordner, exe_name): + """(Fassung, Pfad) der neuesten vorhandenen Datei - oder ('0', '').""" + liste = installierte(ordner, exe_name) + return liste[0] if liste else ("0", "") + + +def aufraeumen(ordner, exe_name, behalten=2): + """Alte Fassungen wegraeumen, die neuesten `behalten` bleiben liegen. + + Nicht aus Ordnungsliebe: die vorletzte Fassung IST die Rueckfallebene. + Deshalb wird nie unter zwei geraeumt. + """ + liste = installierte(ordner, exe_name) + weg = [] + for _fassung, pfad in liste[max(2, behalten):]: + try: + os.remove(pfad) + weg.append(os.path.basename(pfad)) + except OSError: + pass + return weg + + +# --------------------------------------------------------------------------- +# Buchfuehrung +# --------------------------------------------------------------------------- +def buch_lesen(ordner): + pfad = os.path.join(ordner, BUCH) + try: + with open(pfad, encoding="utf-8") as f: + return json.load(f) + except (OSError, ValueError): + return {} + + +def buch_schreiben(ordner, daten): + pfad = os.path.join(ordner, BUCH) + try: + with open(pfad, "w", encoding="utf-8") as f: + json.dump(daten, f, ensure_ascii=False, indent=2) + return True + except OSError: + return False + + +def releases_holen(repository): + url = f"{API}/{repository}/releases?limit=20" + with urllib.request.urlopen(url, timeout=30) as antwort: + return json.loads(antwort.read().decode("utf-8")) + + +def text_holen(url): + with urllib.request.urlopen(url, timeout=30) as antwort: + return antwort.read().decode("utf-8", "replace").split()[0] + + +# --------------------------------------------------------------------------- +# Der Ablauf +# --------------------------------------------------------------------------- +def durchlauf(ordner, repository, exe_name, sagen=print, holen_=None): + """Ein Durchlauf. Gibt den Pfad der Datei zurueck, die gestartet werden soll. + + Nichts wird ueberschrieben: eine neue Fassung wird als eigene Datei + `-.exe` abgelegt. Die vorige bleibt liegen - sie ist die + Rueckfallebene, ohne dass jemand eine Sicherung anlegen muesste. + + `holen_` laesst sich fuer den Pruefstand ersetzen, damit er ohne Netz + auskommt. + """ + holen_ = holen_ or releases_holen + hier, hier_pfad = neueste_installierte(ordner, exe_name) + buch = buch_lesen(ordner) + # Buch und Dateiname zusammen: wer eine Datei umbenennt, verursacht + # hoechstens einen ueberfluessigen Download, keinen Schaden. + if ist_neuer(buch.get("version") or "0", hier): + hier = buch.get("version") + + try: + releases = holen_(repository) + except Exception as e: # noqa: BLE001 - ohne Netz wird einfach gestartet + sagen(f"Kein Update moeglich ({e}). Es wird die vorhandene Fassung gestartet.") + return hier_pfad + + treffer = passendes_release(releases, exe_name) + if not treffer: + sagen("Im Repository liegt keine passende Datei. Vorhandene Fassung wird gestartet.") + return hier_pfad + release, anhang = treffer + dort = release.get("tag_name") or "" + + if not ist_neuer(dort, hier): + sagen(f"Aktuell ({hier}). Kein Update noetig.") + return hier_pfad + + ziel = os.path.join(ordner, dateiname(exe_name, dort)) + if os.path.exists(ziel): + sagen(f"{os.path.basename(ziel)} liegt schon da. Es wird gestartet.") + return ziel + + sagen(f"Neue Fassung {dort} gefunden (vorhanden: {hier}). Wird geladen ...") + zwischen = tempfile.mkdtemp(prefix="update_") + geladen = os.path.join(zwischen, exe_name) + try: + holen(anhang["browser_download_url"], geladen) + + erwartete_summe = "" + url_summe = summe_aus_release(release, exe_name) + if url_summe: + try: + erwartete_summe = text_holen(url_summe) + except Exception: # noqa: BLE001 + sagen("Pruefsumme nicht ladbar - es wird nur die Groesse geprueft.") + else: + sagen("Dieses Release hat keine Pruefsumme - es wird nur die Groesse geprueft.") + + gut, grund = vollstaendig(geladen, anhang.get("size") or 0, erwartete_summe) + if not gut: + sagen(f"Das Update wurde VERWORFEN: {grund}") + sagen("Es wird die vorhandene Fassung gestartet.") + return hier_pfad + + # Erst jetzt in den Installationsordner - vorher lag nichts Halbes dort. + shutil.move(geladen, ziel) + buch_schreiben(ordner, {"version": dort.lstrip("v"), + "datei": os.path.basename(ziel), + "sha256": erwartete_summe, + "vorher": os.path.basename(hier_pfad) if hier_pfad else ""}) + weg = aufraeumen(ordner, exe_name) + if weg: + sagen("Alte Fassungen entfernt: " + ", ".join(weg)) + sagen(f"Aktualisiert auf {dort}.") + return ziel + finally: + shutil.rmtree(zwischen, ignore_errors=True) + + +def starten(pfad): + """Das Programm starten und den Updater beenden.""" + subprocess.Popen([pfad], close_fds=True) + + +if __name__ == "__main__": + import version + + ordner = os.path.dirname(os.path.abspath( + sys.executable if getattr(sys, "frozen", False) else __file__)) + + print(f"Updater - {version.REPOSITORY}") + zu_starten = durchlauf(ordner, version.REPOSITORY, version.EXE_NAME) + + if zu_starten and os.path.exists(zu_starten): + print("Starte", os.path.basename(zu_starten)) + starten(zu_starten) + else: + print(f"FEHLER: keine startbare Fassung von {version.EXE_NAME} in {ordner}") + input("Enter zum Schliessen ...") diff --git a/version.py b/version.py new file mode 100644 index 0000000..4da2e80 --- /dev/null +++ b/version.py @@ -0,0 +1,23 @@ +# -*- coding: utf-8 -*- +"""Die Fassung dieses Programms - an EINER Stelle. + +Wozu +---- +Ohne eine Nummer im Programm kann kein Updater vergleichen, ob das, was im +Repository liegt, neuer ist als das, was hier laeuft. Sie steht deshalb hier +und NUR hier; alles andere (Info-Dialog, Updater, Release) liest sie von hier. + +Regel +----- +Die Nummer wird beim Ausliefern erhoeht und entspricht dem Release-Tag ohne das +"v": Tag `v1.6.0` -> VERSION "1.6.0". Wer das eine ohne das andere aendert, +baut dem Updater eine Falle. +""" + +VERSION = "1.6.0" + +# Zu welchem Repository dieses Programm gehoert - der Updater sucht dort. +REPOSITORY = "TheMockTv/rechnungstool" + +# Wie die ausgelieferte Datei im Release heisst. +EXE_NAME = "Rechnungstool.exe"