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/requirements.txt                        |    6 
 .gitignore                                      |    4 
 package-lock.json                               |   29 +
 desktop/backend/konfiguration.py                |   74 ++
 desktop/frontend/src/bruecke.ts                 |   69 ++
 desktop/generate_icon.py                        |   40 +
 desktop/frontend/package.json                   |   29 +
 desktop/frontend/src/index.ts                   |   15 
 desktop/backend/werkzeuge.py                    |  153 +++++
 desktop/build_exe.ps1                           |   76 ++
 desktop/assets/icon.ico                         |    0 
 desktop/backend/__init__.py                     |    6 
 desktop/frontend/webpack.config.js              |   56 ++
 desktop/frontend/src/index.html                 |   11 
 desktop/frontend/src/components/client-zeile.ts |  135 +++++
 desktop/backend/api.py                          |  180 ++++++
 desktop/frontend/src/global.css                 |  200 +++++++
 desktop/run_app.py                              |   10 
 software-bibliothek.json                        |    5 
 desktop/frontend/src/components/client-app.ts   |  284 ++++++++++
 desktop/frontend/src/raw.d.ts                   |    5 
 package.json                                    |    4 
 desktop/backend/__main__.py                     |   52 +
 desktop/frontend/src/theme.ts                   |   58 ++
 desktop/frontend/tsconfig.json                  |   15 
 desktop/backend/bibliothek.py                   |   85 +++
 26 files changed, 1.599 insertions(+), 2 deletions(-)

diff --git a/.gitignore b/.gitignore
index 9869cab..bdc0001 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,11 +1,15 @@
 # Abhaengigkeiten
 node_modules
+.venv
+__pycache__/
+*.pyc
 
 # Build-Ausgaben
 dist
 build
 build-exe
 exe/
+*.spec
 
 # Logs
 *.log
