Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
46 changes: 41 additions & 5 deletions app/electron/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,13 @@ import {
PluginManager,
} from './plugin-management';
import { addRunCmdConsent, removeRunCmdConsent, runScript, setupRunCmdHandlers } from './runCmd';
import { cleanupHeadlampTray, createHeadlampTray } from './tray';
import {
cleanupHeadlampTray,
createHeadlampTray,
isHeadlampTrayCreated,
isTrayIconEnabled,
setTrayIconEnabled,
} from './tray';
import windowSize from './windowSize';

if (process.env.APPIMAGE) {
Expand Down Expand Up @@ -1706,6 +1712,17 @@ function startElectron() {
mainWindow?.webContents.send('backend-port', actualPort);
});

ipcMain.on('request-tray-icon', () => {
mainWindow?.webContents.send('tray-icon', isTrayIconEnabled());
});

ipcMain.on('set-tray-icon', (event: IpcMainEvent, enabled: boolean) => {
if (typeof enabled !== 'boolean') {
return;
}
applyTrayIconSetting(enabled);
});

setupRunCmdHandlers(mainWindow, ipcMain);

new PluginManagerEventListeners().setupEventHandlers();
Expand Down Expand Up @@ -1774,9 +1791,8 @@ function startElectron() {
app.disableHardwareAcceleration();
}

