Die Runde vom 07.09.2026 hat neben den zwei echten Fehlern eine Reihe Wiederholungen und stumme Stellen gemeldet. Abgearbeitet in Etappen, jede fuer sich pruefbar - nach jeder liefen beide Pruefketten gruen. * Menueleiste: der Wechsel von einem Titel zum anderen brauchte ZWEI Klicks. Der grab_set der offenen Liste schluckt den ersten, die <Button-1>-Bindung des Titels feuert gar nicht. Das native tk.Menu, das diese Leiste ersetzt hat, wechselte schon beim blossen Ueberfahren - zwei Klicks waren also eine Verschlechterung gegenueber dem, was vorher da war. Die Klappliste wird ausserdem jetzt wie jede andere Position ueber theme.auf_bildschirm() begrenzt; am unteren Fensterrand lief sie vorher hinaus. Neu: pruef_menueleiste.py misst beides ueber echte Klickereignisse. * %LOCALAPPDATA%/ravokk wurde an drei Stellen einzeln ausgerechnet. Jetzt fragen alle gemeinsam.standard_ordner(). Laufen die je auseinander, fuehren die beiden Programme zwei Nummernbuecher - und das faellt erst auf, wenn eine Rechnungsnummer zum zweiten Mal vergeben ist (§ 14 Abs. 4 Nr. 4 UStG). * pruefe() stand 35-mal im Quelltext, in sechs Fassungen -> pruefhelfer.py. Die zwei Ausreisser sind mitgezogen: pruef_bilder.py schrieb ein eigenes Format, pruef_storno_verrechnet.py zaehlte in einer Zahl und konnte am Ende nicht sagen, WAS fehlschlug. * dlg_darstellung() und die drei Handgriffe des Umschaltens standen in beiden app.py fast wortgleich -> ThemeMixin in theme.py. Beide trugen inzwischen denselben langen Kommentar zu demselben Fehler; das war der Beweis, dass es eine Sache ist. Programmspezifisch bleiben der Hinweistext im Dialog (R31) und die Frage, ob die Einstellungen ausdruecklich gespeichert werden muessen. * pruef_gemeinsam_automatisch.py sagte im Text "genau ein Buch" und prueft ">= 1" mit einem any() - eine Pruefung, die nicht rot werden kann. Jetzt == 1, und der Dateiname wird mitgeprueft. Dazu neu: die neun Dateien, die im Kopf zusagen, sie laegen in beiden Programmen gleich, werden byteweise verglichen. Bei hinweis.py stimmte die Zusage seit dem 06.09. nicht mehr - gleicher Inhalt, CRLF gegen LF. * .gitattributes, damit die Zeilenenden nicht von der Maschine abhaengen. Ohne das meldet genau diese Pruefung nach einem frischen Checkout einen Unterschied, den es im Repository gar nicht gibt. * Sechs stumme "except OSError" sagen jetzt, warum sie schweigen duerfen, und einer meldet statt zu schweigen: schlaegt die Uebernahme einer alten config.json fehl, faengt das Programm ohne Firmendaten, Katalog und Zaehler neu an - und der Erfolgsfall schrieb eine Zeile, der Fehlerfall nicht. Die 24 stummen tk.TclError bleiben: dort wird ein Widget angefasst, das gerade zerstoert wurde, und eine Meldung waere Rauschen. * Tote Parameter und Variablen aus dem Umbau des Vortags: symbol_setzen(dunkel=), kachel(grund=, radius=), mit_trennlinie, sechs x/y-Berechnungen, die von theme.mittig() sofort ueberschrieben wurden, vier lokale "import ctypes". 39 Pruefstaende, 0 rot. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V4t48uxDok5rJXhC9bX1Ax
404 lines
15 KiB
Python
404 lines
15 KiB
Python
# -*- 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 beschreibbar(ordner):
|
|
"""Darf in diesen Ordner geschrieben werden? Wird ausprobiert, nicht geraten.
|
|
|
|
Ein nicht beschreibbarer Ordner sieht von aussen genauso aus wie ein
|
|
beschreibbarer - erst der Versuch zeigt es.
|
|
"""
|
|
probe = os.path.join(ordner, ".schreibprobe")
|
|
try:
|
|
with open(probe, "w", encoding="ascii") as f:
|
|
f.write("x")
|
|
os.remove(probe)
|
|
return True
|
|
except OSError:
|
|
return False
|
|
|
|
|
|
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:
|
|
# Laeuft die alte Fassung noch oder haelt ein Virenscanner die
|
|
# Datei, sperrt Windows sie. Das ist kein Grund zum Abbruch: die
|
|
# Datei stoert nur, sie schadet nicht, und beim naechsten Mal
|
|
# klappt es. Was WEG ist, steht in der Rueckgabe - was nicht weg
|
|
# ist, taucht dort nicht auf.
|
|
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
|
|
|
|
# Kann in den Installationsordner ueberhaupt geschrieben werden? Liegt die
|
|
# Installation in einem geschuetzten Ordner (etwa "Programme"), schlaegt das
|
|
# Ablegen fehl. Dann wird NICHT geladen - und vor allem: das Programm
|
|
# startet trotzdem. Ein fehlgeschlagenes Update darf nie dazu fuehren, dass
|
|
# gar nichts mehr geht.
|
|
if not beschreibbar(ordner):
|
|
sagen(f"In {ordner} darf nicht geschrieben werden - kein Update moeglich.")
|
|
sagen("Es wird die vorhandene Fassung gestartet.")
|
|
return hier_pfad
|
|
|
|
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.
|
|
try:
|
|
shutil.move(geladen, ziel)
|
|
except OSError as e: # noqa: BLE001
|
|
sagen(f"Die neue Fassung konnte nicht abgelegt werden ({e}).")
|
|
sagen("Es wird die vorhandene Fassung gestartet.")
|
|
return hier_pfad
|
|
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 ...")
|