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
+13
-7
@@ -1,12 +1,18 @@
|
||||
# scripts
|
||||
|
||||
Skripte laufen entweder im Container (`docker compose run --rm toolkit <name>`)
|
||||
oder auf dem Host, wo sie mit Docker sprechen.
|
||||
Befehle der Anwendung ruft man ueber `abdulive.cmd <name>` auf (im Repo:
|
||||
`node apps/cli/main.ts <name>`). Daneben stehen hier die Skripte fuer
|
||||
Paketbau, Installation und Start.
|
||||
|
||||
| Skript | Wo | Zweck |
|
||||
|---|---|---|
|
||||
| `check-env` | Container | Prueft ffmpeg, whisper-cli, Sprachmodell, Volumes, OBS-Erreichbarkeit |
|
||||
| `auth` | Container | OAuth-Flow fuer Broadcaster- und Bot-Token (braucht `--service-ports`) |
|
||||
| `db:migrate` | Container | Migrationen anwenden |
|
||||
| `calibrate` | Container | Facecam-Crop einmessen, schreibt `config/layout.yaml` |
|
||||
| `backup.ps1` | Host | `VACUUM INTO` aus dem Volume `abdulive-db` nach `data/db/backup/`, 30 Generationen |
|
||||
| `build-package.ts` | Entwicklungsrechner / CI | Baut `dist/abdulive-<version>-win-x64.zip` samt Updater-Manifest |
|
||||
| `install.ps1` | Stream-PC | Installiert Programm, Daten und Autostart. Ohne Administratorrechte |
|
||||
| `launcher.ps1` | Stream-PC | Vom Aufgabenplaner gestartet: Update anwenden, dann Daemon starten |
|
||||
| `check-env` | Stream-PC | Prueft ffmpeg, whisper-cli, Schriften, Sprachmodell, Verzeichnisse, OBS |
|
||||
| `auth` | Stream-PC | OAuth-Flow fuer Broadcaster- und Bot-Token |
|
||||
| `db:migrate` | Stream-PC | Migrationen anwenden |
|
||||
| `calibrate` | Stream-PC | Facecam-Crop einmessen, schreibt `config/layout.yaml` |
|
||||
| `sources` | Stream-PC | Listet die OBS-Quellen der laufenden Szene und sagt, welche im 9:16-Ausschnitt landen |
|
||||
| `backup.ps1` | Stream-PC | `VACUUM INTO` aus `%LOCALAPPDATA%\abdulive\db`, 30 Generationen |
|
||||
| `update` | Stream-PC | Neue Fassung pruefen und bereitlegen; eingespielt wird beim naechsten Start |
|
||||
@@ -0,0 +1,56 @@
|
||||
# Sichert die Datenbank.
|
||||
#
|
||||
# `VACUUM INTO` und nicht kopieren: die Datenbank laeuft im WAL-Modus und wird
|
||||
# waehrend des Streams beschrieben. Eine im Schreibvorgang kopierte Datei ist
|
||||
# unbrauchbar - VACUUM INTO schreibt dagegen einen in sich stimmigen Stand,
|
||||
# ohne den laufenden Betrieb anzuhalten.
|
||||
#
|
||||
# Die Sicherung enthaelt Chatverlaeufe und damit personenbezogene Daten. Sie
|
||||
# gehoert nicht ins Repo und nicht in eine Cloud ohne Absprache.
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$DatenOrdner = (Join-Path $env:LOCALAPPDATA 'abdulive'),
|
||||
[string]$Ziel = (Join-Path $env:LOCALAPPDATA 'abdulive\backup'),
|
||||
[int]$Generationen = 30
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$db = Join-Path $DatenOrdner 'db\abdulive.db'
|
||||
if (-not (Test-Path $db)) { throw "Keine Datenbank unter $db" }
|
||||
|
||||
$node = Join-Path $env:LOCALAPPDATA 'Programs\abdulive\node.exe'
|
||||
if (-not (Test-Path $node)) { $node = (Get-Command node -ErrorAction Stop).Source }
|
||||
|
||||
New-Item -ItemType Directory -Force -Path $Ziel | Out-Null
|
||||
$stand = Get-Date -Format 'yyyy-MM-dd_HHmm'
|
||||
$datei = Join-Path $Ziel "abdulive-$stand.db"
|
||||
|
||||
# node:sqlite kann VACUUM INTO direkt - dafuer braucht es kein sqlite3.exe.
|
||||
$skript = @'
|
||||
const { DatabaseSync } = require('node:sqlite');
|
||||
const [quelle, ziel] = process.argv.slice(2);
|
||||
const db = new DatabaseSync(quelle, { readOnly: true });
|
||||
db.exec(`VACUUM INTO '${ziel.replace(/'/g, "''")}'`);
|
||||
db.close();
|
||||
'@
|
||||
$tmp = Join-Path $env:TEMP "abdulive-backup-$PID.cjs"
|
||||
Set-Content -Path $tmp -Value $skript -Encoding utf8
|
||||
try {
|
||||
& $node $tmp $db $datei
|
||||
if ($LASTEXITCODE -ne 0) { throw "VACUUM INTO fehlgeschlagen (Code $LASTEXITCODE)" }
|
||||
} finally {
|
||||
Remove-Item $tmp -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
$mb = [math]::Round((Get-Item $datei).Length / 1MB, 1)
|
||||
Write-Host "Gesichert: $datei ($mb MB)"
|
||||
|
||||
# Aeltere Generationen abraeumen
|
||||
$alt = Get-ChildItem $Ziel -Filter 'abdulive-*.db' |
|
||||
Sort-Object LastWriteTime -Descending | Select-Object -Skip $Generationen
|
||||
foreach ($f in $alt) {
|
||||
Remove-Item $f.FullName
|
||||
Write-Host " entfernt: $($f.Name)"
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
// Baut das Windows-Paket, das Abdu bekommt.
|
||||
//
|
||||
// node scripts/build-package.ts [--version 0.5.0] [--out dist]
|
||||
//
|
||||
// Ergebnis in dist/:
|
||||
// abdulive-<version>-win-x64.zip das Paket
|
||||
// abdulive.json Manifest fuer den Updater
|
||||
//
|
||||
// Laeuft auf jedem Rechner, auch unter Linux in der CI - es wird nichts
|
||||
// ausgefuehrt, nur heruntergeladen, entpackt und wieder eingepackt.
|
||||
// Siehe ADR-0007.
|
||||
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { readZip, writeZip } from '#core/zip.ts';
|
||||
|
||||
const repo = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
|
||||
// ---------------------------------------------------------------- Bausteine
|
||||
|
||||
/**
|
||||
* Node liefert versionierte, dauerhafte Adressen. ffmpeg und whisper.cpp
|
||||
* nicht: BtbN raeumt alte Autobuilds nach einigen Wochen ab. Deshalb wird
|
||||
* die jeweils aktuelle Ausgabe ueber die GitHub-API bestimmt und ihr genauer
|
||||
* Name samt Pruefsumme ins Paket geschrieben - reproduzierbar ist damit das
|
||||
* Paket, nicht die Adresse.
|
||||
*/
|
||||
const NODE_VERSION = 'v24.9.0';
|
||||
|
||||
type Component = {
|
||||
name: string;
|
||||
url: () => Promise<string>;
|
||||
/** Welche Dateien aus dem Archiv, und wohin im Paket. */
|
||||
pick: (entryName: string) => string | null;
|
||||
};
|
||||
|
||||
const COMPONENTS: Component[] = [
|
||||
{
|
||||
name: 'node',
|
||||
url: async () =>
|
||||
`https://nodejs.org/dist/${NODE_VERSION}/node-${NODE_VERSION}-win-x64.zip`,
|
||||
pick: (n) => (n.endsWith('/node.exe') ? 'node.exe' : null),
|
||||
},
|
||||
{
|
||||
name: 'ffmpeg',
|
||||
url: () => githubAsset('BtbN/FFmpeg-Builds',
|
||||
/^ffmpeg-n\d[\w.-]*win64-gpl-shared-[\d.]+\.zip$/),
|
||||
// Aus dem shared-Build nur bin/: die beiden Programme und ihre DLLs.
|
||||
// ffplay wird nicht gebraucht.
|
||||
pick: (n) => {
|
||||
const m = /\/bin\/(.+)$/.exec(n);
|
||||
if (!m?.[1] || m[1].startsWith('ffplay')) return null;
|
||||
return /\.(exe|dll)$/i.test(m[1]) ? `bin/${m[1]}` : null;
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'whisper',
|
||||
url: () => githubAsset('ggml-org/whisper.cpp', /^whisper-bin-x64\.zip$/),
|
||||
// whisper-cli plus die Bibliotheken. Die ggml-cpu-*.dll sind Varianten
|
||||
// fuer verschiedene Befehlssaetze; ausgewaehlt wird zur Laufzeit, es
|
||||
// muessen also alle mit.
|
||||
pick: (n) => {
|
||||
const m = /^Release\/(.+)$/.exec(n);
|
||||
const f = m?.[1];
|
||||
if (!f) return null;
|
||||
if (f === 'whisper-cli.exe') return `bin/${f}`;
|
||||
if (/^(whisper|ggml)[\w.-]*\.dll$/.test(f)) return `bin/${f}`;
|
||||
return null;
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
async function githubAsset(repoName: string, pattern: RegExp): Promise<string> {
|
||||
const res = await fetch(`https://api.github.com/repos/${repoName}/releases/latest`, {
|
||||
headers: { 'user-agent': 'abdulive-build' },
|
||||
});
|
||||
if (!res.ok) throw new Error(`GitHub-API ${res.status} fuer ${repoName}`);
|
||||
const rel = await res.json() as { assets: Array<{ name: string; browser_download_url: string }> };
|
||||
const asset = rel.assets.find((a) => pattern.test(a.name));
|
||||
if (!asset) throw new Error(`Kein passendes Archiv in ${repoName} (${pattern})`);
|
||||
return asset.browser_download_url;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Werkzeug
|
||||
|
||||
const sha256 = (b: Buffer) => crypto.createHash('sha256').update(b).digest('hex');
|
||||
|
||||
async function download(url: string): Promise<Buffer> {
|
||||
const cacheDir = path.join(repo, '.cache');
|
||||
await fs.mkdir(cacheDir, { recursive: true });
|
||||
const file = path.join(cacheDir, path.basename(new URL(url).pathname));
|
||||
|
||||
const cached = await fs.readFile(file).catch(() => null);
|
||||
if (cached) {
|
||||
console.log(` aus dem Zwischenspeicher: ${path.basename(file)} ` +
|
||||
`(${(cached.length / 1e6).toFixed(0)} MB)`);
|
||||
return cached;
|
||||
}
|
||||
|
||||
process.stdout.write(` lade ${path.basename(file)} ... `);
|
||||
const res = await fetch(url, { redirect: 'follow' });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status} fuer ${url}`);
|
||||
const buf = Buffer.from(await res.arrayBuffer());
|
||||
await fs.writeFile(file, buf);
|
||||
console.log(`${(buf.length / 1e6).toFixed(0)} MB`);
|
||||
return buf;
|
||||
}
|
||||
|
||||
/** Alle Dateien unterhalb `dir`, relativ, ohne die ausgeschlossenen. */
|
||||
async function walk(dir: string, base = dir): Promise<string[]> {
|
||||
const out: string[] = [];
|
||||
for (const e of await fs.readdir(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, e.name);
|
||||
if (e.isDirectory()) out.push(...await walk(full, base));
|
||||
else out.push(path.relative(base, full).replace(/\\/g, '/'));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Bau
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const args = process.argv.slice(2);
|
||||
const arg = (n: string) => { const i = args.indexOf(n); return i >= 0 ? args[i + 1] : undefined; };
|
||||
const version = arg('--version') ?? new Date().toISOString().slice(0, 10).replace(/-/g, '.');
|
||||
const outDir = path.resolve(repo, arg('--out') ?? 'dist');
|
||||
|
||||
console.log(`abdulive ${version} fuer Windows x64\n`);
|
||||
|
||||
const files: Array<{ name: string; data: Buffer }> = [];
|
||||
const used: Record<string, { archiv: string; sha256: string }> = {};
|
||||
|
||||
// --- mitgelieferte Programme
|
||||
for (const c of COMPONENTS) {
|
||||
console.log(`${c.name}:`);
|
||||
const url = await c.url();
|
||||
const zip = await download(url);
|
||||
used[c.name] = { archiv: path.basename(new URL(url).pathname), sha256: sha256(zip) };
|
||||
|
||||
let taken = 0;
|
||||
for (const entry of readZip(zip)) {
|
||||
if (entry.isDirectory) continue;
|
||||
const target = c.pick(entry.name);
|
||||
if (!target) continue;
|
||||
files.push({ name: target, data: entry.data() });
|
||||
taken++;
|
||||
}
|
||||
if (!taken) throw new Error(`${c.name}: keine Datei uebernommen - hat sich der Aufbau geaendert?`);
|
||||
console.log(` ${taken} Datei(en) uebernommen`);
|
||||
}
|
||||
|
||||
// --- Anwendung: Quellen, ohne Tests
|
||||
console.log('\nAnwendung:');
|
||||
for (const dir of ['packages', 'apps']) {
|
||||
for (const rel of await walk(path.join(repo, dir))) {
|
||||
if (rel.endsWith('.test.ts')) continue;
|
||||
if (!/\.(ts|sql|json)$/.test(rel)) continue;
|
||||
files.push({ name: `app/${dir}/${rel}`, data: await fs.readFile(path.join(repo, dir, rel)) });
|
||||
}
|
||||
}
|
||||
// Die "imports"-Map darin loest #core/... auf - ohne sie startet nichts.
|
||||
files.push({ name: 'app/package.json', data: await fs.readFile(path.join(repo, 'package.json')) });
|
||||
for (const f of ['config.example.yaml', 'layout.yaml']) {
|
||||
files.push({ name: `app/config/${f}`, data: await fs.readFile(path.join(repo, 'config', f)) });
|
||||
}
|
||||
for (const rel of await walk(path.join(repo, 'assets', 'fonts'))) {
|
||||
files.push({ name: `fonts/${rel}`, data: await fs.readFile(path.join(repo, 'assets/fonts', rel)) });
|
||||
}
|
||||
for (const f of ['launcher.ps1', 'install.ps1', 'backup.ps1']) {
|
||||
files.push({ name: f, data: await fs.readFile(path.join(repo, 'scripts', f)) });
|
||||
}
|
||||
console.log(` ${files.filter((f) => f.name.startsWith('app/')).length} Quelldateien`);
|
||||
|
||||
// --- Kopfdaten
|
||||
const manifestInPackage = {
|
||||
version,
|
||||
gebaut: new Date().toISOString(),
|
||||
node: NODE_VERSION,
|
||||
bausteine: used,
|
||||
};
|
||||
files.push({
|
||||
name: 'version.json',
|
||||
data: Buffer.from(JSON.stringify(manifestInPackage, null, 2) + '\n'),
|
||||
});
|
||||
files.push({
|
||||
name: 'abdulive.cmd',
|
||||
data: Buffer.from('@echo off\r\n"%~dp0node.exe" "%~dp0app\\apps\\cli\\main.ts" %*\r\n'),
|
||||
});
|
||||
|
||||
// --- packen
|
||||
await fs.mkdir(outDir, { recursive: true });
|
||||
const zipName = `abdulive-${version}-win-x64.zip`;
|
||||
const zip = writeZip(files);
|
||||
await fs.writeFile(path.join(outDir, zipName), zip);
|
||||
|
||||
// Das Manifest, das der Updater abfragt. Die Adresse traegt die
|
||||
// Veroeffentlichung ein - hier steht nur, wie die Datei heisst.
|
||||
await fs.writeFile(path.join(outDir, 'abdulive.json'), JSON.stringify({
|
||||
version,
|
||||
datei: zipName,
|
||||
groesse: zip.length,
|
||||
sha256: sha256(zip),
|
||||
veroeffentlicht: new Date().toISOString(),
|
||||
}, null, 2) + '\n');
|
||||
|
||||
const roh = files.reduce((n, f) => n + f.data.length, 0);
|
||||
console.log(`\nFertig: ${path.join(outDir, zipName)}`);
|
||||
console.log(` ${files.length} Dateien, ${(roh / 1e6).toFixed(0)} MB roh, ` +
|
||||
`${(zip.length / 1e6).toFixed(0)} MB gepackt`);
|
||||
console.log(` sha256 ${sha256(zip)}`);
|
||||
console.log('\nDas Sprachmodell (ca. 465 MB) ist nicht dabei - es wird bei der');
|
||||
console.log('Einrichtung geladen (`abdulive fetch-model`).');
|
||||
}
|
||||
|
||||
await main();
|
||||
@@ -0,0 +1,187 @@
|
||||
# Installiert abdulive auf dem Stream-PC.
|
||||
#
|
||||
# .\install.ps1
|
||||
#
|
||||
# Aus einem entpackten Paket heraus ausfuehren - install.ps1 liegt darin neben
|
||||
# node.exe.
|
||||
#
|
||||
# Einen `irm ... | iex`-Einzeiler gibt es bewusst nicht: git.zeasy.dev verlangt
|
||||
# instanzweit eine Anmeldung und antwortet auf jede Adresse mit einer Umleitung
|
||||
# auf /user/login. `iex` fuehrt die Anmeldeseite dann als PowerShell aus, mit
|
||||
# einer Fehlermeldung, aus der niemand die Ursache ablesen kann. Wer eine
|
||||
# Ablage ohne Anmeldung hat, gibt sie mit -Quelle an; dann laedt das Skript
|
||||
# das Paket selbst.
|
||||
#
|
||||
# Ohne Administratorrechte, mit Absicht: Programm und Daten liegen im Profil
|
||||
# des Benutzers, damit auch Updates ohne Rueckfrage durchlaufen. Siehe
|
||||
# ADR-0007.
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
# Nur noetig, wenn nicht aus einem entpackten Paket heraus installiert
|
||||
# wird. Dort muessen abdulive.json und die ZIP-Datei ohne Anmeldung
|
||||
# erreichbar sein.
|
||||
[string]$Quelle,
|
||||
[string]$Ziel = (Join-Path $env:LOCALAPPDATA 'Programs\abdulive'),
|
||||
[switch]$KeinAutostart
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$daten = Join-Path $env:LOCALAPPDATA 'abdulive'
|
||||
|
||||
function Schritt($text) { Write-Host "==> $text" -ForegroundColor Cyan }
|
||||
function Hinweis($text) { Write-Host " $text" -ForegroundColor DarkGray }
|
||||
|
||||
# ---------------------------------------------------------------- Pruefen
|
||||
|
||||
Schritt 'Umgebung pruefen'
|
||||
if ([Environment]::Is64BitOperatingSystem -ne $true) {
|
||||
throw 'Das Paket gibt es nur fuer 64-Bit-Windows.'
|
||||
}
|
||||
$obs = Get-Process obs64, obs -ErrorAction SilentlyContinue
|
||||
Hinweis ("OBS laeuft: " + $(if ($obs) { 'ja' } else { 'nein - macht nichts, wird spaeter gebraucht' }))
|
||||
|
||||
# ---------------------------------------------------------------- Quelle
|
||||
|
||||
$hier = if ($PSScriptRoot) { $PSScriptRoot } else { $null }
|
||||
$ausPaket = $hier -and (Test-Path (Join-Path $hier 'node.exe'))
|
||||
|
||||
if ($ausPaket) {
|
||||
Schritt "Installiere aus dem entpackten Paket ($hier)"
|
||||
$quellordner = $hier
|
||||
} elseif (-not $Quelle) {
|
||||
Write-Host ''
|
||||
Write-Host 'Dieses Skript liegt nicht neben einem Paket, und -Quelle ist nicht gesetzt.' -ForegroundColor Red
|
||||
Write-Host ''
|
||||
Write-Host 'Entweder:'
|
||||
Write-Host ' - das Paket herunterladen, entpacken und darin install.ps1 ausfuehren'
|
||||
Write-Host ' - oder eine Ablage angeben, unter der abdulive.json ohne Anmeldung liegt:'
|
||||
Write-Host ' .\install.ps1 -Quelle https://.../abdulive'
|
||||
Write-Host ''
|
||||
exit 1
|
||||
} else {
|
||||
Schritt 'Lade das aktuelle Paket'
|
||||
$tmp = Join-Path $env:TEMP ("abdulive-" + [guid]::NewGuid().ToString('N').Substring(0, 8))
|
||||
New-Item -ItemType Directory -Force -Path $tmp | Out-Null
|
||||
|
||||
# Nicht auf die Form der Ausnahme verlassen: `-MaximumRedirection 0` wirft
|
||||
# in Windows PowerShell 5.1 eine WebException ganz ohne `Response`, und ein
|
||||
# Zugriff auf .Response.StatusCode laeuft dann selbst auf die Nase.
|
||||
# Stattdessen holen, was kommt, und den Inhalt ansehen.
|
||||
try {
|
||||
$antwort = Invoke-WebRequest "$Quelle/abdulive.json" -UseBasicParsing -ErrorAction Stop
|
||||
} catch {
|
||||
Write-Host ''
|
||||
Write-Host "$Quelle/abdulive.json ist nicht erreichbar." -ForegroundColor Red
|
||||
Write-Host " $($_.Exception.Message)"
|
||||
Write-Host ''
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Eine Anmeldeseite ist HTML und kommt mit Status 200 an, wenn der Umleitung
|
||||
# gefolgt wurde. Genau das ist bei git.zeasy.dev passiert.
|
||||
$typ = [string]$antwort.Headers['Content-Type']
|
||||
$anfang = [string]$antwort.Content
|
||||
if ($typ -like 'text/html*' -or $anfang -match '(?i)^\s*(<!doctype|<html|<a href="/user/login)') {
|
||||
Write-Host ''
|
||||
Write-Host "$Quelle/abdulive.json liefert HTML statt JSON." -ForegroundColor Red
|
||||
Write-Host ' Vermutlich eine Anmeldeseite - die Ablage ist nicht oeffentlich.'
|
||||
Write-Host ' Die Adresse einmal im Browser aufrufen zeigt, was dort steht.'
|
||||
Write-Host ''
|
||||
exit 1
|
||||
}
|
||||
$manifest = $antwort.Content | ConvertFrom-Json
|
||||
Hinweis "Version $($manifest.version), $([math]::Round($manifest.groesse / 1MB)) MB"
|
||||
|
||||
$zip = Join-Path $tmp $manifest.datei
|
||||
Invoke-WebRequest "$Quelle/$($manifest.datei)" -OutFile $zip
|
||||
|
||||
$ist = (Get-FileHash $zip -Algorithm SHA256).Hash.ToLower()
|
||||
if ($ist -ne $manifest.sha256.ToLower()) {
|
||||
throw "Pruefsumme stimmt nicht. Erwartet $($manifest.sha256), bekommen $ist."
|
||||
}
|
||||
Hinweis 'Pruefsumme stimmt.'
|
||||
|
||||
Expand-Archive -Path $zip -DestinationPath (Join-Path $tmp 'paket') -Force
|
||||
$quellordner = Join-Path $tmp 'paket'
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------- Kopieren
|
||||
|
||||
Schritt "Programm nach $Ziel"
|
||||
# Laeuft gerade eine aeltere Fassung, muss sie erst weichen - Windows sperrt
|
||||
# die Dateien laufender Programme.
|
||||
Get-Process node -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.Path -and $_.Path.StartsWith($Ziel, 'OrdinalIgnoreCase') } |
|
||||
ForEach-Object { Hinweis "beende laufende Fassung (PID $($_.Id))"; Stop-Process -Id $_.Id -Force }
|
||||
|
||||
New-Item -ItemType Directory -Force -Path $Ziel | Out-Null
|
||||
foreach ($item in @('app', 'bin', 'fonts', 'node.exe', 'version.json',
|
||||
'abdulive.cmd', 'launcher.ps1', 'install.ps1', 'backup.ps1')) {
|
||||
$von = Join-Path $quellordner $item
|
||||
if (-not (Test-Path $von)) { continue }
|
||||
$nach = Join-Path $Ziel $item
|
||||
if (Test-Path $nach) { Remove-Item -Recurse -Force $nach }
|
||||
Copy-Item -Recurse -Force -Path $von -Destination $nach
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------- Daten
|
||||
|
||||
Schritt "Daten nach $daten"
|
||||
foreach ($unter in @('config', 'db', 'work', 'clips', 'models', 'logs')) {
|
||||
New-Item -ItemType Directory -Force -Path (Join-Path $daten $unter) | Out-Null
|
||||
}
|
||||
# Eine vorhandene config.yaml wird nie ueberschrieben - da stehen Abdus
|
||||
# Einstellungen drin.
|
||||
$cfg = Join-Path $daten 'config\config.yaml'
|
||||
if (-not (Test-Path $cfg)) {
|
||||
Copy-Item (Join-Path $Ziel 'app\config\config.example.yaml') $cfg
|
||||
Hinweis 'config.yaml aus der Vorlage angelegt.'
|
||||
} else {
|
||||
Hinweis 'config.yaml war schon da, bleibt unveraendert.'
|
||||
}
|
||||
Copy-Item (Join-Path $Ziel 'app\config\layout.yaml') (Join-Path $daten 'config\layout.yaml') -Force
|
||||
|
||||
# ---------------------------------------------------------------- Autostart
|
||||
|
||||
if (-not $KeinAutostart) {
|
||||
Schritt 'Autostart einrichten'
|
||||
# Aufgabenplaner statt Windows-Dienst: die Anwendung muss in derselben
|
||||
# Sitzung laufen wie OBS, und ein Dienst braeuchte Administratorrechte
|
||||
# samt eigenem Wrapper-Programm.
|
||||
$ps = (Get-Command powershell.exe).Source
|
||||
$start = New-ScheduledTaskAction -Execute $ps `
|
||||
-Argument "-NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File `"$Ziel\launcher.ps1`""
|
||||
$beiAnmeldung = New-ScheduledTaskTrigger -AtLogOn -User $env:USERNAME
|
||||
$einst = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries `
|
||||
-DontStopIfGoingOnBatteries -StartWhenAvailable -RestartInterval (New-TimeSpan -Minutes 2) `
|
||||
-RestartCount 3 -ExecutionTimeLimit ([TimeSpan]::Zero)
|
||||
Register-ScheduledTask -TaskName 'abdulive' -Action $start -Trigger $beiAnmeldung `
|
||||
-Settings $einst -Force | Out-Null
|
||||
Hinweis 'Aufgabe "abdulive" laeuft ab der naechsten Anmeldung mit.'
|
||||
|
||||
# Taeglich nach einem Update sehen. Angewendet wird es erst beim naechsten
|
||||
# Start - waehrend eines Streams wird nichts ausgetauscht.
|
||||
$pruefen = New-ScheduledTaskAction -Execute (Join-Path $Ziel 'node.exe') `
|
||||
-Argument "`"$Ziel\app\apps\cli\main.ts`" update"
|
||||
$taeglich = New-ScheduledTaskTrigger -Daily -At '05:00'
|
||||
Register-ScheduledTask -TaskName 'abdulive-update' -Action $pruefen -Trigger $taeglich `
|
||||
-Settings $einst -Force | Out-Null
|
||||
Hinweis 'Aufgabe "abdulive-update" sieht taeglich um 05:00 nach.'
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------- Fertig
|
||||
|
||||
$version = (Get-Content (Join-Path $Ziel 'version.json') -Raw | ConvertFrom-Json).version
|
||||
Write-Host ''
|
||||
Write-Host "abdulive $version ist installiert." -ForegroundColor Green
|
||||
Write-Host ''
|
||||
Write-Host 'Naechste Schritte:'
|
||||
Write-Host " 1. Sprachmodell laden (einmalig, ca. 465 MB):"
|
||||
Write-Host " & '$Ziel\abdulive.cmd' fetch-model"
|
||||
Write-Host " 2. Umgebung pruefen:"
|
||||
Write-Host " & '$Ziel\abdulive.cmd' check-env"
|
||||
Write-Host ' 3. Starten und einrichten:'
|
||||
Write-Host ' Start-ScheduledTask -TaskName abdulive'
|
||||
Write-Host ' dann http://localhost:4792/setup im Browser oeffnen'
|
||||
Write-Host ''
|
||||
@@ -0,0 +1,67 @@
|
||||
# Wird vom Aufgabenplaner bei der Anmeldung gestartet.
|
||||
#
|
||||
# Zwei Aufgaben, in dieser Reihenfolge:
|
||||
# 1. Ein bereitgelegtes Update anwenden.
|
||||
# 2. Die Anwendung starten.
|
||||
#
|
||||
# Warum getrennt: Windows sperrt laufende Programmdateien. Solange node.exe
|
||||
# laeuft, laesst es sich nicht ersetzen. Der Updater legt das neue Paket
|
||||
# deshalb nur unter `pending\` ab; ausgetauscht wird hier, bevor irgendetwas
|
||||
# startet. So gibt es nie einen halb ausgetauschten Ordner. Siehe ADR-0007.
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$root = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
$pending = Join-Path $root 'pending'
|
||||
$log = Join-Path $env:LOCALAPPDATA 'abdulive\logs'
|
||||
New-Item -ItemType Directory -Force -Path $log | Out-Null
|
||||
|
||||
function Write-Log($text) {
|
||||
$line = "{0} {1}" -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $text
|
||||
Add-Content -Path (Join-Path $log 'starter.log') -Value $line -Encoding utf8
|
||||
Write-Output $line
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------- Update
|
||||
|
||||
if (Test-Path $pending) {
|
||||
Write-Log 'Update liegt bereit - wende es an.'
|
||||
try {
|
||||
# Sicherheitshalber: laeuft noch etwas aus diesem Ordner?
|
||||
Get-Process node -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.Path -and $_.Path.StartsWith($root, 'OrdinalIgnoreCase') } |
|
||||
ForEach-Object {
|
||||
Write-Log " beende noch laufende node.exe (PID $($_.Id))"
|
||||
Stop-Process -Id $_.Id -Force
|
||||
}
|
||||
Start-Sleep -Milliseconds 500
|
||||
|
||||
foreach ($item in @('app', 'bin', 'fonts', 'node.exe', 'version.json',
|
||||
'abdulive.cmd', 'launcher.ps1', 'install.ps1', 'backup.ps1')) {
|
||||
$new = Join-Path $pending $item
|
||||
if (-not (Test-Path $new)) { continue }
|
||||
$old = Join-Path $root $item
|
||||
if (Test-Path $old) { Remove-Item -Recurse -Force $old }
|
||||
Move-Item -Path $new -Destination $old
|
||||
}
|
||||
Remove-Item -Recurse -Force $pending
|
||||
$v = (Get-Content (Join-Path $root 'version.json') -Raw | ConvertFrom-Json).version
|
||||
Write-Log "Update angewendet, jetzt Version $v."
|
||||
} catch {
|
||||
# Ein misslungenes Update darf den Stream nicht kosten: der alte
|
||||
# Stand ist noch da, also einfach damit weiterlaufen.
|
||||
Write-Log "Update fehlgeschlagen, starte die vorhandene Version: $_"
|
||||
}
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------- Start
|
||||
|
||||
$node = Join-Path $root 'node.exe'
|
||||
$main = Join-Path $root 'app\apps\cli\main.ts'
|
||||
if (-not (Test-Path $node)) { Write-Log "node.exe fehlt in $root"; exit 1 }
|
||||
|
||||
Write-Log 'Starte den Daemon.'
|
||||
& $node $main ingest 2>&1 | ForEach-Object {
|
||||
Add-Content -Path (Join-Path $log 'abdulive.log') -Value $_ -Encoding utf8
|
||||
}
|
||||
Write-Log "Daemon beendet (Code $LASTEXITCODE)."
|
||||
exit $LASTEXITCODE
|
||||
Reference in new issue
Block a user