Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 53 additions & 10 deletions src/app/app.tray.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,30 +3,63 @@
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

const { app, Tray, Menu } = require('electron')
const path = require('path')
const { getTrayIcon } = require('../shared/icons.utils.js')
const { app, Tray, Menu, nativeTheme } = require('electron')
const { getTrayIconPath } = require('../shared/icons.utils.js')
const { createTrayIconWithBadge, clearTrayIconCache } = require('./trayBadge.utils.ts')

let isAppQuitting = false

/** @type {import('electron').Tray | null} */
let trayInstance = null

/** @type {number} */
let currentBadgeCount = 0

/**
* Allow quitting the app if requested. It minimizes to a tray otherwise.
*/
app.on('before-quit', () => {
isAppQuitting = true
})

/**
* Update the tray icon with the current badge count
*/
async function refreshTrayIcon() {
if (!trayInstance) {
return
}

try {
const iconPath = getTrayIconPath()
const icon = await createTrayIconWithBadge(iconPath, currentBadgeCount)
trayInstance.setImage(icon)
} catch (error) {
console.error('Failed to update tray icon with badge:', error)
}
}

/**
* Update the tray badge count
*
* @param {number} count - Number of unread messages (0 to hide badge)
*/
async function updateTrayBadge(count) {
currentBadgeCount = count
await refreshTrayIcon()
}

