Compare commits

..
4 Commits
Author SHA1 Message Date
Davito a277cf1a96 Release v0.1.3 2026-10-14 10:21:26 +00:00
Davito ec0aedccee Release v0.1.2 2026-10-14 10:10:53 +00:00
Davito 1a0acd4a36 Release v0.1.1 2026-10-14 09:12:17 +00:00
Davito dad0d6fd00 EuerChat: Installer und Beschreibung (Release-Downloads) 2026-10-14 11:11:56 +02:00
29 changed files with 29 additions and 13153 deletions

No files matched your search

-12
View File
@@ -1,12 +0,0 @@
# Persoenliche Konfiguration (enthaelt Passwoerter) niemals einchecken
config.json
window_state.json
# Build-Artefakte
src-tauri/target/
src-tauri/gen/
node_modules/
*.exe
# Der Installer wird als Release-Anhang veroeffentlicht, nicht im Repository
dist/
+26 -41
View File
@@ -1,55 +1,40 @@
# EuerChat - euer.tv IRC & Livestream Client (Windows 64-bit) # EuerChat - euer.tv IRC & Livestream Client (Windows 64-bit)
Ein moderner, performanter und schlanker Desktop-Client für Windows 64-bit, gebaut mit **Rust + Tauri v2** und einem ansprechenden **Dark-Mode Web-Frontend**. <p align="center"><img src="assets/logo-original.png" alt="euer radio" width="560"></p>
Ein moderner, schlanker Desktop-Client für Windows 64-bit mit Dark-Mode-Oberfläche: Chat, Livestream und Radio-Player von **euer.tv** in einem Fenster.
**Download:** Den aktuellen Installer findest du unter [Releases](../../releases).
--- ---
## Highlights & Features ## Highlights & Features
* **Eine einzige EXE (`EuerChat.exe`):** * **Integrierter Livestream & Radio-Player (stumm beim Start):**
* Startet standardmäßig sauber und übersichtlich für alle Chat-Gäste und Hörer. * HLS-Videostream in mehreren Qualitätsstufen mit Stummschalt-/Ton-Taste und Lautstärkeregler.
* Die Moderationswerkzeuge sind ausgeblendet, bis das Lua/Modi-Addon in den Optionen aktiviert wird. * Live-Titelanzeige, Hörerzähler und Grußbox direkt neben dem Chat.
* **Integrierter Livestream & Radio-Player (Stumm beim Start):** * Ein-/ausblendbares Videofenster.
* Direkter HLS-Videostream (`https://hls21.euer.tv/hls/1080p.m3u8` & `hls23.euer.tv`).
* **Startet garantiert stumm (`muted`)** mit komfortabler Stummschalt-/Ton-Taste und Lautstärkeregler.
* Live-Titelanzeige & Hörerzähler (`https://api.euer.tv/v2/getjsondata.php?status`).
* Automatisches Abrufen des aktuellen Album-Covers (`https://api.euer.tv/v2/getjsondata.php`).
* Ein-/ausblendbares Videofenster für Live-Übertragungen neben dem Chat.
* **Moderations-Werkzeuge (optional):**
* Erst bei manueller Aktivierung des Lua/Modi-Addons in den Optionen erscheint oben links der Button zum Moderator-Wechsel.
* Automatisches Einloggen bei NickServ (`PRIVMSG NickServ :IDENTIFY <passwort>`).
* Ein-Klick-Wechsel: Wechselt zum Moderatoren-Nick und betritt automatisch `#intern` (mit dem in den Optionen hinterlegten Passwort). Beim Zurückwechseln wird in allen offenen Räumen `!deop` gesendet und `#intern` verlassen.
* **IRC-Verbindung zu euer.tv:** * **IRC-Verbindung zu euer.tv:**
* **Direkt verbinden:** nur den eigenen Namen eingeben, ein Passwort ist optional (nur mit registriertem Account). Verbunden wird fest mit **`irc.euer.tv`**, Port **`6697`** (SSL/TLS, Zertifikate werden geprüft), Raum **`#erde`**. * **Direkt verbinden:** nur den eigenen Namen eingeben, ein Passwort ist optional (nur mit registriertem Account). Verbunden wird mit **`irc.euer.tv`**, Port **`6697`** (SSL/TLS), Raum **`#erde`**.
* **ZNC-Bouncer** für eigene Server (`user/network` + Passwort); selbstsignierte Zertifikate: `"acceptInvalidCerts": true` in der `config.json` setzen (unsicher). * **ZNC-Bouncer** für eigene Server (`user/network` + Passwort).
* Räume unten lassen sich per Maus verschieben, die Oberfläche lässt sich skalieren (75-200 %). * Räume unten lassen sich per Maus verschieben, mehrere Räume und private Chats gleichzeitig.
* **Moderations-Werkzeuge (optional):**
* Erscheinen erst, wenn das Lua/Modi-Addon in den Optionen aktiviert wird.
* Ein-Klick-Wechsel zwischen normalem Nick und Moderator-Nick inkl. `#intern`.
* **Design & Anpassbarkeit:** * **Design & Anpassbarkeit:**
* Schriftarten, Schriftgrößen (11px bis 22px), Zeilenabstände, Zeitstempel frei wählbar. * Schriftarten, Schriftgrößen, Zeilenabstände, Zeitstempel frei wählbar.
* Animierte GIFs (`CATJAM`, `pepeDS` uvm.) & Smileys aus dem Webchat. * Oberfläche skalierbar (75-200 %), Animierte GIFs und Smileys aus dem Webchat.
* **Kein störendes Konsolenfenster:**
* Nativ als reine Windows-GUI-Anwendung kompiliert (`#![windows_subsystem = "windows"]`).
* **Saubere Server-Trennung:**
* Sendet beim Schließen regulär `QUIT :Client beendet`.
* **Integrierter Updater:** * **Integrierter Updater:**
* Sucht beim Start (abschaltbar unter Optionen → Updates) im neuesten Gitea-Release nach einer neueren Version, zeigt die Release-Notizen und installiert auf Wunsch still und startet EuerChat neu. * Sucht beim Start (abschaltbar unter Optionen → Updates) nach einer neueren Version, zeigt die Neuerungen und installiert auf Wunsch still und startet EuerChat neu.
* Es werden nur HTTPS-Adressen des Gitea-Hosts akzeptiert, und der Installer läuft nur, wenn seine SHA-256-Prüfsumme zur Datei `<Installer>.sha256` im Release passt. * Der Installer wird nur ausgeführt, wenn seine SHA-256-Prüfsumme zum Release passt.
--- ---
## Schnellstart ## Installation
1. **Installieren:** `EuerChat_<Version>_x64-setup.exe` ausführen (Installation pro Benutzer, keine Administratorrechte nötig). Beim ersten Start einfach den Namen eingeben und verbinden. `EuerChat_<Version>_x64-setup.exe` ausführen und die Installationsart wählen. Beim ersten Start einfach den Namen eingeben und verbinden.
* Die Konfiguration liegt in `%APPDATA%\EuerChat\config.json` und enthält eventuell Passwörter. Sie ist nicht Teil des Installers und gehört nicht ins Repository (steht in `.gitignore`).
* Alternativ ohne Installation: `EuerChat.exe` direkt starten. Eine alte `config.json` neben der EXE wird noch gelesen und beim nächsten Speichern nach `%APPDATA%` übernommen. * **Standard** (empfohlen): für alle Benutzer, standardmäßig nach `C:\Program Files\EuerChat`, mit Startmenü- und Desktop-Verknüpfung. Erfordert Administratorrechte, Deinstallation über die Windows-Einstellungen. Die Einstellungen liegen in `%APPDATA%\EuerChat\config.json`.
2. **Installer bauen:** * **Portable:** kopiert EuerChat in einen frei wählbaren Ordner, z. B. auf einen USB-Stick. Keine Administratorrechte, keine Registry-Einträge. Die Einstellungen liegen im Unterordner `data`. Entfernen: Ordner löschen.
```powershell
.\build.bat Die Einstellungsdatei kann Passwörter enthalten und sollte nicht weitergegeben werden.
```
Benötigt Rust und Node.js. Der Installer liegt danach in `dist\`, die portable `EuerChat.exe` im Hauptordner.
3. **Tests:** `cd src-tauri; cargo test`
4. **Release veröffentlichen:**
1. Version in `src-tauri\tauri.conf.json` und `src-tauri\Cargo.toml` erhöhen, optional Release-Notizen in `RELEASE_NOTES.md` schreiben.
2. `.\build.bat` ausführen.
3. `.\publish-release.ps1` ausführen. Es legt den Release `v<Version>` auf Gitea an und lädt `EuerChat_<Version>_x64-setup.exe` samt `.sha256` hoch.
Der Token kommt aus der Umgebungsvariable `GITEA_TOKEN` oder aus `%LOCALAPPDATA%\EuerChat\developer\gitea-token.dpapi`.
* Gitea-Adresse und Repository stehen oben in `src-tauri\src\updater.rs` (`GITEA_BASE_URL`, `GITEA_REPO`) und in `publish-release.ps1`. Das Repository muss öffentlich sein, damit der Updater ohne Token auskommt.
+3
View File
@@ -0,0 +1,3 @@
# EuerChat Releases
Letzter veroeffentlichter Release: v0.1.3 (2026-10-14 10:21:26 UTC)
Binary file not shown.

After

Width:  |  Height:  |  Size: 494 KiB

-25
View File
@@ -1,25 +0,0 @@
@echo off
title EuerChat Build
cd /d "%~dp0"
if not exist node_modules (
echo Installiere Tauri-CLI...
call npm install
if errorlevel 1 goto :fail
)
echo Baue Installer (NSIS)...
call npx tauri build
if errorlevel 1 goto :fail
if not exist dist mkdir dist
copy /y "src-tauri\target\release\bundle\nsis\EuerChat_*_x64-setup.exe" dist\ >nul
copy /y src-tauri\target\release\euer-chat.exe EuerChat.exe >nul
echo [OK] Installer liegt in dist\ - EuerChat.exe (portabel) im Hauptordner.
pause
exit /b 0
:fail
echo [FEHLER] Build fehlgeschlagen!
pause
exit /b 1
-136
View File
@@ -1,136 +0,0 @@
{
"base_url": "https://chat.euer.tv/static/emocache/",
"emotes": {
"-___-": "1f611.png",
":'-)": "1f602.png",
"':-)": "1f605.png",
"':-D": "1f605.png",
">:-)": "1f606.png",
"':-(": "1f613.png",
">:-(": "1f620.png",
":'-(": "1f622.png",
"O:-)": "1f607.png",
"0:-3": "1f607.png",
"0:-)": "1f607.png",
"0;^)": "1f607.png",
"O;-)": "1f607.png",
"0;-)": "1f607.png",
"O:-3": "1f607.png",
"-__-": "1f611.png",
":-Þ": "1f61b.png",
"<3": "2764.png",
"</3": "1f494.png",
":')": "1f602.png",
":-D": "1f603.png",
"':)": "1f605.png",
"'=)": "1f605.png",
"':D": "1f605.png",
"'=D": "1f605.png",
">:)": "1f606.png",
">;)": "1f606.png",
">=)": "1f606.png",
"XD": "1f606.png",
"xD": "1f606.png",
";-)": "1f609.png",
"*-)": "1f609.png",
";-]": "1f609.png",
";^)": "1f609.png",
"':(": "1f613.png",
"'=(": "1f613.png",
":-*": "1f618.png",
":^*": "1f618.png",
">:P": "1f61c.png",
"X-P": "1f61c.png",
">:[": "1f61e.png",
":-(": "1f61e.png",
":-[": "1f61e.png",
">:(": "1f620.png",
":'(": "1f622.png",
";-(": "1f622.png",
">.<": "1f623.png",
"#-)": "1f635.png",
"%-)": "1f635.png",
"X-)": "1f635.png",
"\\0/": "1f646.png",
"\\O/": "1f646.png",
"0:3": "1f607.png",
"0:)": "1f607.png",
"O:)": "1f607.png",
"O=)": "1f607.png",
"O:3": "1f607.png",
"B-)": "1f60e.png",
"8-)": "1f60e.png",
"B-D": "1f60e.png",
"8-D": "1f60e.png",
"-_-": "1f611.png",
">:\\": "1f615.png",
">:/": "1f615.png",
":-/": "1f615.png",
":-.": "1f615.png",
":-P": "1f61b.png",
":Þ": "1f61b.png",
":-b": "1f61b.png",
":-O": "1f62e.png",
"O_O": "1f62e.png",
">:O": "1f62e.png",
":-X": "1f636.png",
":-#": "1f636.png",
":-)": "1f642.png",
"(y)": "1f44d.png",
":D": "1f603.png",
"=D": "1f603.png",
";)": "1f609.png",
"*)": "1f609.png",
";]": "1f609.png",
";D": "1f609.png",
":*": "1f618.png",
"=*": "1f618.png",
":(": "1f61e.png",
":[": "1f61e.png",
"=(": "1f61e.png",
":@": "1f620.png",
";(": "1f622.png",
"D:": "1f628.png",
":$": "1f633.png",
"=$": "1f633.png",
"#)": "1f635.png",
"%)": "1f635.png",
"X)": "1f635.png",
"B)": "1f60e.png",
"8)": "1f60e.png",
":/": "1f615.png",
":\\": "1f615.png",
"=/": "1f615.png",
"=\\": "1f615.png",
":L": "1f615.png",
"=L": "1f615.png",
":P": "1f61b.png",
":p": "1f61b.png",
"=P": "1f61b.png",
":b": "1f61b.png",
":O": "1f62e.png",
":X": "1f636.png",
":#": "1f636.png",
"=X": "1f636.png",
"=#": "1f636.png",
":)": "1f642.png",
"=]": "1f642.png",
"=)": "1f642.png",
":]": "1f642.png",
"CATJAM": "catjam.gif",
"Clap": "clap.gif",
"AlienPls": "AlienPls.gif",
"blobDance": "blobDance.gif",
"ClapHD": "ClapHD.gif",
"Dance": "Dance.gif",
"KirbDance": "KirbDance.gif",
"PartyParrot": "PartyParrot.gif",
"pepeDS": "pepeDS.gif",
"PepoCheer": "PepoCheer.gif",
"PepoDance": "PepoDance.gif",
"ppJedi": "ppJedi.gif",
"pugPls": "pugPls.gif",
"RareParrot": "RareParrot.gif",
"xar2EDM": "xar2EDM.gif"
}
}
-247
View File
@@ -1,247 +0,0 @@
{
"name": "euer-chat",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "euer-chat",
"version": "0.1.0",
"devDependencies": {
"@tauri-apps/cli": "^2.2.0"
}
},
"node_modules/@tauri-apps/cli": {
"version": "2.12.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.12.2.tgz",
"integrity": "sha512-PMSppzAnlT+BvZtRKuidxjRSm2r5t8kDpJ/czmmQUa0AtDNTwrjLE8vApK3phCWGg7isfnYL46Dl1jB4ArI0hA==",
"dev": true,
"license": "Apache-2.0 OR MIT",
"bin": {
"tauri": "tauri.js"
},
"engines": {
"node": ">= 10"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/tauri"
},
"optionalDependencies": {
"@tauri-apps/cli-darwin-arm64": "2.12.2",
"@tauri-apps/cli-darwin-x64": "2.12.2",
"@tauri-apps/cli-linux-arm-gnueabihf": "2.12.2",
"@tauri-apps/cli-linux-arm64-gnu": "2.12.2",
"@tauri-apps/cli-linux-arm64-musl": "2.12.2",
"@tauri-apps/cli-linux-riscv64-gnu": "2.12.2",
"@tauri-apps/cli-linux-x64-gnu": "2.12.2",
"@tauri-apps/cli-linux-x64-musl": "2.12.2",
"@tauri-apps/cli-win32-arm64-msvc": "2.12.2",
"@tauri-apps/cli-win32-ia32-msvc": "2.12.2",
"@tauri-apps/cli-win32-x64-msvc": "2.12.2"
}
},
"node_modules/@tauri-apps/cli-darwin-arm64": {
"version": "2.12.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.12.2.tgz",
"integrity": "sha512-Lk3zaBTfzNvA7670Al5PNgo2M/95uaDPe1yp+ILJeDOmol87XiYuK5E5+FUbK5e6U6OORx9qpZdOo2U+5tETtQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-darwin-x64": {
"version": "2.12.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.12.2.tgz",
"integrity": "sha512-2CyYjJ833JB3sKRz4B0W/Tv4oOVC16ws1bxdSCWj1XvrmanWwvZi99e5tNIoCHwHhcOm0jh+sh0PCGQyls6Z+w==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-arm-gnueabihf": {
"version": "2.12.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.12.2.tgz",
"integrity": "sha512-uvt2xbone4JftKarelssi86Kqnl7JiG1Smh5+Ae+nZmtM+JUhTGmqkcqfUPXRW8Ygwog92POZvd4oGDN9j8eXQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-arm64-gnu": {
"version": "2.12.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.12.2.tgz",
"integrity": "sha512-DZlDJ24u3tlocKOYqzR+b2M+Qr+5WFcoMBZ7EfdhHN5j3ZXwBBpms4G4gyNvPCrak5vMzmdZ65ecBXPJcC+5sA==",
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-arm64-musl": {
"version": "2.12.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.12.2.tgz",
"integrity": "sha512-pb+RTZUYH/p0gR4j4ecCCq+FNfz3W3eKuUQvVEBn7MQZx6DzfLO7FaYzt2NyDKWpdNN3lP10mdzOvw3R5P5aAQ==",
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-riscv64-gnu": {
"version": "2.12.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.12.2.tgz",
"integrity": "sha512-nidEvDUJyHYNbIv9MUk6KiEix8IXfRh4Wj2G2SA5g8nFmgbBCwazUNWYiG/LhNeNbfZpBMLerRBUtoQ5K0GidA==",
"cpu": [
"riscv64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-x64-gnu": {
"version": "2.12.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.12.2.tgz",
"integrity": "sha512-gkXyYn3w+Xfc8nz/DleEbkm1M7bBuU2cejqUXNmI98cA2P5kp+oPmS4ALTorb1FaNWjpsKoYZBWsbS9BXxYFkQ==",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-x64-musl": {
"version": "2.12.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.12.2.tgz",
"integrity": "sha512-MrjgoZ09s8Hsn99XvxSNvK8NQTZxi7XiyK3FxxytzfHgz2jb3qUT69CZtsokRkN/zdi9rCgrMtq+w9XNICjcfQ==",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-win32-arm64-msvc": {
"version": "2.12.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.12.2.tgz",
"integrity": "sha512-BR+CYM7iF3lkfyTtVkwMQU/s9hwegLpj3UqDjaxKCPmixQCa1csBBPy3C75V+FhLtOjIXNna4Gt6Vm9oxbV+qA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-win32-ia32-msvc": {
"version": "2.12.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.12.2.tgz",
"integrity": "sha512-zODJo9l3TbNBM+GrO/yPqGeELNMK5cjIc+QHh1WQc3l8tD+jDZGTQyYuAxViCsM6Uh5uWJ//RnCZG9LXrvsXww==",
"cpu": [
"ia32"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-win32-x64-msvc": {
"version": "2.12.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.12.2.tgz",
"integrity": "sha512-cQXI2ZaZmwBDt0XATCqASVanYizxkEE3ceQVlVHXaQ51HzsQUWA04c9Nz7LrrY2lgAu3jFMyat+8Yz3Z2R2qBA==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
}
}
}
-14
View File
@@ -1,14 +0,0 @@
{
"name": "euer-chat",
"version": "0.1.0",
"description": "EuerChat Client fuer euer.tv",
"private": true,
"scripts": {
"tauri": "tauri",
"dev": "tauri dev",
"build": "tauri build"
},
"devDependencies": {
"@tauri-apps/cli": "^2.2.0"
}
}
-178
View File
@@ -1,178 +0,0 @@
param(
[string]$Version = "",
[string]$Notes = "",
[switch]$ValidateOnly
)
# Veroeffentlicht den Installer aus dist\ als Gitea-Release (Tag v<Version>) samt SHA-256-Datei.
# Token: Umgebungsvariable GITEA_TOKEN oder DPAPI-Datei %LOCALAPPDATA%\EuerChat\developer\gitea-token.dpapi
# (ersatzweise die MusicBrain-Token-Datei).
$ErrorActionPreference = "Stop"
Add-Type -AssemblyName System.Net.Http
$Root = Split-Path -Parent $MyInvocation.MyCommand.Path
$BaseUrl = "https://gitea.davito.synology.me"
$Owner = "Davito"
$Repository = "EuerChat"
$LocalAppData = [Environment]::GetFolderPath("LocalApplicationData")
$TokenPaths = @(
(Join-Path $LocalAppData "EuerChat\developer\gitea-token.dpapi"),
(Join-Path $LocalAppData "MusicBrain\developer\gitea-token.dpapi")
)
if ([string]::IsNullOrWhiteSpace($Version)) {
$Version = (Get-Content -LiteralPath (Join-Path $Root "src-tauri\tauri.conf.json") -Raw -Encoding UTF8 | ConvertFrom-Json).version
}
if ($Version -notmatch '^\d+\.\d+\.\d+$') {
throw "Ungueltige Release-Version: $Version"
}
$Tag = "v$Version"
$InstallerName = "EuerChat_${Version}_x64-setup.exe"
$ChecksumName = "$InstallerName.sha256"
$InstallerPath = Join-Path $Root "dist\$InstallerName"
$ChecksumPath = Join-Path $Root "dist\$ChecksumName"
if (-not (Test-Path -LiteralPath $InstallerPath -PathType Leaf)) {
throw "Installer fehlt: $InstallerPath (zuerst build.bat ausfuehren)"
}
$sha256 = [System.Security.Cryptography.SHA256]::Create()
$stream = [System.IO.File]::OpenRead($InstallerPath)
try {
$hash = -join ($sha256.ComputeHash($stream) | ForEach-Object { $_.ToString("x2") })
}
finally {
$stream.Dispose()
$sha256.Dispose()
}
[System.IO.File]::WriteAllText($ChecksumPath, "$hash $InstallerName`n", [System.Text.UTF8Encoding]::new($false))
if ([string]::IsNullOrWhiteSpace($Notes)) {
$notesFile = Join-Path $Root "RELEASE_NOTES.md"
if (Test-Path -LiteralPath $notesFile) {
$Notes = (Get-Content -LiteralPath $notesFile -Raw -Encoding UTF8).Trim()
}
}
$plainToken = $env:GITEA_TOKEN
if ([string]::IsNullOrWhiteSpace($plainToken)) {
$tokenPath = $TokenPaths | Where-Object { Test-Path -LiteralPath $_ -PathType Leaf } | Select-Object -First 1
if (-not $tokenPath) {
throw "Gitea-Token fehlt. Erwartet: GITEA_TOKEN oder $($TokenPaths[0])"
}
$secure = (Get-Content -LiteralPath $tokenPath -Raw -Encoding UTF8).Trim() | ConvertTo-SecureString
$pointer = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($secure)
try { $plainToken = [Runtime.InteropServices.Marshal]::PtrToStringBSTR($pointer) }
finally { [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($pointer) }
}
$client = [System.Net.Http.HttpClient]::new()
$client.Timeout = [TimeSpan]::FromMinutes(20)
$client.DefaultRequestHeaders.Authorization = [System.Net.Http.Headers.AuthenticationHeaderValue]::new("token", $plainToken.Trim())
$client.DefaultRequestHeaders.UserAgent.ParseAdd("EuerChat-Release-Publisher/$Version")
$client.DefaultRequestHeaders.Accept.ParseAdd("application/json")
function Send-Gitea {
param([string]$Method, [string]$Path, [object]$Body, [string]$Action, [switch]$AllowNotFound)
$request = [System.Net.Http.HttpRequestMessage]::new([System.Net.Http.HttpMethod]::new($Method), "$BaseUrl/api/v1$Path")
try {
if ($null -ne $Body) {
$request.Content = [System.Net.Http.StringContent]::new(
($Body | ConvertTo-Json -Depth 8 -Compress), [System.Text.Encoding]::UTF8, "application/json")
}
$response = $client.SendAsync($request).GetAwaiter().GetResult()
try {
$text = $response.Content.ReadAsStringAsync().GetAwaiter().GetResult()
if ($AllowNotFound -and [int]$response.StatusCode -eq 404) { return $null }
if (-not $response.IsSuccessStatusCode) {
throw "$Action fehlgeschlagen (HTTP $([int]$response.StatusCode)): $text"
}
if ([string]::IsNullOrWhiteSpace($text)) { return $null }
return $text | ConvertFrom-Json
}
finally { $response.Dispose() }
}
finally { $request.Dispose() }
}
try {
$repo = Send-Gitea -Method GET -Path "/repos/$Owner/$Repository" -Action "Repository-Zugriff pruefen"
if (-not $repo.permissions.push) {
throw "Der Token besitzt keine Schreibrechte fuer $Owner/$Repository."
}
Write-Host "Repository: $($repo.full_name) Version: $Version SHA256: $hash"
if ($ValidateOnly) { return }
$encodedTag = [Uri]::EscapeDataString($Tag)
$release = Send-Gitea -Method GET -Path "/repos/$Owner/$Repository/releases/tags/$encodedTag" -Action "Release lesen" -AllowNotFound
if ($null -eq $release) {
# Gitea datiert Releases nach dem Commit von main; ohne frischen Commit bleibt /releases/latest alt.
$marker = Send-Gitea -Method GET -Path "/repos/$Owner/$Repository/contents/RELEASES.md?ref=main" -Action "Release-Marker lesen" -AllowNotFound
$markerText = "# EuerChat Releases`n`nLetzter veroeffentlichter Release: v$Version ($([DateTime]::UtcNow.ToString('yyyy-MM-dd HH:mm:ss')) UTC)`n"
$markerBody = @{
message = "Release v$Version"
content = [Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($markerText))
branch = "main"
}
if ($marker) { $markerBody.sha = $marker.sha }
$markerMethod = if ($marker) { "PUT" } else { "POST" }
Send-Gitea -Method $markerMethod -Path "/repos/$Owner/$Repository/contents/RELEASES.md" -Body $markerBody -Action "Release-Marker-Commit anlegen" | Out-Null
Write-Host "Lege Gitea-Release $Tag an..."
$release = Send-Gitea -Method POST -Path "/repos/$Owner/$Repository/releases" -Action "Release anlegen" -Body @{
tag_name = $Tag
target_commitish = "main"
name = "EuerChat $Version"
body = $Notes
draft = $false
prerelease = $false
}
}
else {
Write-Host "Setze vorhandenen Release $Tag fort."
}
$existing = @($release.assets | ForEach-Object { $_.name })
foreach ($assetPath in @($InstallerPath, $ChecksumPath)) {
$assetName = [System.IO.Path]::GetFileName($assetPath)
if ($assetName -in $existing) {
Write-Host "Bereits vorhanden: $assetName"
continue
}
Write-Host "Lade hoch: $assetName"
$uploadUrl = "$BaseUrl/api/v1/repos/$Owner/$Repository/releases/$($release.id)/assets?name=$([Uri]::EscapeDataString($assetName))"
$multipart = [System.Net.Http.MultipartFormDataContent]::new()
$fileStream = [System.IO.File]::OpenRead($assetPath)
$fileContent = [System.Net.Http.StreamContent]::new($fileStream)
$fileContent.Headers.ContentType = [System.Net.Http.Headers.MediaTypeHeaderValue]::new("application/octet-stream")
$multipart.Add($fileContent, "attachment", $assetName)
$request = [System.Net.Http.HttpRequestMessage]::new([System.Net.Http.HttpMethod]::Post, $uploadUrl)
$request.Content = $multipart
try {
$response = $client.SendAsync($request).GetAwaiter().GetResult()
try {
if (-not $response.IsSuccessStatusCode) {
throw "Upload von $assetName fehlgeschlagen (HTTP $([int]$response.StatusCode)): $($response.Content.ReadAsStringAsync().GetAwaiter().GetResult())"
}
}
finally { $response.Dispose() }
}
finally {
$request.Dispose(); $multipart.Dispose(); $fileContent.Dispose(); $fileStream.Dispose()
}
}
$published = Send-Gitea -Method GET -Path "/repos/$Owner/$Repository/releases/tags/$encodedTag" -Action "Veroeffentlichten Release pruefen"
$names = @($published.assets | ForEach-Object { $_.name })
foreach ($required in @($InstallerName, $ChecksumName)) {
if ($required -notin $names) { throw "Release wurde angelegt, aber das Asset fehlt: $required" }
}
Write-Host "Fertig: $BaseUrl/$Owner/$Repository/releases/tag/$Tag"
}
finally {
$client.Dispose()
}
-4577
View File
File diff suppressed because it is too large. Load diff
-31
View File
@@ -1,31 +0,0 @@
[package]
name = "euer-chat"
version = "0.1.0"
description = "EuerChat Client fuer euer.tv"
edition = "2021"
[features]
default = ["custom-protocol"]
custom-protocol = ["tauri/custom-protocol"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = [] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
tokio = { version = "1", features = ["rt", "net", "io-util", "time", "sync", "fs", "macros"] }
tokio-native-tls = "0.3"
native-tls = "0.2"
chrono = { version = "0.4", default-features = false, features = ["clock", "std"] }
reqwest = { version = "0.12", default-features = false, features = ["native-tls"] }
bytes = "1"
sha2 = "0.10"
# Kein panic = "abort": ein Panic in einem Netzwerk-Task soll nicht die ganze App beenden.
[profile.release]
codegen-units = 1
lto = true
opt-level = "s"
strip = true
-3
View File
@@ -1,3 +0,0 @@
fn main() {
tauri_build::build()
}
-9
View File
@@ -1,9 +0,0 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Standard Berechtigungen fuer das Hauptfenster",
"windows": ["main"],
"permissions": [
"core:event:default"
]
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 561 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 155 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 177 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 561 B

-1110
View File
File diff suppressed because it is too large. Load diff
-50
View File
@@ -1,50 +0,0 @@
// Kein Konsolenfenster unter Windows
#![windows_subsystem = "windows"]
mod irc;
mod storage;
mod system;
mod updater;
use std::time::Duration;
use tauri::Manager;
fn main() {
tauri::Builder::default()
.manage(irc::AppState::default())
.manage(updater::UpdaterState::default())
.setup(|app| {
tauri::async_runtime::spawn(updater::cleanup_installers());
if let Some(window) = app.get_webview_window("main") {
storage::restore_window_state(&window);
}
Ok(())
})
.on_window_event(|window, event| {
if let tauri::WindowEvent::CloseRequested { .. } = event {
storage::save_window_state(window);
// Dem Server kurz Zeit geben, das QUIT zu empfangen.
if window.state::<irc::AppState>().request_quit() {
std::thread::sleep(Duration::from_millis(300));
}
}
})
.invoke_handler(tauri::generate_handler![
irc::connect_irc,
irc::send_irc_message,
irc::send_irc_raw,
irc::disconnect_irc,
irc::scan_clones,
storage::save_app_config,
storage::load_app_config,
system::send_grussbox,
system::get_installed_fonts,
system::set_ui_zoom,
updater::get_app_version,
updater::check_for_update,
updater::install_update,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
-139
View File
@@ -1,139 +0,0 @@
//! Persistenz: Benutzerkonfiguration und Fensterposition.
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use tauri::{PhysicalPosition, PhysicalSize};
fn exe_dir() -> PathBuf {
std::env::current_exe()
.ok()
.and_then(|p| p.parent().map(PathBuf::from))
.unwrap_or_else(|| PathBuf::from("."))
}
pub fn data_dir() -> PathBuf {
std::env::var_os("APPDATA")
.map(|d| PathBuf::from(d).join("EuerChat"))
.unwrap_or_else(exe_dir)
}
fn config_path() -> PathBuf {
data_dir().join("config.json")
}
/// Bis Version 0.1 lag die config.json neben der EXE; wird nur noch gelesen.
fn legacy_config_path() -> PathBuf {
exe_dir().join("config.json")
}
#[tauri::command]
pub async fn load_app_config() -> Result<String, String> {
for path in [config_path(), legacy_config_path()] {
match tokio::fs::read_to_string(&path).await {
Ok(content) => return Ok(content),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
Err(e) => return Err(format!("Fehler beim Lesen der Konfigurationsdatei: {e}")),
}
}
Err("Konfigurationsdatei existiert noch nicht.".to_string())
}
#[tauri::command]
pub async fn save_app_config(config_content: String) -> Result<String, String> {
serde_json::from_str::<serde_json::Value>(&config_content)
.map_err(|e| format!("Ungültige Konfiguration: {e}"))?;
let path = config_path();
let err = |e: std::io::Error| format!("Fehler beim Speichern der Konfiguration: {e}");
tokio::fs::create_dir_all(data_dir()).await.map_err(err)?;
// Erst in eine Temp-Datei schreiben, damit ein Absturz die Konfiguration nicht zerstoert.
let tmp = path.with_extension("json.tmp");
tokio::fs::write(&tmp, config_content).await.map_err(err)?;
tokio::fs::rename(&tmp, &path).await.map_err(err)?;
Ok(format!("Konfiguration gespeichert in: {}", path.display()))
}
#[derive(Serialize, Deserialize, Clone, Copy)]
struct WindowState {
x: i32,
y: i32,
width: u32,
height: u32,
maximized: bool,
}
fn window_state_path() -> PathBuf {
data_dir().join("window_state.json")
}
fn read_window_state() -> Option<WindowState> {
serde_json::from_str(&std::fs::read_to_string(window_state_path()).ok()?).ok()
}
/// Wird nur beim Schliessen aufgerufen. Maximiert/minimiert bleibt die letzte normale Geometrie erhalten.
pub fn save_window_state(window: &tauri::Window) {
let maximized = window.is_maximized().unwrap_or(false);
let minimized = window.is_minimized().unwrap_or(false);
let state = if maximized || minimized {
match read_window_state() {
Some(previous) => WindowState { maximized, ..previous },
None => return,
}
} else {
let (Ok(pos), Ok(size)) = (window.outer_position(), window.outer_size()) else {
return;
};
if size.width < 400 || size.height < 300 {
return;
}
WindowState {
x: pos.x,
y: pos.y,
width: size.width,
height: size.height,
maximized: false,
}
};
if let Ok(json) = serde_json::to_string(&state) {
let _ = std::fs::create_dir_all(data_dir());
let _ = std::fs::write(window_state_path(), json);
}
}
fn is_on_a_monitor(window: &tauri::WebviewWindow, state: &WindowState) -> bool {
let (px, py) = (state.x + 50, state.y + 10);
window
.available_monitors()
.map(|monitors| {
monitors.iter().any(|m| {
let (pos, size) = (m.position(), m.size());
px >= pos.x
&& px < pos.x + size.width as i32
&& py >= pos.y
&& py < pos.y + size.height as i32
})
})
.unwrap_or(false)
}
pub fn restore_window_state(window: &tauri::WebviewWindow) {
let Some(state) = read_window_state() else {
return;
};
if state.width < 600 || state.height < 400 {
return;
}
let _ = window.set_size(PhysicalSize::new(state.width, state.height));
if is_on_a_monitor(window, &state) {
let _ = window.set_position(PhysicalPosition::new(state.x, state.y));
} else {
let _ = window.center();
}
if state.maximized {
let _ = window.maximize();
}
}
-141
View File
@@ -1,141 +0,0 @@
//! Windows-spezifische Helfer und die Grussbox.
use std::collections::HashSet;
use std::os::windows::process::CommandExt;
use std::time::Duration;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream;
use tokio::time::timeout;
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
const GRUSSBOX_HOST: &str = "www.euer-radio.de";
const FONT_SUFFIXES: [&str; 15] = [
" Bold Italic", " Bold", " Italic", " Light Italic", " Light",
" Semibold Italic", " Semibold", " SemiLight Italic", " SemiLight",
" Black Italic", " Black", " Medium Italic", " Medium",
" ExtraBold", " Regular",
];
fn read_installed_fonts() -> Vec<String> {
let hives = [
"HKLM\\SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion\\Fonts",
"HKCU\\SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion\\Fonts",
];
let mut fonts = HashSet::new();
for hive in hives {
let Ok(output) = std::process::Command::new("reg")
.args(["query", hive])
.creation_flags(CREATE_NO_WINDOW)
.output()
else {
continue;
};
for line in String::from_utf8_lossy(&output.stdout).lines() {
let Some(pos) = line.find("REG_SZ") else {
continue;
};
let mut name = line[..pos].trim();
if let Some(paren) = name.find('(') {
name = name[..paren].trim();
}
let cleaned = FONT_SUFFIXES
.iter()
.find_map(|s| name.strip_suffix(s))
.unwrap_or(name)
.trim();
// "Cambria & Cambria Math" enthaelt mehrere Familien.
for family in cleaned.split('&').map(str::trim) {
if family.len() > 1 {
fonts.insert(family.to_string());
}
}
}
}
let mut list: Vec<String> = fonts.into_iter().collect();
list.sort_by_key(|f| f.to_lowercase());
list
}
#[tauri::command]
pub async fn get_installed_fonts() -> Vec<String> {
tauri::async_runtime::spawn_blocking(read_installed_fonts)
.await
.unwrap_or_default()
}
/// Skaliert die gesamte Oberflaeche (echter Webview-Zoom, Layout passt sich an).
#[tauri::command]
pub fn set_ui_zoom(window: tauri::WebviewWindow, factor: f64) -> Result<(), String> {
if !factor.is_finite() {
return Err("Ungültiger Zoom-Faktor".to_string());
}
window.set_zoom(factor.clamp(0.5, 3.0)).map_err(|e| e.to_string())
}
fn form_urlencode(input: &str) -> String {
let mut out = String::with_capacity(input.len());
for b in input.bytes() {
match b {
b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => out.push(b as char),
b' ' => out.push('+'),
_ => out.push_str(&format!("%{b:02X}")),
}
}
out
}
async fn post_grussbox(name: &str, text: &str) -> Result<String, String> {
let body = format!("gbname={}&gbtext={}", form_urlencode(name), form_urlencode(text));
let connector = tokio_native_tls::TlsConnector::from(
native_tls::TlsConnector::new().map_err(|e| e.to_string())?,
);
let tcp = TcpStream::connect((GRUSSBOX_HOST, 443))
.await
.map_err(|e| format!("Verbindung zum Grußbox-Server fehlgeschlagen: {e}"))?;
let mut tls = connector
.connect(GRUSSBOX_HOST, tcp)
.await
.map_err(|e| format!("SSL-Verbindung fehlgeschlagen: {e}"))?;
let request = format!(
"POST /gbentry.php HTTP/1.1\r\nHost: {GRUSSBOX_HOST}\r\nContent-Type: application/x-www-form-urlencoded\r\nContent-Length: {}\r\nUser-Agent: EuerChat/{}\r\nConnection: close\r\n\r\n{body}",
body.len(),
env!("CARGO_PKG_VERSION"),
);
tls.write_all(request.as_bytes()).await.map_err(|e| e.to_string())?;
tls.flush().await.map_err(|e| e.to_string())?;
let mut response = Vec::new();
tls.take(64 * 1024)
.read_to_end(&mut response)
.await
.map_err(|e| e.to_string())?;
let response = String::from_utf8_lossy(&response);
let status_ok = response.lines().next().is_some_and(|l| l.contains(" 200"));
if status_ok || response.contains("bermittelt") {
Ok("Eintrag erfolgreich übermittelt!".to_string())
} else {
Err("Der Grußbox-Server hat die Nachricht nicht angenommen.".to_string())
}
}
#[tauri::command]
pub async fn send_grussbox(name: String, text: String) -> Result<String, String> {
let (name, text) = (name.trim(), text.trim());
if name.is_empty() || text.is_empty() {
return Err("Name und Text dürfen nicht leer sein.".to_string());
}
if name.chars().count() > 100 || text.chars().count() > 2000 {
return Err("Name oder Text ist zu lang.".to_string());
}
timeout(Duration::from_secs(20), post_grussbox(name, text))
.await
.map_err(|_| "Zeitüberschreitung beim Senden der Grußbox.".to_string())?
}
-685
View File
@@ -1,685 +0,0 @@
//! Selbst-Updater: laedt neue Versionen aus den Releases eines oeffentlichen Gitea-Repositories.
//!
//! Das Repository braucht kein Token. Es werden nur HTTPS-URLs des vertrauenswuerdigen Hosts
//! akzeptiert (auch bei Weiterleitungen), und der Installer wird nur gestartet, wenn seine
//! SHA-256-Pruefsumme mit der Datei `<installer>.sha256` aus demselben Release uebereinstimmt.
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
use reqwest::{redirect, Client, Response, Url};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use tauri::{AppHandle, Emitter, Manager, State};
use tokio::io::AsyncWriteExt;
use tokio::sync::Mutex;
use tokio::time::timeout;
const GITEA_BASE_URL: &str = "https://gitea.davito.synology.me";
const GITEA_REPO: &str = "Davito/EuerChat";
const MAX_RELEASE_BYTES: usize = 2 * 1024 * 1024;
const MAX_CHECKSUM_BYTES: usize = 64 * 1024;
const MAX_INSTALLER_BYTES: u64 = 300 * 1024 * 1024;
const CONNECT_TIMEOUT: Duration = Duration::from_secs(15);
const READ_TIMEOUT: Duration = Duration::from_secs(30);
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
const DETACHED_PROCESS: u32 = 0x0000_0008;
#[derive(Clone)]
struct Source {
base: Url,
repo: String,
// Nur fuer Tests mit lokalem Mock-Server.
allow_http: bool,
}
impl Source {
fn production() -> Self {
Self {
base: Url::parse(GITEA_BASE_URL).expect("gueltige Gitea-URL"),
repo: GITEA_REPO.to_string(),
allow_http: false,
}
}
fn latest_release_url(&self) -> String {
format!(
"{}/api/v1/repos/{}/releases/latest",
self.base.as_str().trim_end_matches('/'),
self.repo
)
}
fn is_trusted(&self, url: &Url) -> bool {
let scheme_ok = url.scheme() == "https" || (self.allow_http && url.scheme() == "http");
scheme_ok
&& url.host_str() == self.base.host_str()
&& url.port_or_known_default() == self.base.port_or_known_default()
&& url.username().is_empty()
&& url.password().is_none()
}
}
#[derive(Debug, Clone)]
struct Asset {
name: String,
url: String,
}
#[derive(Debug, Clone)]
struct Release {
version: String,
tag: String,
name: String,
body: String,
assets: Vec<Asset>,
}
#[derive(Deserialize)]
struct RawRelease {
tag_name: Option<String>,
name: Option<String>,
body: Option<String>,
assets: Option<Vec<RawAsset>>,
}
#[derive(Deserialize)]
struct RawAsset {
name: Option<String>,
browser_download_url: Option<String>,
}
#[derive(Default)]
pub struct UpdaterState {
pending: Mutex<Option<Release>>,
busy: AtomicBool,
}
#[derive(Serialize)]
pub struct UpdateCheck {
current: String,
latest: String,
available: bool,
name: String,
notes: String,
}
// ---------------------------------------------------------------------------
// Reine Hilfen
// ---------------------------------------------------------------------------
fn parse_version(value: &str) -> Option<(u32, u32, u32)> {
let mut parts = value.trim().trim_start_matches('v').split('.');
let major = parts.next()?.parse().ok()?;
let minor = parts.next()?.parse().ok()?;
let patch = match parts.next() {
Some(p) => p.parse().ok()?,
None => 0,
};
if parts.next().is_some() {
return None;
}
Some((major, minor, patch))
}
fn is_newer(candidate: &str, installed: &str) -> Result<bool, String> {
let parse = |v: &str| parse_version(v).ok_or_else(|| format!("Ungültige Versionsnummer: {v:?}"));
Ok(parse(candidate)? > parse(installed)?)
}
fn installer_name(version: &str) -> String {
format!("EuerChat_{version}_x64-setup.exe")
}
fn is_managed_installer_file(name: &str) -> bool {
let lower = name.to_ascii_lowercase();
let base = lower.strip_suffix(".part").unwrap_or(&lower);
base.starts_with("euerchat_") && base.ends_with("_x64-setup.exe")
}
fn select_assets(release: &Release) -> Result<(&Asset, &Asset), String> {
let expected = installer_name(&release.version);
let find = |wanted: &str| {
release
.assets
.iter()
.find(|a| a.name.eq_ignore_ascii_case(wanted))
};
let installer = find(&expected).ok_or_else(|| {
format!("Release {} enthält nicht den erwarteten Installer {expected}.", release.tag)
})?;
let checksum_name = format!("{expected}.sha256");
let checksum = find(&checksum_name).ok_or_else(|| {
format!("Release {} enthält keine SHA-256-Datei {checksum_name}.", release.tag)
})?;
Ok((installer, checksum))
}
/// Akzeptiert einen reinen Hash oder das `sha256sum`-Format (`<hash> <datei>`).
fn parse_expected_checksum(manifest: &str, filename: &str) -> Result<String, String> {
let mut fallback = None;
for line in manifest.lines().map(str::trim).filter(|l| !l.is_empty()) {
let mut parts = line.split_whitespace();
let Some(hash) = parts.next() else { continue };
if hash.len() != 64 || !hash.bytes().all(|b| b.is_ascii_hexdigit()) {
continue;
}
let hash = hash.to_ascii_lowercase();
match parts.next() {
None => fallback = Some(hash),
Some(listed) if listed.trim_start_matches('*').eq_ignore_ascii_case(filename) => {
return Ok(hash)
}
Some(_) => {}
}
}
fallback.ok_or_else(|| format!("Keine SHA-256-Prüfsumme für {filename} gefunden."))
}
fn updates_dir() -> PathBuf {
crate::storage::data_dir().join("updates")
}
// ---------------------------------------------------------------------------
// Netzwerk
// ---------------------------------------------------------------------------
fn build_client(source: &Source) -> Result<Client, String> {
let trusted = source.clone();
Client::builder()
.user_agent(format!("EuerChat-Updater/{}", env!("CARGO_PKG_VERSION")))
.connect_timeout(CONNECT_TIMEOUT)
.redirect(redirect::Policy::custom(move |attempt| {
if attempt.previous().len() >= 5 {
attempt.error("zu viele Weiterleitungen")
} else if trusted.is_trusted(attempt.url()) {
attempt.follow()
} else {
attempt.error("nicht vertrauenswürdige Weiterleitung")
}
}))
.build()
.map_err(|e| format!("HTTP-Client konnte nicht erstellt werden: {e}"))
}
async fn get(client: &Client, source: &Source, url: &str) -> Result<Response, String> {
let parsed = Url::parse(url).map_err(|_| "Ungültige Update-URL.".to_string())?;
if !source.is_trusted(&parsed) {
return Err("Gitea lieferte eine nicht vertrauenswürdige URL.".to_string());
}
let response = client
.get(parsed)
.send()
.await
.map_err(|e| format!("Der Update-Server ist nicht erreichbar: {e}"))?;
if !source.is_trusted(response.url()) {
return Err("Gitea leitete auf eine nicht vertrauenswürdige URL weiter.".to_string());
}
Ok(response)
}
async fn next_chunk(response: &mut Response) -> Result<Option<bytes::Bytes>, String> {
timeout(READ_TIMEOUT, response.chunk())
.await
.map_err(|_| "Zeitüberschreitung beim Laden vom Update-Server.".to_string())?
.map_err(|e| format!("Fehler beim Laden vom Update-Server: {e}"))
}
async fn read_limited(mut response: Response, limit: usize) -> Result<Vec<u8>, String> {
let too_big = || "Die Antwort des Update-Servers ist unerwartet groß.".to_string();
if response.content_length().is_some_and(|l| l > limit as u64) {
return Err(too_big());
}
let mut buf = Vec::new();
while let Some(chunk) = next_chunk(&mut response).await? {
if buf.len() + chunk.len() > limit {
return Err(too_big());
}
buf.extend_from_slice(&chunk);
}
Ok(buf)
}
async fn fetch_latest_release(client: &Client, source: &Source) -> Result<Release, String> {
let response = get(client, source, &source.latest_release_url()).await?;
match response.status().as_u16() {
200 => {}
404 => return Err("Auf Gitea ist noch kein veröffentlichter Release vorhanden.".to_string()),
code => return Err(format!("Gitea antwortete mit HTTP {code}.")),
}
let payload = read_limited(response, MAX_RELEASE_BYTES).await?;
let raw: RawRelease = serde_json::from_slice(&payload)
.map_err(|_| "Gitea lieferte keine gültigen Release-Daten.".to_string())?;
let tag = raw.tag_name.unwrap_or_default().trim().to_string();
let (major, minor, patch) = parse_version(&tag)
.ok_or_else(|| format!("Ungültige Versionsnummer im Release: {tag:?}"))?;
let mut assets = Vec::new();
for asset in raw.assets.unwrap_or_default() {
let (Some(name), Some(url)) = (asset.name, asset.browser_download_url) else {
continue;
};
let parsed = Url::parse(&url).map_err(|_| "Gitea lieferte eine ungültige Asset-URL.".to_string())?;
if !source.is_trusted(&parsed) {
return Err("Gitea lieferte eine nicht vertrauenswürdige Asset-URL.".to_string());
}
assets.push(Asset { name, url });
}
Ok(Release {
version: format!("{major}.{minor}.{patch}"),
name: raw.name.filter(|n| !n.trim().is_empty()).unwrap_or_else(|| tag.clone()),
body: raw.body.unwrap_or_default(),
tag,
assets,
})
}
/// Laedt den Installer, prueft die SHA-256-Summe und gibt den endgueltigen Pfad zurueck.
async fn download_installer(
client: &Client,
source: &Source,
release: &Release,
dir: &Path,
mut progress: impl FnMut(u8),
) -> Result<PathBuf, String> {
let (installer, checksum) = select_assets(release)?;
if installer.name.contains(['/', '\\']) {
return Err("Der Installername im Release ist ungültig.".to_string());
}
let manifest = read_limited(get(client, source, &checksum.url).await?, MAX_CHECKSUM_BYTES).await?;
let expected = parse_expected_checksum(&String::from_utf8_lossy(&manifest), &installer.name)?;
tokio::fs::create_dir_all(dir)
.await
.map_err(|e| format!("Update-Ordner konnte nicht angelegt werden: {e}"))?;
let final_path = dir.join(&installer.name);
let partial_path = dir.join(format!("{}.part", installer.name));
let result = download_to_file(client, source, &installer.url, &partial_path, &expected, &mut progress).await;
if let Err(e) = result {
let _ = tokio::fs::remove_file(&partial_path).await;
return Err(e);
}
tokio::fs::rename(&partial_path, &final_path)
.await
.map_err(|e| format!("Der Installer konnte nicht gespeichert werden: {e}"))?;
progress(100);
Ok(final_path)
}
async fn download_to_file(
client: &Client,
source: &Source,
url: &str,
path: &Path,
expected_sha256: &str,
progress: &mut impl FnMut(u8),
) -> Result<(), String> {
let mut response = get(client, source, url).await?;
if !response.status().is_success() {
return Err(format!("Der Installer-Download schlug fehl (HTTP {}).", response.status().as_u16()));
}
let total = response.content_length().unwrap_or(0);
if total > MAX_INSTALLER_BYTES {
return Err("Der angebotene Installer ist unerwartet groß.".to_string());
}
let io_err = |e: std::io::Error| format!("Der Installer konnte nicht geschrieben werden: {e}");
let mut file = tokio::fs::File::create(path).await.map_err(io_err)?;
let mut hasher = Sha256::new();
let mut downloaded: u64 = 0;
let mut last_percent = 0u8;
while let Some(chunk) = next_chunk(&mut response).await? {
downloaded += chunk.len() as u64;
if downloaded > MAX_INSTALLER_BYTES {
return Err("Der geladene Installer ist unerwartet groß.".to_string());
}
hasher.update(&chunk);
file.write_all(&chunk).await.map_err(io_err)?;
if let Some(percent) = (downloaded * 100).checked_div(total) {
let percent = percent.min(99) as u8;
if percent != last_percent {
last_percent = percent;
progress(percent);
}
}
}
file.flush().await.map_err(io_err)?;
drop(file);
if downloaded == 0 {
return Err("Der heruntergeladene Installer ist leer.".to_string());
}
let actual: String = hasher.finalize().iter().map(|b| format!("{b:02x}")).collect();
if actual != expected_sha256 {
return Err("Die SHA-256-Prüfung des Installers ist fehlgeschlagen; die Datei wird nicht ausgeführt.".to_string());
}
Ok(())
}
// ---------------------------------------------------------------------------
// Installation
// ---------------------------------------------------------------------------
/// Startet den Installer erst, wenn diese App beendet ist, und danach EuerChat neu.
fn spawn_installer_after_exit(installer: &Path) -> std::io::Result<()> {
use std::os::windows::process::CommandExt;
let quote = |p: &Path| p.to_string_lossy().replace('\'', "''");
let current = std::env::current_exe()?;
let installed_here = current
.parent()
.is_some_and(|dir| dir.join("uninstall.exe").exists());
// Eine portable EXE wird per Installer in %LOCALAPPDATA%\EuerChat aktualisiert; dort neu starten.
let script = format!(
"Wait-Process -Id {pid} -Timeout 30 -ErrorAction SilentlyContinue; \
Start-Process -FilePath '{installer}' -ArgumentList '/S','/UPDATE' -Wait; \
$installed = Join-Path $env:LOCALAPPDATA 'EuerChat\\euer-chat.exe'; \
if (-not {installed_here} -and (Test-Path $installed)) {{ Start-Process -FilePath $installed }} \
else {{ Start-Process -FilePath '{current}' }}",
pid = std::process::id(),
installer = quote(installer),
installed_here = if installed_here { "$true" } else { "$false" },
current = quote(&current),
);
std::process::Command::new("powershell.exe")
.args(["-NoProfile", "-NonInteractive", "-WindowStyle", "Hidden", "-Command", &script])
.creation_flags(CREATE_NO_WINDOW | DETACHED_PROCESS)
.spawn()
.map(|_| ())
}
/// Raeumt alte Installer-Downloads auf; gesperrte Dateien werden beim naechsten Start erneut versucht.
pub async fn cleanup_installers() {
let Ok(mut entries) = tokio::fs::read_dir(updates_dir()).await else {
return;
};
while let Ok(Some(entry)) = entries.next_entry().await {
let is_file = entry.file_type().await.map(|t| t.is_file()).unwrap_or(false);
if is_file && is_managed_installer_file(&entry.file_name().to_string_lossy()) {
let _ = tokio::fs::remove_file(entry.path()).await;
}
}
}
// ---------------------------------------------------------------------------
// Tauri-Commands
// ---------------------------------------------------------------------------
#[tauri::command]
pub fn get_app_version(app: AppHandle) -> String {
app.package_info().version.to_string()
}
#[tauri::command]
pub async fn check_for_update(
app: AppHandle,
state: State<'_, UpdaterState>,
) -> Result<UpdateCheck, String> {
let current = app.package_info().version.to_string();
let source = Source::production();
let client = build_client(&source)?;
let release = fetch_latest_release(&client, &source).await?;
let available = is_newer(&release.version, &current)?;
if available {
// Fehlende Dateien sofort melden, nicht erst beim Installieren.
select_assets(&release)?;
}
let check = UpdateCheck {
current,
latest: release.version.clone(),
available,
name: release.name.clone(),
notes: release.body.clone(),
};
*state.pending.lock().await = available.then_some(release);
Ok(check)
}
#[tauri::command]
pub async fn install_update(
app: AppHandle,
state: State<'_, UpdaterState>,
) -> Result<(), String> {
if state.busy.swap(true, Ordering::SeqCst) {
return Err("Ein Update läuft bereits.".to_string());
}
let result = run_install(&app, &state).await;
if result.is_err() {
state.busy.store(false, Ordering::SeqCst);
}
result
}
async fn run_install(app: &AppHandle, state: &UpdaterState) -> Result<(), String> {
let release = state
.pending
.lock()
.await
.clone()
.ok_or_else(|| "Es ist kein Update vorgemerkt. Bitte erneut nach Updates suchen.".to_string())?;
let source = Source::production();
let client = build_client(&source)?;
let installer = download_installer(&client, &source, &release, &updates_dir(), |percent| {
let _ = app.emit("update-progress", percent);
})
.await?;
spawn_installer_after_exit(&installer)
.map_err(|e| format!("Die Installation konnte nicht gestartet werden: {e}"))?;
// Dem IRC-Server noch ein sauberes QUIT schicken, dann beenden.
if app.state::<crate::irc::AppState>().request_quit() {
tokio::time::sleep(Duration::from_millis(300)).await;
}
app.exit(0);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
#[test]
fn version_parsing_and_comparison() {
assert_eq!(parse_version("v1.2.3"), Some((1, 2, 3)));
assert_eq!(parse_version("0.2"), Some((0, 2, 0)));
assert_eq!(parse_version("1.2.3-beta"), None);
assert_eq!(parse_version("1.2.3.4"), None);
assert!(is_newer("0.10.0", "0.9.9").unwrap());
assert!(!is_newer("v0.1.0", "0.1.0").unwrap());
assert!(is_newer("x", "0.1.0").is_err());
}
#[test]
fn only_the_trusted_https_host_is_accepted() {
let source = Source::production();
let ok = |u: &str| source.is_trusted(&Url::parse(u).unwrap());
assert!(ok("https://gitea.davito.synology.me/Davito/EuerChat/releases/download/v1/a.exe"));
assert!(ok("https://gitea.davito.synology.me:443/x"));
assert!(!ok("http://gitea.davito.synology.me/x"));
assert!(!ok("https://gitea.davito.synology.me:8443/x"));
assert!(!ok("https://evil.example.com/x"));
assert!(!ok("https://gitea.davito.synology.me.evil.com/x"));
assert!(!ok("https://user:pw@gitea.davito.synology.me/x"));
}
#[test]
fn checksum_formats() {
let hash = "a".repeat(64);
assert_eq!(parse_expected_checksum(&hash, "x.exe").unwrap(), hash);
assert_eq!(
parse_expected_checksum(&format!("{} *X.exe\n", hash.to_uppercase()), "x.exe").unwrap(),
hash
);
assert!(parse_expected_checksum("nonsense", "x.exe").is_err());
let other = format!("{} other.exe", "b".repeat(64));
assert!(parse_expected_checksum(&other, "x.exe").unwrap_err().contains("x.exe"));
}
#[test]
fn managed_installer_files() {
assert!(is_managed_installer_file("EuerChat_0.2.0_x64-setup.exe"));
assert!(is_managed_installer_file("EuerChat_0.2.0_x64-setup.exe.part"));
assert!(!is_managed_installer_file("config.json"));
assert!(!is_managed_installer_file("other-setup.exe"));
}
fn release_with(version: &str, names: &[&str]) -> Release {
Release {
version: version.to_string(),
tag: format!("v{version}"),
name: String::new(),
body: String::new(),
assets: names
.iter()
.map(|n| Asset { name: n.to_string(), url: String::new() })
.collect(),
}
}
#[test]
fn asset_selection_requires_installer_and_checksum() {
let both = release_with("0.2.0", &["EuerChat_0.2.0_x64-setup.exe", "EuerChat_0.2.0_x64-setup.exe.sha256"]);
assert!(select_assets(&both).is_ok());
let no_sum = release_with("0.2.0", &["EuerChat_0.2.0_x64-setup.exe"]);
assert!(select_assets(&no_sum).is_err());
let wrong = release_with("0.2.0", &["EuerChat_0.1.0_x64-setup.exe", "EuerChat_0.1.0_x64-setup.exe.sha256"]);
assert!(select_assets(&wrong).is_err());
}
type Routes = Vec<(String, u16, Option<String>, Vec<u8>)>;
/// Minimaler HTTP-Server: Pfad -> (Status, Header-Zusatz, Body).
async fn serve(routes: Routes) -> u16 {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
spawn_server(listener, routes);
port
}
fn spawn_server(listener: TcpListener, routes: Routes) {
tokio::spawn(async move {
loop {
let Ok((mut sock, _)) = listener.accept().await else { return };
let routes = routes.clone();
tokio::spawn(async move {
let mut buf = vec![0u8; 4096];
let n = sock.read(&mut buf).await.unwrap_or(0);
let request = String::from_utf8_lossy(&buf[..n]).to_string();
let path = request.split_whitespace().nth(1).unwrap_or("/").to_string();
let (status, extra, body) = routes
.iter()
.find(|(p, ..)| *p == path)
.map(|(_, s, e, b)| (*s, e.clone(), b.clone()))
.unwrap_or((404, None, Vec::new()));
let head = format!(
"HTTP/1.1 {status} X\r\nContent-Length: {}\r\n{}Connection: close\r\n\r\n",
body.len(),
extra.map(|e| format!("{e}\r\n")).unwrap_or_default()
);
let _ = sock.write_all(head.as_bytes()).await;
let _ = sock.write_all(&body).await;
});
}
});
}
fn test_source(port: u16) -> Source {
Source {
base: Url::parse(&format!("http://127.0.0.1:{port}")).unwrap(),
repo: "Davito/EuerChat".to_string(),
allow_http: true,
}
}
fn sha256_hex(data: &[u8]) -> String {
Sha256::digest(data).iter().map(|b| format!("{b:02x}")).collect()
}
async fn setup(installer: &[u8], checksum_hash: &str) -> (Source, Client) {
// Port vorab reservieren, damit die Asset-URLs im JSON stimmen.
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
let name = "EuerChat_0.2.0_x64-setup.exe";
let json = serde_json::json!({
"tag_name": "v0.2.0",
"name": "EuerChat 0.2.0",
"body": "Neu: Updater",
"assets": [
{"name": name, "browser_download_url": format!("http://127.0.0.1:{port}/dl/{name}")},
{"name": format!("{name}.sha256"), "browser_download_url": format!("http://127.0.0.1:{port}/dl/{name}.sha256")}
]
});
let routes = vec![
("/api/v1/repos/Davito/EuerChat/releases/latest".to_string(), 200, None, json.to_string().into_bytes()),
(format!("/dl/{name}"), 200, None, installer.to_vec()),
(format!("/dl/{name}.sha256"), 200, None, format!("{checksum_hash} {name}\n").into_bytes()),
("/redirect".to_string(), 302, Some("Location: https://evil.example.com/x".to_string()), Vec::new()),
];
spawn_server(listener, routes);
let source = test_source(port);
let client = build_client(&source).unwrap();
(source, client)
}
#[tokio::test]
async fn fetches_release_and_downloads_verified_installer() {
let data = b"fake installer bytes".repeat(1000);
let (source, client) = setup(&data, &sha256_hex(&data)).await;
let release = fetch_latest_release(&client, &source).await.unwrap();
assert_eq!(release.version, "0.2.0");
assert_eq!(release.body, "Neu: Updater");
let dir = std::env::temp_dir().join(format!("euerchat-updater-test-{}", std::process::id()));
let mut last = 0u8;
let path = download_installer(&client, &source, &release, &dir, |p| last = p).await.unwrap();
assert_eq!(std::fs::read(&path).unwrap(), data);
assert_eq!(last, 100);
let _ = std::fs::remove_dir_all(&dir);
}
#[tokio::test]
async fn rejects_installer_with_wrong_checksum_and_removes_partial_file() {
let data = b"tampered".repeat(100);
let (source, client) = setup(&data, &"0".repeat(64)).await;
let release = fetch_latest_release(&client, &source).await.unwrap();
let dir = std::env::temp_dir().join(format!("euerchat-updater-bad-{}", std::process::id()));
let err = download_installer(&client, &source, &release, &dir, |_| {}).await.unwrap_err();
assert!(err.contains("SHA-256"));
let leftovers: Vec<_> = std::fs::read_dir(&dir).unwrap().flatten().collect();
assert!(leftovers.is_empty(), "keine .part- oder fertige Datei erwartet");
let _ = std::fs::remove_dir_all(&dir);
}
#[tokio::test]
async fn rejects_redirect_to_untrusted_host() {
let (source, client) = setup(b"x", &"0".repeat(64)).await;
let url = format!("{}/redirect", source.base.as_str().trim_end_matches('/'));
assert!(get(&client, &source, &url).await.is_err());
}
#[tokio::test]
async fn missing_release_is_reported() {
let port = serve(vec![]).await;
let source = test_source(port);
let client = build_client(&source).unwrap();
let err = fetch_latest_release(&client, &source).await.unwrap_err();
assert!(err.contains("noch kein"));
}
}
-54
View File
@@ -1,54 +0,0 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "EuerChat",
"version": "0.1.0",
"identifier": "tv.euer.chat",
"build": {
"frontendDist": "../ui"
},
"app": {
"withGlobalTauri": true,
"windows": [
{
"label": "main",
"title": "EuerChat",
"width": 1100,
"height": 720,
"minWidth": 800,
"minHeight": 550,
"center": true,
"resizable": true
}
],
"security": {
"csp": {
"default-src": "'self'",
"script-src": "'self'",
"style-src": "'self' 'unsafe-inline'",
"style-src-attr": "'unsafe-inline'",
"img-src": "'self' data: blob: https://chat.euer.tv",
"media-src": "'self' blob: https://*.euer.tv",
"worker-src": "'self' blob:",
"connect-src": "'self' ipc: http://ipc.localhost https://*.euer.tv",
"font-src": "'self'",
"object-src": "'none'",
"frame-src": "'none'",
"base-uri": "'self'",
"form-action": "'self'"
}
}
},
"bundle": {
"active": true,
"targets": ["nsis"],
"icon": ["icons/32x32.png", "icons/128x128.png", "icons/icon.ico"],
"shortDescription": "EuerChat - IRC-Chat und Livestream-Client fuer euer.tv",
"windows": {
"nsis": {
"installMode": "currentUser",
"languages": ["German"],
"displayLanguageSelector": false
}
}
}
}
-10
View File
@@ -1,10 +0,0 @@
@echo off
title EuerChat Client
cd /d "%~dp0"
if exist "EuerChat.exe" (
start "" "EuerChat.exe"
) else (
echo Baue und starte EuerChat...
cd src-tauri
cargo run
)
-2676
View File
File diff suppressed because it is too large. Load diff
-136
View File
@@ -1,136 +0,0 @@
{
"base_url": "https://chat.euer.tv/static/emocache/",
"emotes": {
"-___-": "1f611.png",
":'-)": "1f602.png",
"':-)": "1f605.png",
"':-D": "1f605.png",
">:-)": "1f606.png",
"':-(": "1f613.png",
">:-(": "1f620.png",
":'-(": "1f622.png",
"O:-)": "1f607.png",
"0:-3": "1f607.png",
"0:-)": "1f607.png",
"0;^)": "1f607.png",
"O;-)": "1f607.png",
"0;-)": "1f607.png",
"O:-3": "1f607.png",
"-__-": "1f611.png",
":-Þ": "1f61b.png",
"<3": "2764.png",
"</3": "1f494.png",
":')": "1f602.png",
":-D": "1f603.png",
"':)": "1f605.png",
"'=)": "1f605.png",
"':D": "1f605.png",
"'=D": "1f605.png",
">:)": "1f606.png",
">;)": "1f606.png",
">=)": "1f606.png",
"XD": "1f606.png",
"xD": "1f606.png",
";-)": "1f609.png",
"*-)": "1f609.png",
";-]": "1f609.png",
";^)": "1f609.png",
"':(": "1f613.png",
"'=(": "1f613.png",
":-*": "1f618.png",
":^*": "1f618.png",
">:P": "1f61c.png",
"X-P": "1f61c.png",
">:[": "1f61e.png",
":-(": "1f61e.png",
":-[": "1f61e.png",
">:(": "1f620.png",
":'(": "1f622.png",
";-(": "1f622.png",
">.<": "1f623.png",
"#-)": "1f635.png",
"%-)": "1f635.png",
"X-)": "1f635.png",
"\\0/": "1f646.png",
"\\O/": "1f646.png",
"0:3": "1f607.png",
"0:)": "1f607.png",
"O:)": "1f607.png",
"O=)": "1f607.png",
"O:3": "1f607.png",
"B-)": "1f60e.png",
"8-)": "1f60e.png",
"B-D": "1f60e.png",
"8-D": "1f60e.png",
"-_-": "1f611.png",
">:\\": "1f615.png",
">:/": "1f615.png",
":-/": "1f615.png",
":-.": "1f615.png",
":-P": "1f61b.png",
":Þ": "1f61b.png",
":-b": "1f61b.png",
":-O": "1f62e.png",
"O_O": "1f62e.png",
">:O": "1f62e.png",
":-X": "1f636.png",
":-#": "1f636.png",
":-)": "1f642.png",
"(y)": "1f44d.png",
":D": "1f603.png",
"=D": "1f603.png",
";)": "1f609.png",
"*)": "1f609.png",
";]": "1f609.png",
";D": "1f609.png",
":*": "1f618.png",
"=*": "1f618.png",
":(": "1f61e.png",
":[": "1f61e.png",
"=(": "1f61e.png",
":@": "1f620.png",
";(": "1f622.png",
"D:": "1f628.png",
":$": "1f633.png",
"=$": "1f633.png",
"#)": "1f635.png",
"%)": "1f635.png",
"X)": "1f635.png",
"B)": "1f60e.png",
"8)": "1f60e.png",
":/": "1f615.png",
":\\": "1f615.png",
"=/": "1f615.png",
"=\\": "1f615.png",
":L": "1f615.png",
"=L": "1f615.png",
":P": "1f61b.png",
":p": "1f61b.png",
"=P": "1f61b.png",
":b": "1f61b.png",
":O": "1f62e.png",
":X": "1f636.png",
":#": "1f636.png",
"=X": "1f636.png",
"=#": "1f636.png",
":)": "1f642.png",
"=]": "1f642.png",
"=)": "1f642.png",
":]": "1f642.png",
"CATJAM": "catjam.gif",
"Clap": "clap.gif",
"AlienPls": "AlienPls.gif",
"blobDance": "blobDance.gif",
"ClapHD": "ClapHD.gif",
"Dance": "Dance.gif",
"KirbDance": "KirbDance.gif",
"PartyParrot": "PartyParrot.gif",
"pepeDS": "pepeDS.gif",
"PepoCheer": "PepoCheer.gif",
"PepoDance": "PepoDance.gif",
"ppJedi": "ppJedi.gif",
"pugPls": "pugPls.gif",
"RareParrot": "RareParrot.gif",
"xar2EDM": "xar2EDM.gif"
}
}
-4
View File
@@ -1,4 +0,0 @@
!function e(t){var r,i;r=this,i=function(){"use strict";function r(e,t){(null==t||t>e.length)&&(t=e.length);for(var r=0,i=Array(t);r<t;r++)i[r]=e[r];return i}function i(e,t,r,i,n,a,s){try{var o=e[a](s),l=o.value}catch(e){return void r(e)}o.done?t(l):Promise.resolve(l).then(i,n)}function n(e,t){for(var r=0;r<t.length;r++){var i=t[r];i.enumerable=i.enumerable||!1,i.configurable=!0,"value"in i&&(i.writable=!0),Object.defineProperty(e,y(i.key),i)}}function a(e,t,r){return t&&n(e.prototype,t),r&&n(e,r),Object.defineProperty(e,"prototype",{writable:!1}),e}function s(e,t){var i="undefined"!=typeof Symbol&&e[Symbol.iterator]||e["@@iterator"];if(i)return(i=i.call(e)).next.bind(i);if(Array.isArray(e)||(i=function(e,t){if(e){if("string"==typeof e)return r(e,t);var i={}.toString.call(e).slice(8,-1);return"Object"===i&&e.constructor&&(i=e.constructor.name),"Map"===i||"Set"===i?Array.from(e):"Arguments"===i||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(i)?r(e,t):void 0}}(e))||t){i&&(e=i);var n=0;return function(){return n>=e.length?{done:!0}:{done:!1,value:e[n++]}}}throw new TypeError("Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}function o(e,t,r){return(t=y(t))in e?Object.defineProperty(e,t,{value:r,enumerable:!0,configurable:!0,writable:!0}):e[t]=r,e}function l(){return l=Object.assign?Object.assign.bind():function(e){for(var t=1;t<arguments.length;t++){var r=arguments[t];for(var i in r)({}).hasOwnProperty.call(r,i)&&(e[i]=r[i])}return e},l.apply(null,arguments)}function u(e){return u=Object.setPrototypeOf?Object.getPrototypeOf.bind():function(e){return e.__proto__||Object.getPrototypeOf(e)},u(e)}function d(e,t){e.prototype=Object.create(t.prototype),e.prototype.constructor=e,p(e,t)}function h(){try{var e=!Boolean.prototype.valueOf.call(Reflect.construct(Boolean,[],(function(){})))}catch(e){}return(h=function(){return!!e})()}function f(e,t){var r=Object.keys(e);if(Object.getOwnPropertySymbols){var i=Object.getOwnPropertySymbols(e);t&&(i=i.filter((function(t){return Object.getOwnPropertyDescriptor(e,t).enumerable}))),r.push.apply(r,i)}return r}function c(e){for(var t=1;t<arguments.length;t++){var r=null!=arguments[t]?arguments[t]:{};t%2?f(Object(r),!0).forEach((function(t){o(e,t,r[t])})):Object.getOwnPropertyDescriptors?Object.defineProperties(e,Object.getOwnPropertyDescriptors(r)):f(Object(r)).forEach((function(t){Object.defineProperty(e,t,Object.getOwnPropertyDescriptor(r,t))}))}return e}function g(e,t){if(null==e)return{};var r={};for(var i in e)if({}.hasOwnProperty.call(e,i)){if(-1!==t.indexOf(i))continue;r[i]=e[i]}return r}function v(){
/*! regenerator-runtime -- Copyright (c) 2014-present, Facebook, Inc. -- license (MIT): https://github.com/babel/babel/blob/main/packages/babel-helpers/LICENSE */
var e,t,r="function"==typeof Symbol?Symbol:{},i=r.iterator||"@@iterator",n=r.toStringTag||"@@toStringTag";function a(r,i,n,a){var l=i&&i.prototype instanceof o?i:o,u=Object.create(l.prototype);return m(u,"_invoke",function(r,i,n){var a,o,l,u=0,d=n||[],h=!1,f={p:0,n:0,v:e,a:c,f:c.bind(e,4),d:function(t,r){return a=t,o=0,l=e,f.n=r,s}};function c(r,i){for(o=r,l=i,t=0;!h&&u&&!n&&t<d.length;t++){var n,a=d[t],c=f.p,g=a[2];r>3?(n=g===i)&&(l=a[(o=a[4])?5:(o=3,3)],a[4]=a[5]=e):a[0]<=c&&((n=r<2&&c<a[1])?(o=0,f.v=i,f.n=a[1]):c<g&&(n=r<3||a[0]>i||i>g)&&(a[4]=r,a[5]=i,f.n=g,o=0))}if(n||r>1)return s;throw h=!0,i}return function(n,d,g){if(u>1)throw TypeError("Generator is already running");for(h&&1===d&&c(d,g),o=d,l=g;(t=o<2?e:l)||!h;){a||(o?o<3?(o>1&&(f.n=-1),c(o,l)):f.n=l:f.v=l);try{if(u=2,a){if(o||(n="next"),t=a[n]){if(!(t=t.call(a,l)))throw TypeError("iterator result is not an object");if(!t.done)return t;l=t.value,o<2&&(o=0)}else 1===o&&(t=a.return)&&t.call(a),o<2&&(l=TypeError("The iterator does not provide a '"+n+"' method"),o=1);a=e}else if((t=(h=f.n<0)?l:r.call(i,f))!==s)break}catch(t){a=e,o=1,l=t}finally{u=1}}return{value:t,done:h}}}(r,n,a),!0),u}var s={};function o(){}function l(){}function u(){}t=Object.getPrototypeOf;var d=[][i]?t(t([][i]())):(m(t={},i,(function(){return this})),t),h=u.prototype=o.prototype=Object.create(d);function f(e){return Object.setPrototypeOf?Object.setPrototypeOf(e,u):(e.__proto__=u,m(e,n,"GeneratorFunction")),e.prototype=Object.create(h),e}return l.prototype=u,m(h,"constructor",u),m(u,"constructor",l),l.displayName="GeneratorFunction",m(u,n,"GeneratorFunction"),m(h),m(h,n,"Generator"),m(h,i,(function(){return this})),m(h,"toString",(function(){return"[object Generator]"})),(v=function(){return{w:a,m:f}})()}function m(e,t,r,i){var n=Object.defineProperty;try{n({},"",{})}catch(e){n=0}m=function(e,t,r,i){function a(t,r){m(e,t,(function(e){return this._invoke(t,r,e)}))}t?n?n(e,t,{value:r,enumerable:!i,configurable:!i,writable:!i}):e[t]=r:(a("next",0),a("throw",1),a("return",2))},m(e,t,r,i)}function p(e,t){return p=Object.setPrototypeOf?Object.setPrototypeOf.bind():function(e,t){return e.__proto__=t,e},p(e,t)}function y(e){var t=function(e,t){if("object"!=typeof e||!e)return e;var r=e[Symbol.toPrimitive];if(void 0!==r){var i=r.call(e,t);if("object"!=typeof i)return i;throw new TypeError("@@toPrimitive must return a primitive value.")}return("string"===t?String:Number)(e)}(e,"string");return"symbol"==typeof t?t:t+""}function E(e){var t="function"==typeof Map?new Map:void 0;return E=function(e){if(null===e||!function(e){try{return-1!==Function.toString.call(e).indexOf("[native code]")}catch(t){return"function"==typeof e}}(e))return e;if("function"!=typeof e)throw new TypeError("Super expression must either be null or a function");if(void 0!==t){if(t.has(e))return t.get(e);t.set(e,r)}function r(){return function(e,t,r){if(h())return Reflect.construct.apply(null,arguments);var i=[null];i.push.apply(i,t);var n=new(e.bind.apply(e,i));return r&&p(n,r.prototype),n}(e,arguments,u(this).constructor)}return r.prototype=Object.create(e.prototype,{constructor:{value:r,enumerable:!1,writable:!0,configurable:!0}}),p(r,e)},E(e)}function T(e){return e instanceof ArrayBuffer||"undefined"!=typeof SharedArrayBuffer&&e instanceof SharedArrayBuffer}var S="utf-16",A="utf-16be",I="utf-16le",L="utf-8";function R(e,t){if(e<0)return-R(-e,t);var r=Math.pow(10,t);if(Math.abs(e*r%1-.5)<Number.EPSILON){var i=Math.floor(e*r);return(i%2==0?i:i+1)/r}return Math.round(e*r)/r}function b(){try{return crypto.randomUUID()}catch(i){try{var e=URL.createObjectURL(new Blob),t=e.toString();return URL.revokeObjectURL(e),t.slice(t.lastIndexOf("/")+1)}catch(e){var r=(new Date).getTime();return"xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g,(function(e){var t=(r+16*Math.random())%16|0;return r=Math.floor(r/16),("x"==e?t:3&t|8).toString(16)}))}}}function k(e){return e&&e.__esModule&&Object.prototype.hasOwnProperty.call(e,"default")?e.default:e}var D,P,_={exports:{}},C=(D||(D=1,function(e){var t=Object.prototype.hasOwnProperty,r="~";function i(){}function n(e,t,r){this.fn=e,this.context=t,this.once=r||!1}function a(e,t,i,a,s){if("function"!=typeof i)throw new TypeError("The listener must be a function");var o=new n(i,a||e,s),l=r?r+t:t;return e._events[l]?e._events[l].fn?e._events[l]=[e._events[l],o]:e._events[l].push(o):(e._events[l]=o,e._eventsCount++),e}function s(e,t){0==--e._eventsCount?e._events=new i:delete e._events[t]}function o(){this._events=new i,this._eventsCount=0}Object.create&&(i.prototype=Object.create(null),(new i).__proto__||(r=!1)),o.prototype.eventNames=function(){var e,i,n=[];if(0===this._eventsCount)return n;for(i in e=this._events)t.call(e,i)&&n.push(r?i.slice(1):i);return Object.getOwnPropertySymbols?n.concat(Object.getOwnPropertySymbols(e)):n},o.prototype.listeners=function(e){var t=r?r+e:e,i=this._events[t];if(!i)return[];if(i.fn)return[i.fn];for(var n=0,a=i.length,s=new Array(a);n<a;n++)s[n]=i[n].fn;retLine truncated
//# sourceMappingURL=hls.min.js.map
-592
View File
@@ -1,592 +0,0 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>EuerChat - euer.tv</title>
<link rel="stylesheet" href="style.css">
</head>
<body class="dark-theme">
<div id="app" class="app-container">
<!-- Top Header Bar (Optionen) -->
<header class="app-header">
<div class="header-left">
<div class="header-brand">
<span class="header-brand-title">EuerChat</span>
</div>
<!-- Nur sichtbar, wenn ein Lua-Skript (.luac) geladen und aktiv ist -->
<button class="btn-modi-switch hidden" id="btnModiToggle" title="Zwischen normalem Hörer und Radio-Moderator wechseln">
<span class="modi-dot" id="modiDot"></span>
<span id="modiToggleLabel">📻 Zum Modi wechseln</span>
</button>
</div>
<div class="header-right">
<!-- Options Button -->
<button class="btn-icon" id="btnSettingsModal" title="Optionen & Einstellungen">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path></svg>
<span>Optionen</span>
</button>
</div>
</header>
<!-- Main Content Area -->
<main class="main-layout">
<!-- Chat Box (Expanded full width on the left) -->
<section class="chat-area">
<div class="messages-container" id="messagesContainer">
<!-- Messages populated dynamically -->
</div>
<!-- Chat Input Bar -->
<div class="chat-input-wrapper">
<div class="chat-input-box">
<input type="text" id="chatInput" placeholder="Nachricht an #erde... (Enter zum Senden)" autocomplete="off">
<div class="input-actions">
<button class="btn-emote" id="btnEmotePicker" title="Emotes & GIFs öffnen">
😊
</button>
<button class="btn-send" id="btnSend">
Senden
</button>
</div>
</div>
</div>
<!-- Bottom Bar: Everything related to Chat (Compact Status, Tabs, User identity) -->
<div class="bottom-bar">
<div class="bottom-chat-left">
<div class="connection-status-compact" id="connStatus" title="IRC Verbindungsstatus">
<span class="status-dot disconnected" id="statusDot"></span>
<span class="status-ping hidden" id="statusPing">-- ms</span>
</div>
<button class="btn-connect-header" id="btnHeaderConnect" title="Verbindung herstellen oder trennen">
Verbinden
</button>
</div>
<div class="channel-tabs-container">
<div class="channel-tabs" id="channelList">
<div class="channel-tab active" data-target="#erde">
<span class="chan-prefix">#</span>
<span class="chan-name">erde</span>
<span class="chan-badge unread-badge hidden">0</span>
</div>
<div class="channel-tab" data-target="*server*">
<span class="chan-prefix">ℹ</span>
<span class="chan-name">Server</span>
</div>
</div>
<button class="btn-tab-add" id="btnAddChan" title="Kanal beitreten">+</button>
</div>
<div class="bottom-user-status">
<span class="bottom-user-avatar" id="userAvatar">?</span>
<div class="bottom-user-info">
<span class="bottom-user-nick" id="displayNick">Hoerer</span>
</div>
</div>
</div>
</section>
<!-- Draggable Splitter to manually resize the user list & video player width -->
<div class="sidebar-resizer" id="sidebarResizer" title="Ziehen zum Vergrößern/Verkleinern der Leiste"></div>
<!-- Right Sidebar: User List & Embedded Video Player below it -->
<aside class="sidebar-users">
<div class="sidebar-section-title">
<span>BENUTZER</span>
<span class="user-count" id="userCount">0</span>
</div>
<div class="user-list" id="userList">
<!-- Users populated dynamically -->
</div>
<!-- Media Area: Interpret/Titel drüber, Video Player in der Mitte, Hörer & Grußbox unten -->
<div class="sidebar-media-area" id="sidebarMediaArea">
<!-- Track Info: Interpret / Titel DIREKT ÜBER DEM PLAYER (ohne Cover) -->
<div class="sidebar-track-header" id="sidebarTrackHeader" title="Aktueller Titel & Interpret">
<div class="sidebar-track-info" id="sidebarTrackInfo">
<div class="sidebar-artist" id="sidebarArtist">euer.tv Radio</div>
<div class="sidebar-track" id="sidebarTrack">Lade aktuellen Titel...</div>
</div>
</div>
<!-- Video Player Box -->
<div class="sidebar-video-box" id="sidebarVideoBox">
<div class="sidebar-video-wrapper">
<!-- Video element: MUTED AT START! -->
<video id="streamVideo" playsinline muted autoplay></video>
</div>
<!-- Video Player Controls: Mute, Volume, Quality (1080p, 720p, 360p), Resync -->
<!-- Video Player Controls: Mute, Lautstärke, Hörer, Grußbox, Qualität, Resync in EINER Zeile! -->
<div class="sidebar-player-controls" id="sidebarPlayerControls">
<div class="player-ctrls-left">
<button class="btn-video-mute is-muted" id="btnStreamMute" title="Ton stummschalten oder aktivieren">
<span id="streamMuteIcon">🔇</span>
</button>
<input type="range" id="streamVolSlider" min="0" max="1" step="0.05" value="0.1" class="mini-volume-slider" title="Lautstärke">
</div>
<div class="sidebar-listeners-display twitch-viewer-badge" id="sidebarListenersDisplay" title="Aktuelle Zuhörer*innen">
<svg class="twitch-viewer-icon" viewBox="0 0 20 20" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="10" cy="6.2" r="3.2"></circle>
<path d="M4.5 16.5c0-2.8 2.5-4.8 5.5-4.8s5.5 2 5.5 4.8"></path>
</svg>
<span class="stat-val" id="statTotalListeners">0</span>
</div>
<button class="btn-grussbox-compact" id="btnOpenGrussbox" title="Grußbox öffnen & Nachricht direkt ins Studio senden">
✉ Grußbox
</button>
</div>
<div class="player-ctrls-right">
<!-- Quality selector: 1080p, 720p, 360p -->
<select id="videoQualitySelect" class="video-quality-select" title="Stream-Qualität wählen">
<option value="1080p" selected>1080p</option>
<option value="720p">720p</option>
<option value="360p">360p</option>
</select>
<button class="btn-video-resync" id="btnResyncStream" title="Livestream neu synchronisieren">🔄</button>
</div>
</div>
</div>
</div>
</aside>
</main>
<!-- Emote Picker Popover -->
<div class="emote-picker hidden" id="emotePicker">
<div class="emote-picker-header">
<input type="text" id="emoteSearch" placeholder="Emote suchen (z.B. CATJAM, pepe, :) )">
<button class="btn-close-mini" id="btnCloseEmote">✕</button>
</div>
<div class="emote-picker-tabs">
<button class="emote-tab active" data-tab="all">Alle</button>
<button class="emote-tab" data-tab="gifs">GIFs</button>
<button class="emote-tab" data-tab="smilies">Smilies</button>
</div>
<div class="emote-grid" id="emoteGrid">
<!-- Emotes rendered here -->
</div>
</div>
<!-- Complete Unified Options Modal -->
<div class="modal-overlay hidden" id="settingsModal">
<div class="modal-card modal-card-wide">
<div class="modal-header">
<h2>Optionen & Einstellungen</h2>
<button class="modal-close" id="btnCloseSettingsModal">✕</button>
</div>
<div class="modal-tabs">
<button class="tab-btn active" data-tab-target="secDisplay">Darstellung</button>
<button class="tab-btn" data-tab-target="secServer">Server & ZNC</button>
<button class="tab-btn hidden" data-tab-target="secRadio" id="tabBtnRadio">Radio & Modi</button>
<button class="tab-btn" data-tab-target="secLua" id="tabBtnLua">Lua Addons</button>
<button class="tab-btn" data-tab-target="secUpdate" id="tabBtnUpdate">Updates</button>
</div>
<div class="modal-body settings-body">
<!-- 1. Display Section -->
<div class="settings-section-pane active" id="secDisplay">
<div class="settings-grid">
<div class="settings-col">
<h3 class="settings-section-title">Schrift & Text</h3>
<div class="form-group">
<label for="optFontFamily">Schriftart (System & Windows)</label>
<select id="optFontFamily" class="form-select">
<option value="system">Standard Sans (Segoe UI, System)</option>
<option value="monospace">Monospace / Konsole (Consolas, Courier)</option>
<option value="segoe">Segoe UI</option>
<option value="arial">Arial</option>
<option value="calibri">Calibri</option>
<option value="verdana">Verdana</option>
<option value="comic">Comic Sans MS</option>
<option value="trebuchet">Trebuchet MS</option>
</select>
</div>
<div class="form-group" style="margin-top: 4px;">
<label for="optFontCustom" style="font-size:11.5px;color:var(--text-dim);">Eigene installierte Schriftart manuell eingeben:</label>
<input type="text" id="optFontCustom" class="form-input" placeholder="z. B. Cascadia Code, Bahnschrift, Fira Code..." style="font-size:12px;padding:5px 8px;background:var(--bg-card);border:1px solid var(--border-color);border-radius:var(--radius-sm);color:#fff;width:100%;">
</div>
<div class="form-group">
<div class="label-with-value">
<label for="optFontSize">Schriftgröße</label>
<span class="range-val-badge" id="valFontSize">14px</span>
</div>
<input type="range" id="optFontSize" min="11" max="22" step="1" value="14" class="form-range">
</div>
<div class="form-group">
<label for="optLineHeight">Zeilenabstand & Dichte</label>
<select id="optLineHeight" class="form-select">
<option value="compact">Kompakt (eng wie mIRC / HexChat)</option>
<option value="normal" selected>Normal (ausgewogen)</option>
<option value="cozy">Komfortabel (mehr Abstand)</option>
</select>
</div>
<div class="form-group">
<label for="optTimestamp">Zeitstempel-Format</label>
<select id="optTimestamp" class="form-select">
<option value="hm" selected>HH:mm (z. B. 21:13)</option>
<option value="hms">HH:mm:ss (z. B. 21:13:45)</option>
<option value="none">Ausblenden</option>
</select>
</div>
<div class="form-group">
<label for="optUiScale">Skalierung der Oberfläche</label>
<select id="optUiScale" class="form-select">
<option value="75">75 %</option>
<option value="90">90 %</option>
<option value="100" selected>100 % (Standard)</option>
<option value="110">110 %</option>
<option value="125">125 %</option>
<option value="150">150 %</option>
<option value="175">175 %</option>
<option value="200">200 %</option>
</select>
</div>
</div>
<div class="settings-col">
<h3 class="settings-section-title">Chat & Emotes</h3>
<div class="form-group">
<label for="optEmoteSize">Emote-Größe</label>
<select id="optEmoteSize" class="form-select">
<option value="20">Klein (20px)</option>
<option value="26" selected>Normal (26px)</option>
<option value="34">Groß (34px)</option>
<option value="42">Riesig (42px)</option>
</select>
</div>
<div class="settings-checkboxes">
<label class="form-checkbox">
<input type="checkbox" id="optIrcColors" checked>
<span>mIRC Farbcodes & Formatierung anzeigen</span>
</label>
<label class="form-checkbox">
<input type="checkbox" id="optEmotes" checked>
<span>Emotes & animierte GIFs ersetzen</span>
</label>
<label class="form-checkbox">
<input type="checkbox" id="optHighlight" checked>
<span>Eigenen Nicknamen hervorheben</span>
</label>
<label class="form-checkbox">
<input type="checkbox" id="optAutoScroll" checked>
<span>Automatisch nach unten scrollen</span>
</label>
</div>
<div class="settings-checkboxes" style="margin-top: 12px; padding-top: 10px; border-top: 1px solid var(--border-color);">
<span class="preview-title" style="display:block; margin-bottom: 6px; font-size:10.5px; color:var(--text-dim); font-weight:700;">SICHTBARKEIT & LAYOUT</span>
<label class="form-checkbox">
<input type="checkbox" id="optShowMediaArea" checked>
<span>Livestream-Player, Hörer & Grußbox anzeigen</span>
</label>
</div>
</div>
</div>
<!-- Live Preview -->
<div class="settings-preview-wrapper">
<span class="preview-title">VORSCHAU</span>
<div class="settings-preview" id="settingsPreview">
<div class="chat-msg">
<span class="msg-time">21:13</span>
<span class="msg-nick" style="color:#48dbfb;">DJ_Radio</span>
<span class="msg-text">Hallo zusammen! Viel Spaß mit der Musik <img class="chat-emote" src="https://chat.euer.tv/static/emocache/catjam.gif" alt="CATJAM"></span>
</div>
<div class="chat-msg action-msg">
<span class="msg-time">21:14</span>
<span class="msg-text"><span class="msg-action-star">*</span> <span class="msg-nick" style="color:#1dd1a1;">Kellnerin</span> <span style="color:#facc15;">Jetzt läuft:</span> <span style="color:#38bdf8;">Usher Feat. Pitbull</span> - <span style="color:#a855f7;">Dj Got Us Fallin' In Love</span></span>
</div>
</div>
</div>
</div>
<!-- 2. Server & ZNC Section -->
<div class="settings-section-pane hidden" id="secServer">
<div class="server-type-selector">
<button class="server-type-btn active" id="btnTypeDirect">Direkt (irc.euer.tv)</button>
<button class="server-type-btn" id="btnTypeZnc">ZNC Bouncer</button>
</div>
<!-- Direct Form -->
<form id="formDirect" class="form-sub-section active">
<p class="section-desc">Verbindet direkt mit dem Radio-Chat <strong>irc.euer.tv</strong> (Port 6697, SSL-verschlüsselt) im Raum <strong>#erde</strong>.</p>
<div class="form-group">
<label for="directNick">Name (Nickname)</label>
<input type="text" id="directNick" placeholder="Dein Name im Chat" maxlength="30" autocomplete="off" required>
</div>
<div class="form-group">
<label for="directPass">Passwort (optional, nur mit registriertem Account)</label>
<input type="password" id="directPass" placeholder="Leer lassen, wenn dein Name nicht registriert ist" autocomplete="current-password">
</div>
<div class="form-checkbox">
<input type="checkbox" id="directAutoConnect">
<label for="directAutoConnect">Automatisch beim Start direkt verbinden</label>
</div>
<div class="server-connect-action">
<button type="button" class="btn-success" id="btnSaveDirect" title="Direkt-Einstellungen speichern">💾 Direkt Speichern</button>
<button type="button" class="btn-primary" id="btnConnectNow" title="Mit diesen Daten direkt verbinden">⚡ Jetzt Verbinden</button>
<button type="button" class="btn-secondary" id="btnClearDirect" title="Alle Direkt-Felder leeren">🗑️ Leeren</button>
</div>
<div class="config-save-feedback hidden" id="directSaveFeedback"></div>
</form>
<!-- ZNC Form -->
<form id="formZnc" class="form-sub-section hidden">
<div class="form-row">
<div class="form-group flex-2">
<label>ZNC Host / IP</label>
<input type="text" id="zncHost" placeholder="z. B. meinserver.example.org" required>
</div>
<div class="form-group flex-1">
<label>ZNC Port</label>
<input type="number" id="zncPort" value="8250" placeholder="8250" required>
</div>
</div>
<div class="form-row">
<div class="form-group flex-1">
<label>ZNC Benutzer (z.B. user oder user/network)</label>
<input type="text" id="zncUser" placeholder="z. B. benutzer oder benutzer/netzwerk" required>
</div>
<div class="form-group flex-1">
<label>ZNC Passwort</label>
<input type="password" id="zncPass" placeholder="ZNC Passwort" required>
</div>
</div>
<div class="form-row">
<div class="form-group flex-1">
<label>Nickname</label>
<input type="text" id="zncNick" placeholder="Dein Nickname">
</div>
<div class="form-group flex-1">
<label>Standard-Raum</label>
<input type="text" id="zncChannel" value="#erde">
</div>
</div>
<div class="form-checkbox">
<input type="checkbox" id="zncSsl">
<label for="zncSsl">SSL / TLS Verschlüsselung aktivieren</label>
</div>
<div class="form-checkbox">
<input type="checkbox" id="zncAutoConnect">
<label for="zncAutoConnect">Automatisch beim Start über ZNC verbinden</label>
</div>
<div class="server-connect-action">
<button type="button" class="btn-success" id="btnSaveZnc" title="ZNC-Einstellungen speichern">💾 ZNC Speichern</button>
<button type="button" class="btn-primary" id="btnConnectZncNow" title="Über ZNC verbinden und als Standard speichern">⚡ Über ZNC Verbinden</button>
<button type="button" class="btn-secondary" id="btnClearZnc" title="Alle ZNC-Felder leeren">🗑️ Leeren</button>
</div>
<div class="config-save-feedback hidden" id="zncSaveFeedback"></div>
</form>
</div>
<!-- 3. Radio & Modi Section -->
<div class="settings-section-pane hidden" id="secRadio">
<h3 class="settings-section-title">Radio & Sende-Modus (Modi-Verwaltung)</h3>
<p class="section-desc">
Konfiguriere hier deinen normalen Hörer-Nick und deinen Moderatoren-Nick. Ein Klick auf den Modi-Button oben schaltet automatisch um:
</p>
<div class="form-row">
<div class="form-group flex-1">
<label for="cfgNormalNick">Normaler Nickname (Hörer)</label>
<input type="text" id="cfgNormalNick" placeholder="Dein normaler Nickname">
</div>
<div class="form-group flex-1">
<label for="cfgModiNick">Modi-Nickname (Auf Sendung)</label>
<input type="text" id="cfgModiNick" placeholder="z. B. DJ_Name">
</div>
</div>
<div class="form-group">
<label for="cfgInternPass">Passwort für Raum #intern</label>
<input type="password" id="cfgInternPass" placeholder="Passwort (leer lassen, falls kein Schlüssel nötig)" autocomplete="off">
<small class="field-hint">Wird beim Wechsel zum Modi automatisch für <code>JOIN #intern &lt;passwort&gt;</code> verwendet.</small>
</div>
<div class="modi-status-preview-box">
<div class="modi-preview-item">
<span class="preview-badge normal">🎧 Normal</span>
<span>Nur im Raum <strong>#erde</strong> unterwegs.</span>
</div>
<div class="modi-preview-item">
<span class="preview-badge modi">📻 Modi Aktiv</span>
<span>Nick wechselt zu Moderatoren-Name & betritt automatisch <strong>#intern</strong>.</span>
</div>
</div>
</div>
<!-- 4. Lua Addons Section (Clean & Uncluttered by default) -->
<div class="settings-section-pane hidden" id="secLua">
<h3 class="settings-section-title">LUA ERWEITERUNGEN (*.LUA, *.LUAC)</h3>
<p class="section-desc" style="font-size:12.5px;color:var(--text-muted);margin-bottom:12px;">
Hier können optionale Skripte geladen werden.
</p>
<div class="script-upload-box" id="luaPickerBox">
<input type="file" id="luaFileInput" accept=".lua,.luac" style="display:none">
<button type="button" class="btn-secondary" id="btnSelectLua">
📁 Externe .luac / .lua Datei auswählen...
</button>
<span class="selected-file-name" id="luaFileName">Keine Datei ausgewählt</span>
</div>
<div class="loaded-scripts-list" id="loadedScriptsList">
<div class="script-item" id="scriptStatusText" style="color:var(--text-muted);font-size:12.5px;padding:6px;">
○ Inaktiv (Kein Skript geladen)
</div>
</div>
<!-- Radio Moderations-Details: NUR SICHTBAR WENN EIN SKRIPT GELADEN WURDE! -->
<div class="lua-modi-details hidden" id="luaModiDetails" style="margin-top:14px;border-top:1px solid var(--border-color);padding-top:14px;">
<div class="form-checkbox" style="margin-bottom:14px;">
<input type="checkbox" id="chkEnableLua">
<label for="chkEnableLua"><strong>EuerService Moderations-Addon aktiv</strong> (schaltet Modi-Umschalter frei)</label>
</div>
</div>
</div>
<!-- 5. Updates Section -->
<div class="settings-section-pane hidden" id="secUpdate">
<h3 class="settings-section-title">Updates</h3>
<p class="section-desc">Installierte Version: <strong id="updCurrentVersion">–</strong></p>
<div class="form-checkbox">
<input type="checkbox" id="optAutoUpdate" checked>
<label for="optAutoUpdate">Beim Start automatisch nach Updates suchen</label>
</div>
<div class="server-connect-action">
<button type="button" class="btn-primary" id="btnCheckUpdate">Auf Updates prüfen</button>
</div>
<div class="update-status" id="updStatusText"></div>
</div>
</div>
<div class="modal-actions">
<button class="btn-secondary" id="btnResetSettings">Standard</button>
<button class="btn-primary" id="btnSaveSettings">Übernehmen & Schließen</button>
</div>
</div>
</div>
<!-- Grußbox Modal -->
<div class="modal-overlay hidden" id="grussboxModal">
<div class="modal-card modal-card-sm">
<div class="modal-header">
<h2>✉ Grußbox - Radio euer.tv</h2>
<button class="modal-close" id="btnCloseGrussbox">✕</button>
</div>
<div class="modal-body grussbox-body">
<p class="grussbox-desc">Sende deinen Gruß oder Musikwunsch direkt live ins Studio zum aktuellen Moderator!</p>
<div class="form-group">
<label for="gbNameInput">Dein Name / Nickname</label>
<input type="text" id="gbNameInput" class="form-input" placeholder="Dein Name" maxlength="40">
</div>
<div class="form-group">
<label for="gbTextInput">Dein Gruß / Musikwunsch</label>
<textarea id="gbTextInput" class="form-textarea" rows="4" placeholder="Schreib hier deinen Gruß oder Musikwunsch..." maxlength="500"></textarea>
</div>
<div class="grussbox-feedback hidden" id="gbFeedback"></div>
</div>
<div class="modal-actions">
<button class="btn-secondary" id="btnCancelGruss">Abbrechen</button>
<button class="btn-primary btn-submit-gruss" id="btnSubmitGruss">🚀 Gruß senden</button>
</div>
</div>
</div>
<!-- Update Modal -->
<div class="modal-overlay hidden" id="updateModal">
<div class="modal-card modal-card-sm">
<div class="modal-header">
<h2>⬆ Update verfügbar</h2>
<button class="modal-close" id="btnCloseUpdate">✕</button>
</div>
<div class="modal-body update-body">
<p class="update-summary" id="updSummary"></p>
<span class="preview-title">WAS IST NEU?</span>
<div class="update-notes" id="updNotes"></div>
<div class="update-progress hidden" id="updProgress"><div class="update-progress-bar" id="updProgressBar"></div></div>
<div class="update-status" id="updModalStatus"></div>
</div>
<div class="modal-actions">
<button class="btn-secondary" id="btnUpdateLater">Später</button>
<button class="btn-primary" id="btnUpdateInstall">Jetzt installieren</button>
</div>
</div>
</div>
<!-- User Context Menu Popover (Einfacher Klick auf Nicknamen) -->
<div class="user-context-menu hidden" id="userContextMenu">
<div class="ctx-header">
<span class="ctx-prefix" id="ctxUserPrefix"></span>
<span class="ctx-nick" id="ctxUserNick">Nick</span>
</div>
<div class="ctx-list">
<button class="ctx-item" data-action="query">
<span class="ctx-icon">💬</span> Privatnachricht (PN)
</button>
<button class="ctx-item" data-action="whois">
<span class="ctx-icon">ℹ</span> WHOIS Info
</button>
<button class="ctx-item" data-action="grussbox">
<span class="ctx-icon">✉</span> In Grußbox erwähnen
</button>
<button class="ctx-item" data-action="copy">
<span class="ctx-icon">📋</span> Nickname kopieren
</button>
<div class="ctx-divider"></div>
<div class="ctx-subhead">Kanal-Moderation</div>
<button class="ctx-item" data-action="op">
<span class="ctx-icon">⚡</span> OP geben (+o)
</button>
<button class="ctx-item" data-action="deop">
<span class="ctx-icon">⚡</span> OP entziehen (-o)
</button>
<button class="ctx-item" data-action="voice">
<span class="ctx-icon">🎤</span> Voice geben (+v)
</button>
<button class="ctx-item" data-action="devoice">
<span class="ctx-icon">🎤</span> Voice entziehen (-v)
</button>
<div class="ctx-divider"></div>
<button class="ctx-item ctx-danger" data-action="kick">
<span class="ctx-icon">👢</span> Kicken
</button>
<button class="ctx-item ctx-danger" data-action="ban">
<span class="ctx-icon">⛔</span> Kicken & Bannen
</button>
</div>
</div>
</div>
<script src="hls.min.js"></script>
<script src="app.js"></script>
</body>
</html>
-2283
View File
File diff suppressed because it is too large. Load diff