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