146 lines
5.1 KiB
TypeScript
146 lines
5.1 KiB
TypeScript
// 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<void> => {
|
||
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;
|
||
});
|
||
}
|