diff --git a/desktop/assets/icon.ico b/desktop/assets/icon.ico
new file mode 100644
index 0000000..e4a0d5a
--- /dev/null
+++ b/desktop/assets/icon.ico
Binary files differ
diff --git a/desktop/backend/__init__.py b/desktop/backend/__init__.py
new file mode 100644
index 0000000..730eace
--- /dev/null
+++ b/desktop/backend/__init__.py
@@ -0,0 +1,6 @@
+"""Backend des Software-Bibliothek-Clients.
+
+Der Client zeigt den Katalog der Bibliothek, legt heruntergeladene Programme an
+einen festen Ort und meldet, wenn ein Werkzeug dort veraltet ist. Anders als die
+Weboberflaeche darf er ins Dateisystem sehen — das ist sein einziger Daseinszweck.
+"""
diff --git a/desktop/backend/__main__.py b/desktop/backend/__main__.py
new file mode 100644
index 0000000..ecf3849
--- /dev/null
+++ b/desktop/backend/__main__.py
@@ -0,0 +1,52 @@
+"""Startpunkt: oeffnet ein natives Fenster und verbindet es mit der Api.
+
+Start:  py -m backend        (aus dem Ordner `desktop`, im aktivierten venv)
+
+Das Fenster nutzt das WebView2 von Windows — dieselbe Engine, die der Browser
+ohnehin mitbringt. Es wird also kein zweites Chromium ausgeliefert.
+"""
+
+from __future__ import annotations
+
+import sys
+from pathlib import Path
+
+import webview
+
+from .api import Api
+
+# Im Betrieb aus dem Quelltext liegt die gebaute Oberflaeche unter
+# <projekt>/frontend/dist; in der PyInstaller-exe im Bundle-Verzeichnis.
+if getattr(sys, "frozen", False):
+    _BASIS = Path(sys._MEIPASS)  # type: ignore[attr-defined]
+else:
+    _BASIS = Path(__file__).resolve().parent.parent
+
+FRONTEND_INDEX = _BASIS / "frontend" / "dist" / "index.html"
+
+_PLATZHALTER_HTML = """
+<!doctype html><html lang="de"><body style="font-family:sans-serif;padding:2rem">
+<h1>Oberfläche noch nicht gebaut</h1>
+<p>Im Ordner <code>desktop/frontend</code> einmal <code>npm install</code> und
+<code>npm run build</code> ausführen, dann erneut starten.</p>
+</body></html>
+"""
+
+
+def main() -> None:
+    api = Api()
+    gemeinsam = {
+        "js_api": api,
+        "width": 1100,
+        "height": 780,
+        "min_size": (820, 600),
+    }
+    if FRONTEND_INDEX.exists():
+        webview.create_window("Software-Bibliothek", url=str(FRONTEND_INDEX), **gemeinsam)
+    else:
+        webview.create_window("Software-Bibliothek", html=_PLATZHALTER_HTML, **gemeinsam)
+    webview.start(http_server=True)
+
+
+if __name__ == "__main__":
+    main()
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"
diff --git a/desktop/backend/bibliothek.py b/desktop/backend/bibliothek.py
new file mode 100644
index 0000000..6274a70
--- /dev/null
+++ b/desktop/backend/bibliothek.py
@@ -0,0 +1,85 @@
+"""Zugriff auf den Server der Software-Bibliothek.
+
+Nur die beiden Routen, die aus dem Netzwerk erreichbar sind: Katalog lesen und
+Datei holen. Die Verwaltung bleibt der Weboberflaeche am Server vorbehalten.
+
+Bewusst nur mit der Standardbibliothek — jede Abhaengigkeit mehr muss beim Bau
+der exe mitgeschleppt werden, und fuer zwei GET-Aufrufe lohnt das nicht.
+"""
+
+from __future__ import annotations
+
+import json
+import urllib.error
+import urllib.request
+from pathlib import Path
+from typing import Any, Callable
+
+ZEITGRENZE_KATALOG_S = 15
+ZEITGRENZE_DOWNLOAD_S = 600
+
+
+class BibliothekNichtErreichbar(Exception):
+    """Der Server antwortet nicht oder liefert etwas Unerwartetes."""
+
+
+def _lesbarer_fehler(adresse: str, fehler: Exception) -> BibliothekNichtErreichbar:
+    if isinstance(fehler, urllib.error.HTTPError):
+        return BibliothekNichtErreichbar(f"{adresse} antwortet mit HTTP {fehler.code}.")
+    if isinstance(fehler, urllib.error.URLError):
+        return BibliothekNichtErreichbar(f"{adresse} ist nicht erreichbar: {fehler.reason}")
+    return BibliothekNichtErreichbar(f"{adresse}: {fehler}")
+
+
+def katalog(server_adresse: str) -> list[dict[str, Any]]:
+    """Holt alle Katalogeintraege."""
+    adresse = f"{server_adresse.rstrip('/')}/api/software"
+    try:
+        with urllib.request.urlopen(adresse, timeout=ZEITGRENZE_KATALOG_S) as antwort:
+            gelesen = json.loads(antwort.read().decode("utf-8"))
+    except Exception as fehler:  # noqa: BLE001 - jede Ursache wird zur selben Meldung
+        raise _lesbarer_fehler(server_adresse, fehler) from fehler
+    if not isinstance(gelesen, list):
+        raise BibliothekNichtErreichbar(f"{server_adresse} liefert keinen Katalog.")
+    return gelesen
+
+
+def datei_holen(
+    server_adresse: str,
+    software_id: int,
+    ziel: Path,
+    fortschritt: Callable[[int, int], None] | None = None,
+) -> Path:
+    """Laedt die bereitgestellte Datei nach `ziel`.
+
+    Geschrieben wird zuerst nach `<ziel>.teil` und erst nach vollstaendigem
+    Empfang umbenannt. Ein Abbruch mitten im Download darf keine halbe Datei
+    hinterlassen, die spaeter fuer ein fertiges Programm gehalten wird.
+    """
+    adresse = f"{server_adresse.rstrip('/')}/api/software/{software_id}/download"
+    unfertig = ziel.with_name(ziel.name + ".teil")
+    ziel.parent.mkdir(parents=True, exist_ok=True)
+    try:
+        with urllib.request.urlopen(adresse, timeout=ZEITGRENZE_DOWNLOAD_S) as antwort:
+            gesamt = int(antwort.headers.get("Content-Length") or 0)
+            geladen = 0
+            with unfertig.open("wb") as datei:
+                while True:
+                    brocken = antwort.read(256 * 1024)
+                    if not brocken:
+                        break
+                    datei.write(brocken)
+                    geladen += len(brocken)
+                    if fortschritt:
+                        fortschritt(geladen, gesamt)
+    except Exception as fehler:  # noqa: BLE001
+        unfertig.unlink(missing_ok=True)
+        raise _lesbarer_fehler(server_adresse, fehler) from fehler
+
+    if unfertig.stat().st_size == 0:
+        unfertig.unlink(missing_ok=True)
+        raise BibliothekNichtErreichbar("Der Server hat eine leere Datei geliefert.")
+
+    ziel.unlink(missing_ok=True)
+    unfertig.replace(ziel)
+    return ziel
diff --git a/desktop/backend/konfiguration.py b/desktop/backend/konfiguration.py
new file mode 100644
index 0000000..252837c
--- /dev/null
+++ b/desktop/backend/konfiguration.py
@@ -0,0 +1,74 @@
+"""Einstellungen des Clients: Wo steht die Bibliothek, wohin kommen die Werkzeuge.
+
+Bewusst einstellbar statt eingebaut: Die Bibliothek zieht spaeter auf einen anderen
+Rechner um, und dann soll nicht jeder Arbeitsplatz eine neue exe brauchen.
+"""
+
+from __future__ import annotations
+
+import json
+import os
+from dataclasses import asdict, dataclass
+from pathlib import Path
+
+# Vorgabe, solange nichts eingestellt wurde. Der Rechnername ist dem einer IP
+# vorzuziehen — er ueberlebt einen Wechsel der Adresse.
+STANDARD_SERVER = "http://dhs-main-dt:3000"
+
+# Fester Ort fuer eigenstaendige Programme. Unter C:\ vererbt Windows das
+# Aenderungsrecht an angemeldete Benutzer, ein Update braucht also keine
+# Administratorrechte — anders als unter C:\Programme.
+STANDARD_WERKZEUG_ORDNER = r"C:\D+H\Software"
+
+
+@dataclass
+class Einstellungen:
+    serverAdresse: str = STANDARD_SERVER
+    werkzeugOrdner: str = STANDARD_WERKZEUG_ORDNER
+
+
+def konfigurations_verzeichnis() -> Path:
+    """Je Benutzer, nicht je Installation — die exe darf ausgetauscht werden."""
+    basis = os.environ.get("LOCALAPPDATA") or str(Path.home())
+    return Path(basis) / "Software-Bibliothek-Client"
+
+
+def _konfigurationsdatei() -> Path:
+    return konfigurations_verzeichnis() / "konfiguration.json"
+
+
+def laden() -> Einstellungen:
+    """Liest die Einstellungen; bei jedem Problem gelten die Vorgaben.
+
+    Eine kaputte Datei darf den Start nicht verhindern — der Nutzer soll die
+    Adresse in der Oberflaeche korrigieren koennen, statt vor einem toten
+    Fenster zu sitzen.
+    """
+    datei = _konfigurationsdatei()
+    if not datei.exists():
+        return Einstellungen()
+    try:
+        gelesen = json.loads(datei.read_text(encoding="utf-8"))
+    except (OSError, json.JSONDecodeError):
+        return Einstellungen()
+    if not isinstance(gelesen, dict):
+        return Einstellungen()
+    return Einstellungen(
+        serverAdresse=str(gelesen.get("serverAdresse") or STANDARD_SERVER).strip(),
+        werkzeugOrdner=str(gelesen.get("werkzeugOrdner") or STANDARD_WERKZEUG_ORDNER).strip(),
+    )
+
+
+def speichern(einstellungen: Einstellungen) -> Einstellungen:
+    """Schreibt die Einstellungen und liefert den bereinigten Stand zurueck."""
+    bereinigt = Einstellungen(
+        serverAdresse=(einstellungen.serverAdresse or "").strip().rstrip("/") or STANDARD_SERVER,
+        werkzeugOrdner=(einstellungen.werkzeugOrdner or "").strip() or STANDARD_WERKZEUG_ORDNER,
+    )
+    verzeichnis = konfigurations_verzeichnis()
+    verzeichnis.mkdir(parents=True, exist_ok=True)
+    _konfigurationsdatei().write_text(
+        json.dumps(asdict(bereinigt), indent=2, ensure_ascii=False),
+        encoding="utf-8",
+    )
+    return bereinigt
diff --git a/desktop/backend/werkzeuge.py b/desktop/backend/werkzeuge.py
new file mode 100644
index 0000000..c51b62b
--- /dev/null
+++ b/desktop/backend/werkzeuge.py
@@ -0,0 +1,153 @@
+"""Der Werkzeug-Ordner auf diesem Rechner: lesen, ablegen, vergleichen.
+
+Das ist der Teil, den eine Weboberflaeche nicht kann, und der einzige Grund,
+warum es diesen Client gibt.
+
+Der Kniff gegen jedes Raten: Wir legen die Dateien selbst ab und merken uns dabei
+ihre Pruefsumme. Beim naechsten Start wird nachgerechnet — stimmt sie, wissen wir
+sicher, welche Fassung dort liegt. Stimmt sie nicht, hat jemand von Hand
+eingegriffen, und genau das wird gemeldet statt geschaetzt.
+"""
+
+from __future__ import annotations
+
+import hashlib
+import json
+from dataclasses import dataclass
+from enum import Enum
+from pathlib import Path
+
+# Liegt im Werkzeug-Ordner selbst. Damit zieht der Stand mit, wenn jemand den
+# Ordner verschiebt oder auf einen anderen Rechner kopiert.
+STANDSDATEI = ".bibliothek-stand.json"
+
+# Endung fuer die verdraengte Vorgaengerdatei. Ein laufendes Programm laesst sich
+# unter Windows umbenennen, aber nicht ueberschreiben — daher dieser Umweg.
+ALTLAST_ENDUNG = ".alt"
+
+
+class Zustand(str, Enum):
+    FEHLT = "fehlt"
+    """Nichts abgelegt — das Werkzeug ist auf diesem Rechner nicht vorhanden."""
+
+    AKTUELL = "aktuell"
+    """Liegt da und entspricht genau dem, was die Bibliothek anbietet."""
+
+    VERALTET = "veraltet"
+    """Liegt da, aber die Bibliothek bietet inzwischen eine andere Fassung an."""
+
+    FREMD = "fremd"
+    """Liegt da, stammt aber nicht von uns — von Hand ersetzt oder veraendert."""
+
+
+@dataclass
+class Befund:
+    zustand: Zustand
+    dateiname: str = ""
+    abgelegteVersion: str = ""
+    pfad: str = ""
+
+
+def pruefsumme(datei: Path) -> str:
+    """SHA-256 im Strom — die Dateien sind teils dreistellig viele Megabyte gross."""
+    rechner = hashlib.sha256()
+    with datei.open("rb") as offen:
+        for brocken in iter(lambda: offen.read(1024 * 1024), b""):
+            rechner.update(brocken)
+    return rechner.hexdigest()
+
+
+class Werkzeugordner:
+    def __init__(self, ordner: str | Path) -> None:
+        self.ordner = Path(ordner)
+
+    # --- Stand ---
+
+    def _standsdatei(self) -> Path:
+        return self.ordner / STANDSDATEI
+
+    def _stand_lesen(self) -> dict[str, dict[str, str]]:
+        datei = self._standsdatei()
+        if not datei.exists():
+            return {}
+        try:
+            gelesen = json.loads(datei.read_text(encoding="utf-8"))
+        except (OSError, json.JSONDecodeError):
+            return {}
+        return gelesen if isinstance(gelesen, dict) else {}
+
+    def _stand_schreiben(self, stand: dict[str, dict[str, str]]) -> None:
+        self.ordner.mkdir(parents=True, exist_ok=True)
+        self._standsdatei().write_text(
+            json.dumps(stand, indent=2, ensure_ascii=False), encoding="utf-8"
+        )
+
+    # --- Abfrage ---
+
+    def befund(self, software_id: int, angeboteneVersion: str) -> Befund:
+        """Was liegt hier, und passt es zu dem, was die Bibliothek anbietet?"""
+        vermerk = self._stand_lesen().get(str(software_id))
+        if not vermerk:
+            return Befund(Zustand.FEHLT)
+
+        datei = self.ordner / vermerk.get("dateiname", "")
+        if not vermerk.get("dateiname") or not datei.exists():
+            return Befund(Zustand.FEHLT)
+
+        try:
+            tatsaechlich = pruefsumme(datei)
+        except OSError:
+            return Befund(Zustand.FREMD, vermerk.get("dateiname", ""), pfad=str(datei))
+
+        if tatsaechlich != vermerk.get("pruefsumme"):
+            return Befund(Zustand.FREMD, vermerk["dateiname"], vermerk.get("version", ""), str(datei))
+
+        zustand = Zustand.AKTUELL if vermerk.get("version") == angeboteneVersion else Zustand.VERALTET
+        return Befund(zustand, vermerk["dateiname"], vermerk.get("version", ""), str(datei))
+
+    # --- Ablegen ---
+
+    def ablegen(self, software_id: int, quelle: Path, dateiname: str, version: str) -> Path:
+        """Legt eine geholte Datei ab und vermerkt ihre Pruefsumme.
+
+        Eine vorhandene gleichnamige Datei wird zuerst umbenannt statt geloescht:
+        Laeuft das Programm gerade, laesst Windows das Umbenennen zu, das
+        Ueberschreiben nicht. Der Rest wird beim naechsten Lauf weggeraeumt.
+        """
+        self.ordner.mkdir(parents=True, exist_ok=True)
+        ziel = self.ordner / dateiname
+
+        if ziel.exists():
+            altlast = ziel.with_name(ziel.name + ALTLAST_ENDUNG)
+            altlast.unlink(missing_ok=True)
+            ziel.replace(altlast)
+
+        quelle.replace(ziel)
+
+        stand = self._stand_lesen()
+        stand[str(software_id)] = {
+            "dateiname": dateiname,
+            "version": version,
+            "pruefsumme": pruefsumme(ziel),
+        }
+        self._stand_schreiben(stand)
+        return ziel
+
+    def vergessen(self, software_id: int) -> None:
+        """Nimmt einen Eintrag aus dem Stand — die Datei selbst bleibt liegen."""
+        stand = self._stand_lesen()
+        if stand.pop(str(software_id), None) is not None:
+            self._stand_schreiben(stand)
+
+    def altlasten_aufraeumen(self) -> int:
+        """Loescht verdraengte Vorgaengerdateien, sofern sie niemand mehr haelt."""
+        if not self.ordner.exists():
+            return 0
+        entfernt = 0
+        for datei in self.ordner.glob("*" + ALTLAST_ENDUNG):
+            try:
+                datei.unlink()
+                entfernt += 1
+            except OSError:
+                continue  # laeuft noch — beim naechsten Mal wieder versuchen
+        return entfernt
diff --git a/desktop/build_exe.ps1 b/desktop/build_exe.ps1
new file mode 100644
index 0000000..48d9efe
--- /dev/null
+++ b/desktop/build_exe.ps1
@@ -0,0 +1,76 @@
+# Baut EINE eigenstaendige Windows-.exe des Software-Bibliothek-Clients.
+#
+# Laeuft aus einem FRISCHEN Git-Klon ohne Vorbereitung: die virtuelle Umgebung wird
+# angelegt, Python- und npm-Abhaengigkeiten werden installiert, die Oberflaeche
+# gebaut und alles zu einer Datei gepackt. Damit ist dieses Skript der einzige
+# Bau-Befehl, den die Software-Bibliothek braucht (siehe software-bibliothek.json).
+$ErrorActionPreference = "Stop"
+$desktop = $PSScriptRoot
+$projekt = Split-Path $desktop -Parent
+Set-Location $desktop
+
+$python = "$desktop\.venv\Scripts\python.exe"
+
+# 1. Virtuelle Umgebung sicherstellen. Ein frischer Klon hat keine; der Starter 'py'
+#    ist auf Windows der zuverlaessigste Weg (nicht der Store-Stub).
+if (-not (Test-Path $python)) {
+  Write-Host "Virtuelle Umgebung fehlt - wird angelegt ..."
+  $angelegt = $false
+  foreach ($starter in @(@("py", @("-3")), @("python", @()), @("python3", @()))) {
+    $befehl = $starter[0]
+    if (-not (Get-Command $befehl -ErrorAction SilentlyContinue)) { continue }
+    & $befehl @($starter[1] + @("-m", "venv", "$desktop\.venv"))
+    if ($LASTEXITCODE -eq 0 -and (Test-Path $python)) { $angelegt = $true; break }
+  }
+  if (-not $angelegt) { throw "Keine Python-Installation gefunden (py / python / python3)." }
+}
+
+# 2. Python-Abhaengigkeiten
+Write-Host "Python-Abhaengigkeiten ..."
+& $python -m pip install --upgrade pip --quiet
+& $python -m pip install -r "$desktop\requirements.txt" --quiet
+& $python -m pip install pyinstaller --quiet
+if ($LASTEXITCODE -ne 0) { throw "pip fehlgeschlagen." }
+
+# 3. Oberflaeche bauen. Sie liegt als npm-Workspace im Projekt-Wurzelverzeichnis
+#    und teilt sich die Typen mit dem Server - deshalb wird dort installiert.
+Write-Host "Oberflaeche bauen ..."
+Push-Location $projekt
+npm install --no-audit --no-fund
+if ($LASTEXITCODE -ne 0) { Pop-Location; throw "npm install fehlgeschlagen." }
+npm run build:desktop-ui
+if ($LASTEXITCODE -ne 0) { Pop-Location; throw "Bau der Oberflaeche fehlgeschlagen." }
+Pop-Location
+
+if (-not (Test-Path "$desktop\frontend\dist\index.html")) {
+  throw "Die gebaute Oberflaeche fehlt unter frontend\dist."
+}
+
+# 4. Alte Bau-Artefakte weg, damit nichts von einem frueheren Lauf ueberlebt.
+Remove-Item "$desktop\build", "$desktop\dist" -Recurse -Force -ErrorAction SilentlyContinue
+
+# 5. Eine Datei daraus machen. Die Oberflaeche wird als Datenverzeichnis
+#    mitgepackt; __main__.py findet sie zur Laufzeit unter sys._MEIPASS.
+#
+#    `--collect-all` ist bei den drei webview-Paketen Pflicht: pywebview zeigt das
+#    Fenster unter Windows ueber WebView2, und das spricht es via pythonnet in .NET
+#    an. Diese Bestandteile findet PyInstaller nicht durch Nachverfolgen der
+#    Importe — ohne sie startet die exe und beendet sich sofort wieder.
+Write-Host "PyInstaller ..."
+& $python -m PyInstaller `
+  --noconfirm `
+  --onefile `
+  --windowed `
+  --name "Software-Bibliothek" `
+  --icon "$desktop\assets\icon.ico" `
+  --distpath "$desktop\dist" --workpath "$desktop\build" `
+  --add-data "$desktop\frontend\dist;frontend/dist" `
+  --collect-all webview `
+  --collect-all pythonnet `
+  --collect-all clr_loader `
+  "$desktop\run_app.py"
+if ($LASTEXITCODE -ne 0) { throw "PyInstaller fehlgeschlagen." }
+
+$ergebnis = "$desktop\dist\Software-Bibliothek.exe"
+if (-not (Test-Path $ergebnis)) { throw "Erwartete Datei fehlt: $ergebnis" }
+Write-Host "Fertig: $ergebnis ($([math]::Round((Get-Item $ergebnis).Length / 1MB, 1)) MB)"
diff --git a/desktop/frontend/package.json b/desktop/frontend/package.json
new file mode 100644
index 0000000..1d07ad1
--- /dev/null
+++ b/desktop/frontend/package.json
@@ -0,0 +1,29 @@
+{
+  "name": "bibliothek-desktop-frontend",
+  "version": "0.1.0",
+  "type": "module",
+  "private": true,
+  "scripts": {
+    "build": "webpack --node-env=production",
+    "build:dev": "webpack --node-env=development",
+    "watch": "webpack --watch --node-env=development"
+  },
+  "dependencies": {
+    "@dh-software/dh-components": "^3.0.23",
+    "@dh-software/dh-components-themes": "^1.0.21",
+    "@dh-software/furnview-icons": "^1.0.21",
+    "@dh-software/lit-extensions": "^0.0.14",
+    "bibliothek-types": "^0.1.0",
+    "construct-style-sheets-polyfill": "^3.1.0",
+    "lit": "^3.3.2"
+  },
+  "devDependencies": {
+    "css-loader": "^7.1.2",
+    "html-webpack-plugin": "^5.6.3",
+    "style-loader": "^4.0.0",
+    "ts-loader": "^9.5.2",
+    "typescript": "^5.7.3",
+    "webpack": "^5.97.1",
+    "webpack-cli": "^6.0.1"
+  }
+}
diff --git a/desktop/frontend/src/bruecke.ts b/desktop/frontend/src/bruecke.ts
new file mode 100644
index 0000000..67b919c
--- /dev/null
+++ b/desktop/frontend/src/bruecke.ts
@@ -0,0 +1,69 @@
+/**
+ * Typisierter Zugang zur Python-Seite.
+ *
+ * pywebview hängt seine Brücke erst ein, wenn das Fenster bereit ist, und meldet
+ * das über das Ereignis `pywebviewready`. Jeder Aufruf davor liefe ins Leere —
+ * deshalb wartet `bruecke()` einmalig darauf.
+ */
+import type { Software } from 'bibliothek-types';
+
+/** Zustand einer Datei im Werkzeug-Ordner, ermittelt über ihre Prüfsumme. */
+export type LokalerZustand = 'fehlt' | 'aktuell' | 'veraltet' | 'fremd';
+
+export interface LokalerBefund {
+  zustand: LokalerZustand;
+  dateiname: string;
+  abgelegteVersion: string;
+  pfad: string;
+}
+
+/** Katalogeintrag, angereichert um das, was auf diesem Rechner liegt. */
+export interface EintragMitBefund extends Software {
+  /** Nur bei eigenständigen Programmen gefüllt — bei Installern sagt der Ordner nichts. */
+  lokal: LokalerBefund | null;
+  /** Fließt dieser Eintrag in die Update-Meldung ein? */
+  ueberwacht: boolean;
+}
+
+export interface Einstellungen {
+  serverAdresse: string;
+  werkzeugOrdner: string;
+}
+
+interface PywebviewApi {
+  einstellungen(): Promise<{ einstellungen: Einstellungen; vorgaben: Einstellungen }>;
+  einstellungen_speichern(
+    serverAdresse: string,
+    werkzeugOrdner: string
+  ): Promise<{ ok: boolean; einstellungen: Einstellungen; vorgaben: Einstellungen }>;
+  katalog(): Promise<{ ok: boolean; meldung?: string; eintraege: EintragMitBefund[]; ordner?: string }>;
+  herunterladen(
+    softwareId: number,
+    dateiname: string,
+    version: string,
+    programmart: string
+  ): Promise<{ ok: boolean; meldung: string; pfad?: string }>;
+  ordner_oeffnen(unterordner?: string): Promise<{ ok: boolean }>;
+  verknuepfung_anlegen(pfad: string, name: string): Promise<{ ok: boolean; meldung?: string }>;
+}
+
+declare global {
+  interface Window {
+    pywebview?: { api: PywebviewApi };
+  }
+}
+
+let bereit: Promise<PywebviewApi> | undefined;
+
+export function bruecke(): Promise<PywebviewApi> {
+  if (!bereit) {
+    bereit = new Promise((aufloesen) => {
+      if (window.pywebview?.api) {
+        aufloesen(window.pywebview.api);
+        return;
+      }
+      window.addEventListener('pywebviewready', () => aufloesen(window.pywebview!.api), { once: true });
+    });
+  }
+  return bereit;
+}
diff --git a/desktop/frontend/src/components/client-app.ts b/desktop/frontend/src/components/client-app.ts
new file mode 100644
index 0000000..f8bab1c
--- /dev/null
+++ b/desktop/frontend/src/components/client-app.ts
@@ -0,0 +1,284 @@
+import { LitElement, html, nothing } from 'lit';
+import { customElement, state } from 'lit/decorators.js';
+import { EINSTUFUNGEN, type Einstufung } from 'bibliothek-types';
+import { bruecke, type EintragMitBefund, type Einstellungen } from '../bruecke';
+import { gemerkterModus, setzeTheme, type ThemeModus } from '../theme';
+import './client-zeile';
+
+interface Auswahloption {
+  dataId: string;
+  showContent: string;
+  selected?: boolean;
+}
+
+const THEME_OPTIONEN: Array<{ id: ThemeModus; text: string }> = [
+  { id: 'system', text: 'System' },
+  { id: 'hell', text: 'Hell' },
+  { id: 'dunkel', text: 'Dunkel' },
+];
+
+/** Wurzel-Komponente des Clients. */
+@customElement('client-app')
+export class ClientApp extends LitElement {
+  @state() private eintraege: EintragMitBefund[] = [];
+  @state() private aktiveEinstufung: Einstufung = 'lizenzfrei';
+  @state() private suchbegriff = '';
+  @state() private ladefehler = '';
+  @state() private meldung = '';
+  @state() private laeuftFuer = 0;
+  @state() private einstellungenOffen = false;
+  @state() private einstellungen?: Einstellungen;
+  @state() private serverAdresse = '';
+  @state() private werkzeugOrdner = '';
+  @state() private themeOptionen: Auswahloption[] = [];
+
+  // Light DOM, damit das globale dh-components-Theme greift.
+  createRenderRoot() {
+    return this;
+  }
+
+  connectedCallback(): void {
+    super.connectedCallback();
+    const modus = gemerkterModus();
+    this.themeOptionen = THEME_OPTIONEN.map((eintrag) => ({
+      dataId: eintrag.id,
+      showContent: eintrag.text,
+      selected: eintrag.id === modus,
+    }));
+    void this.alesLaden();
+  }
+
+  render() {
+    return html`
+      <div class="rahmen">
+        <header class="kopf">
+          <h1>Software-Bibliothek</h1>
+          <div class="kopf-aktionen">
+            <dh-select
+              class="theme-select"
+              withoutEmptyDefault
+              notSearchable
+              .selectOptions=${this.themeOptionen}
+              @changeSelectOption=${this.beiTheme}
+            ></dh-select>
+            <dh-button icon-button variant="standard" title="Werkzeug-Ordner öffnen" @click=${this.beiOrdnerOeffnen}>
+              <span slot="img" class="fv-icon-folder"></span>
+            </dh-button>
+            <dh-button icon-button variant="standard" title="Einstellungen" @click=${this.beiEinstellungenOeffnen}>
+              <span slot="img" class="fv-icon-settings"></span>
+            </dh-button>
+          </div>
+        </header>
+
+        ${this.ladefehler ? html`<p class="warnung">${this.ladefehler}</p>` : nothing}
+        ${this.meldung ? html`<p class="hinweis">${this.meldung}</p>` : nothing}
+        ${this.rendereUpdateHinweis()}
+        ${this.einstellungenOffen ? this.rendereEinstellungen() : this.rendereKatalog()}
+      </div>
+    `;
+  }
+
+  // --- Teilansichten ---
+
+  /** Der Grund, warum es diesen Client gibt: ungefragt sagen, was veraltet ist. */
+  private rendereUpdateHinweis() {
+    const veraltet = this.eintraege.filter(
+      (eintrag) => eintrag.ueberwacht && (eintrag.lokal?.zustand === 'veraltet' || eintrag.lokal?.zustand === 'fremd')
+    );
+    if (veraltet.length === 0) {
+      return nothing;
+    }
+    return html`<p class="update-hinweis">
+      ${veraltet.length === 1 ? 'Ein Werkzeug ist nicht auf dem neuesten Stand' : `${veraltet.length} Werkzeuge sind nicht auf dem neuesten Stand`}:
+      ${veraltet.map((eintrag) => eintrag.name).join(', ')}.
+    </p>`;
+  }
+
+  private rendereKatalog() {
+    if (this.eintraege.length === 0) {
+      return html`<p class="leer">${this.ladefehler ? '' : 'Katalog wird geladen …'}</p>`;
+    }
+    return html`
+      <dh-input class="suche" type="text" placeholder="Suchen" .value=${this.suchbegriff} @input=${this.beiSuche}></dh-input>
+      <dh-tab stretch @clickTab=${this.beiReiterWechsel}>
+        ${EINSTUFUNGEN.map(
+          (eintrag) => html`<div
+            slot="tab-head"
+            content=${eintrag.id}
+            class=${eintrag.id === this.aktiveEinstufung ? 'selected' : ''}
+          >
+            ${eintrag.text} (${this.mitEinstufung(eintrag.id).length})
+          </div>`
+        )}
+        ${EINSTUFUNGEN.map(
+          (eintrag) => html`<div
+            slot="tab-content"
+            tab=${eintrag.id}
+            class=${eintrag.id === this.aktiveEinstufung ? '' : 'hidden'}
+          >
+            ${eintrag.id === this.aktiveEinstufung ? this.rendereListe() : nothing}
+          </div>`
+        )}
+      </dh-tab>
+    `;
+  }
+
+  private rendereListe() {
+    const gefiltert = this.gefiltert();
+    if (gefiltert.length === 0) {
+      return html`<p class="leer">
+        ${this.suchbegriff ? html`Keine Treffer für „${this.suchbegriff}".` : 'In dieser Einstufung ist nichts hinterlegt.'}
+      </p>`;
+    }
+    return html`<div class="liste">
+      ${gefiltert.map(
+        (eintrag) => html`<client-zeile
+          .eintrag=${eintrag}
+          ?laeuft=${this.laeuftFuer === eintrag.id}
+          @holen=${this.beiHolen}
+          @verknuepfen=${this.beiVerknuepfen}
+        ></client-zeile>`
+      )}
+    </div>`;
+  }
+
+  private rendereEinstellungen() {
+    return html`
+      <dh-card variant="outlined" no-hover-effect no-click-effect>
+        <span slot="header">Einstellungen</span>
+        <dh-form-grid slot="content">
+          <dh-form-field label="Adresse der Bibliothek">
+            <dh-input type="text" .value=${this.serverAdresse} @input=${this.beiServerAdresse}></dh-input>
+            <span class="feldhinweis">
+              Rechnername statt IP-Adresse ist die bessere Wahl — er überlebt einen Wechsel der Adresse.
+            </span>
+          </dh-form-field>
+          <dh-form-field label="Werkzeug-Ordner">
+            <dh-input type="text" .value=${this.werkzeugOrdner} @input=${this.beiWerkzeugOrdner}></dh-input>
+            <span class="feldhinweis">
+              Hierhin kommen eigenständige Programme; Installationsprogramme in den Unterordner
+              „Installationsprogramme".
+            </span>
+          </dh-form-field>
+        </dh-form-grid>
+        <div slot="actions">
+          <dh-button buttonText="Speichern" variant="filled" @click=${this.beiEinstellungenSpeichern}></dh-button>
+          <dh-button buttonText="Abbrechen" variant="standard" @click=${this.beiEinstellungenSchliessen}></dh-button>
+        </div>
+      </dh-card>
+    `;
+  }
+
+  // --- Daten ---
+
+  private async alesLaden(): Promise<void> {
+    const api = await bruecke();
+    const stand = await api.einstellungen();
+    this.einstellungen = stand.einstellungen;
+    this.serverAdresse = stand.einstellungen.serverAdresse;
+    this.werkzeugOrdner = stand.einstellungen.werkzeugOrdner;
+    await this.katalogLaden();
+  }
+
+  private async katalogLaden(): Promise<void> {
+    const api = await bruecke();
+    const antwort = await api.katalog();
+    if (!antwort.ok) {
+      this.ladefehler = `${antwort.meldung} — Adresse in den Einstellungen prüfen.`;
+      this.eintraege = [];
+      return;
+    }
+    this.ladefehler = '';
+    this.eintraege = antwort.eintraege;
+  }
+
+  private mitEinstufung(einstufung: Einstufung): EintragMitBefund[] {
+    return this.eintraege.filter((eintrag) => eintrag.einstufung === einstufung && !eintrag.elternId);
+  }
+
+  private gefiltert(): EintragMitBefund[] {
+    const suche = this.suchbegriff.trim().toLowerCase();
+    const inDerEinstufung = this.mitEinstufung(this.aktiveEinstufung);
+    if (!suche) {
+      return inDerEinstufung;
+    }
+    return inDerEinstufung.filter(
+      (eintrag) =>
+        eintrag.name.toLowerCase().includes(suche) || eintrag.beschreibung.toLowerCase().includes(suche)
+    );
+  }
+
+  // --- Ereignisse ---
+
+  private beiTheme(ereignis: CustomEvent<{ selectedOption: { dataId: string } }>): void {
+    setzeTheme(ereignis.detail.selectedOption.dataId as ThemeModus);
+  }
+
+  private beiSuche(ereignis: CustomEvent<string>): void {
+    this.suchbegriff = ereignis.detail;
+  }
+
+  /** dh-tab liefert die Kennung des Reiters unmittelbar als `detail`. */
+  private beiReiterWechsel(ereignis: CustomEvent<string>): void {
+    this.aktiveEinstufung = ereignis.detail as Einstufung;
+  }
+
+  private async beiHolen(ereignis: CustomEvent<EintragMitBefund>): Promise<void> {
+    const eintrag = ereignis.detail;
+    this.laeuftFuer = eintrag.id;
+    this.meldung = `${eintrag.name} wird geholt …`;
+    const api = await bruecke();
+    const ergebnis = await api.herunterladen(eintrag.id, eintrag.dateiname, eintrag.version, eintrag.programmart);
+    this.meldung = ergebnis.meldung;
+    this.laeuftFuer = 0;
+    await this.katalogLaden();
+  }
+
+  private async beiVerknuepfen(ereignis: CustomEvent<EintragMitBefund>): Promise<void> {
+    const eintrag = ereignis.detail;
+    if (!eintrag.lokal?.pfad) {
+      return;
+    }
+    const api = await bruecke();
+    const ergebnis = await api.verknuepfung_anlegen(eintrag.lokal.pfad, eintrag.name);
+    this.meldung = ergebnis.meldung ?? '';
+  }
+
+  private async beiOrdnerOeffnen(): Promise<void> {
+    const api = await bruecke();
+    await api.ordner_oeffnen();
+  }
+
+  private beiEinstellungenOeffnen(): void {
+    this.serverAdresse = this.einstellungen?.serverAdresse ?? '';
+    this.werkzeugOrdner = this.einstellungen?.werkzeugOrdner ?? '';
+    this.einstellungenOffen = true;
+  }
+
+  private beiEinstellungenSchliessen(): void {
+    this.einstellungenOffen = false;
+  }
+
+  private beiServerAdresse(ereignis: CustomEvent<string>): void {
+    this.serverAdresse = ereignis.detail;
+  }
+
+  private beiWerkzeugOrdner(ereignis: CustomEvent<string>): void {
+    this.werkzeugOrdner = ereignis.detail;
+  }
+
+  private async beiEinstellungenSpeichern(): Promise<void> {
+    const api = await bruecke();
+    const ergebnis = await api.einstellungen_speichern(this.serverAdresse, this.werkzeugOrdner);
+    this.einstellungen = ergebnis.einstellungen;
+    this.einstellungenOffen = false;
+    this.meldung = 'Einstellungen gespeichert.';
+    await this.katalogLaden();
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'client-app': ClientApp;
+  }
+}
diff --git a/desktop/frontend/src/components/client-zeile.ts b/desktop/frontend/src/components/client-zeile.ts
new file mode 100644
index 0000000..29656d3
--- /dev/null
+++ b/desktop/frontend/src/components/client-zeile.ts
@@ -0,0 +1,135 @@
+import { LitElement, html, nothing } from 'lit';
+import { customElement, property } from 'lit/decorators.js';
+import { event, type EventRef } from '@dh-software/lit-extensions';
+import type { EintragMitBefund, LokalerZustand } from '../bruecke';
+
+/** Was der jeweilige Zustand für den Nutzer bedeutet. */
+const ZUSTAND_TEXT: Record<LokalerZustand, string> = {
+  fehlt: 'nicht auf diesem Rechner',
+  aktuell: 'aktuell',
+  veraltet: 'veraltet',
+  fremd: 'von Hand verändert',
+};
+
+const ZUSTAND_KLASSE: Record<LokalerZustand, string> = {
+  fehlt: 'zustand-fehlt',
+  aktuell: 'zustand-aktuell',
+  veraltet: 'zustand-veraltet',
+  fremd: 'zustand-fremd',
+};
+
+/**
+ * Eine Zeile des Katalogs im Client. Anders als in der Weboberfläche steht hier,
+ * was auf diesem Rechner liegt — das ist der ganze Unterschied.
+ */
+@customElement('client-zeile')
+export class ClientZeile extends LitElement {
+  @property({ attribute: false }) eintrag!: EintragMitBefund;
+  @property({ type: Boolean }) laeuft = false;
+
+  @event({ bubbles: true, composed: true }, 'holen')
+  private holen!: EventRef<EintragMitBefund>;
+
+  @event({ bubbles: true, composed: true }, 'verknuepfen')
+  private verknuepfen!: EventRef<EintragMitBefund>;
+
+  // Light DOM, damit das globale dh-components-Theme greift.
+  createRenderRoot() {
+    return this;
+  }
+
+  render() {
+    const eintrag = this.eintrag;
+    return html`
+      <div class="zeile">
+        <div class="zeile-info">
+          <span class="zeile-name">
+            ${eintrag.name}
+            ${eintrag.einstufung === 'lizenzpflichtig'
+              ? html`<span class="marke lizenz-marke">lizenzpflichtig</span>`
+              : nothing}
+            ${eintrag.einstufung === 'intern' ? html`<span class="marke intern-marke">intern</span>` : nothing}
+          </span>
+          <span class="zeile-meta">${this.rendereStand()}${this.rendereZustand()}</span>
+          ${eintrag.hinweis ? html`<span class="zeile-hinweis">${eintrag.hinweis}</span>` : nothing}
+        </div>
+        <div class="zeile-aktionen">${this.rendereAktion()}${this.rendereVerknuepfung()}</div>
+      </div>
+    `;
+  }
+
+  private rendereStand() {
+    const zeitpunkt = this.eintrag.veroeffentlichtAm || this.eintrag.dateiStandVom;
+    if (!zeitpunkt) {
+      return nothing;
+    }
+    const datum = new Date(zeitpunkt);
+    if (Number.isNaN(datum.getTime())) {
+      return nothing;
+    }
+    return html`Stand ${datum.toLocaleDateString('de-DE')}`;
+  }
+
+  /** Nur bei eigenständigen Programmen aussagekräftig — Installer verraten hier nichts. */
+  private rendereZustand() {
+    const befund = this.eintrag.lokal;
+    if (!befund) {
+      return nothing;
+    }
+    return html` &nbsp;·&nbsp;
+      <span class=${ZUSTAND_KLASSE[befund.zustand]}>${ZUSTAND_TEXT[befund.zustand]}</span>`;
+  }
+
+  /**
+   * Ein rundes Symbol wie in der Weboberfläche. Was der Klick bewirkt, steht
+   * ohnehin schon als Zustand in der Zeile — die Beschriftung wäre eine
+   * Wiederholung. Steht ein Update an, ist der Knopf gefüllt statt flach.
+   */
+  private rendereAktion() {
+    const eintrag = this.eintrag;
+    if (!eintrag.dateiname) {
+      return html`<span class="zeile-hinweis">keine Datei hinterlegt</span>`;
+    }
+    const zustand = eintrag.lokal?.zustand;
+    const stehtAn = zustand === 'veraltet' || zustand === 'fremd';
+    const zweck = stehtAn ? 'aktualisieren' : zustand === 'aktuell' ? 'erneut holen' : 'holen';
+    return html`<dh-button
+      icon-button
+      variant=${stehtAn ? 'filled' : 'standard'}
+      title=${`${eintrag.name} ${zweck}`}
+      ?disabled=${this.laeuft}
+      @click=${this.beiHolen}
+    >
+      <span slot="img" class="fv-icon-download"></span>
+    </dh-button>`;
+  }
+
+  private rendereVerknuepfung() {
+    const befund = this.eintrag.lokal;
+    if (!befund || befund.zustand === 'fehlt') {
+      return nothing;
+    }
+    return html`<dh-button
+      icon-button
+      variant="standard"
+      title="Verknüpfung auf dem Desktop anlegen"
+      @click=${this.beiVerknuepfen}
+    >
+      <span slot="img" class="fv-icon-link"></span>
+    </dh-button>`;
+  }
+
+  private beiHolen(): void {
+    this.holen.dispatch(this.eintrag);
+  }
+
+  private beiVerknuepfen(): void {
+    this.verknuepfen.dispatch(this.eintrag);
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'client-zeile': ClientZeile;
+  }
+}
diff --git a/desktop/frontend/src/global.css b/desktop/frontend/src/global.css
new file mode 100644
index 0000000..5fafb71
--- /dev/null
+++ b/desktop/frontend/src/global.css
@@ -0,0 +1,200 @@
+html,
+body {
+  margin: 0;
+  min-height: 100%;
+  background: var(--md-sys-color-surface, #ffffff);
+  color: var(--md-sys-color-on-surface, #1a1a1a);
+  font-family: system-ui, -apple-system, 'Segoe UI', sans-serif;
+}
+
+.rahmen {
+  padding: 20px 24px 32px;
+  max-width: 1000px;
+  margin: 0 auto;
+}
+
+.kopf {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 16px;
+  margin-bottom: 16px;
+}
+
+.kopf h1 {
+  font-size: 1.3rem;
+  margin: 0;
+}
+
+.kopf-aktionen {
+  display: flex;
+  gap: 8px;
+  align-items: center;
+}
+
+.theme-select {
+  min-width: 140px;
+}
+
+.hinweis {
+  background: var(--md-sys-color-secondary-container, #e6e0ec);
+  color: var(--md-sys-color-on-secondary-container, #1d1b20);
+  padding: 12px 16px;
+  border-radius: 8px;
+  margin-bottom: 16px;
+}
+
+.warnung {
+  padding: 12px 16px;
+  margin-bottom: 16px;
+  border-radius: 8px;
+  border: 1px solid var(--md-sys-color-error, #b00020);
+  background: var(--md-sys-color-error-container, rgba(176, 0, 32, 0.12));
+  color: var(--md-sys-color-on-error-container, #410002);
+}
+
+/* Die Meldung, für die es den Client gibt — deutlicher als ein normaler Hinweis. */
+.update-hinweis {
+  padding: 12px 16px;
+  margin-bottom: 16px;
+  border-radius: 8px;
+  border-left: 4px solid var(--md-sys-color-primary, #ff8014);
+  background: var(--md-sys-color-surface-container-high, #eeeeee);
+  color: var(--md-sys-color-on-surface, #1a1a1a);
+}
+
+.suche {
+  display: block;
+  max-width: 400px;
+  margin-bottom: 12px;
+}
+
+dh-tab {
+  display: block;
+}
+
+[slot='tab-content'].hidden {
+  display: none;
+}
+
+.leer {
+  padding: 16px 4px;
+  opacity: 0.7;
+}
+
+.liste {
+  border: 1px solid var(--md-sys-color-outline-variant, #cac4d0);
+  border-radius: 8px;
+  overflow: hidden;
+}
+
+.liste > client-zeile {
+  display: block;
+  border-bottom: 1px solid var(--md-sys-color-outline-variant, #e7e0ec);
+}
+
+.liste > client-zeile:last-child {
+  border-bottom: none;
+}
+
+.zeile {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 16px;
+  padding: 8px 16px;
+}
+
+.zeile-info {
+  display: flex;
+  flex-direction: column;
+  min-width: 0;
+  flex: 1 1 auto;
+  overflow: hidden;
+}
+
+.zeile-info > * {
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+
+.zeile-name {
+  font-weight: 600;
+  display: flex;
+  align-items: center;
+  gap: 8px;
+}
+
+.zeile-meta {
+  font-size: 0.85em;
+  opacity: 0.7;
+}
+
+.zeile-hinweis {
+  font-size: 0.85em;
+  color: var(--md-sys-color-on-surface-variant, #49454f);
+  font-style: italic;
+}
+
+/* Feste Spalten, damit die Schaltflächen über alle Zeilen untereinander stehen. */
+.zeile-aktionen {
+  display: grid;
+  grid-template-columns: 48px 48px;
+  gap: 8px;
+  align-items: center;
+  justify-items: center;
+  flex-shrink: 0;
+}
+
+.marke {
+  flex-shrink: 0;
+  font-size: 0.72em;
+  font-weight: 500;
+  text-transform: uppercase;
+  letter-spacing: 0.04em;
+  padding: 2px 8px;
+  border-radius: 999px;
+}
+
+.intern-marke {
+  background: var(--md-sys-color-primary-container, #ffe4cc);
+  color: var(--md-sys-color-on-primary-container, #4a1e00);
+}
+
+.lizenz-marke {
+  background: var(--md-sys-color-tertiary-container, #ffd9a0);
+  color: var(--md-sys-color-on-tertiary-container, #2b1700);
+}
+
+.zustand-aktuell {
+  color: var(--md-sys-color-primary, #ff8014);
+}
+
+.zustand-veraltet,
+.zustand-fremd {
+  color: var(--md-sys-color-error, #b00020);
+  font-weight: 500;
+}
+
+.zustand-fehlt {
+  font-style: italic;
+}
+
+.feldhinweis {
+  display: block;
+  font-size: 0.8em;
+  opacity: 0.7;
+  margin-top: 4px;
+}
+
+dh-card {
+  display: block;
+}
+
+[slot='actions'] {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 8px;
+  align-items: center;
+}
diff --git a/desktop/frontend/src/index.html b/desktop/frontend/src/index.html
new file mode 100644
index 0000000..374524e
--- /dev/null
+++ b/desktop/frontend/src/index.html
@@ -0,0 +1,11 @@
+<!DOCTYPE html>
+<html lang="de">
+  <head>
+    <meta charset="UTF-8" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <title>Software-Bibliothek</title>
+  </head>
+  <body>
+    <client-app></client-app>
+  </body>
+</html>
diff --git a/desktop/frontend/src/index.ts b/desktop/frontend/src/index.ts
new file mode 100644
index 0000000..58a7a82
--- /dev/null
+++ b/desktop/frontend/src/index.ts
@@ -0,0 +1,15 @@
+import 'construct-style-sheets-polyfill';
+
+// Theme in fester Reihenfolge: Tokens -> Styles -> Komponenten.
+import { initTheme } from './theme';
+import '@dh-software/dh-components-themes/styles.css';
+import '@dh-software/dh-components';
+
+import './global.css';
+
+import { injectStylesheets } from '@dh-software/furnview-icons';
+injectStylesheets(true);
+
+initTheme();
+
+import './components/client-app';
diff --git a/desktop/frontend/src/raw.d.ts b/desktop/frontend/src/raw.d.ts
new file mode 100644
index 0000000..d21a9d1
--- /dev/null
+++ b/desktop/frontend/src/raw.d.ts
@@ -0,0 +1,5 @@
+/** Import mit `?raw` liefert den Dateiinhalt als Zeichenkette (webpack asset/source). */
+declare module '*?raw' {
+  const inhalt: string;
+  export default inhalt;
+}
diff --git a/desktop/frontend/src/theme.ts b/desktop/frontend/src/theme.ts
new file mode 100644
index 0000000..4c0222f
--- /dev/null
+++ b/desktop/frontend/src/theme.ts
@@ -0,0 +1,58 @@
+/**
+ * Theme-Umschaltung (System/hell/dunkel) auf Basis der Design-System-Defines.
+ * Beide Defines werden als <style> eingehängt; je nach Modus ist genau eines
+ * aktiv (das andere per `disabled` abgeschaltet). „System" folgt der
+ * Windows-Einstellung (prefers-color-scheme).
+ *
+ * Gleiches Vorgehen wie in der Weboberfläche und im meeting-transkriptor, damit
+ * die Bedienung über unsere Anwendungen hinweg einheitlich bleibt.
+ */
+// Relativer Pfad (umgeht das exports-Feld des Pakets, das ?raw sonst blockt).
+import hellCss from '../../../node_modules/@dh-software/dh-components-themes/build/defines.orange-light.css?raw';
+import dunkelCss from '../../../node_modules/@dh-software/dh-components-themes/build/defines.orange-dark.css?raw';
+
+export type ThemeModus = 'system' | 'hell' | 'dunkel';
+
+const SPEICHER_SCHLUESSEL = 'software-bibliothek-client-theme';
+
+let hellStil: HTMLStyleElement;
+let dunkelStil: HTMLStyleElement;
+let aktuellerModus: ThemeModus = 'system';
+const dunkelMedia = window.matchMedia('(prefers-color-scheme: dark)');
+
+function anwenden(): void {
+  const dunkel = aktuellerModus === 'dunkel' || (aktuellerModus === 'system' && dunkelMedia.matches);
+  hellStil.disabled = dunkel;
+  dunkelStil.disabled = !dunkel;
+  document.documentElement.style.colorScheme = dunkel ? 'dark' : 'light';
+  document.documentElement.dataset.theme = dunkel ? 'dunkel' : 'hell';
+}
+
+/** Liest den zuletzt gewählten Modus (Standard: System). */
+export function gemerkterModus(): ThemeModus {
+  const gemerkt = localStorage.getItem(SPEICHER_SCHLUESSEL);
+  return gemerkt === 'hell' || gemerkt === 'dunkel' || gemerkt === 'system' ? gemerkt : 'system';
+}
+
+/** Hängt beide Theme-Defines ein und aktiviert den gemerkten Modus. */
+export function initTheme(): void {
+  hellStil = document.createElement('style');
+  hellStil.textContent = hellCss;
+  dunkelStil = document.createElement('style');
+  dunkelStil.textContent = dunkelCss;
+  document.head.append(hellStil, dunkelStil);
+  dunkelMedia.addEventListener('change', () => {
+    if (aktuellerModus === 'system') {
+      anwenden();
+    }
+  });
+  aktuellerModus = gemerkterModus();
+  anwenden();
+}
+
+/** Setzt den Modus und merkt ihn für den nächsten Aufruf. */
+export function setzeTheme(modus: ThemeModus): void {
+  aktuellerModus = modus;
+  localStorage.setItem(SPEICHER_SCHLUESSEL, modus);
+  anwenden();
+}
diff --git a/desktop/frontend/tsconfig.json b/desktop/frontend/tsconfig.json
new file mode 100644
index 0000000..995f101
--- /dev/null
+++ b/desktop/frontend/tsconfig.json
@@ -0,0 +1,15 @@
+{
+  "compilerOptions": {
+    "target": "ES2020",
+    "lib": ["DOM", "DOM.Iterable", "ES2021"],
+    "module": "ESNext",
+    "moduleResolution": "bundler",
+    "experimentalDecorators": true,
+    "useDefineForClassFields": false,
+    "strict": true,
+    "esModuleInterop": true,
+    "skipLibCheck": true,
+    "forceConsistentCasingInFileNames": true
+  },
+  "exclude": ["node_modules", "dist"]
+}
diff --git a/desktop/frontend/webpack.config.js b/desktop/frontend/webpack.config.js
new file mode 100644
index 0000000..2cb3038
--- /dev/null
+++ b/desktop/frontend/webpack.config.js
@@ -0,0 +1,56 @@
+import path from 'path';
+import { fileURLToPath } from 'url';
+import HtmlWebpackPlugin from 'html-webpack-plugin';
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const isProduction = process.env.NODE_ENV === 'production';
+
+export default {
+  entry: './src/index.ts',
+  mode: isProduction ? 'production' : 'development',
+  devtool: isProduction ? false : 'inline-source-map',
+  experiments: {
+    topLevelAwait: true,
+  },
+  module: {
+    rules: [
+      {
+        test: /\.ts$/,
+        use: 'ts-loader',
+        exclude: /node_modules\/(?!@dh-software)/,
+      },
+      {
+        test: /\.css$/i,
+        resourceQuery: { not: [/raw/] },
+        use: ['style-loader', 'css-loader'],
+      },
+      {
+        // Import mit ?raw liefert den CSS-Quelltext als String (Theme-Umschaltung).
+        resourceQuery: /raw/,
+        type: 'asset/source',
+      },
+      {
+        test: /\.(svg|ttf|woff|woff2|png|jpg|gif|ico)$/i,
+        type: 'asset',
+      },
+    ],
+  },
+  resolve: {
+    extensions: ['.ts', '.js'],
+  },
+  plugins: [
+    new HtmlWebpackPlugin({
+      template: 'src/index.html',
+      // Das Fenster laedt die Datei direkt vom Dateisystem — relative Pfade.
+      publicPath: '',
+    }),
+  ],
+  output: {
+    path: path.resolve(__dirname, 'dist'),
+    filename: 'index.[contenthash].js',
+    clean: true,
+  },
+  stats: {
+    errorDetails: true,
+  },
+};
diff --git a/desktop/generate_icon.py b/desktop/generate_icon.py
new file mode 100644
index 0000000..c415606
--- /dev/null
+++ b/desktop/generate_icon.py
@@ -0,0 +1,40 @@
+"""Erzeugt das App-Icon als assets/icon.ico.
+
+Pillow wird nur zum Erzeugen gebraucht, nicht zur Laufzeit — deshalb steht es
+bewusst nicht in requirements.txt:
+
+    .venv\\Scripts\\python.exe -m pip install pillow
+    .venv\\Scripts\\python.exe generate_icon.py
+
+Das Ergebnis gehoert ins Repo: Ein frischer Klon soll bauen koennen, ohne
+Pillow zu installieren.
+"""
+
+from pathlib import Path
+
+from PIL import Image, ImageDraw
+
+GROESSE = 256
+ORANGE = (255, 128, 20, 255)  # Markenfarbe --md-sys-color-primary
+WEISS = (255, 255, 255, 255)
+
+bild = Image.new("RGBA", (GROESSE, GROESSE), (0, 0, 0, 0))
+zeichnung = ImageDraw.Draw(bild)
+
+# Hintergrund: abgerundetes Quadrat in Markenorange — wie beim Transkriptor,
+# damit unsere Werkzeuge in der Taskleiste als Familie erkennbar sind.
+zeichnung.rounded_rectangle((8, 8, 248, 248), radius=52, fill=ORANGE)
+
+# Pfeil nach unten: das Zeichen fuer „holen", dasselbe wie in der Oberflaeche.
+zeichnung.rounded_rectangle((112, 44, 144, 132), radius=10, fill=WEISS)
+zeichnung.polygon([(80, 118), (176, 118), (128, 186)], fill=WEISS)
+
+# Ablage darunter — angedeutet als offene Schale, nicht als geschlossener Kasten.
+zeichnung.rounded_rectangle((66, 198, 190, 216), radius=9, fill=WEISS)
+zeichnung.rounded_rectangle((66, 160, 84, 216), radius=9, fill=WEISS)
+zeichnung.rounded_rectangle((172, 160, 190, 216), radius=9, fill=WEISS)
+
+ziel = Path(__file__).resolve().parent / "assets" / "icon.ico"
+ziel.parent.mkdir(parents=True, exist_ok=True)
+bild.save(ziel, sizes=[(16, 16), (24, 24), (32, 32), (48, 48), (64, 64), (128, 128), (256, 256)])
+print(f"geschrieben: {ziel}")
diff --git a/desktop/requirements.txt b/desktop/requirements.txt
new file mode 100644
index 0000000..32f2f62
--- /dev/null
+++ b/desktop/requirements.txt
@@ -0,0 +1,6 @@
+# Abhaengigkeiten des Software-Bibliothek-Clients.
+#
+# Bewusst genau eine: Der Rest — Katalog holen, Dateien ablegen, Pruefsummen
+# rechnen — laeuft mit der Standardbibliothek. Jedes Paket mehr muesste beim Bau
+# der exe mitgeschleppt werden.
+pywebview>=5.3  # natives Fenster (WebView2 unter Windows) + Bruecke zur Oberflaeche
diff --git a/desktop/run_app.py b/desktop/run_app.py
new file mode 100644
index 0000000..63effb6
--- /dev/null
+++ b/desktop/run_app.py
@@ -0,0 +1,10 @@
+"""Direkter Startpunkt (auch Einstiegspunkt fuer den exe-Bau).
+
+Entwicklung:  .venv\\Scripts\\python.exe run_app.py
+oder:         .venv\\Scripts\\python.exe -m backend
+"""
+
+from backend.__main__ import main
+
+if __name__ == "__main__":
+    main()
diff --git a/package-lock.json b/package-lock.json
index 154e9b4..61ed40e 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8,12 +8,35 @@
       "name": "software-bibliothek",
       "version": "0.1.0",
       "workspaces": [
-        "./packages/*"
+        "./packages/*",
+        "./desktop/frontend"
       ],
       "devDependencies": {
         "postject": "^1.0.0-alpha.6",
         "prettier": "^3.4.2",
         "typescript": "^5.7.2"
+      }
+    },
+    "desktop/frontend": {
+      "name": "bibliothek-desktop-frontend",
+      "version": "0.1.0",
+      "dependencies": {
+        "@dh-software/dh-components": "^3.0.23",
+        "@dh-software/dh-components-themes": "^1.0.21",
+        "@dh-software/furnview-icons": "^1.0.21",
+        "@dh-software/lit-extensions": "^0.0.14",
+        "bibliothek-types": "^0.1.0",
+        "construct-style-sheets-polyfill": "^3.1.0",
+        "lit": "^3.3.2"
+      },
+      "devDependencies": {
+        "css-loader": "^7.1.2",
+        "html-webpack-plugin": "^5.6.3",
+        "style-loader": "^4.0.0",
+        "ts-loader": "^9.5.2",
+        "typescript": "^5.7.3",
+        "webpack": "^5.97.1",
+        "webpack-cli": "^6.0.1"
       }
     },
     "node_modules/@cspotcode/source-map-support": {
@@ -2140,6 +2163,10 @@
       "resolved": "packages/bibliothek-client",
       "link": true
     },
+    "node_modules/bibliothek-desktop-frontend": {
+      "resolved": "desktop/frontend",
+      "link": true
+    },
     "node_modules/bibliothek-server": {
       "resolved": "packages/bibliothek-server",
       "link": true
diff --git a/package.json b/package.json
index a0491dc..99f7c4e 100644
--- a/package.json
+++ b/package.json
@@ -4,11 +4,13 @@
   "description": "Interne Software-Bibliothek — Katalog, Datei-Download, Zugriffsprotokoll (D+H)",
   "private": true,
   "workspaces": [
-    "./packages/*"
+    "./packages/*",
+    "./desktop/frontend"
   ],
   "scripts": {
     "build": "npm run build -w packages/bibliothek-types && npm run build -w packages/bibliothek-server && npm run build -w packages/bibliothek-client",
     "build:exe": "npm run build && npm run bundle -w packages/bibliothek-server && node bauen-exe.cjs",
+    "build:desktop-ui": "npm run build -w packages/bibliothek-types && npm run build -w desktop/frontend",
     "dev:server": "npm run dev -w packages/bibliothek-server",
     "dev:client": "npm run dev -w packages/bibliothek-client"
   },
diff --git a/software-bibliothek.json b/software-bibliothek.json
new file mode 100644
index 0000000..b5d616e
--- /dev/null
+++ b/software-bibliothek.json
@@ -0,0 +1,5 @@
+{
+  "_hinweis": "Bau-Anleitung für die Software-Bibliothek selbst. Verteilt wird von hier nicht der Server (den legt man beim Aufsetzen hin), sondern der Client für die Arbeitsplätze. Verfahren: Skill build-secrets im Repo dh-claude-rules.",
+  "buildCommand": "powershell -ExecutionPolicy Bypass -File desktop/build_exe.ps1",
+  "artifact": "desktop/dist/Software-Bibliothek.exe"
+}

--
Gitblit v1.9.3