Compare commits

...
2 Commits
21 changed files with 1011 additions and 37 deletions

No files matched your search

+9 -4
View File
@@ -1,5 +1,7 @@
# EuerChat - euer.tv IRC & Livestream Client (Windows 64-bit) # EuerChat - euer.tv IRC & Livestream Client (Windows 64-bit)
<p align="center"><img src="assets/logo-wide.png" alt="euer.tv chat" width="560"></p>
Ein moderner, performanter und schlanker Desktop-Client für Windows 64-bit, gebaut mit **Rust + Tauri v2** und einem ansprechenden **Dark-Mode Web-Frontend**. Ein moderner, performanter und schlanker Desktop-Client für Windows 64-bit, gebaut mit **Rust + Tauri v2** und einem ansprechenden **Dark-Mode Web-Frontend**.
--- ---
@@ -38,14 +40,17 @@ Ein moderner, performanter und schlanker Desktop-Client für Windows 64-bit, geb
## Schnellstart ## Schnellstart
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. 1. **Installieren:** `EuerChat_<Version>_x64-setup.exe` ausführen und die Installationsart wählen. Beim ersten Start einfach den Namen eingeben und verbinden.
* Die Konfiguration liegt in `%APPDATA%\EuerChat\config.json` und enthält eventuell Passwörter. Sie ist nicht Teil des Installers und gehört nicht ins Repository (steht in `.gitignore`). * **Standard** (empfohlen): für alle Benutzer, standardmäßig nach `C:\Program Files\EuerChat`, mit Startmenü- und Desktop-Verknüpfung. Erfordert Administratorrechte (UAC-Abfrage), Deinstallation über die Windows-Einstellungen. Die Konfiguration liegt in `%APPDATA%\EuerChat\config.json`.
* 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. * **Portable:** nur Kopieren in einen frei wählbaren Ordner (Vorschlag: neben dem Installer), z. B. auf einen USB-Stick. Keine Administratorrechte, keine Registry-Einträge. Die Konfiguration liegt im Unterordner `data` (Marker-Datei `portable.flag`). Entfernen: Ordner löschen.
* Die Konfiguration kann Passwörter enthalten. Sie ist nicht Teil des Installers und gehört nicht ins Repository (steht in `.gitignore`).
* Der Installer lässt sich auch still nutzen: `/S /MODE=standard|portable /D=<Ordner>` (`/D=` immer zuletzt). Der Updater verwendet genau diese Parameter und aktualisiert Standard- und Portable-Installationen an Ort und Stelle (Standard mit UAC-Abfrage, Portable ohne). Einstellungen bleiben erhalten.
* Alternativ ohne Installer: `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:** 2. **Installer bauen:**
```powershell ```powershell
.\build.bat .\build.bat
``` ```
Benötigt Rust und Node.js. Der Installer liegt danach in `dist\`, die portable `EuerChat.exe` im Hauptordner. Benötigt Rust, Node.js und NSIS 3 (`winget install NSIS.NSIS`; der von Tauri geladene NSIS-Cache wird ebenfalls gefunden). Das Skript baut die App ohne Tauri-Bundler und dann den eigenen Installer `installer\EuerChat.nsi`. Ergebnis: `dist\EuerChat_<Version>_x64-setup.exe`, dazu die lose `EuerChat.exe` im Hauptordner.
3. **Tests:** `cd src-tauri; cargo test` 3. **Tests:** `cd src-tauri; cargo test`
4. **Release veröffentlichen:** 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. 1. Version in `src-tauri\tauri.conf.json` und `src-tauri\Cargo.toml` erhöhen, optional Release-Notizen in `RELEASE_NOTES.md` schreiben.
+49
View File
@@ -0,0 +1,49 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
<defs>
<radialGradient id="iorb" cx="50%" cy="32%" r="82%">
<stop offset="0" stop-color="#ff5a3d"/><stop offset=".36" stop-color="#e50b0b"/>
<stop offset=".76" stop-color="#8c0000"/><stop offset="1" stop-color="#3d0000"/>
</radialGradient>
<linearGradient id="iring" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#ffffff"/><stop offset=".45" stop-color="#8a8a8a"/><stop offset="1" stop-color="#e9e9e9"/>
</linearGradient>
<linearGradient id="ichrome" x1="0" y1="0" x2="1" y2="0">
<stop offset="0" stop-color="#555"/><stop offset=".2" stop-color="#bdbdbd"/><stop offset=".4" stop-color="#fff"/>
<stop offset=".58" stop-color="#cfcfcf"/><stop offset=".82" stop-color="#777"/><stop offset="1" stop-color="#454545"/>
</linearGradient>
<linearGradient id="igloss" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#fff" stop-opacity=".8"/><stop offset="1" stop-color="#fff" stop-opacity="0"/>
</linearGradient>
<linearGradient id="ibubble" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#ffffff"/><stop offset="1" stop-color="#c3ccd3"/>
</linearGradient>
<clipPath id="iorbClip"><circle cx="256" cy="256" r="214"/></clipPath>
<clipPath id="iheadClip"><rect x="198" y="118" width="116" height="172" rx="58"/></clipPath>
<filter id="ishadow" x="-40%" y="-30%" width="180%" height="170%">
<feDropShadow dx="3" dy="9" stdDeviation="7" flood-color="#000" flood-opacity=".6"/>
</filter>
<filter id="iblur" x="-100%" y="-100%" width="300%" height="300%"><feGaussianBlur stdDeviation="6"/></filter>
</defs>
<circle cx="256" cy="256" r="252" fill="url(#iring)"/>
<circle cx="256" cy="256" r="237" fill="#0b0b0b"/>
<circle cx="256" cy="256" r="214" fill="url(#iorb)"/>
<g clip-path="url(#iorbClip)">
<ellipse cx="256" cy="118" rx="172" ry="86" fill="url(#igloss)" opacity=".75"/>
<ellipse cx="256" cy="478" rx="200" ry="96" fill="#ff3a1c" opacity=".5"/>
</g>
<g filter="url(#ishadow)" transform="rotate(-10 256 260)">
<rect x="198" y="118" width="116" height="172" rx="58" fill="url(#ichrome)"/>
<g clip-path="url(#iheadClip)"><line x1="196" x2="316" y1="144" y2="144" stroke="#1c1c1c" stroke-width="9" stroke-linecap="round"/><line x1="204" x2="308" y1="150.5" y2="150.5" stroke="#fff" stroke-opacity=".45" stroke-width="2" stroke-linecap="round"/><line x1="196" x2="316" y1="161" y2="161" stroke="#1c1c1c" stroke-width="9" stroke-linecap="round"/><line x1="204" x2="308" y1="167.5" y2="167.5" stroke="#fff" stroke-opacity=".45" stroke-width="2" stroke-linecap="round"/><line x1="196" x2="316" y1="178" y2="178" stroke="#1c1c1c" stroke-width="9" stroke-linecap="round"/><line x1="204" x2="308" y1="184.5" y2="184.5" stroke="#fff" stroke-opacity=".45" stroke-width="2" stroke-linecap="round"/><line x1="196" x2="316" y1="195" y2="195" stroke="#1c1c1c" stroke-width="9" stroke-linecap="round"/><line x1="204" x2="308" y1="201.5" y2="201.5" stroke="#fff" stroke-opacity=".45" stroke-width="2" stroke-linecap="round"/><line x1="196" x2="316" y1="212" y2="212" stroke="#1c1c1c" stroke-width="9" stroke-linecap="round"/><line x1="204" x2="308" y1="218.5" y2="218.5" stroke="#fff" stroke-opacity=".45" stroke-width="2" stroke-linecap="round"/><line x1="196" x2="316" y1="229" y2="229" stroke="#1c1c1c" stroke-width="9" stroke-linecap="round"/><line x1="204" x2="308" y1="235.5" y2="235.5" stroke="#fff" stroke-opacity=".45" stroke-width="2" stroke-linecap="round"/><line x1="196" x2="316" y1="246" y2="246" stroke="#1c1c1c" stroke-width="9" stroke-linecap="round"/><line x1="204" x2="308" y1="252.5" y2="252.5" stroke="#fff" stroke-opacity=".45" stroke-width="2" stroke-linecap="round"/><line x1="196" x2="316" y1="263" y2="263" stroke="#1c1c1c" stroke-width="9" stroke-linecap="round"/><line x1="204" x2="308" y1="269.5" y2="269.5" stroke="#fff" stroke-opacity=".45" stroke-width="2" stroke-linecap="round"/></g>
<rect x="198" y="118" width="116" height="172" rx="58" fill="none" stroke="#2c2c2c" stroke-width="5"/>
<rect x="214" y="288" width="84" height="26" rx="10" fill="url(#ichrome)" stroke="#2c2c2c" stroke-width="4"/>
<path d="M236 314 H276 L268 372 H244 Z" fill="url(#ichrome)" stroke="#2c2c2c" stroke-width="4" stroke-linejoin="round"/>
<rect x="230" y="372" width="52" height="26" rx="6" fill="#2a2a2a" stroke="#777" stroke-width="3"/>
</g>
<circle cx="296" cy="150" r="16" fill="#fff" opacity=".9" filter="url(#iblur)"/>
<path d="M296 122 L301 145 L324 150 L301 155 L296 178 L291 155 L268 150 L291 145 Z" fill="#fff"/>
<g filter="url(#ishadow)">
<path d="M322 392 L294 446 L362 396 Z" fill="url(#ibubble)" stroke="#0b0b0b" stroke-width="10" stroke-linejoin="round"/>
<rect x="288" y="296" width="146" height="102" rx="32" fill="url(#ibubble)" stroke="#0b0b0b" stroke-width="10"/>
<path d="M322 392 L318 400 L352 394 Z" fill="url(#ibubble)"/>
<circle cx="326" cy="347" r="12" fill="#d00000"/><circle cx="361" cy="347" r="12" fill="#d00000"/><circle cx="396" cy="347" r="12" fill="#d00000"/>
</g></svg>

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 264 KiB

+82
View File
@@ -0,0 +1,82 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1911" height="560" viewBox="0 0 1911 560">
<defs>
<linearGradient id="wsilver" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#fff"/><stop offset=".5" stop-color="#9c9c9c"/><stop offset="1" stop-color="#f1f1f1"/>
</linearGradient>
<linearGradient id="wletters" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#ffffff"/><stop offset=".55" stop-color="#e6e6e6"/><stop offset="1" stop-color="#bcbcbc"/>
</linearGradient>
<filter id="wtshadow" x="-10%" y="-20%" width="120%" height="160%">
<feDropShadow dx="0" dy="6" stdDeviation="6" flood-color="#000" flood-opacity=".5"/>
</filter>
<linearGradient id="wpillStroke" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#ff5a45"/><stop offset=".4" stop-color="#e00000"/><stop offset="1" stop-color="#7d0000"/>
</linearGradient>
<linearGradient id="wpillFill" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#ffffff"/><stop offset="1" stop-color="#d9d9d9"/>
</linearGradient>
<filter id="wpillShadow" x="-5%" y="-20%" width="110%" height="150%">
<feDropShadow dx="0" dy="10" stdDeviation="9" flood-color="#000" flood-opacity=".45"/>
</filter>
</defs>
<rect x="46" y="86" width="1819" height="388" rx="194" fill="url(#wpillFill)" stroke="url(#wpillStroke)" stroke-width="56" filter="url(#wpillShadow)"/>
<rect x="74" y="114" width="1763" height="332" rx="166" fill="none" stroke="#6d0000" stroke-opacity=".55" stroke-width="6"/>
<text x="468" y="335" font-family="'Segoe UI Black','Arial Black',Verdana,sans-serif" font-size="170" text-anchor="middle" textLength="596" lengthAdjust="spacingAndGlyphs" stroke-linejoin="round" fill="#1a1a1a" stroke="#1a1a1a" stroke-width="34" filter="url(#wtshadow)">euer.tv</text>
<text x="468" y="335" font-family="'Segoe UI Black','Arial Black',Verdana,sans-serif" font-size="170" text-anchor="middle" textLength="596" lengthAdjust="spacingAndGlyphs" stroke-linejoin="round" fill="none" stroke="url(#wsilver)" stroke-width="22.1">euer.tv</text>
<text x="468" y="335" font-family="'Segoe UI Black','Arial Black',Verdana,sans-serif" font-size="170" text-anchor="middle" textLength="596" lengthAdjust="spacingAndGlyphs" stroke-linejoin="round" fill="url(#wletters)" stroke="#5e5e5e" stroke-width="2.04">euer.tv</text>
<text x="1563.5" y="335" font-family="'Segoe UI Black','Arial Black',Verdana,sans-serif" font-size="170" text-anchor="middle" textLength="355" lengthAdjust="spacingAndGlyphs" stroke-linejoin="round" fill="#1a1a1a" stroke="#1a1a1a" stroke-width="34" filter="url(#wtshadow)">chat</text>
<text x="1563.5" y="335" font-family="'Segoe UI Black','Arial Black',Verdana,sans-serif" font-size="170" text-anchor="middle" textLength="355" lengthAdjust="spacingAndGlyphs" stroke-linejoin="round" fill="none" stroke="url(#wsilver)" stroke-width="22.1">chat</text>
<text x="1563.5" y="335" font-family="'Segoe UI Black','Arial Black',Verdana,sans-serif" font-size="170" text-anchor="middle" textLength="355" lengthAdjust="spacingAndGlyphs" stroke-linejoin="round" fill="url(#wletters)" stroke="#5e5e5e" stroke-width="2.04">chat</text>
<svg x="806" y="10" width="540" height="540" viewBox="0 0 512 512">
<defs>
<radialGradient id="worb" cx="50%" cy="32%" r="82%">
<stop offset="0" stop-color="#ff5a3d"/><stop offset=".36" stop-color="#e50b0b"/>
<stop offset=".76" stop-color="#8c0000"/><stop offset="1" stop-color="#3d0000"/>
</radialGradient>
<linearGradient id="wring" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#ffffff"/><stop offset=".45" stop-color="#8a8a8a"/><stop offset="1" stop-color="#e9e9e9"/>
</linearGradient>
<linearGradient id="wchrome" x1="0" y1="0" x2="1" y2="0">
<stop offset="0" stop-color="#555"/><stop offset=".2" stop-color="#bdbdbd"/><stop offset=".4" stop-color="#fff"/>
<stop offset=".58" stop-color="#cfcfcf"/><stop offset=".82" stop-color="#777"/><stop offset="1" stop-color="#454545"/>
</linearGradient>
<linearGradient id="wgloss" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#fff" stop-opacity=".8"/><stop offset="1" stop-color="#fff" stop-opacity="0"/>
</linearGradient>
<linearGradient id="wbubble" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#ffffff"/><stop offset="1" stop-color="#c3ccd3"/>
</linearGradient>
<clipPath id="worbClip"><circle cx="256" cy="256" r="214"/></clipPath>
<clipPath id="wheadClip"><rect x="198" y="118" width="116" height="172" rx="58"/></clipPath>
<filter id="wshadow" x="-40%" y="-30%" width="180%" height="170%">
<feDropShadow dx="3" dy="9" stdDeviation="7" flood-color="#000" flood-opacity=".6"/>
</filter>
<filter id="wblur" x="-100%" y="-100%" width="300%" height="300%"><feGaussianBlur stdDeviation="6"/></filter>
</defs>
<circle cx="256" cy="256" r="252" fill="url(#wring)"/>
<circle cx="256" cy="256" r="237" fill="#0b0b0b"/>
<circle cx="256" cy="256" r="214" fill="url(#worb)"/>
<g clip-path="url(#worbClip)">
<ellipse cx="256" cy="118" rx="172" ry="86" fill="url(#wgloss)" opacity=".75"/>
<ellipse cx="256" cy="478" rx="200" ry="96" fill="#ff3a1c" opacity=".5"/>
</g>
<g filter="url(#wshadow)" transform="rotate(-10 256 260)">
<rect x="198" y="118" width="116" height="172" rx="58" fill="url(#wchrome)"/>
<g clip-path="url(#wheadClip)"><line x1="196" x2="316" y1="144" y2="144" stroke="#1c1c1c" stroke-width="9" stroke-linecap="round"/><line x1="204" x2="308" y1="150.5" y2="150.5" stroke="#fff" stroke-opacity=".45" stroke-width="2" stroke-linecap="round"/><line x1="196" x2="316" y1="161" y2="161" stroke="#1c1c1c" stroke-width="9" stroke-linecap="round"/><line x1="204" x2="308" y1="167.5" y2="167.5" stroke="#fff" stroke-opacity=".45" stroke-width="2" stroke-linecap="round"/><line x1="196" x2="316" y1="178" y2="178" stroke="#1c1c1c" stroke-width="9" stroke-linecap="round"/><line x1="204" x2="308" y1="184.5" y2="184.5" stroke="#fff" stroke-opacity=".45" stroke-width="2" stroke-linecap="round"/><line x1="196" x2="316" y1="195" y2="195" stroke="#1c1c1c" stroke-width="9" stroke-linecap="round"/><line x1="204" x2="308" y1="201.5" y2="201.5" stroke="#fff" stroke-opacity=".45" stroke-width="2" stroke-linecap="round"/><line x1="196" x2="316" y1="212" y2="212" stroke="#1c1c1c" stroke-width="9" stroke-linecap="round"/><line x1="204" x2="308" y1="218.5" y2="218.5" stroke="#fff" stroke-opacity=".45" stroke-width="2" stroke-linecap="round"/><line x1="196" x2="316" y1="229" y2="229" stroke="#1c1c1c" stroke-width="9" stroke-linecap="round"/><line x1="204" x2="308" y1="235.5" y2="235.5" stroke="#fff" stroke-opacity=".45" stroke-width="2" stroke-linecap="round"/><line x1="196" x2="316" y1="246" y2="246" stroke="#1c1c1c" stroke-width="9" stroke-linecap="round"/><line x1="204" x2="308" y1="252.5" y2="252.5" stroke="#fff" stroke-opacity=".45" stroke-width="2" stroke-linecap="round"/><line x1="196" x2="316" y1="263" y2="263" stroke="#1c1c1c" stroke-width="9" stroke-linecap="round"/><line x1="204" x2="308" y1="269.5" y2="269.5" stroke="#fff" stroke-opacity=".45" stroke-width="2" stroke-linecap="round"/></g>
<rect x="198" y="118" width="116" height="172" rx="58" fill="none" stroke="#2c2c2c" stroke-width="5"/>
<rect x="214" y="288" width="84" height="26" rx="10" fill="url(#wchrome)" stroke="#2c2c2c" stroke-width="4"/>
<path d="M236 314 H276 L268 372 H244 Z" fill="url(#wchrome)" stroke="#2c2c2c" stroke-width="4" stroke-linejoin="round"/>
<rect x="230" y="372" width="52" height="26" rx="6" fill="#2a2a2a" stroke="#777" stroke-width="3"/>
</g>
<circle cx="296" cy="150" r="16" fill="#fff" opacity=".9" filter="url(#wblur)"/>
<path d="M296 122 L301 145 L324 150 L301 155 L296 178 L291 155 L268 150 L291 145 Z" fill="#fff"/>
<g filter="url(#wshadow)">
<path d="M322 392 L294 446 L362 396 Z" fill="url(#wbubble)" stroke="#0b0b0b" stroke-width="10" stroke-linejoin="round"/>
<rect x="288" y="296" width="146" height="102" rx="32" fill="url(#wbubble)" stroke="#0b0b0b" stroke-width="10"/>
<path d="M322 392 L318 400 L352 394 Z" fill="url(#wbubble)"/>
<circle cx="326" cy="347" r="12" fill="#d00000"/><circle cx="361" cy="347" r="12" fill="#d00000"/><circle cx="396" cy="347" r="12" fill="#d00000"/>
</g></svg>
</svg>

After

Width:  |  Height:  |  Size: 8.0 KiB

+16 -4
View File
@@ -8,14 +8,26 @@ if not exist node_modules (
if errorlevel 1 goto :fail if errorlevel 1 goto :fail
) )
echo Baue Installer (NSIS)... echo Baue EuerChat.exe ...
call npx tauri build call npx tauri build --no-bundle
if errorlevel 1 goto :fail if errorlevel 1 goto :fail
for /f "usebackq delims=" %%v in (`node -p "require('./src-tauri/tauri.conf.json').version"`) do set VERSION=%%v
set MAKENSIS=%LOCALAPPDATA%\tauri\NSIS\makensis.exe
if not exist "%MAKENSIS%" set MAKENSIS=%ProgramFiles(x86)%\NSIS\makensis.exe
if not exist "%MAKENSIS%" (
echo [FEHLER] makensis nicht gefunden. NSIS 3 installieren: winget install NSIS.NSIS
goto :fail
)
if not exist dist mkdir dist if not exist dist mkdir dist
copy /y "src-tauri\target\release\bundle\nsis\EuerChat_*_x64-setup.exe" dist\ >nul echo Baue Installer %VERSION% ...
"%MAKENSIS%" /V2 /DVERSION=%VERSION% installer\EuerChat.nsi
if errorlevel 1 goto :fail
copy /y src-tauri\target\release\euer-chat.exe EuerChat.exe >nul copy /y src-tauri\target\release\euer-chat.exe EuerChat.exe >nul
echo [OK] Installer liegt in dist\ - EuerChat.exe (portabel) im Hauptordner. echo [OK] Installer: dist\EuerChat_%VERSION%_x64-setup.exe
pause pause
exit /b 0 exit /b 0
+394
View File
@@ -0,0 +1,394 @@
; EuerChat-Installer
; Standard (Program Files, alle Benutzer) oder Portable (frei waehlbarer Ordner).
; Parameter, auch fuer stille Updates: /S /UPDATE /MODE=standard|portable /NS /D=<Ordner> (/D= immer zuletzt)
Unicode true
ManifestDPIAware true
SetCompressor /SOLID lzma
RequestExecutionLevel user
!ifndef VERSION
!define VERSION "0.0.0"
!endif
!ifndef EXE_SRC
!define EXE_SRC "..\src-tauri\target\release\euer-chat.exe"
!endif
!ifndef OUT_FILE
!define OUT_FILE "..\dist\EuerChat_${VERSION}_x64-setup.exe"
!endif
!define PRODUCT "EuerChat"
!define PUBLISHER "euer.tv"
!define MAINEXE "euer-chat.exe"
!define BUNDLEID "tv.euer.chat"
!define WEBVIEW2APPGUID "{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}"
!define PLACEHOLDER_DIR "placeholder\${PRODUCT}"
; TEST_USER_CONTEXT: Testbuild ohne Administratorrechte (HKCU, eigener Registry-Schluessel).
!ifdef TEST_USER_CONTEXT
!define ROOT HKCU
!define UNINSTKEY "Software\Microsoft\Windows\CurrentVersion\Uninstall\${PRODUCT}-Test"
!else
!define ROOT HKLM
!define UNINSTKEY "Software\Microsoft\Windows\CurrentVersion\Uninstall\${PRODUCT}"
!endif
Name "${PRODUCT}"
OutFile "${OUT_FILE}"
BrandingText "${PRODUCT} ${VERSION}"
InstallDir "${PLACEHOLDER_DIR}"
VIProductVersion "${VERSION}.0"
VIAddVersionKey "ProductName" "${PRODUCT}"
VIAddVersionKey "FileDescription" "${PRODUCT} Installer"
VIAddVersionKey "FileVersion" "${VERSION}"
VIAddVersionKey "ProductVersion" "${VERSION}"
VIAddVersionKey "CompanyName" "${PUBLISHER}"
VIAddVersionKey "LegalCopyright" "${PUBLISHER}"
!include MUI2.nsh
!include FileFunc.nsh
!include LogicLib.nsh
!include nsDialogs.nsh
!include x64.nsh
!insertmacro GetParameters
!insertmacro GetOptions
!insertmacro un.GetParameters
!insertmacro un.GetOptions
Var Portable
Var UpdateMode
Var NoShortcut
Var Elevated
Var ModeGiven
Var RadioStandard
Var RadioPortable
!define MUI_ICON "..\src-tauri\icons\icon.ico"
!define MUI_UNICON "..\src-tauri\icons\icon.ico"
!define MUI_ABORTWARNING
!define MUI_WELCOMEFINISHPAGE_BITMAP "sidebar.bmp"
!define MUI_UNWELCOMEFINISHPAGE_BITMAP "sidebar.bmp"
!define MUI_HEADERIMAGE
!define MUI_HEADERIMAGE_BITMAP "header.bmp"
!define MUI_HEADERIMAGE_UNBITMAP "header.bmp"
!define MUI_HEADERIMAGE_RIGHT
; Seiten
!define MUI_PAGE_CUSTOMFUNCTION_PRE WelcomePre
!insertmacro MUI_PAGE_WELCOME
Page custom ModePageCreate ModePageLeave
!insertmacro MUI_PAGE_DIRECTORY
!insertmacro MUI_PAGE_INSTFILES
!define MUI_FINISHPAGE_NOAUTOCLOSE
!define MUI_FINISHPAGE_RUN
!define MUI_FINISHPAGE_RUN_TEXT "${PRODUCT} jetzt starten"
!define MUI_FINISHPAGE_RUN_FUNCTION RunMainBinary
!insertmacro MUI_PAGE_FINISH
!insertmacro MUI_UNPAGE_CONFIRM
!insertmacro MUI_UNPAGE_INSTFILES
!insertmacro MUI_LANGUAGE "German"
; ---------------------------------------------------------------------------
; Hilfsfunktionen
; ---------------------------------------------------------------------------
Function SetDefaultDir
${If} $Portable = 1
StrCpy $INSTDIR "$EXEDIR\${PRODUCT}"
${Else}
ReadRegStr $0 ${ROOT} "${UNINSTKEY}" "InstallLocation"
${If} $0 != ""
StrCpy $INSTDIR $0
${Else}
StrCpy $INSTDIR "$PROGRAMFILES64\${PRODUCT}"
${EndIf}
${EndIf}
FunctionEnd
; Startet den Installer bei Bedarf mit Administratorrechten neu (nur fuer die Standard-Installation).
Function RequireAdmin
!ifndef TEST_USER_CONTEXT
System::Call 'shell32::IsUserAnAdmin()i.r0'
${If} $0 = 0
${If} $Elevated = 1
${IfNot} ${Silent}
MessageBox MB_ICONSTOP "Für die Standard-Installation sind Administratorrechte nötig."
${EndIf}
Abort
${EndIf}
StrCpy $1 "/MODE=standard /ELEVATED"
${If} ${Silent}
StrCpy $1 "$1 /S"
${EndIf}
${If} $UpdateMode = 1
StrCpy $1 "$1 /UPDATE"
${EndIf}
${If} $NoShortcut = 1
StrCpy $1 "$1 /NS"
${EndIf}
StrCpy $1 "$1 /D=$INSTDIR"
System::Call 'shell32::ShellExecute(i 0, t "runas", t "$EXEPATH", t "$1", t "", i 1) i .r2'
${If} $2 > 32
Quit
${EndIf}
${IfNot} ${Silent}
MessageBox MB_ICONEXCLAMATION "Ohne Administratorrechte kann die Standard-Installation nicht fortgesetzt werden. Wähle stattdessen „Portable“."
${EndIf}
Abort
${EndIf}
!endif
FunctionEnd
Function CloseRunningApp
nsExec::ExecToStack 'cmd /c tasklist /FI "IMAGENAME eq ${MAINEXE}" /NH | find /I "${MAINEXE}"'
Pop $0
Pop $1
${If} $0 = 0
${IfNot} ${Silent}
MessageBox MB_OKCANCEL|MB_ICONEXCLAMATION "${PRODUCT} läuft noch und wird für die Installation beendet." IDOK +2
Abort
${EndIf}
nsExec::ExecToStack 'taskkill /F /IM "${MAINEXE}"'
Pop $0
Pop $1
Sleep 800
${EndIf}
FunctionEnd
Function RunMainBinary
; Ueber den Explorer starten, damit die App ohne Administratorrechte laeuft.
Exec '"$WINDIR\explorer.exe" "$INSTDIR\${MAINEXE}"'
FunctionEnd
; ---------------------------------------------------------------------------
; Installer
; ---------------------------------------------------------------------------
Function .onInit
${If} ${RunningX64}
SetRegView 64
${EndIf}
${GetParameters} $R9
${GetOptions} $R9 "/UPDATE" $0
${IfNot} ${Errors}
StrCpy $UpdateMode 1
${EndIf}
${GetOptions} $R9 "/NS" $0
${IfNot} ${Errors}
StrCpy $NoShortcut 1
${EndIf}
${GetOptions} $R9 "/ELEVATED" $0
${IfNot} ${Errors}
StrCpy $Elevated 1
${EndIf}
${GetOptions} $R9 "/MODE=" $0
${IfNot} ${Errors}
StrCpy $ModeGiven 1
${If} $0 == "portable"
StrCpy $Portable 1
${EndIf}
${EndIf}
; Stille Installation ohne Angabe: Standard
${If} ${Silent}
StrCpy $ModeGiven 1
${EndIf}
${If} $INSTDIR == "${PLACEHOLDER_DIR}"
Call SetDefaultDir
${EndIf}
${If} $ModeGiven = 1
${AndIf} $Portable <> 1
Call RequireAdmin
${EndIf}
FunctionEnd
Function WelcomePre
${If} $Elevated = 1
Abort
${EndIf}
FunctionEnd
Function ModePageCreate
${If} $ModeGiven = 1
Abort
${EndIf}
!insertmacro MUI_HEADER_TEXT "Installationsart" "Wie soll ${PRODUCT} installiert werden?"
nsDialogs::Create 1018
Pop $0
${NSD_CreateRadioButton} 0 6u 100% 12u "Standard-Installation (empfohlen)"
Pop $RadioStandard
${NSD_AddStyle} $RadioStandard ${WS_GROUP}
${NSD_CreateLabel} 12u 20u 94% 38u "Installiert ${PRODUCT} für alle Benutzer, standardmäßig nach C:\Program Files. Mit Startmenü- und Desktop-Verknüpfung; deinstallierbar über die Windows-Einstellungen. Erfordert Administratorrechte."
Pop $0
${NSD_CreateRadioButton} 0 66u 100% 12u "Portable (Ordner frei wählbar)"
Pop $RadioPortable
${NSD_CreateLabel} 12u 80u 94% 46u "Kopiert ${PRODUCT} nur in einen Ordner deiner Wahl, z. B. auf einen USB-Stick. Keine Administratorrechte und keine Einträge in der Registry. Die Einstellungen liegen im Unterordner „data“. Zum Entfernen genügt es, den Ordner zu löschen."
Pop $0
${If} $Portable = 1
${NSD_Check} $RadioPortable
${Else}
${NSD_Check} $RadioStandard
${EndIf}
nsDialogs::Show
FunctionEnd
Function ModePageLeave
${NSD_GetState} $RadioPortable $0
${If} $0 = ${BST_CHECKED}
StrCpy $Portable 1
${Else}
StrCpy $Portable 0
${EndIf}
Call SetDefaultDir
${If} $Portable = 0
Call RequireAdmin
${EndIf}
FunctionEnd
Section "WebView2"
${If} $UpdateMode <> 1
ReadRegStr $4 HKLM "SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate\Clients\${WEBVIEW2APPGUID}" "pv"
${If} $4 == ""
ReadRegStr $4 HKCU "SOFTWARE\Microsoft\EdgeUpdate\Clients\${WEBVIEW2APPGUID}" "pv"
${EndIf}
${If} $4 == ""
DetailPrint "Microsoft Edge WebView2 wird heruntergeladen ..."
Delete "$TEMP\MicrosoftEdgeWebview2Setup.exe"
NSISdl::download "https://go.microsoft.com/fwlink/p/?LinkId=2124703" "$TEMP\MicrosoftEdgeWebview2Setup.exe"
Pop $0
${If} $0 != "success"
Abort "WebView2 konnte nicht heruntergeladen werden. Bitte die Internetverbindung prüfen und erneut versuchen."
${EndIf}
DetailPrint "Microsoft Edge WebView2 wird installiert ..."
ExecWait '"$TEMP\MicrosoftEdgeWebview2Setup.exe" /silent /install' $1
${If} $1 <> 0
Abort "Die Installation von WebView2 ist fehlgeschlagen."
${EndIf}
${EndIf}
${EndIf}
SectionEnd
Section "Install"
SetOutPath $INSTDIR
Call CloseRunningApp
File "${EXE_SRC}"
${If} $Portable = 1
; Die Marker-Datei schaltet den portablen Datenordner der App frei.
FileOpen $0 "$INSTDIR\portable.flag" w
FileWrite $0 "${PRODUCT} portable: Einstellungen liegen im Ordner data.$\r$\n"
FileClose $0
CreateDirectory "$INSTDIR\data"
${Else}
Delete "$INSTDIR\portable.flag"
WriteUninstaller "$INSTDIR\uninstall.exe"
!ifdef TEST_USER_CONTEXT
SetShellVarContext current
!else
SetShellVarContext all
!endif
WriteRegStr ${ROOT} "${UNINSTKEY}" "DisplayName" "${PRODUCT}"
WriteRegStr ${ROOT} "${UNINSTKEY}" "DisplayVersion" "${VERSION}"
WriteRegStr ${ROOT} "${UNINSTKEY}" "Publisher" "${PUBLISHER}"
WriteRegStr ${ROOT} "${UNINSTKEY}" "InstallLocation" "$INSTDIR"
WriteRegStr ${ROOT} "${UNINSTKEY}" "DisplayIcon" "$INSTDIR\${MAINEXE}"
WriteRegStr ${ROOT} "${UNINSTKEY}" "UninstallString" '"$INSTDIR\uninstall.exe"'
WriteRegDWORD ${ROOT} "${UNINSTKEY}" "NoModify" 1
WriteRegDWORD ${ROOT} "${UNINSTKEY}" "NoRepair" 1
WriteRegDWORD ${ROOT} "${UNINSTKEY}" "EstimatedSize" 7600
CreateShortcut "$SMPROGRAMS\${PRODUCT}.lnk" "$INSTDIR\${MAINEXE}" "" "$INSTDIR\${MAINEXE}" 0
; Desktop-Verknuepfung bei jeder Neuinstallation (nicht bei stillen Updates oder /NS)
${If} $UpdateMode <> 1
${AndIf} $NoShortcut <> 1
CreateShortcut "$DESKTOP\${PRODUCT}.lnk" "$INSTDIR\${MAINEXE}" "" "$INSTDIR\${MAINEXE}" 0
${EndIf}
${EndIf}
SectionEnd
; ---------------------------------------------------------------------------
; Deinstallation (nur Standard-Installation)
; ---------------------------------------------------------------------------
Function un.onInit
${If} ${RunningX64}
SetRegView 64
${EndIf}
${GetParameters} $R9
${GetOptions} $R9 "/ELEVATED" $0
${IfNot} ${Errors}
StrCpy $Elevated 1
${EndIf}
${GetOptions} $R9 "/UPDATE" $0
${IfNot} ${Errors}
StrCpy $UpdateMode 1
${EndIf}
!ifndef TEST_USER_CONTEXT
System::Call 'shell32::IsUserAnAdmin()i.r0'
${If} $0 = 0
${If} $Elevated = 1
Abort
${EndIf}
StrCpy $1 "/ELEVATED"
${If} ${Silent}
StrCpy $1 "$1 /S"
${EndIf}
${If} $UpdateMode = 1
StrCpy $1 "$1 /UPDATE"
${EndIf}
StrCpy $1 "$1 _?=$INSTDIR"
System::Call 'shell32::ShellExecute(i 0, t "runas", t "$EXEPATH", t "$1", t "", i 1) i .r2'
${If} $2 > 32
Quit
${EndIf}
Abort
${EndIf}
!endif
FunctionEnd
Section "Uninstall"
!ifdef TEST_USER_CONTEXT
SetShellVarContext current
!else
SetShellVarContext all
!endif
nsExec::ExecToStack 'taskkill /F /IM "${MAINEXE}"'
Pop $0
Pop $1
Sleep 500
Delete "$INSTDIR\${MAINEXE}"
Delete "$INSTDIR\uninstall.exe"
RMDir "$INSTDIR"
Delete "$SMPROGRAMS\${PRODUCT}.lnk"
Delete "$DESKTOP\${PRODUCT}.lnk"
DeleteRegKey ${ROOT} "${UNINSTKEY}"
${If} $UpdateMode <> 1
${AndIfNot} ${Silent}
MessageBox MB_YESNO|MB_ICONQUESTION|MB_DEFBUTTON2 "Sollen auch die Einstellungen von ${PRODUCT} (Konfiguration und gespeicherte Passwörter) gelöscht werden?" IDNO skip_data
SetShellVarContext current
RMDir /r "$APPDATA\${PRODUCT}"
RMDir /r "$LOCALAPPDATA\${BUNDLEID}"
skip_data:
${EndIf}
SectionEnd
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

+2 -1
View File
@@ -6,7 +6,8 @@
"scripts": { "scripts": {
"tauri": "tauri", "tauri": "tauri",
"dev": "tauri dev", "dev": "tauri dev",
"build": "tauri build" "build": "tauri build",
"brand": "node tools/make-branding.mjs"
}, },
"devDependencies": { "devDependencies": {
"@tauri-apps/cli": "^2.2.0" "@tauri-apps/cli": "^2.2.0"
+2
View File
@@ -1,3 +1,5 @@
fn main() { fn main() {
// Tauri bettet das Symbol nur beim Ausfuehren dieses Skripts ein; ohne diese Zeile bliebe ein geaendertes Icon unbemerkt.
println!("cargo:rerun-if-changed=icons/icon.ico");
tauri_build::build() tauri_build::build()
} }
Binary file not shown.

Before

Width:  |  Height:  |  Size: 561 B

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 155 B

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 177 B

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 561 B

After

Width:  |  Height:  |  Size: 119 KiB

+9 -1
View File
@@ -5,14 +5,22 @@ use std::path::PathBuf;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use tauri::{PhysicalPosition, PhysicalSize}; use tauri::{PhysicalPosition, PhysicalSize};
fn exe_dir() -> PathBuf { pub fn exe_dir() -> PathBuf {
std::env::current_exe() std::env::current_exe()
.ok() .ok()
.and_then(|p| p.parent().map(PathBuf::from)) .and_then(|p| p.parent().map(PathBuf::from))
.unwrap_or_else(|| PathBuf::from(".")) .unwrap_or_else(|| PathBuf::from("."))
} }
/// Der Installer legt bei der portablen Installation die Marker-Datei `portable.flag` neben die EXE.
pub fn is_portable() -> bool {
exe_dir().join("portable.flag").is_file()
}
pub fn data_dir() -> PathBuf { pub fn data_dir() -> PathBuf {
if is_portable() {
return exe_dir().join("data");
}
std::env::var_os("APPDATA") std::env::var_os("APPDATA")
.map(|d| PathBuf::from(d).join("EuerChat")) .map(|d| PathBuf::from(d).join("EuerChat"))
.unwrap_or_else(exe_dir) .unwrap_or_else(exe_dir)
+87 -16
View File
@@ -366,28 +366,65 @@ async fn download_to_file(
// Installation // Installation
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
enum InstallKind {
/// Mit dem Standard-Installer installiert (uninstall.exe neben der EXE), meist in Program Files.
Standard,
/// Portable Installation (portable.flag neben der EXE).
Portable,
/// Lose EXE ohne Installation, z. B. aus dem Build-Ordner.
Loose,
}
fn detect_install_kind(dir: &Path) -> InstallKind {
if dir.join("portable.flag").is_file() {
InstallKind::Portable
} else if dir.join("uninstall.exe").is_file() {
InstallKind::Standard
} else {
InstallKind::Loose
}
}
/// PowerShell-Skript: wartet auf das Ende dieser App, installiert still und startet EuerChat neu.
fn build_update_script(pid: u32, installer: &Path, current_exe: &Path, kind: InstallKind) -> String {
let quote = |p: &Path| p.to_string_lossy().replace('\'', "''");
let dir = current_exe.parent().map(quote).unwrap_or_default();
// Standard-Installationen liegen meist in Program Files und brauchen Administratorrechte (UAC).
let (args, elevate) = match kind {
InstallKind::Standard => (format!("/S /UPDATE /MODE=standard /D={dir}"), true),
InstallKind::Portable => (format!("/S /UPDATE /MODE=portable /D={dir}"), false),
InstallKind::Loose => ("/S /UPDATE /MODE=standard".to_string(), true),
};
let verb = if elevate { " -Verb RunAs" } else { "" };
let relaunch = match kind {
InstallKind::Loose => format!(
"$t = Join-Path $env:ProgramFiles 'EuerChat\\euer-chat.exe'; \
if (Test-Path $t) {{ Start-Process -FilePath $t }} else {{ Start-Process -FilePath '{}' }}",
quote(current_exe)
),
_ => format!("Start-Process -FilePath '{}'", quote(current_exe)),
};
// Bricht der Nutzer die UAC-Abfrage ab, wird trotzdem die bisherige Version neu gestartet.
format!(
"Wait-Process -Id {pid} -Timeout 30 -ErrorAction SilentlyContinue; \
try {{ Start-Process -FilePath '{installer}' -ArgumentList '{args}'{verb} -Wait }} catch {{}}; \
{relaunch}",
installer = quote(installer),
)
}
/// Startet den Installer erst, wenn diese App beendet ist, und danach EuerChat neu. /// Startet den Installer erst, wenn diese App beendet ist, und danach EuerChat neu.
fn spawn_installer_after_exit(installer: &Path) -> std::io::Result<()> { fn spawn_installer_after_exit(installer: &Path) -> std::io::Result<()> {
use std::os::windows::process::CommandExt; use std::os::windows::process::CommandExt;
let quote = |p: &Path| p.to_string_lossy().replace('\'', "''");
let current = std::env::current_exe()?; let current = std::env::current_exe()?;
let installed_here = current let kind = current
.parent() .parent()
.is_some_and(|dir| dir.join("uninstall.exe").exists()); .map_or(InstallKind::Loose, detect_install_kind);
let script = build_update_script(std::process::id(), installer, &current, kind);
// 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") std::process::Command::new("powershell.exe")
.args(["-NoProfile", "-NonInteractive", "-WindowStyle", "Hidden", "-Command", &script]) .args(["-NoProfile", "-NonInteractive", "-WindowStyle", "Hidden", "-Command", &script])
@@ -529,6 +566,40 @@ mod tests {
assert!(parse_expected_checksum(&other, "x.exe").unwrap_err().contains("x.exe")); assert!(parse_expected_checksum(&other, "x.exe").unwrap_err().contains("x.exe"));
} }
#[test]
fn install_kind_detection() {
let dir = std::env::temp_dir().join(format!("euerchat-kind-test-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
assert_eq!(detect_install_kind(&dir), InstallKind::Loose);
std::fs::write(dir.join("uninstall.exe"), b"x").unwrap();
assert_eq!(detect_install_kind(&dir), InstallKind::Standard);
std::fs::write(dir.join("portable.flag"), b"x").unwrap();
assert_eq!(detect_install_kind(&dir), InstallKind::Portable);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn update_script_per_install_kind() {
let installer = Path::new(r"C:\Users\O'Brien\AppData\Roaming\EuerChat\updates\EuerChat_0.2.0_x64-setup.exe");
let exe = Path::new(r"C:\Program Files\EuerChat\euer-chat.exe");
let standard = build_update_script(42, installer, exe, InstallKind::Standard);
assert!(standard.contains("Wait-Process -Id 42"));
assert!(standard.contains("/MODE=standard /D=C:\\Program Files\\EuerChat'"));
assert!(standard.contains("-Verb RunAs"));
assert!(standard.contains("O''Brien"), "Apostrophe müssen verdoppelt werden");
assert!(standard.contains("Start-Process -FilePath 'C:\\Program Files\\EuerChat\\euer-chat.exe'"));
let portable = build_update_script(1, installer, Path::new(r"E:\Tools\EuerChat\euer-chat.exe"), InstallKind::Portable);
assert!(portable.contains("/MODE=portable /D=E:\\Tools\\EuerChat'"));
assert!(!portable.contains("RunAs"), "portable Updates brauchen keine Administratorrechte");
let loose = build_update_script(1, installer, Path::new(r"D:\build\euer-chat.exe"), InstallKind::Loose);
assert!(loose.contains("/MODE=standard'"));
assert!(!loose.contains("/D="));
assert!(loose.contains("$env:ProgramFiles"));
}
#[test] #[test]
fn managed_installer_files() { 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"));
+2 -11
View File
@@ -39,16 +39,7 @@
} }
}, },
"bundle": { "bundle": {
"active": true, "active": false,
"targets": ["nsis"], "icon": ["icons/32x32.png", "icons/128x128.png", "icons/icon.ico"]
"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
}
}
} }
} }
+350
View File
@@ -0,0 +1,350 @@
// Erzeugt das EuerChat-Logo (Icon, Wortmarke, Installer-Bilder) aus SVG und rendert es mit Edge (Headless).
// Aufruf: node tools/make-branding.mjs
import { spawn } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import zlib from 'node:zlib';
import { fileURLToPath } from 'node:url';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const EDGE = [
'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe',
'C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe',
].find(fs.existsSync);
if (!EDGE) throw new Error('Microsoft Edge nicht gefunden');
const FONT = "'Segoe UI Black','Arial Black',Verdana,sans-serif";
// ---------------------------------------------------------------------------
// SVG-Bausteine
// ---------------------------------------------------------------------------
/** Rotes Orb-Symbol mit Chrom-Mikrofon und Chat-Blase (512x512). `p` macht die IDs eindeutig. */
function iconInner(p) {
const slots = [];
for (let y = 144; y <= 268; y += 17) {
slots.push(`<line x1="196" x2="316" y1="${y}" y2="${y}" stroke="#1c1c1c" stroke-width="9" stroke-linecap="round"/>`);
slots.push(`<line x1="204" x2="308" y1="${y + 6.5}" y2="${y + 6.5}" stroke="#fff" stroke-opacity=".45" stroke-width="2" stroke-linecap="round"/>`);
}
return `
<defs>
<radialGradient id="${p}orb" cx="50%" cy="32%" r="82%">
<stop offset="0" stop-color="#ff5a3d"/><stop offset=".36" stop-color="#e50b0b"/>
<stop offset=".76" stop-color="#8c0000"/><stop offset="1" stop-color="#3d0000"/>
</radialGradient>
<linearGradient id="${p}ring" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#ffffff"/><stop offset=".45" stop-color="#8a8a8a"/><stop offset="1" stop-color="#e9e9e9"/>
</linearGradient>
<linearGradient id="${p}chrome" x1="0" y1="0" x2="1" y2="0">
<stop offset="0" stop-color="#555"/><stop offset=".2" stop-color="#bdbdbd"/><stop offset=".4" stop-color="#fff"/>
<stop offset=".58" stop-color="#cfcfcf"/><stop offset=".82" stop-color="#777"/><stop offset="1" stop-color="#454545"/>
</linearGradient>
<linearGradient id="${p}gloss" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#fff" stop-opacity=".8"/><stop offset="1" stop-color="#fff" stop-opacity="0"/>
</linearGradient>
<linearGradient id="${p}bubble" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#ffffff"/><stop offset="1" stop-color="#c3ccd3"/>
</linearGradient>
<clipPath id="${p}orbClip"><circle cx="256" cy="256" r="214"/></clipPath>
<clipPath id="${p}headClip"><rect x="198" y="118" width="116" height="172" rx="58"/></clipPath>
<filter id="${p}shadow" x="-40%" y="-30%" width="180%" height="170%">
<feDropShadow dx="3" dy="9" stdDeviation="7" flood-color="#000" flood-opacity=".6"/>
</filter>
<filter id="${p}blur" x="-100%" y="-100%" width="300%" height="300%"><feGaussianBlur stdDeviation="6"/></filter>
</defs>
<circle cx="256" cy="256" r="252" fill="url(#${p}ring)"/>
<circle cx="256" cy="256" r="237" fill="#0b0b0b"/>
<circle cx="256" cy="256" r="214" fill="url(#${p}orb)"/>
<g clip-path="url(#${p}orbClip)">
<ellipse cx="256" cy="118" rx="172" ry="86" fill="url(#${p}gloss)" opacity=".75"/>
<ellipse cx="256" cy="478" rx="200" ry="96" fill="#ff3a1c" opacity=".5"/>
</g>
<g filter="url(#${p}shadow)" transform="rotate(-10 256 260)">
<rect x="198" y="118" width="116" height="172" rx="58" fill="url(#${p}chrome)"/>
<g clip-path="url(#${p}headClip)">${slots.join('')}</g>
<rect x="198" y="118" width="116" height="172" rx="58" fill="none" stroke="#2c2c2c" stroke-width="5"/>
<rect x="214" y="288" width="84" height="26" rx="10" fill="url(#${p}chrome)" stroke="#2c2c2c" stroke-width="4"/>
<path d="M236 314 H276 L268 372 H244 Z" fill="url(#${p}chrome)" stroke="#2c2c2c" stroke-width="4" stroke-linejoin="round"/>
<rect x="230" y="372" width="52" height="26" rx="6" fill="#2a2a2a" stroke="#777" stroke-width="3"/>
</g>
<circle cx="296" cy="150" r="16" fill="#fff" opacity=".9" filter="url(#${p}blur)"/>
<path d="M296 122 L301 145 L324 150 L301 155 L296 178 L291 155 L268 150 L291 145 Z" fill="#fff"/>
<g filter="url(#${p}shadow)">
<path d="M322 392 L294 446 L362 396 Z" fill="url(#${p}bubble)" stroke="#0b0b0b" stroke-width="10" stroke-linejoin="round"/>
<rect x="288" y="296" width="146" height="102" rx="32" fill="url(#${p}bubble)" stroke="#0b0b0b" stroke-width="10"/>
<path d="M322 392 L318 400 L352 394 Z" fill="url(#${p}bubble)"/>
<circle cx="326" cy="347" r="12" fill="#d00000"/><circle cx="361" cy="347" r="12" fill="#d00000"/><circle cx="396" cy="347" r="12" fill="#d00000"/>
</g>`;
}
function iconSvg(size) {
return `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 512 512">${iconInner('i')}</svg>`;
}
/** Chrom-Schriftzug: dunkle Kontur, silberner Rand, helle Fuellung. */
function chromeText(p, x, y, size, text, width) {
const common = `x="${x}" y="${y}" font-family="${FONT}" font-size="${size}" text-anchor="middle" textLength="${width}" lengthAdjust="spacingAndGlyphs" stroke-linejoin="round"`;
return `
<text ${common} fill="#1a1a1a" stroke="#1a1a1a" stroke-width="${size * 0.2}" filter="url(#${p}tshadow)">${text}</text>
<text ${common} fill="none" stroke="url(#${p}silver)" stroke-width="${size * 0.13}">${text}</text>
<text ${common} fill="url(#${p}letters)" stroke="#5e5e5e" stroke-width="${size * 0.012}">${text}</text>`;
}
function textDefs(p) {
return `
<linearGradient id="${p}silver" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#fff"/><stop offset=".5" stop-color="#9c9c9c"/><stop offset="1" stop-color="#f1f1f1"/>
</linearGradient>
<linearGradient id="${p}letters" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#ffffff"/><stop offset=".55" stop-color="#e6e6e6"/><stop offset="1" stop-color="#bcbcbc"/>
</linearGradient>
<filter id="${p}tshadow" x="-10%" y="-20%" width="120%" height="160%">
<feDropShadow dx="0" dy="6" stdDeviation="6" flood-color="#000" flood-opacity=".5"/>
</filter>`;
}
/** Breites Logo im Stil des euer.radio-Logos: [euer.tv] (Orb) [chat] in einer roten Pille. */
function wideSvg(measure) {
const size = 170;
const wL = Math.round(measure.left);
const wR = Math.round(measure.right);
const orb = 540;
const gap = 60;
const padEnd = 150;
const width = padEnd + wL + gap + orb + gap + wR + padEnd;
const height = 560;
const cx = padEnd + wL + gap + orb / 2;
const baseline = 335;
return {
width,
height,
svg: `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
<defs>
${textDefs('w')}
<linearGradient id="wpillStroke" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#ff5a45"/><stop offset=".4" stop-color="#e00000"/><stop offset="1" stop-color="#7d0000"/>
</linearGradient>
<linearGradient id="wpillFill" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#ffffff"/><stop offset="1" stop-color="#d9d9d9"/>
</linearGradient>
<filter id="wpillShadow" x="-5%" y="-20%" width="110%" height="150%">
<feDropShadow dx="0" dy="10" stdDeviation="9" flood-color="#000" flood-opacity=".45"/>
</filter>
</defs>
<rect x="46" y="86" width="${width - 92}" height="388" rx="194" fill="url(#wpillFill)" stroke="url(#wpillStroke)" stroke-width="56" filter="url(#wpillShadow)"/>
<rect x="74" y="114" width="${width - 148}" height="332" rx="166" fill="none" stroke="#6d0000" stroke-opacity=".55" stroke-width="6"/>
${chromeText('w', padEnd + wL / 2 + 20, baseline, size, 'euer.tv', wL)}
${chromeText('w', width - padEnd - wR / 2 - 20, baseline, size, 'chat', wR)}
<svg x="${cx - orb / 2}" y="${(height - orb) / 2}" width="${orb}" height="${orb}" viewBox="0 0 512 512">${iconInner('w')}</svg>
</svg>`,
};
}
/** Hochformat fuer die Willkommensseite des Installers (164x314). */
function sidebarSvg() {
return `<svg xmlns="http://www.w3.org/2000/svg" width="164" height="314" viewBox="0 0 164 314">
<defs>
${textDefs('s')}
<linearGradient id="sbg" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#2b0606"/><stop offset="1" stop-color="#050000"/></linearGradient>
<radialGradient id="sglow" cx="50%" cy="34%" r="60%"><stop offset="0" stop-color="#ff2a10" stop-opacity=".55"/><stop offset="1" stop-color="#ff2a10" stop-opacity="0"/></radialGradient>
</defs>
<rect width="164" height="314" fill="url(#sbg)"/>
<rect width="164" height="314" fill="url(#sglow)"/>
<svg x="17" y="28" width="130" height="130" viewBox="0 0 512 512">${iconInner('s')}</svg>
${chromeText('s', 82, 214, 31, 'euer.tv', 126)}
${chromeText('s', 82, 262, 40, 'chat', 92)}
</svg>`;
}
function headerSvg(wide) {
const w = 150, h = 57;
const scale = Math.min(w / wide.width, h / wide.height);
return `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">
<rect width="${w}" height="${h}" fill="#fff"/>
<svg x="${(w - wide.width * scale) / 2}" y="${(h - wide.height * scale) / 2}" width="${wide.width * scale}" height="${wide.height * scale}" viewBox="0 0 ${wide.width} ${wide.height}">${wide.svg.replace(/^<svg[^>]*>/, '').replace(/<\/svg>\s*$/, '')}</svg>
</svg>`;
}
// ---------------------------------------------------------------------------
// Rendering mit Edge (Chrome DevTools Protocol)
// ---------------------------------------------------------------------------
async function startEdge() {
const port = 9341;
const profile = fs.mkdtempSync(path.join(os.tmpdir(), 'euerchat-brand-'));
const proc = spawn(EDGE, [
'--headless=new', `--remote-debugging-port=${port}`, `--user-data-dir=${profile}`,
'--hide-scrollbars', '--force-device-scale-factor=1', '--disable-gpu', 'about:blank',
], { stdio: 'ignore' });
let page;
for (let i = 0; i < 50 && !page; i++) {
await new Promise((r) => setTimeout(r, 200));
try {
const targets = await (await fetch(`http://127.0.0.1:${port}/json`)).json();
page = targets.find((t) => t.type === 'page');
} catch { /* Edge startet noch */ }
}
if (!page) throw new Error('Edge-Debugport nicht erreichbar');
const ws = new WebSocket(page.webSocketDebuggerUrl);
await new Promise((r) => (ws.onopen = r));
let id = 0;
const pending = new Map();
ws.onmessage = (e) => {
const m = JSON.parse(e.data);
if (m.id && pending.has(m.id)) { pending.get(m.id)(m.result ?? m.error); pending.delete(m.id); }
};
const send = (method, params = {}) => new Promise((res) => { const i = ++id; pending.set(i, res); ws.send(JSON.stringify({ id: i, method, params })); });
await send('Page.enable');
await send('Emulation.setDefaultBackgroundColorOverride', { color: { r: 0, g: 0, b: 0, a: 0 } });
return {
send,
async html(markup, width, height) {
await send('Emulation.setDeviceMetricsOverride', { width, height, deviceScaleFactor: 1, mobile: false });
const doc = `<!doctype html><html><body style="margin:0;background:transparent;overflow:hidden">${markup}</body></html>`;
await send('Page.navigate', { url: 'data:text/html;base64,' + Buffer.from(doc).toString('base64') });
await new Promise((r) => setTimeout(r, 500));
await send('Runtime.evaluate', { expression: 'document.fonts.ready.then(()=>1)', awaitPromise: true });
},
async png() {
const r = await send('Page.captureScreenshot', { format: 'png', fromSurface: true });
return Buffer.from(r.data, 'base64');
},
async eval(expression) {
const r = await send('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true });
return r.result?.value;
},
close() { ws.close(); proc.kill(); },
};
}
// ---------------------------------------------------------------------------
// ICO / BMP
// ---------------------------------------------------------------------------
/** ICO mit PNG-Frames, groesste zuerst (Tauri nimmt das erste Bild als Fenstersymbol). */
function makeIco(frames) {
const sorted = [...frames].sort((a, b) => b.size - a.size);
const header = Buffer.alloc(6);
header.writeUInt16LE(1, 2);
header.writeUInt16LE(sorted.length, 4);
let offset = 6 + sorted.length * 16;
const entries = sorted.map((f) => {
const e = Buffer.alloc(16);
e[0] = f.size >= 256 ? 0 : f.size;
e[1] = f.size >= 256 ? 0 : f.size;
e.writeUInt16LE(1, 4);
e.writeUInt16LE(32, 6);
e.writeUInt32LE(f.png.length, 8);
e.writeUInt32LE(offset, 12);
offset += f.png.length;
return e;
});
return Buffer.concat([header, ...entries, ...sorted.map((f) => f.png)]);
}
function decodePng(buf) {
let pos = 8;
let width = 0, height = 0, colorType = 0;
const idat = [];
while (pos < buf.length) {
const len = buf.readUInt32BE(pos);
const type = buf.toString('ascii', pos + 4, pos + 8);
const data = buf.subarray(pos + 8, pos + 8 + len);
if (type === 'IHDR') { width = data.readUInt32BE(0); height = data.readUInt32BE(4); colorType = data[9]; }
if (type === 'IDAT') idat.push(data);
pos += 12 + len;
}
const bpp = colorType === 6 ? 4 : 3;
const raw = zlib.inflateSync(Buffer.concat(idat));
const stride = width * bpp;
const px = Buffer.alloc(height * stride);
for (let y = 0; y < height; y++) {
const filter = raw[y * (stride + 1)];
for (let x = 0; x < stride; x++) {
const v = raw[y * (stride + 1) + 1 + x];
const a = x >= bpp ? px[y * stride + x - bpp] : 0;
const b = y > 0 ? px[(y - 1) * stride + x] : 0;
const c = x >= bpp && y > 0 ? px[(y - 1) * stride + x - bpp] : 0;
let out;
if (filter === 0) out = v;
else if (filter === 1) out = v + a;
else if (filter === 2) out = v + b;
else if (filter === 3) out = v + ((a + b) >> 1);
else {
const p = a + b - c;
const pa = Math.abs(p - a), pb = Math.abs(p - b), pc = Math.abs(p - c);
out = v + (pa <= pb && pa <= pc ? a : pb <= pc ? b : c);
}
px[y * stride + x] = out & 255;
}
}
return { width, height, bpp, px };
}
/** 24-Bit-BMP (NSIS kann nur BMP), Transparenz wird auf `bg` gemischt. */
function pngToBmp(png, bg = [255, 255, 255]) {
const { width, height, bpp, px } = decodePng(png);
const rowSize = Math.ceil((width * 3) / 4) * 4;
const out = Buffer.alloc(54 + rowSize * height);
out.write('BM', 0);
out.writeUInt32LE(out.length, 2);
out.writeUInt32LE(54, 10);
out.writeUInt32LE(40, 14);
out.writeInt32LE(width, 18);
out.writeInt32LE(height, 22);
out.writeUInt16LE(1, 26);
out.writeUInt16LE(24, 28);
out.writeUInt32LE(rowSize * height, 34);
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const i = (y * width + x) * bpp;
const alpha = bpp === 4 ? px[i + 3] / 255 : 1;
const o = 54 + (height - 1 - y) * rowSize + x * 3;
for (let c = 0; c < 3; c++) out[o + c] = Math.round(px[i + 2 - c] * alpha + bg[2 - c] * (1 - alpha));
}
}
return out;
}
// ---------------------------------------------------------------------------
const edge = await startEdge();
try {
// Textbreiten der Wortmarke messen, damit nichts verzerrt wird
await edge.html(`<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"><text id="a" font-family="${FONT}" font-size="170">euer.tv</text><text id="b" font-family="${FONT}" font-size="170">chat</text></svg>`, 400, 200);
const measure = {
left: await edge.eval("document.getElementById('a').getComputedTextLength()"),
right: await edge.eval("document.getElementById('b').getComputedTextLength()"),
};
console.log('Textbreiten:', measure);
const render = async (svg, w, h) => { await edge.html(svg, w, h); return edge.png(); };
const out = {
icons: path.join(root, 'src-tauri', 'icons'),
assets: path.join(root, 'assets'),
installer: path.join(root, 'installer'),
ui: path.join(root, 'ui'),
};
for (const d of Object.values(out)) fs.mkdirSync(d, { recursive: true });
const frames = [];
for (const size of [256, 128, 64, 48, 32, 24, 16]) frames.push({ size, png: await render(iconSvg(size), size, size) });
fs.writeFileSync(path.join(out.icons, 'icon.ico'), makeIco(frames));
const bySize = (s) => frames.find((f) => f.size === s).png;
fs.writeFileSync(path.join(out.icons, '32x32.png'), bySize(32));
fs.writeFileSync(path.join(out.icons, '128x128.png'), bySize(128));
fs.writeFileSync(path.join(out.icons, 'icon.png'), await render(iconSvg(512), 512, 512));
fs.writeFileSync(path.join(out.ui, 'logo.png'), bySize(128));
const wide = wideSvg(measure);
fs.writeFileSync(path.join(out.assets, 'logo-wide.png'), await render(wide.svg, wide.width, wide.height));
fs.writeFileSync(path.join(out.assets, 'logo-icon.svg'), iconSvg(512));
fs.writeFileSync(path.join(out.assets, 'logo-wide.svg'), wide.svg);
fs.writeFileSync(path.join(out.installer, 'sidebar.bmp'), pngToBmp(await render(sidebarSvg(), 164, 314), [5, 0, 0]));
fs.writeFileSync(path.join(out.installer, 'header.bmp'), pngToBmp(await render(headerSvg(wide), 150, 57)));
console.log('Fertig:', Object.values(out).join('\n '));
} finally {
edge.close();
}
+1
View File
@@ -12,6 +12,7 @@
<header class="app-header"> <header class="app-header">
<div class="header-left"> <div class="header-left">
<div class="header-brand"> <div class="header-brand">
<img class="header-logo" src="logo.png" alt="">
<span class="header-brand-title">EuerChat</span> <span class="header-brand-title">EuerChat</span>
</div> </div>
<!-- Nur sichtbar, wenn ein Lua-Skript (.luac) geladen und aktiv ist --> <!-- Nur sichtbar, wenn ein Lua-Skript (.luac) geladen und aktiv ist -->
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

+8
View File
@@ -78,6 +78,14 @@ body.dark-theme {
.header-brand { .header-brand {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 10px;
}
.header-logo {
height: 38px;
width: 38px;
flex-shrink: 0;
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.5));
} }
.header-brand-title { .header-brand-title {