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';