Updater: Prüfung beim Start und unter Hilfe, Setup herunterladen, DB sichern, still installieren, Neustart

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kerim AtilganandClaude Opus 5.5 committed 2026-11-03 15:18:22 +01:00
1 parent 968c68ac1a
commit c84b44f892
6 files changed
+260 -2

No files matched your search

+5
View File
@@ -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-<alte Version>.db` im Einstellungsordner),
installiert still und startet neu. Die einzelne .exe und Linux öffnen stattdessen die Download-Seite.
**Linux:** `Provisionsmanager95-linux-x86_64-<Version>.tar.gz` entpacken und `Provisionsmanager95` starten
(ab Debian 12 / Ubuntu 24.04). Daten liegen unter `~/.config/Provisionsmanager95`.
+8
View File
@@ -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;
+95 -1
View File
@@ -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
+5 -1
View File
@@ -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()
+82
View File
@@ -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
+65
View File
@@ -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)