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

---
 packages/bibliothek-server/src/bezugsquellen/github.ts |   95 +++++++++++++++++++++++++++++++++++++++++++++++
 1 files changed, 95 insertions(+), 0 deletions(-)

diff --git a/packages/bibliothek-server/src/bezugsquellen/github.ts b/packages/bibliothek-server/src/bezugsquellen/github.ts
new file mode 100644
index 0000000..de969d7
--- /dev/null
+++ b/packages/bibliothek-server/src/bezugsquellen/github.ts
@@ -0,0 +1,95 @@
+import {
+  abgleichMisserfolg,
+  alsIsoDatum,
+  dateiVorhanden,
+  ersetzeDateiSicher,
+  inhaltBeanstanden,
+  type Abgleichauftrag,
+  type Bezugsquelle,
+  type QuellenAbgleich,
+} from './basis';
+
+interface GithubAsset {
+  name: string;
+  browser_download_url: string;
+}
+
+interface GithubRelease {
+  tag_name: string;
+  /** Veröffentlichungszeitpunkt des Releases beim Anbieter. */
+  published_at: string;
+  assets: GithubAsset[];
+}
+
+/** GitHub-Releases: holt das neueste Release und lädt ein passendes Installer-Asset. */
+export class GithubQuelle implements Bezugsquelle {
+  async gleicheAb(auftrag: Abgleichauftrag): Promise<QuellenAbgleich> {
+    try {
+      const antwort = await fetch(`https://api.github.com/repos/${auftrag.wert}/releases/latest`, {
+        headers: { 'User-Agent': 'software-bibliothek', Accept: 'application/vnd.github+json' },
+      });
+      if (!antwort.ok) {
+        return abgleichMisserfolg(auftrag.aktuelleVersion, `GitHub nicht erreichbar (HTTP ${antwort.status}).`);
+      }
+      const release = (await antwort.json()) as GithubRelease;
+      const asset = waehlePassendesAsset(release.assets);
+      if (!asset) {
+        return abgleichMisserfolg(auftrag.aktuelleVersion, `Im Release ${release.tag_name} liegt keine herunterladbare Datei.`);
+      }
+      const veroeffentlichtAm = alsIsoDatum(release.published_at);
+      if (release.tag_name === auftrag.aktuelleVersion && dateiVorhanden(auftrag.zielordner, asset.name)) {
+        return { erfolg: true, geaendert: false, version: release.tag_name, dateiname: asset.name, meldung: 'Bereits aktuell.', veroeffentlichtAm };
+      }
+
+      const dateiAntwort = await fetch(asset.browser_download_url, { headers: { 'User-Agent': 'software-bibliothek' }, redirect: 'follow' });
+      if (!dateiAntwort.ok) {
+        return abgleichMisserfolg(auftrag.aktuelleVersion, `Asset-Download fehlgeschlagen (HTTP ${dateiAntwort.status}).`);
+      }
+      const inhalt = Buffer.from(await dateiAntwort.arrayBuffer());
+      const beanstandung = inhaltBeanstanden(inhalt, asset.name, dateiAntwort.headers.get('content-type'), auftrag.vorherigeGroesse);
+      if (beanstandung) {
+        return abgleichMisserfolg(auftrag.aktuelleVersion, beanstandung);
+      }
+
+      ersetzeDateiSicher(auftrag.zielordner, asset.name, inhalt);
+      return { erfolg: true, geaendert: true, version: release.tag_name, dateiname: asset.name, meldung: `Auf ${release.tag_name} aktualisiert.`, veroeffentlichtAm };
+    } catch (fehler) {
+      return abgleichMisserfolg(auftrag.aktuelleVersion, `GitHub-Abgleich fehlgeschlagen: ${fehler instanceof Error ? fehler.message : String(fehler)}`);
+    }
+  }
+}
+
+/**
+ * Wählt aus den Release-Dateien den passenden Windows-Installer aus.
+ *
+ * Bewertet statt „erster Treffer gewinnt", damit die Reihenfolge im Release
+ * das Ergebnis nicht beeinflusst: 64-Bit vor allem anderen, ARM und 32-Bit
+ * ausgeschlossen, `.exe` vor `.msi`, portable Varianten nur als Notnagel.
+ */
+export function waehlePassendesAsset(assets: GithubAsset[]): GithubAsset | undefined {
+  const kandidaten = assets
+    .filter((eintrag) => /\.(exe|msi|msix|zip)$/i.test(eintrag.name))
+    .filter((eintrag) => !/arm|aarch|x86|ia32|32.?bit/i.test(eintrag.name));
+  if (kandidaten.length === 0) {
+    return undefined;
+  }
+  const bewertet = kandidaten.map((eintrag) => ({ eintrag, punkte: bewerteAsset(eintrag.name) }));
+  bewertet.sort((links, rechts) => rechts.punkte - links.punkte);
+  return bewertet[0].eintrag;
+}
+
+function bewerteAsset(name: string): number {
+  let punkte = 0;
+  if (/x64|amd64|win64|64.?bit/i.test(name)) {
+    punkte += 100; // ausdrücklich 64-Bit
+  }
+  if (/\.exe$/i.test(name)) {
+    punkte += 20; // Installer bevorzugt gegenüber .msi/.zip
+  } else if (/\.msi$/i.test(name)) {
+    punkte += 10;
+  }
+  if (/portable/i.test(name)) {
+    punkte -= 50; // nur nehmen, wenn es nichts anderes gibt
+  }
+  return punkte;
+}

--
Gitblit v1.9.3