Server-Bibliothek
vor 18 Std. 98bce1b0da675715b2e5482c0ad61ba21135f62f
Add a desktop client that knows what is installed locally

A web page cannot see the file system, so it can only guess which version of a
tool sits on a workstation. This client can look: it places the files itself and
records their checksum, so on the next start it compares and reports instead of
estimating — including when someone replaced a file by hand.

Python with pywebview, mirroring the meeting-transkriptor: the same
dh-components frontend, shown in a native window through the WebView2 that
Windows already ships. No second browser engine is bundled; the executable is
14 MB rather than the ~150 MB an Electron build would cost.

The frontend is an npm workspace so it shares bibliothek-types with the server.
software-bibliothek.json builds the client, which lets the library distribute it
the same way it distributes every other internal tool.
3 Dateien geändert
23 Dateien hinzugefügt
1601 ■■■■■ Geänderte Dateien
.gitignore 4 ●●●● Patch | Ansicht | Raw | Blame | Historie
desktop/assets/icon.ico Patch | Ansicht | Raw | Blame | Historie
desktop/backend/__init__.py 6 ●●●●● Patch | Ansicht | Raw | Blame | Historie
desktop/backend/__main__.py 52 ●●●●● Patch | Ansicht | Raw | Blame | Historie
desktop/backend/api.py 180 ●●●●● Patch | Ansicht | Raw | Blame | Historie
desktop/backend/bibliothek.py 85 ●●●●● Patch | Ansicht | Raw | Blame | Historie
desktop/backend/konfiguration.py 74 ●●●●● Patch | Ansicht | Raw | Blame | Historie
desktop/backend/werkzeuge.py 153 ●●●●● Patch | Ansicht | Raw | Blame | Historie
desktop/build_exe.ps1 76 ●●●●● Patch | Ansicht | Raw | Blame | Historie
desktop/frontend/package.json 29 ●●●●● Patch | Ansicht | Raw | Blame | Historie
desktop/frontend/src/bruecke.ts 69 ●●●●● Patch | Ansicht | Raw | Blame | Historie
desktop/frontend/src/components/client-app.ts 284 ●●●●● Patch | Ansicht | Raw | Blame | Historie
desktop/frontend/src/components/client-zeile.ts 135 ●●●●● Patch | Ansicht | Raw | Blame | Historie
desktop/frontend/src/global.css 200 ●●●●● Patch | Ansicht | Raw | Blame | Historie
desktop/frontend/src/index.html 11 ●●●●● Patch | Ansicht | Raw | Blame | Historie
desktop/frontend/src/index.ts 15 ●●●●● Patch | Ansicht | Raw | Blame | Historie
desktop/frontend/src/raw.d.ts 5 ●●●●● Patch | Ansicht | Raw | Blame | Historie
desktop/frontend/src/theme.ts 58 ●●●●● Patch | Ansicht | Raw | Blame | Historie
desktop/frontend/tsconfig.json 15 ●●●●● Patch | Ansicht | Raw | Blame | Historie
desktop/frontend/webpack.config.js 56 ●●●●● Patch | Ansicht | Raw | Blame | Historie
desktop/generate_icon.py 40 ●●●●● Patch | Ansicht | Raw | Blame | Historie
desktop/requirements.txt 6 ●●●●● Patch | Ansicht | Raw | Blame | Historie
desktop/run_app.py 10 ●●●●● Patch | Ansicht | Raw | Blame | Historie
package-lock.json 29 ●●●●● Patch | Ansicht | Raw | Blame | Historie
package.json 4 ●●● Patch | Ansicht | Raw | Blame | Historie
software-bibliothek.json 5 ●●●●● Patch | Ansicht | Raw | Blame | Historie
.gitignore
@@ -1,11 +1,15 @@
# Abhaengigkeiten
node_modules
.venv
__pycache__/
*.pyc
# Build-Ausgaben
dist
build
build-exe
exe/
*.spec
# Logs
*.log
desktop/assets/icon.ico
desktop/backend/__init__.py
Neue Datei
@@ -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.
"""
desktop/backend/__main__.py
Neue Datei
@@ -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()
desktop/backend/api.py
Neue Datei
@@ -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"
desktop/backend/bibliothek.py
Neue Datei
@@ -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
desktop/backend/konfiguration.py
Neue Datei
@@ -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
desktop/backend/werkzeuge.py
Neue Datei
@@ -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
desktop/build_exe.ps1
Neue Datei
@@ -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)"
desktop/frontend/package.json
Neue Datei
@@ -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"
  }
}
desktop/frontend/src/bruecke.ts
Neue Datei
@@ -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;
}
desktop/frontend/src/components/client-app.ts
Neue Datei
@@ -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;
  }
}
desktop/frontend/src/components/client-zeile.ts
Neue Datei
@@ -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;
  }
}
desktop/frontend/src/global.css
Neue Datei
@@ -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;
}
desktop/frontend/src/index.html
Neue Datei
@@ -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>
desktop/frontend/src/index.ts
Neue Datei
@@ -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';
desktop/frontend/src/raw.d.ts
Neue Datei
@@ -0,0 +1,5 @@
/** Import mit `?raw` liefert den Dateiinhalt als Zeichenkette (webpack asset/source). */
declare module '*?raw' {
  const inhalt: string;
  export default inhalt;
}
desktop/frontend/src/theme.ts
Neue Datei
@@ -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();
}
desktop/frontend/tsconfig.json
Neue Datei
@@ -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"]
}
desktop/frontend/webpack.config.js
Neue Datei
@@ -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,
  },
};
desktop/generate_icon.py
Neue Datei
@@ -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}")
desktop/requirements.txt
Neue Datei
@@ -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
desktop/run_app.py
Neue Datei
@@ -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()
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
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"
  },
software-bibliothek.json
Neue Datei
@@ -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"
}