// System tray + Windows taskbar overlay badge. Renderer pushes the // current aggregate unread count via CHANNELS.TRAY_UNREAD; we update the // tooltip and (on Windows) set an overlay icon on the main window's // taskbar button. // // Overlay image is a pre-rendered 16x16 red dot embedded as base64 so // the module is self-contained — no runtime canvas dependency. import { app, BrowserWindow, ipcMain, Menu, nativeImage, type NativeImage, Tray, } from 'electron'; import path from 'node:path'; import { CHANNELS } from '../ipc-types'; let trayRef: Tray | null = null; let overlayImage: NativeImage | null = null; function resolveIconPath(): string { return path.join(process.resourcesPath || app.getAppPath(), 'icon.ico'); } function resolveIconPathDev(): string { return path.join(app.getAppPath(), 'resources', 'icon.ico'); } // 16×16 grey square — last-ditch fallback when neither the packaged // nor dev icon file resolves. Tray constructor on Windows throws when // handed an empty NativeImage, which would tear down the whole // registrar before `ipcMain.handle(TRAY_UNREAD)` runs — leaving the // taskbar overlay badge wired but the renderer's invoke rejecting // with "No handler registered". A non-empty placeholder keeps the // constructor happy so the IPC handler always gets registered. const FALLBACK_TRAY_PNG_BASE64 = 'iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAH0lEQVR42mNk' + 'YGD4z0ABYBxVOKpwVOGowlGFwwoBAEnYAR9XlIldAAAAAElFTkSuQmCC'; function loadTrayIcon(): NativeImage { for (const p of [resolveIconPath(), resolveIconPathDev()]) { try { const img = nativeImage.createFromPath(p); if (!img.isEmpty()) return img; } catch { /* try next */ } } return nativeImage.createFromBuffer(Buffer.from(FALLBACK_TRAY_PNG_BASE64, 'base64')); } function buildOverlay(): NativeImage { if (overlayImage) return overlayImage; // 16x16 PNG, solid red circle. Inline base64 so no external file lookup. const base64 = 'iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAPElEQVR42mNkYGD4z0AGYBxVSF2F//' + '//Z2BgYGD4/58kBYz4FDAxMDAwMDIwMDD8//+foArGUYWjCoc1AABTgwUBf3lZtAAAAABJRU5ErkJggg=='; overlayImage = nativeImage.createFromBuffer(Buffer.from(base64, 'base64')); return overlayImage; } export function register(mainWindow: BrowserWindow): void { // Tray icon is best-effort: if neither the packaged resource nor the // dev path resolves (e.g. icon.ico isn't shipped under resourcesPath // in packaged builds — only the app icon goes into the .exe metadata), // we still want the taskbar overlay badge to work. setOverlayIcon is // a BrowserWindow method, so it functions even when the systray icon // creation fails. try { const icon = loadTrayIcon(); if (!icon.isEmpty()) { trayRef = new Tray(icon); trayRef.setToolTip('Netralax'); const menu = Menu.buildFromTemplate([ { label: 'Open', click: (): void => { if (mainWindow.isDestroyed()) return; if (mainWindow.isMinimized()) mainWindow.restore(); mainWindow.show(); mainWindow.focus(); }, }, { type: 'separator' }, { label: 'Quit', click: (): void => { app.quit(); }, }, ]); trayRef.setContextMenu(menu); trayRef.on('click', (): void => { if (mainWindow.isDestroyed()) return; if (mainWindow.isMinimized()) mainWindow.restore(); if (mainWindow.isVisible()) mainWindow.focus(); else mainWindow.show(); }); } } catch (err: unknown) { // Swallow: the overlay badge below is the user-visible bit. A missing // systray icon is cosmetic and shouldn't take the unread handler with it. console.warn('[tray] systray init failed, overlay badge still active', err); } 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 { trayRef?.destroy(); } catch { /* already gone */ } trayRef = null; }); }