release: publish Twitch VOD Manager 1.0.3

Polish navigation, settings, streamer and queue workflows; add safe pause and partial-file lifecycle handling; apply the product identity across Windows surfaces; and replace the public README with a complete English product guide and isolated screenshot.
This commit is contained in:
Sucukdeluxe
2026-08-10 19:25:08 +02:00
parent f9e415da88
commit b3c2a8b9fd
48 changed files with 4035 additions and 826 deletions
+41
View File
@@ -0,0 +1,41 @@
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import * as ResEdit from 'resedit';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { prepareWindowsDevExecutable } from './dev-executable';
let tempDirectory: string;
beforeEach(() => {
tempDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'tvm-dev-exe-'));
});
afterEach(() => {
fs.rmSync(tempDirectory, { recursive: true, force: true });
});
describe.runIf(process.platform === 'win32')('prepareWindowsDevExecutable', () => {
it('bettet App-Icon und Produktnamen in die gestartete Entwicklungs-EXE ein', async () => {
const sourcePath = path.join(process.env.WINDIR || 'C:\\Windows', 'System32', 'where.exe');
const destinationPath = path.join(tempDirectory, 'Twitch VOD Manager.exe');
const iconPath = path.resolve('build/icon.ico');
await prepareWindowsDevExecutable({
sourcePath,
destinationPath,
iconPath,
version: '1.0.3'
});
const executable = ResEdit.NtExecutable.from(fs.readFileSync(destinationPath));
const resources = ResEdit.NtExecutableResource.from(executable);
const iconGroups = ResEdit.Resource.IconGroupEntry.fromEntries(resources.entries);
const versionInfo = ResEdit.Resource.VersionInfo.fromEntries(resources.entries)[0];
const strings = versionInfo.getStringValues(versionInfo.getAllLanguagesForStringValues()[0]);
expect(iconGroups[0].icons).toHaveLength(6);
expect(strings.ProductName).toBe('Twitch VOD Manager');
expect(strings.FileDescription).toBe('Twitch VOD Manager');
});
});
+71
View File
@@ -0,0 +1,71 @@
import * as fs from 'fs';
import * as path from 'path';
import * as ResEdit from 'resedit';
import { writeFileAtomicSync } from './infra/fs-atomic';
export interface WindowsDevExecutableOptions {
sourcePath: string;
destinationPath: string;
iconPath: string;
version: string;
}
export async function prepareWindowsDevExecutable(options: WindowsDevExecutableOptions): Promise<string> {
const source = path.resolve(options.sourcePath);
const destination = path.resolve(options.destinationPath);
const icon = path.resolve(options.iconPath);
const stampPath = `${destination}.json`;
const sourceStats = fs.statSync(source);
const iconStats = fs.statSync(icon);
const fingerprint = JSON.stringify({
sourceSize: sourceStats.size,
sourceModified: sourceStats.mtimeMs,
iconSize: iconStats.size,
iconModified: iconStats.mtimeMs,
version: options.version
});
if (fs.existsSync(destination) && fs.existsSync(stampPath) && fs.readFileSync(stampPath, 'utf8') === fingerprint) {
return destination;
}
fs.mkdirSync(path.dirname(destination), { recursive: true });
const temporaryDestination = `${destination}.tmp`;
fs.copyFileSync(source, temporaryDestination);
try {
const executable = ResEdit.NtExecutable.from(fs.readFileSync(temporaryDestination), { ignoreCert: true });
const resources = ResEdit.NtExecutableResource.from(executable);
const versionEntries = ResEdit.Resource.VersionInfo.fromEntries(resources.entries);
const versionInfo = versionEntries[0] || ResEdit.Resource.VersionInfo.createEmpty();
const languages = versionInfo.getAllLanguagesForStringValues();
const language = languages[0] || { lang: 0x0409, codepage: 1200 };
versionInfo.setStringValues(language, {
FileDescription: 'Twitch VOD Manager',
ProductName: 'Twitch VOD Manager',
InternalName: 'Twitch VOD Manager',
OriginalFilename: 'Twitch VOD Manager.exe'
});
versionInfo.setFileVersion(options.version);
versionInfo.setProductVersion(options.version);
versionInfo.outputToResourceEntries(resources.entries);
const iconFile = ResEdit.Data.IconFile.from(fs.readFileSync(icon));
ResEdit.Resource.IconGroupEntry.replaceIconsForResource(
resources.entries,
1,
language.lang,
iconFile.icons.map((entry) => entry.data)
);
resources.outputResource(executable);
fs.writeFileSync(temporaryDestination, Buffer.from(executable.generate()));
fs.rmSync(destination, { force: true });
fs.renameSync(temporaryDestination, destination);
writeFileAtomicSync(stampPath, fingerprint);
return destination;
} catch (error) {
fs.rmSync(temporaryDestination, { force: true });
throw error;
}
}
+17
View File
@@ -0,0 +1,17 @@
import { describe, expect, test } from 'vitest';
import { isRendererReloadTarget } from './dev-reload';
describe('isRendererReloadTarget', () => {
test('reloads renderer output and static renderer assets', () => {
expect(isRendererReloadTarget('renderer.js')).toBe(true);
expect(isRendererReloadTarget('renderer-settings.js')).toBe(true);
expect(isRendererReloadTarget('index.html')).toBe(true);
expect(isRendererReloadTarget('styles.css')).toBe(true);
});
test('does not reload for main-process output', () => {
expect(isRendererReloadTarget('main.js')).toBe(false);
expect(isRendererReloadTarget('preload.js')).toBe(false);
expect(isRendererReloadTarget('main/domain/config.js')).toBe(false);
});
});
+31
View File
@@ -0,0 +1,31 @@
import { watch, type FSWatcher } from 'node:fs';
const staticRendererAssets = new Set(['index.html', 'styles.css', 'workspace.css']);
export function isRendererReloadTarget(fileName: string): boolean {
const normalized = fileName.replaceAll('\\', '/');
const baseName = normalized.split('/').at(-1) ?? '';
return staticRendererAssets.has(baseName) || /^renderer(?:[-.].+)?\.js$/.test(baseName);
}
export function watchRendererChanges(
outputDirectory: string,
sourceDirectory: string,
reload: () => void,
): () => void {
let reloadTimer: NodeJS.Timeout | undefined;
const scheduleReload = (fileName: string | Buffer | null): void => {
if (!fileName || !isRendererReloadTarget(fileName.toString())) return;
if (reloadTimer) clearTimeout(reloadTimer);
reloadTimer = setTimeout(reload, 125);
};
const watchers: FSWatcher[] = [
watch(outputDirectory, { recursive: true }, (_, fileName) => scheduleReload(fileName)),
watch(sourceDirectory, { recursive: true }, (_, fileName) => scheduleReload(fileName)),
];
return () => {
if (reloadTimer) clearTimeout(reloadTimer);
for (const watcher of watchers) watcher.close();
};
}
+15
View File
@@ -0,0 +1,15 @@
import { describe, expect, test } from 'vitest';
import { getWindowsAppIdentity } from './app-identity';
describe('getWindowsAppIdentity', () => {
test('trennt Hot-Dev von der veröffentlichten Windows-Identität', () => {
expect(getWindowsAppIdentity(true)).toEqual({
name: 'Twitch VOD Manager',
appUserModelId: 'io.github.sucukdeluxe.twitch-vod-manager.development'
});
expect(getWindowsAppIdentity(false)).toEqual({
name: 'Twitch VOD Manager',
appUserModelId: 'io.github.sucukdeluxe.twitch-vod-manager'
});
});
});
+13
View File
@@ -0,0 +1,13 @@
export interface WindowsAppIdentity {
name: string;
appUserModelId: string;
}
export function getWindowsAppIdentity(isDevelopment: boolean): WindowsAppIdentity {
return {
name: 'Twitch VOD Manager',
appUserModelId: isDevelopment
? 'io.github.sucukdeluxe.twitch-vod-manager.development'
: 'io.github.sucukdeluxe.twitch-vod-manager'
};
}
+8
View File
@@ -41,6 +41,14 @@ describe('tBackend', () => {
}
});
test('German backend messages use native umlauts', () => {
const text = Object.values(BACKEND_MESSAGES.de).join('\n').toLocaleLowerCase('de-DE');
const forbidden = ['ungueltig', 'integritaetspruefung', 'fur ', 'benoetigt', 'prufe '];
for (const token of forbidden) {
expect(text).not.toContain(token);
}
});
test('no template literal left after substitution for typical params', () => {
// attemptFailed has {attempt}, {max}, {errorClass}, {error}
const result = tBackend('attemptFailed', { attempt: 1, max: 3, errorClass: 'network', error: 'ETIMEDOUT' }, 'en');
+11 -11
View File
@@ -1,10 +1,10 @@
// Backend-Messages (User-visible aus main.ts produziert). Pure: Sprache wird
// als Parameter uebergeben statt aus globalem config geholt.
// als Parameter übergeben statt aus globalem config geholt.
export const BACKEND_MESSAGES = {
de: {
invalidVodUrl: 'Ungueltige VOD-URL',
invalidClipUrl: 'Ungueltige Clip-URL',
invalidVodUrl: 'Ungültige VOD-URL',
invalidClipUrl: 'Ungültige Clip-URL',
clipNotFound: 'Clip nicht gefunden',
streamlinkAutoInstallFailed: 'Streamlink fehlt und konnte nicht automatisch installiert werden. Siehe debug.log.',
streamlinkMissing: 'Streamlink fehlt.',
@@ -15,10 +15,10 @@ export const BACKEND_MESSAGES = {
ffmpegSplitFailed: 'FFmpeg Split fehlgeschlagen.',
fileTooSmall: 'Datei zu klein ({bytes} Bytes)',
clipFileTooSmall: 'Clip-Datei zu klein ({bytes} Bytes) - Twitch hat den Stream evtl. nicht ausgeliefert.',
integrityNoVideo: 'Integritaetspruefung fehlgeschlagen: Kein Videostream gefunden.',
integrityTooShort: 'Integritaetspruefung fehlgeschlagen: Dauer zu kurz ({duration}s).',
integrityDurationMismatch: 'Integritaetspruefung fehlgeschlagen: {actual}s statt erwarteter ~{expected}s.',
integrityFailedGeneric: 'Integritaetspruefung fehlgeschlagen.',
integrityNoVideo: 'Integritätsprüfung fehlgeschlagen: Kein Videostream gefunden.',
integrityTooShort: 'Integritätsprüfung fehlgeschlagen: Dauer zu kurz ({duration}s).',
integrityDurationMismatch: 'Integritätsprüfung fehlgeschlagen: {actual}s statt erwarteter ~{expected}s.',
integrityFailedGeneric: 'Integritätsprüfung fehlgeschlagen.',
downloadCancelled: 'Download wurde abgebrochen.',
downloadPaused: 'Download wurde pausiert.',
downloadFailedExitCode: 'Download fehlgeschlagen (Exit-Code {code})',
@@ -26,12 +26,12 @@ export const BACKEND_MESSAGES = {
notAllClipPartsDownloaded: 'Nicht alle Clip-Teile konnten heruntergeladen werden.',
notAllPartsDownloaded: 'Nicht alle Teile konnten heruntergeladen werden.',
mergeGroupFileMissing: 'Heruntergeladene Datei {index} fehlt.',
diskSpaceShortFor: 'Zu wenig Speicherplatz fur {context}: frei {free}, benoetigt ~{required}.',
diskSpaceShortFor: 'Zu wenig Speicherplatz für {context}: frei {free}, benötigt ~{required}.',
diskSpaceShortGeneric: 'Zu wenig Speicherplatz.',
attemptFailed: 'Versuch {attempt}/{max} fehlgeschlagen ({errorClass}): {error}',
retryingIn: 'Neuer Versuch in {seconds}s ({errorClass})...',
statusCheckingTools: 'Prufe Download-Tools...',
statusDownloadStarted: 'Download gestartet',
statusCheckingTools: 'Prüfe Download-Tools...',
statusDownloadStarted: 'Download wird gestartet',
statusBytesDownloaded: '{bytes} heruntergeladen',
statusFetchingChatReplay: 'Chat-Replay wird heruntergeladen...',
statusChatMessagesFetched: 'Chat-Nachrichten geladen: {count}',
@@ -70,7 +70,7 @@ export const BACKEND_MESSAGES = {
attemptFailed: 'Attempt {attempt}/{max} failed ({errorClass}): {error}',
retryingIn: 'Retrying in {seconds}s ({errorClass})...',
statusCheckingTools: 'Checking download tools...',
statusDownloadStarted: 'Download started',
statusDownloadStarted: 'Starting download ',
statusBytesDownloaded: '{bytes} downloaded',
statusFetchingChatReplay: 'Fetching chat replay...',
statusChatMessagesFetched: 'Chat messages fetched: {count}',
+1 -1
View File
@@ -36,7 +36,7 @@ const CONFIG_KV_KEYS = [
'auto_record_poll_seconds', 'filename_template_vod', 'filename_template_parts',
'filename_template_clip', 'smart_queue_scheduler', 'prevent_duplicate_downloads',
'persist_queue_on_restart', 'auto_resume_queue_on_startup',
'notify_on_each_completion',
'notify_on_each_completion', 'sidebar_split_view',
] as const;
function backupOnce(srcPath: string): void {
+64
View File
@@ -0,0 +1,64 @@
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { PartialDownloadRegistry } from './partial-download';
let tempDirectory: string;
let registryPath: string;
beforeEach(() => {
tempDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'tvm-partial-'));
registryPath = path.join(tempDirectory, 'partial-downloads.json');
});
afterEach(() => {
fs.rmSync(tempDirectory, { recursive: true, force: true });
});
describe('PartialDownloadRegistry', () => {
it('veröffentlicht erst nach erfolgreichem Commit den endgültigen Dateinamen', () => {
const registry = new PartialDownloadRegistry(registryPath);
const finalPath = path.join(tempDirectory, 'video.mp4');
const partialPath = registry.begin(finalPath);
fs.writeFileSync(partialPath, 'vollständig');
expect(partialPath).toBe(`${finalPath}.tvm-part`);
expect(fs.existsSync(finalPath)).toBe(false);
registry.commit(partialPath, finalPath);
expect(fs.readFileSync(finalPath, 'utf8')).toBe('vollständig');
expect(fs.existsSync(partialPath)).toBe(false);
expect(fs.existsSync(registryPath)).toBe(false);
});
it('entfernt eine abgebrochene Teil-Datei', () => {
const registry = new PartialDownloadRegistry(registryPath);
const finalPath = path.join(tempDirectory, 'abbruch.mp4');
const partialPath = registry.begin(finalPath);
fs.writeFileSync(partialPath, 'unvollständig');
registry.discard(partialPath);
expect(fs.existsSync(partialPath)).toBe(false);
expect(fs.existsSync(finalPath)).toBe(false);
expect(fs.existsSync(registryPath)).toBe(false);
});
it('räumt nach einem simulierten Crash registrierte Teil-Dateien beim nächsten Start auf', () => {
const firstRun = new PartialDownloadRegistry(registryPath);
const finalPath = path.join(tempDirectory, 'crash.mp4');
const partialPath = firstRun.begin(finalPath);
fs.writeFileSync(partialPath, 'unvollständig');
const secondRun = new PartialDownloadRegistry(registryPath);
const removed = secondRun.cleanup();
expect(removed).toEqual([partialPath]);
expect(fs.existsSync(partialPath)).toBe(false);
expect(fs.existsSync(finalPath)).toBe(false);
expect(fs.existsSync(registryPath)).toBe(false);
});
});
+83
View File
@@ -0,0 +1,83 @@
import * as fs from 'fs';
import * as path from 'path';
import { writeFileAtomicSync } from '../infra/fs-atomic';
const PARTIAL_SUFFIX = '.tvm-part';
export class PartialDownloadRegistry {
private readonly paths = new Set<string>();
constructor(private readonly registryPath: string) {
this.load(this.registryPath);
this.load(`${this.registryPath}.tmp`);
}
begin(finalPath: string): string {
const partialPath = path.resolve(`${finalPath}${PARTIAL_SUFFIX}`);
if (fs.existsSync(partialPath)) fs.rmSync(partialPath, { force: true });
this.paths.add(partialPath);
this.persist();
return partialPath;
}
commit(partialPath: string, finalPath: string): void {
const normalizedPartialPath = this.normalize(partialPath);
const normalizedFinalPath = path.resolve(finalPath);
if (!fs.existsSync(normalizedPartialPath)) throw new Error(`Teil-Datei fehlt: ${normalizedPartialPath}`);
if (fs.existsSync(normalizedFinalPath)) throw new Error(`Zieldatei existiert bereits: ${normalizedFinalPath}`);
fs.renameSync(normalizedPartialPath, normalizedFinalPath);
this.paths.delete(normalizedPartialPath);
this.persist();
}
discard(partialPath: string): void {
const normalizedPartialPath = this.normalize(partialPath);
if (fs.existsSync(normalizedPartialPath)) fs.rmSync(normalizedPartialPath, { force: true });
this.paths.delete(normalizedPartialPath);
this.persist();
}
cleanup(): string[] {
const removed: string[] = [];
for (const partialPath of this.paths) {
if (fs.existsSync(partialPath)) {
fs.rmSync(partialPath, { force: true });
removed.push(partialPath);
}
}
this.paths.clear();
this.persist();
return removed;
}
private normalize(filePath: string): string {
const normalizedPath = path.resolve(filePath);
if (!normalizedPath.endsWith(PARTIAL_SUFFIX)) throw new Error(`Ungültiger Teil-Dateipfad: ${normalizedPath}`);
return normalizedPath;
}
private load(filePath: string): void {
try {
if (!fs.existsSync(filePath)) return;
const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8'));
const values = Array.isArray(parsed) ? parsed : parsed?.paths;
if (!Array.isArray(values)) return;
for (const value of values) {
if (typeof value !== 'string') continue;
try {
this.paths.add(this.normalize(value));
} catch { }
}
} catch { }
}
private persist(): void {
if (this.paths.size === 0) {
fs.rmSync(this.registryPath, { force: true });
fs.rmSync(`${this.registryPath}.tmp`, { force: true });
return;
}
fs.mkdirSync(path.dirname(this.registryPath), { recursive: true });
writeFileAtomicSync(this.registryPath, JSON.stringify({ paths: [...this.paths] }, null, 2));
}
}
+89
View File
@@ -0,0 +1,89 @@
import { PassThrough, Writable } from 'stream';
import { describe, expect, it } from 'vitest';
import { createPausableOutput } from './pausable-output';
function waitForTurn(): Promise<void> {
return new Promise((resolve) => setImmediate(resolve));
}
describe('createPausableOutput', () => {
it('setzt denselben Ausgabestrom ohne Datenverlust fort', async () => {
const source = new PassThrough();
const chunks: Buffer[] = [];
const target = new Writable({
write(chunk, _encoding, callback) {
chunks.push(Buffer.from(chunk));
callback();
}
});
const output = createPausableOutput(source, target);
source.write('erster-');
await waitForTurn();
output.pause();
source.write('zweiter-');
await waitForTurn();
expect(Buffer.concat(chunks).toString()).toBe('erster-');
output.resume();
source.end('dritter');
await output.finished;
expect(Buffer.concat(chunks).toString()).toBe('erster-zweiter-dritter');
expect(output.isPaused()).toBe(false);
});
it('schließt einen während der Pause beendeten Quellstrom erst nach dem Fortsetzen ab', async () => {
const source = new PassThrough();
const chunks: Buffer[] = [];
const target = new Writable({
write(chunk, _encoding, callback) {
chunks.push(Buffer.from(chunk));
callback();
}
});
const output = createPausableOutput(source, target);
let finished = false;
void output.finished.then(() => {
finished = true;
});
source.write('vorher-');
await waitForTurn();
output.pause();
source.end('nachher');
await waitForTurn();
expect(finished).toBe(false);
expect(Buffer.concat(chunks).toString()).toBe('vorher-');
output.resume();
await output.finished;
expect(Buffer.concat(chunks).toString()).toBe('vorher-nachher');
});
it('schließt Quell- und Zielstrom bei einem Abbruch zuverlässig', async () => {
const source = new PassThrough();
const chunks: Buffer[] = [];
const target = new Writable({
write(chunk, _encoding, callback) {
chunks.push(Buffer.from(chunk));
callback();
}
});
const output = createPausableOutput(source, target);
source.write('behalten');
await waitForTurn();
output.pause();
await output.cancel();
await output.finished;
expect(source.destroyed).toBe(true);
expect(target.destroyed).toBe(true);
expect(Buffer.concat(chunks).toString()).toBe('behalten');
});
});
+74
View File
@@ -0,0 +1,74 @@
import { Readable, Writable } from 'stream';
export interface PausableOutput {
pause(): void;
resume(): void;
cancel(): Promise<void>;
isPaused(): boolean;
finished: Promise<void>;
}
export function createPausableOutput(source: Readable, target: Writable): PausableOutput {
let paused = false;
let settled = false;
let resolveFinished: () => void = () => {};
let rejectFinished: (error: Error) => void = () => {};
let resolveClosed: () => void = () => {};
const finished = new Promise<void>((resolve, reject) => {
resolveFinished = resolve;
rejectFinished = reject;
});
const closed = new Promise<void>((resolve) => {
resolveClosed = resolve;
});
const attach = () => source.pipe(target, { end: false });
const finish = () => {
if (!settled) target.end();
};
target.once('finish', () => {
settled = true;
resolveFinished();
});
target.once('close', () => {
resolveClosed();
if (!settled) {
settled = true;
resolveFinished();
}
});
target.once('error', (error) => {
settled = true;
source.destroy(error);
rejectFinished(error);
});
source.once('end', finish);
source.once('error', (error) => target.destroy(error));
attach();
return {
pause() {
if (paused || settled) return;
paused = true;
source.unpipe(target);
source.pause();
},
resume() {
if (!paused || settled) return;
paused = false;
attach();
source.resume();
},
async cancel() {
if (!settled) {
paused = false;
source.unpipe(target);
source.destroy();
target.destroy();
}
await closed;
},
isPaused: () => paused,
finished
};
}
+19
View File
@@ -0,0 +1,19 @@
import { describe, expect, test } from 'vitest';
import { buildVodPreviewFrameUrls } from './vod-preview';
describe('buildVodPreviewFrameUrls', () => {
test('builds four full-HD frame URLs from a Twitch thumb0 URL', () => {
const input = 'https://static-cdn.jtvnw.net/cf_vods/example/thumb/thumb0-1920x1080.jpg';
expect(buildVodPreviewFrameUrls(input)).toEqual([
'https://static-cdn.jtvnw.net/cf_vods/example/thumb/thumb0-1920x1080.jpg',
'https://static-cdn.jtvnw.net/cf_vods/example/thumb/thumb1-1920x1080.jpg',
'https://static-cdn.jtvnw.net/cf_vods/example/thumb/thumb2-1920x1080.jpg',
'https://static-cdn.jtvnw.net/cf_vods/example/thumb/thumb3-1920x1080.jpg'
]);
});
test('rejects URLs that are not Twitch full-HD VOD thumbnails', () => {
expect(buildVodPreviewFrameUrls('https://example.invalid/thumb0-320x180.jpg')).toEqual([]);
});
});
+20
View File
@@ -0,0 +1,20 @@
const TWITCH_VOD_THUMBNAIL_PATTERN = /thumb0-1920x1080\.jpg$/;
export function buildVodPreviewFrameUrls(thumbnailUrl: string): string[] {
let parsed: URL;
try {
parsed = new URL(thumbnailUrl);
} catch {
return [];
}
if (parsed.protocol !== 'https:' || parsed.hostname !== 'static-cdn.jtvnw.net' || !TWITCH_VOD_THUMBNAIL_PATTERN.test(parsed.pathname)) {
return [];
}
return [0, 1, 2, 3].map((index) => {
const frameUrl = new URL(parsed.toString());
frameUrl.pathname = frameUrl.pathname.replace('thumb0-1920x1080.jpg', `thumb${index}-1920x1080.jpg`);
return frameUrl.toString();
});
}
+1 -1
View File
@@ -32,7 +32,7 @@ const DEFAULT_SUCCESS = `<!doctype html><html><head><meta charset="utf-8"><title
<style>body{font-family:system-ui,-apple-system,Segoe UI,sans-serif;background:#0e0e10;color:#efeff1;display:flex;align-items:center;justify-content:center;height:100vh;margin:0}
.box{text-align:center;padding:2rem 3rem;background:#1f1f23;border-radius:8px}
h1{color:#9146FF;margin:0 0 0.5rem}</style></head>
<body><div class="box"><h1>Login erfolgreich</h1><p>Du kannst dieses Fenster jetzt schliessen.</p></div></body></html>`;
<body><div class="box"><h1>Login erfolgreich</h1><p>Du kannst dieses Fenster jetzt schließen.</p></div></body></html>`;
const DEFAULT_ERROR = `<!doctype html><html><head><meta charset="utf-8"><title>Fehler</title>
<style>body{font-family:system-ui,-apple-system,Segoe UI,sans-serif;background:#0e0e10;color:#efeff1;display:flex;align-items:center;justify-content:center;height:100vh;margin:0}