Compare commits

..
1 Commits
29 changed files with 13157 additions and 54 deletions

No files matched your search

+12
View File
@@ -0,0 +1,12 @@
# 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/
+45 -51
View File
@@ -1,61 +1,55 @@
# EuerChat - euer.tv IRC & Livestream Client (Windows 64-bit)
<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: Chat, Livestream und Radio-Player von **euer.tv** in einem Fenster. EuerChat ist ein Werkzeug für die **Moderatoren von euer radio** – alle Moderationsfunktionen sind fest eingebaut, es wird kein Addon benötigt.
**Download:** Den aktuellen Installer und die PDF-Anleitung findest du unter [Releases](../../releases).
Ein moderner, performanter und schlanker Desktop-Client für Windows 64-bit, gebaut mit **Rust + Tauri v2** und einem ansprechenden **Dark-Mode Web-Frontend**.
---
## Highlights
## Highlights & Features
### Livestream & Radio-Player
* HLS-Videostream in mehreren Qualitätsstufen, **startet immer stumm**, mit Ton-Taste und Lautstärkeregler.
* Live-Titelanzeige, Album-Cover, Hörerzähler und Grußbox direkt neben dem Chat.
* **Vollbild** per Symbol oder Doppelklick; die Steuerung blendet sich bei Ruhe aus, **Esc** beendet.
* Schnellschalter für den Videobereich: ein-/ausblenden sowie oben/unten neben der Nickliste.
* Der Stream ist mit einem Rahmen in der Akzentfarbe (optional mit Neon-Glanz) eingefasst, Playerleiste und Titelzeile passen sich dem Farbschema an.
### Chat
* Verbindung zu **`irc.euer.tv`**, Port **`6697`** (SSL/TLS, Zertifikate werden geprüft), Raum **`#erde`**. Name eingeben, verbinden, fertig.
* Optional **ZNC-Bouncer** für eigene Server (`user/network` + Passwort).
* Mehrere Räume und private Chats gleichzeitig, Räume lassen sich per Maus umsortieren.
* Schwebende Leiste an jeder Nachricht (Antworten, Kopieren, Ignorieren), Zähler für Erwähnungen und Alias-Treffer an den Reitern.
* Animierte GIFs und Smileys aus dem Webchat, optionale Bild-Vorschau für https-Links (standardmäßig aus).
* Hinweise bei Erwähnung, privater Nachricht und Alias-Warnung: Ton, Taskleisten-Blinken und Windows-Meldung, wenn das Fenster im Hintergrund ist.
### Moderations-Werkzeuge
* **Modi-Umschalter:** Ein Klick wechselt zwischen normalem Nick und Moderator-Nick inklusive `#intern`; automatischer NickServ-Login.
* **Alias-Erkennung:** EuerChat merkt sich zu jedem Host die benutzten Nicks. Taucht jemand unter neuem Namen auf, erscheint eine Warnung („Hans war vorher als Davito unterwegs“) mit Chip in Nutzerliste und Chat. Nutzer, die gleichzeitig im Raum sind, werden nur als Haushalt-Info angezeigt.
* **Profilkarte:** Host, frühere Nicks, Notiz, eigene Nickfarbe sowie PN, Ansprechen und Ignorieren; Hosts lassen sich als unbedenklich markieren.
### Design & Anpassbarkeit
* **9 Farbschemata:** Dunkel, Mitternacht, AMOLED, Graphit, Synthwave, Wald, Bernstein, Hell und Creme.
* **14 Akzentfarben** (darunter Neon-Pink, Neon-Grün, Elektrisch-Blau, Neon-Gelb) plus frei wählbare eigene Farbe.
* **Hintergrund-Tönung** in der Akzentfarbe (Aus, Dezent, Mittel, Stark) und optionaler **Neon-Glanz**.
* **Schnellauswahl** mit fertigen Design-Kombinationen, Vorschau in den Einstellungen in Echtzeit.
* **Lesbarkeit garantiert:** Textfarben werden für jedes Schema und jede Akzentfarbe automatisch auf ausreichenden Kontrast geprüft.
* Layout „Klassisch“ oder „Karten“, Animationen abschaltbar.
* Schriftarten, Schriftgrößen, Zeilenabstände und Zeitstempel frei wählbar; Oberfläche skalierbar (75-200 %).
* Nickfarben mit vier Paletten (Lebhaft, Pastell, Kontrast, Farbenblind), eigene Farbe je Nutzer, Rollen-Symbole für Operator und Voice.
* Übersichtliche Einstellungen in acht Reitern: Darstellung, Farben & Design, Allgemein, Benachrichtigungen, Server & ZNC, Radio & Modi, Alias-Erkennung, Updates.
### Integrierter Updater
* Sucht beim Start (abschaltbar unter Optionen → Updates) nach einer neueren Version, zeigt die Neuerungen und installiert auf Wunsch still; EuerChat startet danach neu.
* Der Installer wird nur ausgeführt, wenn seine SHA-256-Prüfsumme zum Release passt.
* **Eine einzige EXE (`EuerChat.exe`):**
* Startet standardmäßig sauber und übersichtlich für alle Chat-Gäste und Hörer.
* Die Moderationswerkzeuge sind ausgeblendet, bis das Lua/Modi-Addon in den Optionen aktiviert wird.
* **Integrierter Livestream & Radio-Player (Stumm beim Start):**
* 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:**
* **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`**.
* **ZNC-Bouncer** für eigene Server (`user/network` + Passwort); selbstsignierte Zertifikate: `"acceptInvalidCerts": true` in der `config.json` setzen (unsicher).
* Räume unten lassen sich per Maus verschieben, die Oberfläche lässt sich skalieren (75-200 %).
* **Design & Anpassbarkeit:**
* Schriftarten, Schriftgrößen (11px bis 22px), Zeilenabstände, Zeitstempel frei wählbar.
* Animierte GIFs (`CATJAM`, `pepeDS` uvm.) & 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:**
* 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.
* 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.
---
## Installation
## Schnellstart
`EuerChat_<Version>_x64-setup.exe` ausführen und die Installationsart wählen. Beim ersten Start den Namen eingeben und verbinden.
* **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`.
* **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.
Die Einstellungsdatei kann Passwörter enthalten und sollte nicht weitergegeben werden.
## Anleitung
Zu jeder Version gibt es im Release eine **PDF-Anleitung** (`EuerChat_Anleitung_<Version>.pdf`) mit Screenshots, in der jede Option für Einsteiger erklärt ist.
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.
* 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.
2. **Installer bauen:**
```powershell
.\build.bat
```
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
@@ -1,3 +0,0 @@
# EuerChat Releases
Letzter veroeffentlichter Release: v1.2.9 (2026-10-27 16:13:59 UTC)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 494 KiB

+25
View File
@@ -0,0 +1,25 @@
@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
@@ -0,0 +1,136 @@
{
"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
@@ -0,0 +1,247 @@
{
"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
@@ -0,0 +1,14 @@
{
"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
@@ -0,0 +1,178 @@
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
@@ -0,0 +1,31 @@
[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
@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}
+9
View File
@@ -0,0 +1,9 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Standard Berechtigungen fuer das Hauptfenster",
"windows": ["main"],
"permissions": [
"core:event:default"
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 561 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 155 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 177 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 561 B

+1110
View File
File diff suppressed because it is too large. Load diff
+50
View File
@@ -0,0 +1,50 @@
// 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
@@ -0,0 +1,139 @@
//! 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
@@ -0,0 +1,141 @@
//! 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
@@ -0,0 +1,685 @@
//! 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
@@ -0,0 +1,54 @@
{
"$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
@@ -0,0 +1,10 @@
@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
@@ -0,0 +1,136 @@
{
"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
@@ -0,0 +1,4 @@
!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
@@ -0,0 +1,592 @@
<!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