Native Windows-Auslieferung statt Container
Docker faellt weg. Das Projekt hat keine npm-Abhaengigkeiten und Node fuehrt TypeScript direkt aus - der Container schrieb nur noch drei Programme fest, und dafuer braucht es keine Virtualisierung, sondern einen Ordner. Der Anlass war, dass ADR-0004 zwar `docker compose pull` versprach, es aber nie ein veroeffentlichtes Image gab. Wer installieren wollte, musste whisper.cpp aus dem Quelltext uebersetzen. Plattformschicht (packages/core/src/platform.ts): - Programme werden neben der Anwendung gesucht, nicht im PATH - Daten unter %LOCALAPPDATA%, damit Installation und Update ohne Administratorrechte auskommen - Schriften mitgeliefert: ohne fontsdir setzt libass wortlos eine Ersatzschrift ein - gemessen 720 statt 826 Pixel Zeilenbreite Windows-Eigenheiten, an echtem ffmpeg 8.1 durchprobiert: - Pfade im Filtergraphen brauchen alle drei Schritte (Vorwaertsschraegstriche, escapter Doppelpunkt, Anfuehrungszeichen); je zwei davon reichen nicht - `nice` gibt es nicht, ersetzt durch os.setPriority Auslieferung: - scripts/build-package.ts baut das Paket, laeuft auch unter Linux - install.ps1 ohne Administratorrechte, Autostart ueber den Aufgabenplaner - update legt neue Fassungen bereit; eingespielt wird beim naechsten Start, nie mitten im Stream - ZIP lesen und schreiben ohne Abhaengigkeiten (packages/core/src/zip.ts) Kein `irm ... | iex`-Einzeiler: git.zeasy.dev antwortete instanzweit mit einer Umleitung auf /user/login, und `iex` fuehrte die Anmeldeseite als PowerShell aus. Der Updater verfolgt Umleitungen jetzt nicht und hat keine erfundene Vorgabeadresse mehr. Ausserdem: backup.ps1 war an zwei Stellen dokumentiert, aber nie geschrieben. ADR-0007 ersetzt ADR-0004. 84 Tests, Typpruefung sauber. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AhVSfMFdWnXKCv5JfLnArZ
This commit is contained in:
1 parent
71dd660d01
commit
5f6301268a
41 files changed
+2254
-312
No files matched your search
@@ -0,0 +1,154 @@
|
||||
// Nach einer neuen Fassung sehen und sie bereitlegen.
|
||||
//
|
||||
// Bereitlegen, nicht einspielen: Windows sperrt die Dateien laufender
|
||||
// Programme, und mitten im Stream soll ohnehin nichts ausgetauscht werden.
|
||||
// Das neue Paket landet unter `pending\`; `launcher.ps1` tauscht es beim
|
||||
// naechsten Start aus, wenn nichts laeuft. Siehe ADR-0007.
|
||||
//
|
||||
// Die Adresse steht in der Konfiguration und hat bewusst KEINE Vorgabe. Ein
|
||||
// erfundener Standardwert sieht so lange richtig aus, bis jemand ihn aufruft
|
||||
// und eine Anmeldeseite als Skript ausgefuehrt bekommt.
|
||||
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { loadConfig } from '#core/config.ts';
|
||||
import { installRoot } from '#core/platform.ts';
|
||||
import { readZip } from '#core/zip.ts';
|
||||
import { readSecrets } from '#twitch/secrets.ts';
|
||||
|
||||
type Manifest = {
|
||||
version: string;
|
||||
datei: string;
|
||||
groesse: number;
|
||||
sha256: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Versionen vergleichen. `2026.9.9` ist aelter als `2026.10.1` - ein
|
||||
* Zeichenkettenvergleich saehe das andersherum.
|
||||
*/
|
||||
export function isNewer(candidate: string, current: string): boolean {
|
||||
const parts = (v: string) => v.split(/[.\-+]/).map((p) => (/^\d+$/.test(p) ? Number(p) : p));
|
||||
const a = parts(candidate);
|
||||
const b = parts(current);
|
||||
for (let i = 0; i < Math.max(a.length, b.length); i++) {
|
||||
const x = a[i] ?? 0;
|
||||
const y = b[i] ?? 0;
|
||||
if (x === y) continue;
|
||||
if (typeof x === 'number' && typeof y === 'number') return x > y;
|
||||
return String(x) > String(y);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Eine Antwort, die wie eine Anmeldeseite aussieht, ist keine Datei.
|
||||
*
|
||||
* Genau das ist einmal passiert: `git.zeasy.dev` ist komplett angemeldet-only
|
||||
* und antwortet auf jede Adresse mit 303 auf `/user/login`. Wer dem folgt,
|
||||
* bekommt HTML - und `iex` versucht, es als PowerShell auszufuehren.
|
||||
*/
|
||||
export function looksLikeLogin(contentType: string | null, body: Buffer): boolean {
|
||||
if (contentType?.includes('text/html')) return true;
|
||||
const head = body.subarray(0, 200).toString('utf8').toLowerCase();
|
||||
return head.includes('<!doctype html') || head.includes('<html') ||
|
||||
head.includes('/user/login');
|
||||
}
|
||||
|
||||
async function localVersion(): Promise<string> {
|
||||
const raw = await fs.readFile(path.join(installRoot, 'version.json'), 'utf8').catch(() => null);
|
||||
if (!raw) return '0';
|
||||
return (JSON.parse(raw) as { version?: string }).version ?? '0';
|
||||
}
|
||||
|
||||
/** Adresse und - falls hinterlegt - der Zugriffstoken. */
|
||||
function quelle(): { url: string; headers: Record<string, string> } {
|
||||
const url = (process.env['ABDULIVE_RELEASE_URL'] ?? loadConfig().update?.url ?? '').trim();
|
||||
if (!url) {
|
||||
throw new Error(
|
||||
'Keine Update-Adresse hinterlegt.\n\n' +
|
||||
' In config.yaml eintragen:\n' +
|
||||
' update:\n' +
|
||||
' url: "https://.../abdulive"\n\n' +
|
||||
' Dort muessen `abdulive.json` und die darin genannte ZIP-Datei liegen.\n' +
|
||||
' Gebaut werden beide mit: node scripts/build-package.ts');
|
||||
}
|
||||
const headers: Record<string, string> = { 'user-agent': 'abdulive-update' };
|
||||
const token = process.env['ABDULIVE_UPDATE_TOKEN'] ?? readSecrets().update?.token;
|
||||
// Gitea und Forgejo erwarten genau diese Form; ein Bearer-Token lehnen sie ab.
|
||||
if (token) headers['authorization'] = `token ${token}`;
|
||||
return { url: url.replace(/\/+$/, ''), headers };
|
||||
}
|
||||
|
||||
async function hole(url: string, headers: Record<string, string>): Promise<Buffer> {
|
||||
// Umleitungen NICHT automatisch folgen: eine Anmeldeseite ist genau das,
|
||||
// und ihr zu folgen liefert HTML statt einer Fehlermeldung.
|
||||
const res = await fetch(url, { headers, redirect: 'manual' });
|
||||
|
||||
if (res.status >= 300 && res.status < 400) {
|
||||
const ziel = res.headers.get('location') ?? '(ohne Angabe)';
|
||||
throw new Error(
|
||||
`${url}\n antwortet mit ${res.status} und leitet nach ${ziel} um.\n\n` +
|
||||
(ziel.includes('login')
|
||||
? ' Das ist eine Anmeldeseite - die Ablage ist nicht oeffentlich.\n' +
|
||||
' Entweder die Pakete an einen Ort ohne Anmeldung legen, oder einen\n' +
|
||||
' Lese-Token auf der Einrichtungsseite hinterlegen.'
|
||||
: ' Umleitungen werden hier absichtlich nicht verfolgt.'));
|
||||
}
|
||||
if (!res.ok) throw new Error(`${url}\n antwortet mit HTTP ${res.status}.`);
|
||||
|
||||
const body = Buffer.from(await res.arrayBuffer());
|
||||
if (looksLikeLogin(res.headers.get('content-type'), body)) {
|
||||
throw new Error(
|
||||
`${url}\n liefert eine HTML-Seite statt einer Datei.\n\n` +
|
||||
' Vermutlich eine Anmelde- oder Fehlerseite. Die Adresse im Browser\n' +
|
||||
' aufrufen zeigt, was dort wirklich steht.');
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
export async function checkForUpdate(opts: { force?: boolean } = {}): Promise<void> {
|
||||
const { url, headers } = quelle();
|
||||
const current = await localVersion();
|
||||
console.log(`Quelle: ${url}`);
|
||||
console.log(`Installiert: ${current}`);
|
||||
|
||||
const manifest = JSON.parse((await hole(`${url}/abdulive.json`, headers)).toString('utf8')) as Manifest;
|
||||
console.log(`Angeboten: ${manifest.version}`);
|
||||
|
||||
if (!opts.force && !isNewer(manifest.version, current)) {
|
||||
console.log('\nNichts zu tun.');
|
||||
return;
|
||||
}
|
||||
|
||||
const pending = path.join(installRoot, 'pending');
|
||||
// Ein Rest von einem abgebrochenen Versuch darf nicht mit eingespielt
|
||||
// werden - also erst weg damit.
|
||||
await fs.rm(pending, { recursive: true, force: true });
|
||||
|
||||
process.stdout.write(`\nLade ${manifest.datei} ... `);
|
||||
const zip = await hole(`${url}/${manifest.datei}`, headers);
|
||||
console.log(`${(zip.length / 1e6).toFixed(0)} MB`);
|
||||
|
||||
const sum = crypto.createHash('sha256').update(zip).digest('hex');
|
||||
if (sum !== manifest.sha256.toLowerCase()) {
|
||||
throw new Error(
|
||||
`Pruefsumme stimmt nicht.\n erwartet ${manifest.sha256}\n bekommen ${sum}\n` +
|
||||
'Das Paket wird nicht eingespielt.');
|
||||
}
|
||||
console.log('Pruefsumme stimmt.');
|
||||
|
||||
let count = 0;
|
||||
for (const entry of readZip(zip)) {
|
||||
if (entry.isDirectory) continue;
|
||||
const target = path.join(pending, entry.name);
|
||||
await fs.mkdir(path.dirname(target), { recursive: true });
|
||||
await fs.writeFile(target, entry.data());
|
||||
count++;
|
||||
}
|
||||
console.log(`${count} Dateien nach ${pending} gelegt.`);
|
||||
console.log('\nEingespielt wird beim naechsten Start - waehrend eines Streams');
|
||||
console.log('wird nichts ausgetauscht. Sofort uebernehmen:');
|
||||
console.log(' Stop-ScheduledTask -TaskName abdulive; Start-ScheduledTask -TaskName abdulive');
|
||||
}
|
||||
Reference in new issue
Block a user