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` ·
+ <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