From f9e340dbeca0464693cefdbb21714a56919440ac Mon Sep 17 00:00:00 2001 From: byGalax Date: Tue, 12 May 2026 21:30:54 +0200 Subject: [PATCH] feat(branding): Netralax rebrand + Discord-style taskbar unread badge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit App productName becomes Netralax (driving exe name and window title); existing installs keep their %APPDATA%\ChatApp profile via an explicit app.setPath('userData', appData/ChatApp) so no Login/Sounds/Secret store data is lost. The Windows taskbar overlay now renders a red bubble with the actual unread count (Discord parity) instead of just a static red dot. Renderer paints a 64×64 PNG via canvas — full-bleed red circle, white bold count with a "99+" cap, no outer ring — and passes the data URL through the existing setTrayUnread IPC. Main decodes via nativeImage and applies it as the BrowserWindow overlay icon. Falls back to the static dot if the renderer canvas pipeline is unavailable. Also: app.setName('Netralax') + setAppUserModelId('cloud.netralax.desktop') for Windows taskbar grouping and notification source attribution, and release.mjs now reads productName dynamically from package.json so the artifact lookup stays correct after the rename. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/desktop/electron/main.ts | 19 +++++++- apps/desktop/electron/modules/tray.ts | 39 ++++++++++------ apps/desktop/electron/preload-types.d.ts | 2 +- apps/desktop/electron/preload.ts | 7 ++- apps/desktop/package.json | 2 +- apps/desktop/src/lib/trayBadge.ts | 57 ++++++++++++++++++++++-- scripts/release.mjs | 7 ++- 7 files changed, 110 insertions(+), 23 deletions(-) diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index 4eb9b3c..9a24c4f 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -37,6 +37,23 @@ const __dirnameSafe = path.dirname(__filenameSafe); const DEV_URL = 'http://localhost:1420'; const WINDOW_STATE_FILE = 'window-state.json'; +// App branding. productName in package.json drives the packaged exe name +// (Netralax.exe) and electron-builder installer title. setName + the +// AppUserModelId below cover the live process: window title fallback, +// Windows taskbar grouping, notification source attribution. +app.setName('Netralax'); +if (process.platform === 'win32') { + app.setAppUserModelId('cloud.netralax.desktop'); +} + +// Pin userData to %APPDATA%\ChatApp regardless of productName so existing +// installs keep their profile, sounds, secrets, SQLite. Electron's default +// is %APPDATA%\, which after the Netralax rename would point +// at an empty fresh dir — same painful migration as the Tauri → Electron +// cut. Anchored to `appData` (the platform-AppData root) so productName +// changes can't drag it. +app.setPath('userData', path.join(app.getPath('appData'), 'ChatApp')); + // Run dev side-by-side with the installed packaged build by isolating the // renderer profile / secret-store / SQLite / IndexedDB / localStorage in // a separate userData dir. Without this both share `%APPDATA%\ChatApp`, @@ -74,7 +91,7 @@ async function createWindow(): Promise { const state = await loadState(WINDOW_STATE_FILE); const win = new BrowserWindow({ - title: app.isPackaged ? 'ChatApp' : 'ChatApp (Dev)', + title: app.isPackaged ? 'Netralax' : 'Netralax (Dev)', width: state.width, height: state.height, ...(state.x !== undefined ? { x: state.x } : {}), diff --git a/apps/desktop/electron/modules/tray.ts b/apps/desktop/electron/modules/tray.ts index 476165e..27beb58 100644 --- a/apps/desktop/electron/modules/tray.ts +++ b/apps/desktop/electron/modules/tray.ts @@ -55,7 +55,7 @@ function buildOverlay(): NativeImage { export function register(mainWindow: BrowserWindow): void { const icon = loadTrayIcon(); trayRef = new Tray(icon.isEmpty() ? nativeImage.createEmpty() : icon); - trayRef.setToolTip('ChatApp'); + trayRef.setToolTip('Netralax'); const menu = Menu.buildFromTemplate([ { @@ -83,20 +83,31 @@ export function register(mainWindow: BrowserWindow): void { else mainWindow.show(); }); - ipcMain.handle(CHANNELS.TRAY_UNREAD, async (_evt, count: number): Promise => { - const n = Math.max(0, Math.floor(Number(count) || 0)); - if (trayRef && !trayRef.isDestroyed()) { - trayRef.setToolTip(n > 0 ? `ChatApp — ${n} unread` : 'ChatApp'); - } - if (mainWindow.isDestroyed()) return; - if (process.platform === 'win32') { - if (n > 0) { - mainWindow.setOverlayIcon(buildOverlay(), `${n} unread`); - } else { - mainWindow.setOverlayIcon(null, ''); + ipcMain.handle( + CHANNELS.TRAY_UNREAD, + async (_evt, count: number, badgeDataUrl?: string | null): Promise => { + const n = Math.max(0, Math.floor(Number(count) || 0)); + if (trayRef && !trayRef.isDestroyed()) { + trayRef.setToolTip(n > 0 ? `Netralax — ${n} ungelesen` : 'Netralax'); } - } - }); + if (mainWindow.isDestroyed()) return; + if (process.platform !== 'win32') return; + if (n <= 0) { + mainWindow.setOverlayIcon(null, ''); + return; + } + // Discord-style: prefer the renderer-painted badge (red circle with + // the actual unread number). Fall back to the static red dot only if + // the renderer didn't supply one or decoding failed — keeps the + // visual indicator alive even when the canvas pipeline is unavailable. + let overlay: NativeImage | null = null; + if (typeof badgeDataUrl === 'string' && badgeDataUrl.startsWith('data:image/')) { + const decoded = nativeImage.createFromDataURL(badgeDataUrl); + if (!decoded.isEmpty()) overlay = decoded; + } + mainWindow.setOverlayIcon(overlay ?? buildOverlay(), `${n} ungelesen`); + }, + ); app.on('before-quit', () => { try { diff --git a/apps/desktop/electron/preload-types.d.ts b/apps/desktop/electron/preload-types.d.ts index 94edadd..6af34cd 100644 --- a/apps/desktop/electron/preload-types.d.ts +++ b/apps/desktop/electron/preload-types.d.ts @@ -58,7 +58,7 @@ export interface ElectronAPI { notify: (args: NotifyArgs) => Promise; getNotificationPermission: () => Promise<'granted' | 'denied' | 'default'>; - setTrayUnread: (count: number) => Promise; + setTrayUnread: (count: number, badgeDataUrl?: string | null) => Promise; secureStoreOpen: (args: SecureStoreOpenArgs) => Promise; secureStoreGet: (handle: string, key: string) => Promise; diff --git a/apps/desktop/electron/preload.ts b/apps/desktop/electron/preload.ts index 1d17a94..17ed4b6 100644 --- a/apps/desktop/electron/preload.ts +++ b/apps/desktop/electron/preload.ts @@ -94,7 +94,12 @@ const api = { ipcRenderer.invoke(CHANNELS.NOTIFY_PERMISSION), // Tray ------------------------------------------------------------------- - setTrayUnread: (count: number): Promise => ipcRenderer.invoke(CHANNELS.TRAY_UNREAD, count), + // `badgeDataUrl` (optional): renderer-painted PNG (data:image/png;base64) + // that main applies as the Windows taskbar overlay icon. We render in the + // renderer because main has no Canvas2D; passing a finished image avoids + // bundling a native canvas backend just for a 32×32 badge. + setTrayUnread: (count: number, badgeDataUrl?: string | null): Promise => + ipcRenderer.invoke(CHANNELS.TRAY_UNREAD, count, badgeDataUrl ?? null), // Secure store ----------------------------------------------------------- secureStoreOpen: (args: SecureStoreOpenArgs): Promise => diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 5d1e9f0..d21ac23 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -54,7 +54,7 @@ }, "build": { "appId": "com.meinname.chatapp", - "productName": "ChatApp", + "productName": "Netralax", "directories": { "output": "release", "buildResources": "resources" diff --git a/apps/desktop/src/lib/trayBadge.ts b/apps/desktop/src/lib/trayBadge.ts index 04ef031..7f06888 100644 --- a/apps/desktop/src/lib/trayBadge.ts +++ b/apps/desktop/src/lib/trayBadge.ts @@ -1,14 +1,63 @@ import { isTauriRuntime } from './globalShortcut'; // Pushes the current aggregate unread count to main, which updates the -// Tray tooltip and (on Windows) the taskbar overlay icon. No-op -// outside the Electron runtime (e.g. browser dev preview) so no guards -// needed at call-sites. +// Tray tooltip and (on Windows) the taskbar overlay icon. We render the +// badge here in the renderer because main has no Canvas2D — painting a +// red bubble with the count, encoding to PNG, and handing the buffer +// to main keeps the implementation free of a native canvas dependency. +// +// No-op outside the Electron runtime (e.g. browser dev preview) so no +// guards needed at call-sites. export async function updateTrayUnread(count: number): Promise { if (!isTauriRuntime()) return; + const n = Math.max(0, Math.floor(count)); + const badgeDataUrl = n > 0 ? renderBadgePng(n) : null; try { - await window.electronAPI.setTrayUnread(Math.max(0, Math.floor(count))); + await window.electronAPI.setTrayUnread(n, badgeDataUrl); } catch (err: unknown) { console.warn('updateTrayUnread failed', err); } } + +// Discord-style red bubble with white count. +// +// Source is rendered at 64×64 so Windows' high-quality downsample to the +// 16×16 taskbar overlay slot retains crisp edges (4× supersampling). +// Earlier 32×32 + 2px white ring blurred badly: the ring became a half +// pixel at the target size, and the bigger font fell into AA mush. Now +// no outer ring (Discord doesn't use one either) and a full-bleed circle. +function renderBadgePng(count: number): string | null { + if (typeof document === 'undefined') return null; + const size = 64; + const canvas = document.createElement('canvas'); + canvas.width = size; + canvas.height = size; + const ctx = canvas.getContext('2d'); + if (!ctx) return null; + ctx.imageSmoothingEnabled = true; + ctx.imageSmoothingQuality = 'high'; + + // Solid red bubble, full bleed. + const center = size / 2; + ctx.beginPath(); + ctx.arc(center, center, center, 0, Math.PI * 2); + ctx.fillStyle = '#ef4444'; + ctx.fill(); + + // Count label — Discord parity: cap at 99 with a "+" once we cross it. + // Sizes are tuned per glyph count so each variant fills the bubble + // without clipping when Windows downsamples to 16×16. + const label = count > 99 ? '99+' : String(count); + const fontPx = label.length >= 3 ? 30 : label.length === 2 ? 40 : 48; + ctx.fillStyle = '#fff'; + // Segoe UI is the Windows system font; explicit weight 800 keeps the + // glyph chunky after downsampling. Fallbacks cover macOS/Linux. + ctx.font = `800 ${fontPx}px "Segoe UI", system-ui, -apple-system, sans-serif`; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + // +2 vertical nudge: most system fonts render numerals visually high + // relative to the baseline mid-point; the offset re-centres them. + ctx.fillText(label, center, center + 2); + + return canvas.toDataURL('image/png'); +} diff --git a/scripts/release.mjs b/scripts/release.mjs index 02343ac..1cdc46a 100644 --- a/scripts/release.mjs +++ b/scripts/release.mjs @@ -89,7 +89,12 @@ execSync('pnpm --filter @chat-app/desktop run build:win', { // --- Locate artifacts ----------------------------------------------------- const releaseDir = join(ROOT, 'apps/desktop/release'); -const exeName = `ChatApp Setup ${versionArg}.exe`; +// productName lives in the electron-builder block of the desktop package. +// Read it back from disk (post-bump) so installer filenames stay in sync +// after a rebrand (e.g. ChatApp → Netralax) without manual script edits. +const pkgJson = JSON.parse(readFileSync(pkgJsonPath, 'utf8')); +const productName = pkgJson?.build?.productName ?? 'ChatApp'; +const exeName = `${productName} Setup ${versionArg}.exe`; const blockmapName = `${exeName}.blockmap`; const latestYml = 'latest.yml';