app.on('ready', async () => {
await Promise.all([startServerIfNeeded(), createWindow()]);
hasTray = createHeadlampTray({
function buildTrayOptions() {
return {
backendToken,
createWindow,
getBackendPort: () => actualPort,
Expand All @@ -1786,7 +1802,27 @@ function startElectron() {
isQuitting = true;
app.quit();
},
});
};
}

/**
* Applies the system tray preference at runtime: persists it, then creates or
* removes the tray so the change takes effect without restarting the app.
*/
function applyTrayIconSetting(enabled: boolean) {
setTrayIconEnabled(enabled);

if (enabled && !isHeadlampTrayCreated()) {
hasTray = createHeadlampTray(buildTrayOptions());
} else if (!enabled && isHeadlampTrayCreated()) {
cleanupHeadlampTray();
hasTray = false;
}
}

app.on('ready', async () => {
await Promise.all([startServerIfNeeded(), createWindow()]);
hasTray = createHeadlampTray(buildTrayOptions());
});
app.on('activate', async function () {
if (mainWindow === null) {
Expand Down
9 changes: 8 additions & 1 deletion app/electron/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ contextBridge.exposeInMainWorld('desktopApi', {
'request-plugin-permission-secrets',
'open-plugin-folder',
'request-backend-port',
'request-tray-icon',
'set-tray-icon',
'cluster-changed',
];
if (validChannels.includes(channel)) {
Expand All @@ -52,10 +54,15 @@ contextBridge.exposeInMainWorld('desktopApi', {
'plugin-permission-secrets',
'open-about-dialog',
'backend-port',
'tray-icon',
];
if (validChannels.includes(channel)) {
// Deliberately strip event as it includes `sender`
ipcRenderer.on(channel, (event, ...args) => func(...args));
const wrapped = (event: unknown, ...args: unknown[]) => func(...args);
ipcRenderer.on(channel, wrapped);
// Return an unsubscribe function so callers can clean up the exact
// wrapped listener (removeListener with the original func cannot).
return () => ipcRenderer.removeListener(channel, wrapped);
Comment on lines 60 to +65
}
},

Expand Down
87 changes: 87 additions & 0 deletions app/electron/tray.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
/*
* Copyright 2025 The Kubernetes Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import fs from 'node:fs';
import os from 'node:os';
import path from 'path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { isTrayIconEnabled, setTrayIconEnabled } from './tray';

function tmpPath(): string {
return path.join(os.tmpdir(), `tray-test-${Date.now()}-${Math.random()}.json`);
}

describe('tray icon setting', () => {
let filePath: string;

beforeEach(() => {
filePath = tmpPath();
try {
if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
} catch {
// ignore
}
});

afterEach(() => {
try {
if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
} catch {
// ignore
}
});

it('defaults to enabled when the settings file does not exist', () => {
expect(fs.existsSync(filePath)).toBe(false);
expect(isTrayIconEnabled(filePath)).toBe(true);
});

it('defaults to enabled when the setting is unset but other settings exist', () => {
fs.writeFileSync(filePath, JSON.stringify({ confirmedCommands: {} }), 'utf-8');
expect(isTrayIconEnabled(filePath)).toBe(true);
});

it('persists and reads back a disabled preference', () => {
setTrayIconEnabled(false, filePath);
expect(isTrayIconEnabled(filePath)).toBe(false);
});

it('persists and reads back an enabled preference', () => {
setTrayIconEnabled(false, filePath);
setTrayIconEnabled(true, filePath);
expect(isTrayIconEnabled(filePath)).toBe(true);
});

it('preserves unrelated settings when toggling', () => {
fs.writeFileSync(filePath, JSON.stringify({ confirmedCommands: { foo: true } }), 'utf-8');
setTrayIconEnabled(false, filePath);
const saved = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
expect(saved.confirmedCommands).toEqual({ foo: true });
expect(saved.enableSystemTray).toBe(false);
});

it('falls back to enabled when the file contains valid non-object JSON', () => {
fs.writeFileSync(filePath, JSON.stringify(['not', 'an', 'object']), 'utf-8');
expect(isTrayIconEnabled(filePath)).toBe(true);
});

it('does not throw and writes an object when the file contains non-object JSON', () => {
fs.writeFileSync(filePath, JSON.stringify('a string'), 'utf-8');
expect(() => setTrayIconEnabled(false, filePath)).not.toThrow();
const saved = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
expect(saved).toEqual({ enableSystemTray: false });
});
});
33 changes: 33 additions & 0 deletions app/electron/tray.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@
import { BrowserWindow, Menu, nativeImage, Tray } from 'electron';
import { MenuItemConstructorOptions } from 'electron/main';
import path from 'path';
import { loadSettings, saveSettings, SETTINGS_PATH } from './settings';

const TRAY_SETTING_KEY = 'enableSystemTray';

type ClusterStatus = {
name: string;
Expand All @@ -41,6 +44,32 @@ export function shouldRunTray(): boolean {
return ['darwin', 'linux', 'win32'].includes(process.platform);
}

// settings.json is user-editable, so it may contain valid JSON that is not a
// plain object (e.g. an array or string). Treat anything else as empty.
function asSettingsObject(value: unknown): Record<string, any> {
return value !== null && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, any>)
: {};
}

/**
* Whether the user has the system tray icon enabled.
* Defaults to true when the setting is unset, to preserve existing behavior.
*/
export function isTrayIconEnabled(settingsPath: string = SETTINGS_PATH): boolean {
const settings = asSettingsObject(loadSettings(settingsPath));
return settings[TRAY_SETTING_KEY] !== false;
}

/**
* Persists whether the system tray icon should be created.
*/
export function setTrayIconEnabled(enabled: boolean, settingsPath: string = SETTINGS_PATH): void {
const settings = asSettingsObject(loadSettings(settingsPath));
settings[TRAY_SETTING_KEY] = enabled;
saveSettings(settingsPath, settings);
}

export function isHeadlampTrayCreated(): boolean {
return !!tray && !tray.isDestroyed();
}
Expand All @@ -67,6 +96,10 @@ export function createHeadlampTray(options: HeadlampTrayOptions): boolean {
return false;
}

if (!isTrayIconEnabled()) {
return false;
}

if (isHeadlampTrayCreated()) {
return true;
}
Expand Down
40 changes: 40 additions & 0 deletions frontend/src/components/App/Settings/Settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { capitalize } from 'lodash';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useDispatch } from 'react-redux';
import { isElectron } from '../../../helpers/isElectron';
import LocaleSelect from '../../../i18n/LocaleSelect/LocaleSelect';
import { setAppSettings } from '../../../redux/configSlice';
import { defaultTableRowsPerPageOptions } from '../../../redux/configSlice';
Expand Down Expand Up @@ -50,6 +51,7 @@ export default function Settings() {
);
const [sortSidebar, setSortSidebar] = useState<boolean>(storedSortSidebar);
const [useEvict, setUseEvict] = useState<boolean>(storedUseEvict);
const [trayIcon, setTrayIcon] = useState<boolean>(true);
const dispatch = useDispatch();
const themeName = useTypedSelector(state => state.theme.name);
const appThemes = useAppThemes();
Expand Down Expand Up @@ -82,8 +84,28 @@ export default function Settings() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [useEvict]);

useEffect(() => {
if (!isElectron()) {
return;
}

const handler = (enabled: boolean) => setTrayIcon(enabled);
const unsubscribe = window.desktopApi?.receive('tray-icon', handler);
window.desktopApi?.send('request-tray-icon');

return () => {
unsubscribe?.();
};
Comment thread
VlatkoMilisav marked this conversation as resolved.
}, []);

function handleTrayIconChange(enabled: boolean) {
setTrayIcon(enabled);
window.desktopApi?.send('set-tray-icon', enabled);
}

const sidebarLabelID = 'sort-sidebar-label';
const evictLabelID = 'use-evict-label';
const trayIconLabelID = 'tray-icon-label';
const tableRowsLabelID = 'rows-per-page-label';
const timezoneLabelID = 'timezone-label';

Expand Down Expand Up @@ -165,6 +187,24 @@ export default function Settings() {
),
nameID: evictLabelID,
},
...(isElectron()
? [
{
name: t('translation|Show system tray icon'),
value: (
<Switch
color="primary"
checked={trayIcon}
onChange={e => handleTrayIconChange(e.target.checked)}
inputProps={{
'aria-labelledby': trayIconLabelID,
}}
/>
),
nameID: trayIconLabelID,
},
]
: []),
]}
/>
<Box
Expand Down
1 change: 1 addition & 0 deletions frontend/src/i18n/locales/ar/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,7 @@
"Timezone to display for dates": "المنطقة الزمنية لعرض التواريخ",
"Sort sidebar items alphabetically": "ترتيب عناصر الشريط الجانبي أبجديًا",
"Use evict for pod deletion": "استخدام evict لحذف الـ pod",
"Show system tray icon": "",
"Theme": "السمة",
"Theme has been forced by your administrator": "",
"Keyboard Shortcuts": "اختصارات لوحة المفاتيح",
Expand Down
1 change: 1 addition & 0 deletions frontend/src/i18n/locales/de/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,7 @@
"Timezone to display for dates": "Zeitzone",
"Sort sidebar items alphabetically": "",
"Use evict for pod deletion": "",
"Show system tray icon": "",
"Theme": "Design",
"Theme has been forced by your administrator": "",
"Keyboard Shortcuts": "",
Expand Down
1 change: 1 addition & 0 deletions frontend/src/i18n/locales/en/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,7 @@
"Timezone to display for dates": "Timezone to display for dates",
"Sort sidebar items alphabetically": "Sort sidebar items alphabetically",
"Use evict for pod deletion": "Use evict for pod deletion",
"Show system tray icon": "Show system tray icon",
"Theme": "Theme",
"Theme has been forced by your administrator": "Theme has been forced by your administrator",
"Keyboard Shortcuts": "Keyboard Shortcuts",
Expand Down
1 change: 1 addition & 0 deletions frontend/src/i18n/locales/es/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,7 @@
"Timezone to display for dates": "Huso horario para mostrar en fechas",
"Sort sidebar items alphabetically": "",
"Use evict for pod deletion": "",
"Show system tray icon": "",
"Theme": "Tema",
"Theme has been forced by your administrator": "",
"Keyboard Shortcuts": "",
Expand Down
1 change: 1 addition & 0 deletions frontend/src/i18n/locales/fr/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,7 @@
"Timezone to display for dates": "Fuseau horaire à afficher pour les dates",
"Sort sidebar items alphabetically": "Trier les éléments de la barre latérale par ordre alphabétique",
"Use evict for pod deletion": "Utiliser l'éviction pour la suppression de pods",
"Show system tray icon": "",
"Theme": "Thème",
"Theme has been forced by your administrator": "Le thème a été imposé par votre administrateur",
"Keyboard Shortcuts": "",
Expand Down
1 change: 1 addition & 0 deletions frontend/src/i18n/locales/he/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,7 @@
"Timezone to display for dates": "Timezone to display for dates",
"Sort sidebar items alphabetically": "Sort sidebar items alphabetically",
"Use evict for pod deletion": "Use evict for pod deletion",
"Show system tray icon": "",
"Theme": "Theme",
"Theme has been forced by your administrator": "",
"Keyboard Shortcuts": "Keyboard Shortcuts",
Expand Down
1 change: 1 addition & 0 deletions frontend/src/i18n/locales/hi/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,7 @@
"Timezone to display for dates": "",
"Sort sidebar items alphabetically": "",
"Use evict for pod deletion": "",
"Show system tray icon": "",
"Theme": "",
"Theme has been forced by your administrator": "",
"Keyboard Shortcuts": "",
Expand Down
1 change: 1 addition & 0 deletions frontend/src/i18n/locales/it/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,7 @@
"Timezone to display for dates": "Fuso orario per la visualizzazione delle date",
"Sort sidebar items alphabetically": "",
"Use evict for pod deletion": "",
"Show system tray icon": "",
"Theme": "Tema",
"Theme has been forced by your administrator": "",
"Keyboard Shortcuts": "",
Expand Down
1 change: 1 addition & 0 deletions frontend/src/i18n/locales/ja/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,7 @@
"Timezone to display for dates": "日付表示のタイムゾーン",
"Sort sidebar items alphabetically": "",
"Use evict for pod deletion": "",
"Show system tray icon": "",
"Theme": "テーマ",
"Theme has been forced by your administrator": "",
"Keyboard Shortcuts": "",
Expand Down
1 change: 1 addition & 0 deletions frontend/src/i18n/locales/ko/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,7 @@
"Timezone to display for dates": "날짜 표시용 시간대",
"Sort sidebar items alphabetically": "사이드바 항목을 가나다순으로 정렬",
"Use evict for pod deletion": "",
"Show system tray icon": "",
"Theme": "테마",
"Theme has been forced by your administrator": "",
"Keyboard Shortcuts": "",
Expand Down
1 change: 1 addition & 0 deletions frontend/src/i18n/locales/pt/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,7 @@
"Timezone to display for dates": "Fuso horário para mostrar em datas",
"Sort sidebar items alphabetically": "",
"Use evict for pod deletion": "",
"Show system tray icon": "",
"Theme": "Tema",
"Theme has been forced by your administrator": "",
"Keyboard Shortcuts": "",
Expand Down
Loading
Loading