From fc23d63b893d68959d1f0c10b3d7ca59581f1fe8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A1szl=C3=B3=20Monda?= Date: Fri, 17 Jul 2026 15:12:37 +0200 Subject: [PATCH 1/2] feat: add minimize to system tray support (#850) Let Agent hide to the tray on minimize, restore from the tray icon, and start hidden via --start-minimized-to-tray. Co-authored-by: Cursor --- packages/uhk-agent/src/electron-main.ts | 25 +- .../uhk-agent/src/services/tray.service.ts | 214 ++++++++++++++++++ packages/uhk-agent/src/util/command-line.ts | 6 + .../src/util/get-application-settings.ts | 17 ++ packages/uhk-agent/src/util/index.ts | 1 + .../src/models/application-settings.ts | 4 + .../src/models/command-line-args.ts | 4 + .../agent/settings/settings.component.html | 11 + .../agent/settings/settings.component.ts | 14 +- packages/uhk-web/src/app/store/actions/app.ts | 9 + packages/uhk-web/src/app/store/effects/app.ts | 2 + packages/uhk-web/src/app/store/index.ts | 2 + .../src/app/store/reducers/app.reducer.ts | 12 +- 13 files changed, 312 insertions(+), 9 deletions(-) create mode 100644 packages/uhk-agent/src/services/tray.service.ts create mode 100644 packages/uhk-agent/src/util/get-application-settings.ts diff --git a/packages/uhk-agent/src/electron-main.ts b/packages/uhk-agent/src/electron-main.ts index 21715136e32..4d8b4c4e3fd 100644 --- a/packages/uhk-agent/src/electron-main.ts +++ b/packages/uhk-agent/src/electron-main.ts @@ -21,6 +21,7 @@ import { AppUpdateService } from './services/app-update.service'; import { AppService } from './services/app.service'; import { SudoService } from './services/sudo.service'; import { SmartMacroDocService } from './services/smart-macro-doc.service'; +import { TrayService } from './services/tray.service'; import isDev from 'electron-is-dev'; import { setMenu } from './electron-menu'; import { loadWindowState, saveWindowState } from './util/window'; @@ -58,6 +59,7 @@ let appService: AppService; let sudoService: SudoService; let packagesDir: string; let smartMacroDocService: SmartMacroDocService; +let trayService: TrayService; let areServicesInited = false; @@ -89,6 +91,7 @@ async function createWindow() { logger.misc('[Electron Main] Create new window.'); + const iconPath = path.join(import.meta.dirname, 'renderer/assets/images/agent-app-icon.png'); const loadedWindowState = loadWindowState(logger); if(!smartMacroDocService.isRunning) { await smartMacroDocService.start(); @@ -106,11 +109,14 @@ async function createWindow() { spellcheck: false, preload: path.join(import.meta.dirname, 'preload.js') }, - icon: path.join(import.meta.dirname, 'renderer/assets/images/agent-app-icon.png'), + icon: iconPath, backgroundColor: await getWindowBackgroundColor(), show: false }); + trayService = new TrayService(logger, iconPath); + trayService.init(win); + if (loadedWindowState.isFullScreen) { win.setFullScreen(true); } else if (loadedWindowState.isMaximized) { @@ -143,7 +149,11 @@ async function createWindow() { }); win.once('ready-to-show', () => { - win.show(); + if (options['start-minimized-to-tray']) { + trayService.startInTray(); + } else { + win.show(); + } }); win.webContents.on('did-finish-load', () => { @@ -273,6 +283,10 @@ if (isSecondInstance) { app.exit(); }); + app.on('before-quit', () => { + trayService?.markQuitting(); + }); + app.on('will-quit', () => { }); @@ -284,16 +298,15 @@ if (isSecondInstance) { .catch((error) => { logger.error('[Electron Main] when activating the app: ', error); }); + } else if (!win.isVisible()) { + trayService.showWindow(); } }); app.on('second-instance', () => { // Someone tried to run a second instance, we should focus our window. if (win) { - if (win.isMinimized()) { - win.restore(); - } - win.focus(); + trayService.showWindow(); } }); } diff --git a/packages/uhk-agent/src/services/tray.service.ts b/packages/uhk-agent/src/services/tray.service.ts new file mode 100644 index 00000000000..56552c96b9d --- /dev/null +++ b/packages/uhk-agent/src/services/tray.service.ts @@ -0,0 +1,214 @@ +import { app, BrowserWindow, Menu, nativeImage, Tray } from 'electron'; + +import { LogService } from 'uhk-common'; +import { getApplicationSettingsFromStorage } from '../util/get-application-settings'; + +const MINIMIZE_SUPPRESS_MS = 500; + +export class TrayService { + private tray: Tray | null = null; + private contextMenu: Menu | null = null; + private hiddenToTray = false; + private isQuitting = false; + private suppressMinimizeToTray = false; + private suppressMinimizeTimeout: NodeJS.Timeout | null = null; + private win: BrowserWindow | null = null; + + constructor(private logService: LogService, + private iconPath: string) { + } + + init(win: BrowserWindow): void { + this.win = win; + + win.on('minimize', () => { + void this.handleMinimize(); + }); + + win.on('close', () => { + if (this.isQuitting) { + this.destroy(); + } + }); + } + + markQuitting(): void { + this.isQuitting = true; + } + + showWindow(): void { + this.revealWindow(); + } + + startInTray(): void { + this.ensureTray(); + this.hideToTray(); + } + + private getWindow(): BrowserWindow | null { + const win = this.win; + if (!win || win.isDestroyed()) { + return null; + } + + return win; + } + + private runWithMinimizeSuppressed(action: () => void): void { + this.suppressMinimizeToTray = true; + if (this.suppressMinimizeTimeout) { + clearTimeout(this.suppressMinimizeTimeout); + } + + action(); + + this.suppressMinimizeTimeout = setTimeout(() => { + this.suppressMinimizeToTray = false; + this.suppressMinimizeTimeout = null; + }, MINIMIZE_SUPPRESS_MS); + } + + private hideToTray(): void { + const win = this.getWindow(); + if (!win) { + return; + } + + this.hiddenToTray = true; + this.runWithMinimizeSuppressed(() => { + if (process.platform === 'linux') { + win.setSkipTaskbar(true); + } + + win.hide(); + }); + this.logService.misc('[TrayService] Window hidden to tray'); + } + + private revealWindow(): void { + const win = this.getWindow(); + if (!win) { + return; + } + + // Defer until after tray click / context menu handling so focus is not stolen. + setImmediate(() => { + const currentWin = this.getWindow(); + if (!currentWin) { + return; + } + + this.hiddenToTray = false; + this.runWithMinimizeSuppressed(() => { + if (process.platform === 'linux') { + currentWin.setSkipTaskbar(false); + } + + currentWin.show(); + + if (currentWin.isMinimized()) { + currentWin.restore(); + } + + currentWin.focus(); + }); + this.logService.misc('[TrayService] Window restored from tray'); + }); + } + + private toggleWindow(): void { + if (this.hiddenToTray) { + this.revealWindow(); + return; + } + + const win = this.getWindow(); + if (!win) { + return; + } + + setImmediate(() => { + if (this.hiddenToTray || !win.isVisible() || win.isMinimized()) { + this.revealWindow(); + return; + } + + this.hideToTray(); + }); + } + + private async handleMinimize(): Promise { + if (this.suppressMinimizeToTray || this.hiddenToTray) { + return; + } + + const win = this.getWindow(); + if (!win) { + return; + } + + const { minimizeToTray = false } = await getApplicationSettingsFromStorage(); + if (!minimizeToTray || this.suppressMinimizeToTray || this.hiddenToTray) { + return; + } + + this.ensureTray(); + this.hideToTray(); + } + + private ensureTray(): void { + if (this.tray) { + return; + } + + const icon = nativeImage.createFromPath(this.iconPath); + const trayIcon = process.platform === 'darwin' + ? icon.resize({ width: 16, height: 16 }) + : icon.resize({ width: 22, height: 22 }); + + if (process.platform === 'darwin') { + trayIcon.setTemplateImage(true); + } + + this.tray = new Tray(trayIcon); + this.tray.setToolTip('UHK Agent'); + + this.contextMenu = Menu.buildFromTemplate([ + { + label: 'Show UHK Agent', + click: () => this.revealWindow(), + }, + { type: 'separator' }, + { + label: 'Quit', + click: () => { + this.isQuitting = true; + app.quit(); + }, + }, + ]); + + // setContextMenu shows the menu on right-click on Linux/Windows and on left-click on macOS. + // popUpContextMenu is not supported on Linux, so setContextMenu must be used there. + this.tray.setContextMenu(this.contextMenu); + + if (process.platform !== 'darwin') { + this.tray.on('click', () => { + this.toggleWindow(); + }); + } + + this.logService.misc('[TrayService] Tray icon created'); + } + + private destroy(): void { + if (!this.tray) { + return; + } + + this.tray.destroy(); + this.tray = null; + this.contextMenu = null; + this.logService.misc('[TrayService] Tray icon destroyed'); + } +} diff --git a/packages/uhk-agent/src/util/command-line.ts b/packages/uhk-agent/src/util/command-line.ts index 6ac838b95f9..4a8ef8944c5 100644 --- a/packages/uhk-agent/src/util/command-line.ts +++ b/packages/uhk-agent/src/util/command-line.ts @@ -23,6 +23,7 @@ const optionDefinitions: commandLineArgs.OptionDefinition[] = [ { name: 'serial-number', type: String }, { name: 'simulate-invalid-codesign-signature', type: Boolean }, { name: 'spe', type: Boolean }, // simulate privilege escalation error + { name: 'start-minimized-to-tray', type: Boolean }, { name: 'usb-interface', type: Number }, { name: 'usb-non-blocking', type: Boolean }, { name: 'vid', type: Number }, @@ -131,6 +132,11 @@ const sections: commandLineUsage.Section[] = [ description: 'Simulate privilege escalation error', type: Boolean }, + { + name: 'start-minimized-to-tray', + description: 'Start the Agent window hidden in the system tray', + type: Boolean + }, { name: 'usb-interface', description: 'Use the specified USB interface id. If you set it you have to set the vid and pid too.', diff --git a/packages/uhk-agent/src/util/get-application-settings.ts b/packages/uhk-agent/src/util/get-application-settings.ts new file mode 100644 index 00000000000..1bca1cf00e9 --- /dev/null +++ b/packages/uhk-agent/src/util/get-application-settings.ts @@ -0,0 +1,17 @@ +import settings from 'electron-settings'; + +import { ApplicationSettings } from 'uhk-common'; + +const APPLICATION_SETTINGS_KEY = 'application-settings'; + +export async function getApplicationSettingsFromStorage(): Promise { + const value = await settings.get(APPLICATION_SETTINGS_KEY); + if (!value) { + return { + checkForUpdateOnStartUp: true, + everAttemptedSavingToKeyboard: false, + }; + } + + return JSON.parse(value as string); +} diff --git a/packages/uhk-agent/src/util/index.ts b/packages/uhk-agent/src/util/index.ts index 3774098410d..52ea4ffef16 100644 --- a/packages/uhk-agent/src/util/index.ts +++ b/packages/uhk-agent/src/util/index.ts @@ -5,6 +5,7 @@ export * from './copy-smart-macro-doc-to-webserver'; export * from './copy-smart-macro-loading-html'; export * from './create-png'; export * from './delete-user-config-history'; +export * from './get-application-settings'; export * from './get-default-firmware-path'; export * from './get-smart-macro-doc-root-path'; export * from './get-udev-file-content-async'; diff --git a/packages/uhk-common/src/models/application-settings.ts b/packages/uhk-common/src/models/application-settings.ts index 9f91a359c0d..5ed6a3894f2 100644 --- a/packages/uhk-common/src/models/application-settings.ts +++ b/packages/uhk-common/src/models/application-settings.ts @@ -26,6 +26,10 @@ export interface ApplicationSettings { * If extra module is connected then ignore this setting. */ keyboardHalvesAlwaysJoined?: boolean; + /** + * If true, minimizing the Agent window hides it to the system tray instead of the taskbar. + */ + minimizeToTray?: boolean; /** * Smart Macro panel width in percent; */ diff --git a/packages/uhk-common/src/models/command-line-args.ts b/packages/uhk-common/src/models/command-line-args.ts index 9251c05c890..0fd838040f3 100644 --- a/packages/uhk-common/src/models/command-line-args.ts +++ b/packages/uhk-common/src/models/command-line-args.ts @@ -92,6 +92,10 @@ export interface CommandLineArgs extends DeviceIdentifier { * simulate privilege escalation error */ spe?: boolean; + /** + * Start the Agent window hidden in the system tray. + */ + 'start-minimized-to-tray'?: boolean; /** * Use USB non-blocking communication */ diff --git a/packages/uhk-web/src/app/components/agent/settings/settings.component.html b/packages/uhk-web/src/app/components/agent/settings/settings.component.html index 8563b1c6338..830c6418c1a 100644 --- a/packages/uhk-web/src/app/components/agent/settings/settings.component.html +++ b/packages/uhk-web/src/app/components/agent/settings/settings.component.html @@ -10,6 +10,17 @@

+
+
+ +
+
+
diff --git a/packages/uhk-web/src/app/components/agent/settings/settings.component.ts b/packages/uhk-web/src/app/components/agent/settings/settings.component.ts index 7087de85d47..b3ea24a37a1 100644 --- a/packages/uhk-web/src/app/components/agent/settings/settings.component.ts +++ b/packages/uhk-web/src/app/components/agent/settings/settings.component.ts @@ -12,16 +12,18 @@ import { getAnimationEnabled, getAppTheme, getIsAdvancedSettingsMenuVisible, + getMinimizeToTray, getOperatingSystem, getSupportedThemes, - keyboardHalvesAlwaysJoined + keyboardHalvesAlwaysJoined, + runningInElectron } from '../../../store'; import { State as UpdateSettingsState } from '../../../store/reducers/auto-update-settings'; import { CheckForUpdateNowAction, ToggleCheckForUpdateOnStartupAction } from '../../../store/actions/auto-update-settings'; -import { OpenConfigFolderAction, SetAppThemeAction, ToggleAnimationEnabledAction, ToggleKeyboardHalvesAlwaysJoinedAction } from '../../../store/actions/app'; +import { OpenConfigFolderAction, SetAppThemeAction, ToggleAnimationEnabledAction, ToggleKeyboardHalvesAlwaysJoinedAction, ToggleMinimizeToTrayAction } from '../../../store/actions/app'; import { ToggleAlwaysEnableAdvancedModeAction } from '../../../store/actions/advance-settings.action'; import { OperatingSystem } from '../../../models/operating-system'; @@ -37,6 +39,8 @@ import { OperatingSystem } from '../../../models/operating-system'; export class SettingsComponent { updateSettingsState$: Observable; animationEnabled$: Observable; + minimizeToTray$: Observable; + runningInElectron$: Observable; appTheme$: Observable; themes$: Observable; isLinux$: Observable; @@ -48,6 +52,8 @@ export class SettingsComponent { constructor(private store: Store) { this.updateSettingsState$ = store.select(appUpdateSettingsState); this.animationEnabled$ = store.select(getAnimationEnabled); + this.minimizeToTray$ = store.select(getMinimizeToTray); + this.runningInElectron$ = store.select(runningInElectron); this.appTheme$ = store.select(getAppTheme); this.themes$ = store.select(getSupportedThemes); this.isLinux$ = store.select(getOperatingSystem).pipe(map(os => os === OperatingSystem.Linux)); @@ -80,6 +86,10 @@ export class SettingsComponent { this.store.dispatch(new ToggleKeyboardHalvesAlwaysJoinedAction(enabled)); } + toggleMinimizeToTray(enabled: boolean): void { + this.store.dispatch(new ToggleMinimizeToTrayAction(enabled)); + } + toggleAlwaysEnableAdvancedMode(enabled: boolean): void { this.store.dispatch(new ToggleAlwaysEnableAdvancedModeAction(enabled)); } diff --git a/packages/uhk-web/src/app/store/actions/app.ts b/packages/uhk-web/src/app/store/actions/app.ts index 555cf4eb3dd..8bb9d45620a 100644 --- a/packages/uhk-web/src/app/store/actions/app.ts +++ b/packages/uhk-web/src/app/store/actions/app.ts @@ -29,6 +29,7 @@ export enum ActionTypes { SetupPermissionError = '[app] Setup permission error', ToggleAnimationEnabled = '[app] Toggle animation enabled', ToggleKeyboardHalvesAlwaysJoined = '[app] Toggle keyboard halves always joined', + ToggleMinimizeToTray = '[app] Toggle minimize to tray', SetAppTheme = '[app] Set application theme', LoadAppStartInfo = '[app] Load app start info', StartKeypressCapturing = '[app] Start keypress capturing', @@ -173,6 +174,13 @@ export class ToggleKeyboardHalvesAlwaysJoinedAction implements Action { } } +export class ToggleMinimizeToTrayAction implements Action { + type = ActionTypes.ToggleMinimizeToTray; + + constructor(public payload: boolean) { + } +} + export class SetAppThemeAction implements Action { type = ActionTypes.SetAppTheme; @@ -235,6 +243,7 @@ export type Actions | SetupPermissionErrorAction | ToggleAnimationEnabledAction | ToggleKeyboardHalvesAlwaysJoinedAction + | ToggleMinimizeToTrayAction | SetAppThemeAction | LoadAppStartInfoAction | StartKeypressCapturingAction diff --git a/packages/uhk-web/src/app/store/effects/app.ts b/packages/uhk-web/src/app/store/effects/app.ts index 0aa3b5cdf50..4c075fc2fdf 100644 --- a/packages/uhk-web/src/app/store/effects/app.ts +++ b/packages/uhk-web/src/app/store/effects/app.ts @@ -60,6 +60,7 @@ export class ApplicationEffects { everAttemptedSavingToKeyboard: false, animationEnabled: true, keyboardHalvesAlwaysJoined: false, + minimizeToTray: false, ...appSettings }; @@ -146,6 +147,7 @@ export class ApplicationEffects { ActionTypes.SetAppTheme, ActionTypes.ToggleAnimationEnabled, ActionTypes.ToggleKeyboardHalvesAlwaysJoined, + ActionTypes.ToggleMinimizeToTray, AdvanceSettingsActionTypes.toggleAlwaysEnableAdvancedMode, UpdateActionTypes.ToggleCheckForUpdateOnStartup, DeviceActionTypes.SaveConfiguration, diff --git a/packages/uhk-web/src/app/store/index.ts b/packages/uhk-web/src/app/store/index.ts index 359e1157c14..e3629d7ef92 100644 --- a/packages/uhk-web/src/app/store/index.ts +++ b/packages/uhk-web/src/app/store/index.ts @@ -202,6 +202,7 @@ export const firmwareUpgradeAllowed = createSelector(runningOnNotSupportedWindow export const getEverAttemptedSavingToKeyboard = createSelector(appState, fromApp.getEverAttemptedSavingToKeyboard); export const getUdevFileContent = createSelector(appState, fromApp.getUdevFileContent); export const getAnimationEnabled = createSelector(appState, fromApp.getAnimationEnabled); +export const getMinimizeToTray = createSelector(appState, fromApp.getMinimizeToTray); export const getAppTheme = createSelector(appState, fromApp.getAppTheme); export const getUhkThemeColors = createSelector(getAppTheme, (theme): UhkThemeColors => { return defaultUhkThemeColors(theme); @@ -849,6 +850,7 @@ export const getApplicationSettings = createSelector( appTheme: app.appTheme, backlightingColorPalette, keyboardHalvesAlwaysJoined, + minimizeToTray: app.minimizeToTray, alwaysEnableAdvancedMode, smartMacroPanelWidth }; diff --git a/packages/uhk-web/src/app/store/reducers/app.reducer.ts b/packages/uhk-web/src/app/store/reducers/app.reducer.ts index 2c197e58b24..c735a4f9993 100644 --- a/packages/uhk-web/src/app/store/reducers/app.reducer.ts +++ b/packages/uhk-web/src/app/store/reducers/app.reducer.ts @@ -20,6 +20,7 @@ const DEFAULT_ERROR_PANEL_HEIGHT = 10; export interface State { appTheme: AppTheme; animationEnabled: boolean; + minimizeToTray: boolean; errorPanelHeight: number; isRunningOnWayland: boolean; started: boolean; @@ -43,6 +44,7 @@ export interface State { export const initialState: State = { appTheme: AppTheme.System, animationEnabled: true, + minimizeToTray: false, errorPanelHeight: DEFAULT_ERROR_PANEL_HEIGHT, isRunningOnWayland: false, started: false, @@ -207,7 +209,8 @@ export function reducer( errorPanelHeight: settings.errorPanelHeight || DEFAULT_ERROR_PANEL_HEIGHT, everAttemptedSavingToKeyboard: settings.everAttemptedSavingToKeyboard, animationEnabled: settings.animationEnabled, - appTheme: settings.appTheme || AppTheme.System + appTheme: settings.appTheme || AppTheme.System, + minimizeToTray: settings.minimizeToTray ?? false, }; } @@ -223,6 +226,12 @@ export function reducer( animationEnabled: (action as App.ToggleAnimationEnabledAction).payload }; + case App.ActionTypes.ToggleMinimizeToTray: + return { + ...state, + minimizeToTray: (action as App.ToggleMinimizeToTrayAction).payload + }; + case App.ActionTypes.SetAppTheme: return { ...state, @@ -266,6 +275,7 @@ export const keypressCapturing = (state: State): boolean => state.keypressCaptur export const getEverAttemptedSavingToKeyboard = (state: State): boolean => state.everAttemptedSavingToKeyboard; export const getUdevFileContent = (state: State): string => state.udevFileContent; export const getAnimationEnabled = (state: State): boolean => state.animationEnabled; +export const getMinimizeToTray = (state: State): boolean => state.minimizeToTray; export const getAppTheme = (state: State): AppTheme => state.appTheme; export const getHardwareConfiguration = (state: State): HardwareConfiguration => state.hardwareConfig; export const getPlatform = (state: State): string => state.platform; From afd4cfbd21cb6af8fbc1853a8ff2d9bd4457b439 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A1szl=C3=B3=20Monda?= Date: Tue, 21 Jul 2026 13:25:24 +0200 Subject: [PATCH 2/2] fix: polish system tray toggling and Linux tray behaviour (#850) Show the tray icon at startup when enabled, sync toggles immediately over IPC, restore maximized state from tray, and notify on Linux that disabled tray icons clear only after restart. Co-authored-by: Cursor --- packages/uhk-agent/src/electron-main.ts | 14 +- .../uhk-agent/src/services/tray.service.ts | 184 ++++++++++++++++-- packages/uhk-common/src/util/ipcEvents.ts | 2 + .../agent/settings/settings.component.html | 9 + .../src/app/services/app-renderer.service.ts | 16 +- packages/uhk-web/src/app/store/effects/app.ts | 15 ++ 6 files changed, 216 insertions(+), 24 deletions(-) diff --git a/packages/uhk-agent/src/electron-main.ts b/packages/uhk-agent/src/electron-main.ts index 4d8b4c4e3fd..a1057a3297f 100644 --- a/packages/uhk-agent/src/electron-main.ts +++ b/packages/uhk-agent/src/electron-main.ts @@ -149,11 +149,15 @@ async function createWindow() { }); win.once('ready-to-show', () => { - if (options['start-minimized-to-tray']) { - trayService.startInTray(); - } else { - win.show(); - } + void (async () => { + await trayService.initTrayIfEnabled(); + + if (options['start-minimized-to-tray']) { + trayService.startInTray(); + } else { + win.show(); + } + })(); }); win.webContents.on('did-finish-load', () => { diff --git a/packages/uhk-agent/src/services/tray.service.ts b/packages/uhk-agent/src/services/tray.service.ts index 56552c96b9d..3300f3b77d2 100644 --- a/packages/uhk-agent/src/services/tray.service.ts +++ b/packages/uhk-agent/src/services/tray.service.ts @@ -1,6 +1,6 @@ -import { app, BrowserWindow, Menu, nativeImage, Tray } from 'electron'; +import { app, BrowserWindow, ipcMain, Menu, nativeImage, Tray } from 'electron'; -import { LogService } from 'uhk-common'; +import { IpcEvents, LogService } from 'uhk-common'; import { getApplicationSettingsFromStorage } from '../util/get-application-settings'; const MINIMIZE_SUPPRESS_MS = 500; @@ -9,17 +9,51 @@ export class TrayService { private tray: Tray | null = null; private contextMenu: Menu | null = null; private hiddenToTray = false; + private isMaximized = false; private isQuitting = false; private suppressMinimizeToTray = false; private suppressMinimizeTimeout: NodeJS.Timeout | null = null; + private trayActive = false; + private wasFullScreenBeforeTray = false; + private wasMaximizedBeforeTray = false; private win: BrowserWindow | null = null; + private readonly minimizeToTrayChangedHandler = (_event: Electron.IpcMainEvent, args: [boolean]) => { + this.setMinimizeToTrayEnabled(args[0] ?? false); + }; + + private readonly trayClickHandler = (): void => { + this.toggleWindow(); + }; + constructor(private logService: LogService, private iconPath: string) { } init(win: BrowserWindow): void { this.win = win; + this.isMaximized = win.isMaximized(); + + win.on('maximize', () => { + this.isMaximized = true; + }); + + win.on('unmaximize', () => { + if (this.suppressMinimizeToTray) { + return; + } + + // On Windows, unmaximize can fire immediately before minimize when hiding a + // maximized window. Defer clearing so minimize-to-tray keeps the state. + setImmediate(() => { + const currentWin = this.getWindow(); + if (!currentWin || currentWin.isMinimized()) { + return; + } + + this.isMaximized = false; + }); + }); win.on('minimize', () => { void this.handleMinimize(); @@ -30,6 +64,32 @@ export class TrayService { this.destroy(); } }); + + ipcMain.on(IpcEvents.app.minimizeToTrayChanged, this.minimizeToTrayChangedHandler); + } + + setMinimizeToTrayEnabled(enabled: boolean): void { + if (enabled) { + this.ensureTray(); + this.logService.misc('[TrayService] Tray enabled'); + return; + } + + if (this.hiddenToTray) { + this.revealWindow(); + } + + this.destroyTrayIcon(); + this.logService.misc('[TrayService] Tray disabled'); + } + + async initTrayIfEnabled(): Promise { + const { minimizeToTray = false } = await getApplicationSettingsFromStorage(); + if (!minimizeToTray) { + return; + } + + this.ensureTray(); } markQuitting(): void { @@ -54,6 +114,15 @@ export class TrayService { return win; } + private notifyMinimizeToTrayDisabledOnLinux(): void { + const win = this.getWindow(); + if (!win) { + return; + } + + win.webContents.send(IpcEvents.app.minimizeToTrayDisabledOnLinux); + } + private runWithMinimizeSuppressed(action: () => void): void { this.suppressMinimizeToTray = true; if (this.suppressMinimizeTimeout) { @@ -68,21 +137,34 @@ export class TrayService { }, MINIMIZE_SUPPRESS_MS); } + private captureWindowStateBeforeTray(win: BrowserWindow): void { + this.wasFullScreenBeforeTray = win.isFullScreen(); + this.wasMaximizedBeforeTray = this.isMaximized || win.isMaximized(); + } + private hideToTray(): void { const win = this.getWindow(); if (!win) { return; } + this.captureWindowStateBeforeTray(win); this.hiddenToTray = true; this.runWithMinimizeSuppressed(() => { if (process.platform === 'linux') { win.setSkipTaskbar(true); } + if (win.isMinimized()) { + win.restore(); + } + win.hide(); }); - this.logService.misc('[TrayService] Window hidden to tray'); + this.logService.misc('[TrayService] Window hidden to tray', { + wasMaximized: this.wasMaximizedBeforeTray, + wasFullScreen: this.wasFullScreenBeforeTray, + }); } private revealWindow(): void { @@ -91,6 +173,9 @@ export class TrayService { return; } + const restoreMaximized = this.wasMaximizedBeforeTray; + const restoreFullScreen = this.wasFullScreenBeforeTray; + // Defer until after tray click / context menu handling so focus is not stolen. setImmediate(() => { const currentWin = this.getWindow(); @@ -110,13 +195,27 @@ export class TrayService { currentWin.restore(); } + if (restoreFullScreen) { + currentWin.setFullScreen(true); + } else if (restoreMaximized) { + currentWin.maximize(); + } + + this.isMaximized = restoreMaximized; currentWin.focus(); }); - this.logService.misc('[TrayService] Window restored from tray'); + this.logService.misc('[TrayService] Window restored from tray', { + restoreMaximized, + restoreFullScreen, + }); }); } private toggleWindow(): void { + if (!this.trayActive) { + return; + } + if (this.hiddenToTray) { this.revealWindow(); return; @@ -156,11 +255,7 @@ export class TrayService { this.hideToTray(); } - private ensureTray(): void { - if (this.tray) { - return; - } - + private buildTrayIcon(): Electron.NativeImage { const icon = nativeImage.createFromPath(this.iconPath); const trayIcon = process.platform === 'darwin' ? icon.resize({ width: 16, height: 16 }) @@ -170,10 +265,11 @@ export class TrayService { trayIcon.setTemplateImage(true); } - this.tray = new Tray(trayIcon); - this.tray.setToolTip('UHK Agent'); + return trayIcon; + } - this.contextMenu = Menu.buildFromTemplate([ + private buildContextMenu(): Menu { + return Menu.buildFromTemplate([ { label: 'Show UHK Agent', click: () => this.revealWindow(), @@ -187,28 +283,82 @@ export class TrayService { }, }, ]); + } + + private createTrayInstance(): void { + this.tray = new Tray(this.buildTrayIcon()); + this.logService.misc('[TrayService] Tray instance created'); + } + + private activateTray(): void { + if (!this.tray || this.tray.isDestroyed()) { + return; + } + + this.tray.setImage(this.buildTrayIcon()); + this.tray.setToolTip('UHK Agent'); + this.contextMenu = this.buildContextMenu(); // setContextMenu shows the menu on right-click on Linux/Windows and on left-click on macOS. // popUpContextMenu is not supported on Linux, so setContextMenu must be used there. this.tray.setContextMenu(this.contextMenu); if (process.platform !== 'darwin') { - this.tray.on('click', () => { - this.toggleWindow(); - }); + this.tray.removeAllListeners('click'); + this.tray.on('click', this.trayClickHandler); } - this.logService.misc('[TrayService] Tray icon created'); + this.trayActive = true; + this.logService.misc('[TrayService] Tray icon activated'); + } + + private ensureTray(): void { + if (this.tray && !this.tray.isDestroyed()) { + if (!this.trayActive) { + this.activateTray(); + } + + return; + } + + this.createTrayInstance(); + this.activateTray(); } private destroy(): void { - if (!this.tray) { + ipcMain.removeListener(IpcEvents.app.minimizeToTrayChanged, this.minimizeToTrayChangedHandler); + this.destroyTrayInstance(); + } + + private destroyTrayIcon(): void { + const hadActiveTray = this.trayActive; + + this.destroyTrayInstance(); + + // Linux uses Chromium's StatusNotifierItem integration, which does not reliably + // unregister tray icons when Tray.destroy() is called. The panel entry can remain + // visible (sometimes as a stale or broken icon) until Agent exits. Do not try to + // hide the icon with createEmpty() or a blank image — that produces a worse placeholder. + // See https://github.com/electron/electron/issues/49517 + if (process.platform === 'linux' && hadActiveTray) { + this.notifyMinimizeToTrayDisabledOnLinux(); + } + } + + private destroyTrayInstance(): void { + if (!this.tray || this.tray.isDestroyed()) { + this.tray = null; + this.contextMenu = null; + this.trayActive = false; return; } + this.tray.removeAllListeners(); + this.tray.setContextMenu(null); this.tray.destroy(); this.tray = null; this.contextMenu = null; + this.trayActive = false; this.logService.misc('[TrayService] Tray icon destroyed'); } } diff --git a/packages/uhk-common/src/util/ipcEvents.ts b/packages/uhk-common/src/util/ipcEvents.ts index 0c8b05b524e..2ba4e23e9b0 100644 --- a/packages/uhk-common/src/util/ipcEvents.ts +++ b/packages/uhk-common/src/util/ipcEvents.ts @@ -6,6 +6,8 @@ export class App { public static readonly openConfigFolder = 'open-config-folder'; public static readonly openUrl = 'open-url'; public static readonly getConfig = 'app-get-config'; + public static readonly minimizeToTrayChanged = 'app-minimize-to-tray-changed'; + public static readonly minimizeToTrayDisabledOnLinux = 'app-minimize-to-tray-disabled-on-linux'; public static readonly setConfig = 'app-set-config'; } diff --git a/packages/uhk-web/src/app/components/agent/settings/settings.component.html b/packages/uhk-web/src/app/components/agent/settings/settings.component.html index 830c6418c1a..3c92992ccac 100644 --- a/packages/uhk-web/src/app/components/agent/settings/settings.component.html +++ b/packages/uhk-web/src/app/components/agent/settings/settings.component.html @@ -18,6 +18,11 @@

(change)="toggleMinimizeToTray($event.target.checked)" > Minimize to system tray +

@@ -83,3 +88,7 @@

The Follow operating system theme option may not be supported on all Linux distributions.

+ + +

On Linux, disabling this option stops minimize-to-tray behaviour immediately, but the tray icon may remain visible until Agent is restarted.

+
diff --git a/packages/uhk-web/src/app/services/app-renderer.service.ts b/packages/uhk-web/src/app/services/app-renderer.service.ts index abe1235b226..9573965bbb5 100644 --- a/packages/uhk-web/src/app/services/app-renderer.service.ts +++ b/packages/uhk-web/src/app/services/app-renderer.service.ts @@ -1,9 +1,9 @@ import { Injectable, NgZone } from '@angular/core'; import { Action, Store } from '@ngrx/store'; -import { AppStartInfo, IpcEvents, LogService } from 'uhk-common'; +import { AppStartInfo, IpcEvents, LogService, NotificationType } from 'uhk-common'; import { AppState } from '../store'; -import { ElectronMainLogReceivedAction, ProcessAppStartInfoAction } from '../store/actions/app'; +import { ElectronMainLogReceivedAction, ProcessAppStartInfoAction, ShowNotificationAction } from '../store/actions/app'; import { IpcCommonRenderer } from './ipc-common-renderer'; @Injectable() @@ -36,11 +36,23 @@ export class AppRendererService { this.ipcRenderer.send(IpcEvents.app.openUrl, url); } + setMinimizeToTray(enabled: boolean): void { + this.logService.misc(`[AppRendererService] set minimize to tray: ${enabled}`); + this.ipcRenderer.send(IpcEvents.app.minimizeToTrayChanged, enabled); + } + private registerEvents() { this.ipcRenderer.on(IpcEvents.app.getAppStartInfoReply, (event: string, arg: AppStartInfo) => { this.dispatchStoreAction(new ProcessAppStartInfoAction(arg)); }); + this.ipcRenderer.on(IpcEvents.app.minimizeToTrayDisabledOnLinux, () => { + this.dispatchStoreAction(new ShowNotificationAction({ + type: NotificationType.Info, + message: 'The tray icon will disappear after you restart Agent.' + })); + }); + this.ipcRenderer.on('__ELECTRON_LOG_IPC__', (event: string, { level, data }) => { const message = []; diff --git a/packages/uhk-web/src/app/store/effects/app.ts b/packages/uhk-web/src/app/store/effects/app.ts index 4c075fc2fdf..228556e4e16 100644 --- a/packages/uhk-web/src/app/store/effects/app.ts +++ b/packages/uhk-web/src/app/store/effects/app.ts @@ -26,6 +26,7 @@ import { SaveApplicationSettingsSuccessAction, SetAppThemeAction, ShowNotificationAction, + ToggleMinimizeToTrayAction, UndoLastAction } from '../actions/app'; import { ActionTypes as UpdateActionTypes } from '../actions/auto-update-settings'; @@ -178,6 +179,20 @@ export class ApplicationEffects { { dispatch: false } ); + minimizeToTrayChanged$ = createEffect(() => this.actions$ + .pipe( + ofType(ActionTypes.ToggleMinimizeToTray), + map(action => action.payload), + withLatestFrom(this.store.select(runningInElectron)), + tap(([enabled, inElectron]) => { + if (inElectron) { + this.appRendererService.setMinimizeToTray(enabled); + } + }) + ), + { dispatch: false } + ); + navigateTo$ = createEffect(() => this.actions$ .pipe( ofType(ActionTypes.NavigateTo),