updater.py und version.py nachgezogen (gleiche Dateien wie im Rechnungstool)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V4t48uxDok5rJXhC9bX1Ax
This commit is contained in:
parent
40f53c85a0
commit
b9094e7ebb
2 changed files with 391 additions and 0 deletions
368
updater.py
Normal file
368
updater.py
Normal file
|
|
@ -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 `<exe>.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
|
||||
`<Name>-<Nummer>.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 ...")
|
||||
23
version.py
Normal file
23
version.py
Normal file
|
|
@ -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.7.0` -> VERSION "1.7.0". Wer das eine ohne das andere aendert,
|
||||
baut dem Updater eine Falle.
|
||||
"""
|
||||
|
||||
VERSION = "1.7.0"
|
||||
|
||||
# Zu welchem Repository dieses Programm gehoert - der Updater sucht dort.
|
||||
REPOSITORY = "TheMockTv/Steuerrechnungstool"
|
||||
|
||||
# Wie die ausgelieferte Datei im Release heisst.
|
||||
EXE_NAME = "Steuerrechnungstool.exe"
|
||||
Loading…
Add table
Add a link
Reference in a new issue