Skip to content
Closed
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
3 changes: 2 additions & 1 deletion electron/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import type { Server } from 'node:http';
import { join } from 'path';
import { GatewayManager } from '../gateway/manager';
import { registerIpcHandlers } from './ipc-handlers';
import { createTray } from './tray';
import { createTray, updateTrayStatus } from './tray';
import { createMenu } from './menu';

import { appUpdater, registerUpdateHandlers } from './updater';
Expand Down Expand Up @@ -236,6 +236,7 @@ async function initialize(): Promise<void> {
// renderer subscribers observe the full startup lifecycle.
gatewayManager.on('status', (status: { state: string }) => {
hostEventBus.emit('gateway:status', status);
updateTrayStatus(status.state);
if (status.state === 'running') {
void ensureClawXContext().catch((error) => {
logger.warn('Failed to re-merge ClawX context after gateway reconnect:', error);
Expand Down
6 changes: 6 additions & 0 deletions electron/main/ipc-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ import {
} from '../services/providers/provider-runtime-sync';
import { validateApiKeyWithProvider } from '../services/providers/provider-validation';
import { appUpdater } from './updater';
import { updateTrayMenu, type TrayTranslations } from './tray';
import { PORTS } from '../utils/config';

type AppRequest = {
Expand Down Expand Up @@ -2269,6 +2270,11 @@ function registerSettingsHandlers(gatewayManager: GatewayManager): void {
await handleProxySettingsChange();
return { success: true, settings };
});

ipcMain.handle('tray:updateLanguage', async (_, translations: TrayTranslations, gatewayRunning: boolean) => {
updateTrayMenu(translations, gatewayRunning);
return { success: true };
});
}
function registerUsageHandlers(): void {
ipcMain.handle('usage:recentTokenHistory', async (_, limit?: number) => {
Expand Down
222 changes: 140 additions & 82 deletions electron/main/tray.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,39 @@ import { Tray, Menu, BrowserWindow, app, nativeImage } from 'electron';
import { join } from 'path';

let tray: Tray | null = null;
let mainWindowRef: BrowserWindow | null = null;

export interface TrayTranslations {
tooltipRunning: string;
tooltipStopped: string;
show: string;
gatewayStatus: string;
running: string;
stopped: string;
quickActions: string;
openDashboard: string;
openChat: string;
openSettings: string;
checkUpdates: string;
quit: string;
}

const defaultTranslations: TrayTranslations = {
tooltipRunning: 'ClawX - Gateway Running',
tooltipStopped: 'ClawX - Gateway Stopped',
show: 'Show ClawX',
gatewayStatus: 'Gateway Status',
running: 'Running',
stopped: 'Stopped',
quickActions: 'Quick Actions',
openDashboard: 'Open Dashboard',
openChat: 'Open Chat',
openSettings: 'Open Settings',
checkUpdates: 'Check for Updates...',
quit: 'Quit ClawX',
};

let currentTranslations: TrayTranslations = defaultTranslations;

/**
* Resolve the icons directory path (works in both dev and packaged mode)
Expand All @@ -17,101 +50,59 @@ function getIconsDir(): string {
return join(__dirname, '../../resources/icons');
}

/**
* Create system tray icon and menu
*/
export function createTray(mainWindow: BrowserWindow): Tray {
// Use platform-appropriate icon for system tray
const iconsDir = getIconsDir();
let iconPath: string;

if (process.platform === 'win32') {
// Windows: use .ico for best quality in system tray
iconPath = join(iconsDir, 'icon.ico');
} else if (process.platform === 'darwin') {
// macOS: use Template.png for proper status bar icon
// The "Template" suffix tells macOS to treat it as a template image
iconPath = join(iconsDir, 'tray-icon-Template.png');
} else {
// Linux: use 32x32 PNG
iconPath = join(iconsDir, '32x32.png');
}

let icon = nativeImage.createFromPath(iconPath);

// Fallback to icon.png if platform-specific icon not found
if (icon.isEmpty()) {
icon = nativeImage.createFromPath(join(iconsDir, 'icon.png'));
// Still try to set as template for macOS
if (process.platform === 'darwin') {
icon.setTemplateImage(true);
}
}

// Note: Using "Template" suffix in filename automatically marks it as template image
// But we can also explicitly set it for safety
if (process.platform === 'darwin') {
icon.setTemplateImage(true);
}

tray = new Tray(icon);

// Set tooltip
tray.setToolTip('ClawX - AI Assistant');

function buildContextMenu(translations: TrayTranslations, gatewayRunning: boolean): Electron.Menu {
const showWindow = () => {
if (mainWindow.isDestroyed()) return;
mainWindow.show();
mainWindow.focus();
if (!mainWindowRef || mainWindowRef.isDestroyed()) return;
mainWindowRef.show();
mainWindowRef.focus();
};

// Create context menu
const contextMenu = Menu.buildFromTemplate([
return Menu.buildFromTemplate([
{
label: 'Show ClawX',
label: translations.show,
click: showWindow,
},
{
type: 'separator',
},
{
label: 'Gateway Status',
label: translations.gatewayStatus,
enabled: false,
},
{
label: ' Running',
label: ` ${gatewayRunning ? translations.running : translations.stopped}`,
type: 'checkbox',
checked: true,
checked: gatewayRunning,
enabled: false,
},
{
type: 'separator',
},
{
label: 'Quick Actions',
label: translations.quickActions,
submenu: [
{
label: 'Open Dashboard',
label: translations.openDashboard,
click: () => {
if (mainWindow.isDestroyed()) return;
mainWindow.show();
mainWindow.webContents.send('navigate', '/');
if (!mainWindowRef || mainWindowRef.isDestroyed()) return;
mainWindowRef.show();
mainWindowRef.webContents.send('navigate', '/');
},
},
{
label: 'Open Chat',
label: translations.openChat,
click: () => {
if (mainWindow.isDestroyed()) return;
mainWindow.show();
mainWindow.webContents.send('navigate', '/chat');
if (!mainWindowRef || mainWindowRef.isDestroyed()) return;
mainWindowRef.show();
mainWindowRef.webContents.send('navigate', '/chat');
},
},
{
label: 'Open Settings',
label: translations.openSettings,
click: () => {
if (mainWindow.isDestroyed()) return;
mainWindow.show();
mainWindow.webContents.send('navigate', '/settings');
if (!mainWindowRef || mainWindowRef.isDestroyed()) return;
mainWindowRef.show();
mainWindowRef.webContents.send('navigate', '/settings');
},
},
],
Expand All @@ -120,53 +111,119 @@ export function createTray(mainWindow: BrowserWindow): Tray {
type: 'separator',
},
{
label: 'Check for Updates...',
label: translations.checkUpdates,
click: () => {
if (mainWindow.isDestroyed()) return;
mainWindow.webContents.send('update:check');
if (!mainWindowRef || mainWindowRef.isDestroyed()) return;
mainWindowRef.webContents.send('update:check');
},
},
{
type: 'separator',
},
{
label: 'Quit ClawX',
label: translations.quit,
click: () => {
app.quit();
},
},
]);
}

/**
* Create system tray icon and menu
*/
export function createTray(mainWindow: BrowserWindow): Tray {
// Store window reference for later use
mainWindowRef = mainWindow;

// Use platform-appropriate icon for system tray
const iconsDir = getIconsDir();
let iconPath: string;

if (process.platform === 'win32') {
// Windows: use .ico for best quality in system tray
iconPath = join(iconsDir, 'icon.ico');
} else if (process.platform === 'darwin') {
// macOS: use Template.png for proper status bar icon
// The "Template" suffix tells macOS to treat it as a template image
iconPath = join(iconsDir, 'tray-icon-Template.png');
} else {
// Linux: use 32x32 PNG
iconPath = join(iconsDir, '32x32.png');
}

let icon = nativeImage.createFromPath(iconPath);

// Fallback to icon.png if platform-specific icon not found
if (icon.isEmpty()) {
icon = nativeImage.createFromPath(join(iconsDir, 'icon.png'));
// Still try to set as template for macOS
if (process.platform === 'darwin') {
icon.setTemplateImage(true);
}
}

// Note: Using "Template" suffix in filename automatically marks it as template image
// But we can also explicitly set it for safety
if (process.platform === 'darwin') {
icon.setTemplateImage(true);
}

tray = new Tray(icon);

tray.setContextMenu(contextMenu);
// Set initial tooltip (will be updated via updateTrayMenu)
tray.setToolTip(defaultTranslations.tooltipRunning);

// Set context menu
tray.setContextMenu(buildContextMenu(defaultTranslations, true));

// Click to show window (Windows/Linux)
tray.on('click', () => {
if (mainWindow.isDestroyed()) return;
if (mainWindow.isVisible()) {
mainWindow.hide();
if (!mainWindowRef || mainWindowRef.isDestroyed()) return;
if (mainWindowRef.isVisible()) {
mainWindowRef.hide();
} else {
mainWindow.show();
mainWindow.focus();
mainWindowRef.show();
mainWindowRef.focus();
}
});

// Double-click to show window (Windows)
tray.on('double-click', () => {
if (mainWindow.isDestroyed()) return;
mainWindow.show();
mainWindow.focus();
if (!mainWindowRef || mainWindowRef.isDestroyed()) return;
mainWindowRef.show();
mainWindowRef.focus();
});

return tray;
}

/**
* Update tray tooltip with Gateway status
* Update tray menu with translations and gateway status
*/
export function updateTrayStatus(status: string): void {
if (tray) {
tray.setToolTip(`ClawX - ${status}`);
}
export function updateTrayMenu(translations: TrayTranslations, gatewayRunning: boolean): void {
if (!tray) return;

currentTranslations = translations;
tray.setToolTip(gatewayRunning ? translations.tooltipRunning : translations.tooltipStopped);
tray.setContextMenu(buildContextMenu(translations, gatewayRunning));
}

/**
* Get current tray translations
*/
export function getCurrentTrayTranslations(): TrayTranslations {
return currentTranslations;
}

/**
* Update tray tooltip with Gateway status (legacy function)
*/
export function updateTrayStatus(status: 'running' | 'stopped'): void {
if (!tray) return;
const isRunning = status === 'running';
tray.setToolTip(isRunning ? currentTranslations.tooltipRunning : currentTranslations.tooltipStopped);
tray.setContextMenu(buildContextMenu(currentTranslations, isRunning));
}

/**
Expand All @@ -176,5 +233,6 @@ export function destroyTray(): void {
if (tray) {
tray.destroy();
tray = null;
mainWindowRef = null;
}
}
1 change: 1 addition & 0 deletions electron/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ const electronAPI = {
'settings:setMany',
'settings:getAll',
'settings:reset',
'tray:updateLanguage',
'usage:recentTokenHistory',
// Update
'update:status',
Expand Down
14 changes: 14 additions & 0 deletions src/i18n/locales/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,5 +55,19 @@
"notRunning": "Gateway Not Running",
"notRunningDesc": "The OpenClaw Gateway needs to be running to use this feature. It will start automatically, or you can start it from Settings.",
"warning": "Gateway is not running."
},
"tray": {
"tooltipRunning": "ClawX - Gateway Running",
"tooltipStopped": "ClawX - Gateway Stopped",
"show": "Show ClawX",
"gatewayStatus": "Gateway Status",
"running": "Running",
"stopped": "Stopped",
"quickActions": "Quick Actions",
"openDashboard": "Open Dashboard",
"openChat": "Open Chat",
"openSettings": "Open Settings",
"checkUpdates": "Check for Updates...",
"quit": "Quit ClawX"
}
}
14 changes: 14 additions & 0 deletions src/i18n/locales/ja/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,5 +55,19 @@
"notRunning": "ゲートウェイが停止中",
"notRunningDesc": "この機能を使用するには OpenClaw ゲートウェイが実行されている必要があります。自動的に起動するか、設定から起動できます。",
"warning": "ゲートウェイが停止中です。"
},
"tray": {
"tooltipRunning": "ClawX - ゲートウェイ実行中",
"tooltipStopped": "ClawX - ゲートウェイ停止",
"show": "ClawX を表示",
"gatewayStatus": "ゲートウェイ状態",
"running": "実行中",
"stopped": "停止",
"quickActions": "クイックアクション",
"openDashboard": "ダッシュボードを開く",
"openChat": "チャットを開く",
"openSettings": "設定を開く",
"checkUpdates": "アップデートを確認...",
"quit": "ClawX を終了"
}
}
Loading
Loading