/**
* Setup tray with an icon that provides a context menu.
*
* @param {import('electron').BrowserWindow} browserWindow Browser window, associated with the tray
* @return {import('electron').Tray} Tray instance
*/
function setupTray(browserWindow) {
const icon = path.resolve(__dirname, getTrayIcon())
const tray = new Tray(icon)
tray.setToolTip(app.name)
tray.setContextMenu(Menu.buildFromTemplate([
const iconPath = getTrayIconPath()
trayInstance = new Tray(iconPath)
trayInstance.setToolTip(app.name)
trayInstance.setContextMenu(Menu.buildFromTemplate([
{
label: 'Open',
click: () => browserWindow.show(),
Expand All @@ -35,7 +68,13 @@ function setupTray(browserWindow) {
role: 'quit',
},
]))
tray.on('click', () => browserWindow.show())
trayInstance.on('click', () => browserWindow.show())

// Refresh icon when theme changes (for monochrome icon support)
nativeTheme.on('updated', () => {
clearTrayIconCache()
refreshTrayIcon()
})

browserWindow.on('close', (event) => {
if (!isAppQuitting) {
Expand All @@ -45,12 +84,16 @@ function setupTray(browserWindow) {
})

browserWindow.on('closed', () => {
tray.destroy()
if (trayInstance) {
trayInstance.destroy()
trayInstance = null
}
})

return tray
return trayInstance
}

module.exports = {
setupTray,
updateTrayBadge,
}
146 changes: 146 additions & 0 deletions src/app/trayBadge.utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

import {
type NativeImage,

BrowserWindow, nativeImage,
} from 'electron'
import fs from 'node:fs'

// Cache for the base icon data
let cachedBaseIconPath: string | null = null
let cachedBaseIconDataUrl: string | null = null

/**
* Create a tray icon with a badge overlay showing the unread count
*
* @param baseIconPath - Path to the base tray icon
* @param count - Number to display in the badge (0 to hide badge)
* @return NativeImage with badge overlay, or the original icon if count is 0
*/
export async function createTrayIconWithBadge(baseIconPath: string, count: number): Promise<NativeImage> {
// If no unread messages, return the base icon
if (count <= 0) {
return nativeImage.createFromPath(baseIconPath)
}

// Read and cache the base icon as data URL
if (cachedBaseIconPath !== baseIconPath || !cachedBaseIconDataUrl) {
cachedBaseIconPath = baseIconPath
const buffer = fs.readFileSync(baseIconPath)
cachedBaseIconDataUrl = `data:image/png;base64,${buffer.toString('base64')}`
}

// Create invisible window for rendering
const win = new BrowserWindow({
width: 64,
height: 64,
show: false,
frame: false,
transparent: true,
webPreferences: {
offscreen: true,
nodeIntegration: false,
contextIsolation: true,
},
})

const displayText = count > 99 ? '99+' : String(count)
const fontSize = count > 99 ? 11 : 16

// Generate HTML with canvas to draw the icon + badge
const html = `
<!DOCTYPE html>
<html>
<head>
<style>
* { margin: 0; padding: 0; }
body { background: transparent; }
canvas { display: block; }
</style>
</head>
<body>
<canvas id="canvas" width="32" height="32"></canvas>
<script>
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
const img = new Image();
img.onload = () => {
// Draw base icon
ctx.drawImage(img, 0, 0, 32, 32);

// Badge dimensions
const badgeRadius = 11;
const badgeCenterX = 32 - badgeRadius;
const badgeCenterY = 32 - badgeRadius;

// Draw badge background (red circle)
ctx.beginPath();
ctx.arc(badgeCenterX, badgeCenterY, badgeRadius, 0, 2 * Math.PI);
ctx.fillStyle = '#E53935';
ctx.fill();

// Draw badge border
ctx.strokeStyle = '#FFFFFF';
ctx.lineWidth = 1;
ctx.stroke();

// Draw count text
ctx.font = 'bold ${fontSize}px sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillStyle = '#FFFFFF';
ctx.fillText('${displayText}', badgeCenterX, badgeCenterY + 1);

// Signal rendering complete
window.renderComplete = true;
};
img.src = '${cachedBaseIconDataUrl}';
</script>
</body>
</html>
`

try {
await win.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(html)}`)

// Wait for rendering to complete
await new Promise<void>((resolve) => {
const checkComplete = async () => {
const complete = await win.webContents.executeJavaScript('window.renderComplete')
if (complete) {
resolve()
} else {
setTimeout(checkComplete, 10)
}
}
setTimeout(checkComplete, 50)
})

// Capture the canvas content
const image = await win.webContents.capturePage({
x: 0,
y: 0,
width: 32,
height: 32,
})

win.destroy()
return image
} catch (error) {
console.error('Error creating badge icon:', error)
win.destroy()
return nativeImage.createFromPath(baseIconPath)
}
}

/**
* Clear the cached base icon (useful when theme changes)
*/
export function clearTrayIconCache(): void {
cachedBaseIconPath = null
cachedBaseIconDataUrl = null
}
6 changes: 5 additions & 1 deletion src/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ const { spawn } = require('node:child_process')
const fs = require('node:fs')
const path = require('node:path')
const { setupMenu } = require('./app/app.menu.js')
const { updateTrayBadge } = require('./app/app.tray.js')
const { loadAppConfig, getAppConfig, setAppConfig } = require('./app/AppConfig.ts')
const { appData } = require('./app/AppData.js')
const { registerAppProtocolHandler } = require('./app/appProtocol.ts')
Expand Down Expand Up @@ -97,7 +98,10 @@ ipcMain.handle('app:getSystemL10n', () => ({
}))
ipcMain.handle('app:enableWebRequestInterceptor', (event, ...args) => enableWebRequestInterceptor(...args))
ipcMain.handle('app:disableWebRequestInterceptor', (event, ...args) => disableWebRequestInterceptor(...args))
ipcMain.handle('app:setBadgeCount', async (event, count) => app.setBadgeCount(count))
ipcMain.handle('app:setBadgeCount', async (event, count) => {
app.setBadgeCount(count)
await updateTrayBadge(count)
})
ipcMain.on('app:relaunch', () => {
app.relaunch()
app.exit(0)
Expand Down
12 changes: 11 additions & 1 deletion src/shared/icons.utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ const icons = {
}

/**
* Get tray icon
* Get tray icon (relative path for webpack)
*/
function getTrayIcon() {
const monochrome = getAppConfig('monochromeTrayIcon')
Expand All @@ -52,6 +52,15 @@ function getTrayIcon() {
return icons.tray[platform][kind]
}

/**
* Get absolute path to the tray icon for the current platform and theme
*
* @return {string} Absolute path to the tray icon
*/
function getTrayIconPath() {
return path.resolve(__dirname, getTrayIcon())
}

/**
* Get BrowserWindow icon for the current platform
*
Expand All @@ -68,5 +77,6 @@ function getBrowserWindowIcon() {

module.exports = {
getTrayIcon,
getTrayIconPath,
getBrowserWindowIcon,
}
Loading