From 98bce1b0da675715b2e5482c0ad61ba21135f62f Mon Sep 17 00:00:00 2001
From: Server-Bibliothek <server-bibliothek@lokal>
Date: Di, 04 Aug 2026 07:00:04 +0200
Subject: [PATCH] Add a desktop client that knows what is installed locally
---
desktop/backend/api.py | 180 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
1 files changed, 180 insertions(+), 0 deletions(-)
diff --git a/desktop/backend/api.py b/desktop/backend/api.py
new file mode 100644
index 0000000..7d7ea53
--- /dev/null
+++ b/desktop/backend/api.py
@@ -0,0 +1,180 @@
+"""Bruecke zwischen Oberflaeche (pywebview) und Backend.
+
+Jede oeffentliche Methode ist aus dem Frontend als
+`window.pywebview.api.<methode>(...)` erreichbar und liefert JSON-taugliche Werte.
+Fehler werden nicht geworfen, sondern als `{ok: false, meldung: ...}` gemeldet —
+ein Ausnahmefehler ueber die Bruecke kaeme in der Oberflaeche nur als
+unverstaendlicher Text an.
+"""
+
+from __future__ import annotations
+
+import subprocess
+import tempfile
+from dataclasses import asdict
+from pathlib import Path
+from typing import Any
+
+from . import bibliothek, konfiguration
+from .werkzeuge import Werkzeugordner, Zustand
+
+# Installationsprogramme kommen nicht zwischen die eigenstaendigen Werkzeuge —
+# sie werden einmal ausgefuehrt und sind danach entbehrlich.
+UNTERORDNER_INSTALLER = "Installationsprogramme"
+
+# Vorerst melden nur unsere eigenen Werkzeuge Updates. Fremdprogramme kuemmern
+# sich teils selbst darum, und wo nicht, fehlt uns ein verlaesslicher Weg, ihre
+# installierte Fassung zu erkennen. Bewusst eng gehalten, leicht zu weiten.
+EINSTUFUNG_MIT_UPDATEPRUEFUNG = "intern"
+
+
+class Api:
+ def __init__(self) -> None:
+ self._einstellungen = konfiguration.laden()
+ self._ordner = Werkzeugordner(self._einstellungen.werkzeugOrdner)
+ self._ordner.altlasten_aufraeumen()
+
+ # --- Einstellungen ---
+
+ def einstellungen(self) -> dict[str, Any]:
+ return {
+ "einstellungen": asdict(self._einstellungen),
+ "vorgaben": {
+ "serverAdresse": konfiguration.STANDARD_SERVER,
+ "werkzeugOrdner": konfiguration.STANDARD_WERKZEUG_ORDNER,
+ },
+ }
+
+ def einstellungen_speichern(self, serverAdresse: str, werkzeugOrdner: str) -> dict[str, Any]:
+ self._einstellungen = konfiguration.speichern(
+ konfiguration.Einstellungen(serverAdresse=serverAdresse, werkzeugOrdner=werkzeugOrdner)
+ )
+ self._ordner = Werkzeugordner(self._einstellungen.werkzeugOrdner)
+ return {"ok": True, **self.einstellungen()}
+
+ # --- Katalog ---
+
+ def katalog(self) -> dict[str, Any]:
+ """Katalog der Bibliothek, angereichert um den Befund auf diesem Rechner."""
+ try:
+ eintraege = bibliothek.katalog(self._einstellungen.serverAdresse)
+ except bibliothek.BibliothekNichtErreichbar as fehler:
+ return {"ok": False, "meldung": str(fehler), "eintraege": []}
+
+ angereichert = []
+ for eintrag in eintraege:
+ eigenstaendig = eintrag.get("programmart") == "eigenstaendig"
+ befund = (
+ self._ordner.befund(int(eintrag["id"]), str(eintrag.get("version") or ""))
+ if eigenstaendig
+ else None
+ )
+ angereichert.append(
+ {
+ **eintrag,
+ "lokal": asdict(befund) if befund else None,
+ "ueberwacht": eigenstaendig
+ and eintrag.get("einstufung") == EINSTUFUNG_MIT_UPDATEPRUEFUNG,
+ }
+ )
+ return {"ok": True, "eintraege": angereichert, "ordner": str(self._ordner.ordner)}
+
+ def veraltete(self) -> list[dict[str, str]]:
+ """Kurzfassung fuer die Meldung beim Start: was ist ueberwacht und nicht aktuell."""
+ stand = self.katalog()
+ if not stand["ok"]:
+ return []
+ return [
+ {"name": eintrag["name"], "zustand": eintrag["lokal"]["zustand"]}
+ for eintrag in stand["eintraege"]
+ if eintrag["ueberwacht"]
+ and eintrag["lokal"]
+ and eintrag["lokal"]["zustand"] in (Zustand.VERALTET.value, Zustand.FREMD.value)
+ ]
+
+ # --- Holen ---
+
+ def herunterladen(self, software_id: int, dateiname: str, version: str, programmart: str) -> dict[str, Any]:
+ """Holt eine Datei und legt sie am passenden Ort ab.
+
+ Eigenstaendige Programme kommen in den Werkzeug-Ordner und werden im Stand
+ vermerkt; Installationsprogramme daneben in einen eigenen Unterordner, ohne
+ Vermerk — was installiert ist, sagt der Ordner ohnehin nicht.
+ """
+ if not dateiname:
+ return {"ok": False, "meldung": "Für diesen Eintrag liegt keine Datei bereit."}
+
+ eigenstaendig = programmart == "eigenstaendig"
+ with tempfile.TemporaryDirectory(prefix="bibliothek-") as zwischenlager:
+ zwischenziel = Path(zwischenlager) / dateiname
+ try:
+ bibliothek.datei_holen(self._einstellungen.serverAdresse, int(software_id), zwischenziel)
+ except bibliothek.BibliothekNichtErreichbar as fehler:
+ return {"ok": False, "meldung": str(fehler)}
+
+ try:
+ if eigenstaendig:
+ abgelegt = self._ordner.ablegen(int(software_id), zwischenziel, dateiname, version)
+ else:
+ ziel = self._ordner.ordner / UNTERORDNER_INSTALLER / dateiname
+ ziel.parent.mkdir(parents=True, exist_ok=True)
+ ziel.unlink(missing_ok=True)
+ zwischenziel.replace(ziel)
+ abgelegt = ziel
+ except OSError as fehler:
+ return {"ok": False, "meldung": f"Ablegen fehlgeschlagen: {fehler}"}
+
+ return {
+ "ok": True,
+ "pfad": str(abgelegt),
+ "meldung": (
+ f"Abgelegt unter {abgelegt}. Lief das Programm gerade, wirkt die neue "
+ "Fassung nach dem nächsten Start."
+ if eigenstaendig
+ else f"Abgelegt unter {abgelegt}. Nach der Installation kann die Datei gelöscht werden."
+ ),
+ }
+
+ # --- Windows-Handreichungen ---
+
+ def ordner_oeffnen(self, unterordner: str = "") -> dict[str, Any]:
+ ziel = self._ordner.ordner / unterordner if unterordner else self._ordner.ordner
+ ziel.mkdir(parents=True, exist_ok=True)
+ subprocess.Popen(["explorer", str(ziel)]) # noqa: S603,S607 - fester Befehl
+ return {"ok": True}
+
+ def verknuepfung_anlegen(self, pfad: str, name: str) -> dict[str, Any]:
+ """Legt eine Verknuepfung auf dem Desktop an — das kann kein Browser."""
+ ziel = Path(pfad)
+ if not ziel.exists():
+ return {"ok": False, "meldung": "Die Datei liegt nicht (mehr) an diesem Ort."}
+ skript = (
+ "$verknuepfung = (New-Object -ComObject WScript.Shell)."
+ "CreateShortcut([Environment]::GetFolderPath('Desktop') + '\\' + $env:VERKNUEPFUNG_NAME + '.lnk');"
+ "$verknuepfung.TargetPath = $env:VERKNUEPFUNG_ZIEL;"
+ "$verknuepfung.WorkingDirectory = Split-Path $env:VERKNUEPFUNG_ZIEL;"
+ "$verknuepfung.Save()"
+ )
+ try:
+ subprocess.run( # noqa: S603
+ ["powershell", "-NoProfile", "-NonInteractive", "-Command", skript],
+ check=True,
+ capture_output=True,
+ timeout=30,
+ env={**_umgebung(), "VERKNUEPFUNG_ZIEL": str(ziel), "VERKNUEPFUNG_NAME": _dateiname_saeubern(name)},
+ )
+ except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as fehler:
+ return {"ok": False, "meldung": f"Verknüpfung fehlgeschlagen: {fehler}"}
+ return {"ok": True, "meldung": f"Verknüpfung „{name}“ liegt auf dem Desktop."}
+
+
+def _umgebung() -> dict[str, str]:
+ import os
+
+ return dict(os.environ)
+
+
+def _dateiname_saeubern(name: str) -> str:
+ verboten = '\\/:*?"<>|'
+ gesaeubert = "".join(zeichen for zeichen in name if zeichen not in verboten).strip()
+ return gesaeubert or "Programm"
--
Gitblit v1.9.3