diff --git a/README.md b/README.md index 49e1a5d..6ebfce3 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # EuerChat - euer.tv IRC & Livestream Client (Windows 64-bit) +

euer.tv chat

+ Ein moderner, performanter und schlanker Desktop-Client für Windows 64-bit, gebaut mit **Rust + Tauri v2** und einem ansprechenden **Dark-Mode Web-Frontend**. --- diff --git a/assets/logo-icon.svg b/assets/logo-icon.svg new file mode 100644 index 0000000..c1f312f --- /dev/null +++ b/assets/logo-icon.svg @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/assets/logo-wide.png b/assets/logo-wide.png new file mode 100644 index 0000000..3660e4c Binary files /dev/null and b/assets/logo-wide.png differ diff --git a/assets/logo-wide.svg b/assets/logo-wide.svg new file mode 100644 index 0000000..b976e07 --- /dev/null +++ b/assets/logo-wide.svg @@ -0,0 +1,82 @@ + + + + + + + + + + + + + + + + + + + + + + + + + +euer.tv +euer.tv +euer.tv + +chat +chat +chat + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/installer/EuerChat.nsi b/installer/EuerChat.nsi index 908511c..dbec3e4 100644 --- a/installer/EuerChat.nsi +++ b/installer/EuerChat.nsi @@ -68,6 +68,12 @@ 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 @@ -307,10 +313,11 @@ Section "Install" WriteRegDWORD ${ROOT} "${UNINSTKEY}" "NoRepair" 1 WriteRegDWORD ${ROOT} "${UNINSTKEY}" "EstimatedSize" 7600 - CreateShortcut "$SMPROGRAMS\${PRODUCT}.lnk" "$INSTDIR\${MAINEXE}" + 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}" + CreateShortcut "$DESKTOP\${PRODUCT}.lnk" "$INSTDIR\${MAINEXE}" "" "$INSTDIR\${MAINEXE}" 0 ${EndIf} ${EndIf} SectionEnd diff --git a/installer/header.bmp b/installer/header.bmp new file mode 100644 index 0000000..349e671 Binary files /dev/null and b/installer/header.bmp differ diff --git a/installer/sidebar.bmp b/installer/sidebar.bmp new file mode 100644 index 0000000..ab794e4 Binary files /dev/null and b/installer/sidebar.bmp differ diff --git a/package.json b/package.json index c94b6d7..4cccd35 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,8 @@ "scripts": { "tauri": "tauri", "dev": "tauri dev", - "build": "tauri build" + "build": "tauri build", + "brand": "node tools/make-branding.mjs" }, "devDependencies": { "@tauri-apps/cli": "^2.2.0" diff --git a/src-tauri/build.rs b/src-tauri/build.rs index d860e1e..60f6ec3 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -1,3 +1,5 @@ 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() } diff --git a/src-tauri/icons/128x128.png b/src-tauri/icons/128x128.png index 613c57a..ce0929a 100644 Binary files a/src-tauri/icons/128x128.png and b/src-tauri/icons/128x128.png differ diff --git a/src-tauri/icons/32x32.png b/src-tauri/icons/32x32.png index 5e71b91..7bab16d 100644 Binary files a/src-tauri/icons/32x32.png and b/src-tauri/icons/32x32.png differ diff --git a/src-tauri/icons/icon.ico b/src-tauri/icons/icon.ico index 34da097..44304ec 100644 Binary files a/src-tauri/icons/icon.ico and b/src-tauri/icons/icon.ico differ diff --git a/src-tauri/icons/icon.png b/src-tauri/icons/icon.png index 613c57a..a614aac 100644 Binary files a/src-tauri/icons/icon.png and b/src-tauri/icons/icon.png differ diff --git a/tools/make-branding.mjs b/tools/make-branding.mjs new file mode 100644 index 0000000..3953461 --- /dev/null +++ b/tools/make-branding.mjs @@ -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(``); + slots.push(``); + } + return ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ${slots.join('')} + + + + + + + + + + + + +`; +} + +function iconSvg(size) { + return `${iconInner('i')}`; +} + +/** 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} +${text} +${text}`; +} + +function textDefs(p) { + return ` + + + + + + + + +`; +} + +/** 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: ` + + ${textDefs('w')} + + + + + + + + + + + + +${chromeText('w', padEnd + wL / 2 + 20, baseline, size, 'euer.tv', wL)} +${chromeText('w', width - padEnd - wR / 2 - 20, baseline, size, 'chat', wR)} +${iconInner('w')} +`, + }; +} + +/** Hochformat fuer die Willkommensseite des Installers (164x314). */ +function sidebarSvg() { + return ` + + ${textDefs('s')} + + + + + +${iconInner('s')} +${chromeText('s', 82, 214, 31, 'euer.tv', 126)} +${chromeText('s', 82, 262, 40, 'chat', 92)} +`; +} + +function headerSvg(wide) { + const w = 150, h = 57; + const scale = Math.min(w / wide.width, h / wide.height); + return ` + +${wide.svg.replace(/^]*>/, '').replace(/<\/svg>\s*$/, '')} +`; +} + +// --------------------------------------------------------------------------- +// 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 = `${markup}`; + 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(`euer.tvchat`, 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(); +} diff --git a/ui/index.html b/ui/index.html index 35f5648..deb738d 100644 --- a/ui/index.html +++ b/ui/index.html @@ -12,6 +12,7 @@
+ EuerChat
diff --git a/ui/logo.png b/ui/logo.png new file mode 100644 index 0000000..ce0929a Binary files /dev/null and b/ui/logo.png differ diff --git a/ui/style.css b/ui/style.css index 9c1be8b..65f1075 100644 --- a/ui/style.css +++ b/ui/style.css @@ -78,6 +78,14 @@ body.dark-theme { .header-brand { display: flex; 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 {