diff --git a/packages/uhk-agent/src/electron-main.ts b/packages/uhk-agent/src/electron-main.ts index 21715136e32..349e104616d 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; @@ -111,10 +113,20 @@ async function createWindow() { show: false }); - if (loadedWindowState.isFullScreen) { - win.setFullScreen(true); - } else if (loadedWindowState.isMaximized) { - win.maximize(); + if (!trayService) { + trayService = new TrayService(logger, win); + } + else { + trayService.init(win); + } + + const startMinimizedToTray = !!options['start-minimized-to-tray']; + if (!startMinimizedToTray) { + if (loadedWindowState.isFullScreen) { + win.setFullScreen(true); + } else if (loadedWindowState.isMaximized) { + win.maximize(); + } } setMenu(win, options.devtools); @@ -143,7 +155,18 @@ async function createWindow() { }); win.once('ready-to-show', () => { - win.show(); + void (async () => { + await trayService.initTrayIfEnabled(); + + if (startMinimizedToTray) { + trayService.startInTray({ + isFullScreen: loadedWindowState.isFullScreen, + isMaximized: loadedWindowState.isMaximized, + }); + } else { + win.show(); + } + })(); }); win.webContents.on('did-finish-load', () => { @@ -205,6 +228,8 @@ async function windowClosed() { sudoService = null; await smartMacroDocService.stop(); smartMacroDocService = null; + trayService?.destroy(); + trayService = null; } if (isSecondInstance) { @@ -273,9 +298,6 @@ if (isSecondInstance) { app.exit(); }); - app.on('will-quit', () => { - }); - app.on('activate', () => { // On macOS it's common to re-create a window in the app when the // dock icon is clicked and there are no other windows open. @@ -284,17 +306,14 @@ if (isSecondInstance) { .catch((error) => { logger.error('[Electron Main] when activating the app: ', error); }); + } else if (!win.isVisible()) { + trayService.revealWindow(); } }); 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.revealWindow(); }); } // In this file you can include the rest of your app's specific main process diff --git a/packages/uhk-agent/src/images/tray-icons/agent-tray-icon.svg b/packages/uhk-agent/src/images/tray-icons/agent-tray-icon.svg new file mode 100644 index 00000000000..cdd166979ef --- /dev/null +++ b/packages/uhk-agent/src/images/tray-icons/agent-tray-icon.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/packages/uhk-agent/src/images/tray-icons/trayIcon-linux.png b/packages/uhk-agent/src/images/tray-icons/trayIcon-linux.png new file mode 100644 index 00000000000..8863f5d80b9 Binary files /dev/null and b/packages/uhk-agent/src/images/tray-icons/trayIcon-linux.png differ diff --git a/packages/uhk-agent/src/images/tray-icons/trayIcon-linux@2x.png b/packages/uhk-agent/src/images/tray-icons/trayIcon-linux@2x.png new file mode 100644 index 00000000000..069d4441d4b Binary files /dev/null and b/packages/uhk-agent/src/images/tray-icons/trayIcon-linux@2x.png differ diff --git a/packages/uhk-agent/src/images/tray-icons/trayIcon.png b/packages/uhk-agent/src/images/tray-icons/trayIcon.png new file mode 100644 index 00000000000..c12e8fa19d6 Binary files /dev/null and b/packages/uhk-agent/src/images/tray-icons/trayIcon.png differ diff --git a/packages/uhk-agent/src/images/tray-icons/trayIcon@2x.png b/packages/uhk-agent/src/images/tray-icons/trayIcon@2x.png new file mode 100644 index 00000000000..bd52b97be5f Binary files /dev/null and b/packages/uhk-agent/src/images/tray-icons/trayIcon@2x.png differ diff --git a/packages/uhk-agent/src/services/app-update.service.ts b/packages/uhk-agent/src/services/app-update.service.ts index 18f7514d72a..15cc8417187 100644 --- a/packages/uhk-agent/src/services/app-update.service.ts +++ b/packages/uhk-agent/src/services/app-update.service.ts @@ -2,11 +2,9 @@ import { ipcMain } from 'electron'; import { autoUpdater } from 'electron-updater'; import { UpdateInfo, ProgressInfo } from 'builder-util-runtime'; import isDev from 'electron-is-dev'; -import storage from 'electron-settings'; import { inspect } from 'node:util'; import { - ApplicationSettings, CommandLineArgs, ERR_UPDATER_INVALID_SIGNATURE, IpcEvents, @@ -143,17 +141,4 @@ export class AppUpdateService extends MainServiceBase { return checkForUpdateOnStartUp; } - - private async getApplicationSettings(): Promise { - const value = await storage.get('application-settings'); - if (!value) { - return { - checkForUpdateOnStartUp: true, - everAttemptedSavingToKeyboard: false - }; - } - - return JSON.parse(value); - } - } diff --git a/packages/uhk-agent/src/services/main-service-base.ts b/packages/uhk-agent/src/services/main-service-base.ts index b5a3ec8bfc4..0e9bd073bcc 100644 --- a/packages/uhk-agent/src/services/main-service-base.ts +++ b/packages/uhk-agent/src/services/main-service-base.ts @@ -1,4 +1,5 @@ -import { LogService } from 'uhk-common'; +import storage from 'electron-settings'; +import { ApplicationSettings, LogService } from 'uhk-common'; export class MainServiceBase { constructor(protected logService: LogService, @@ -14,4 +15,16 @@ export class MainServiceBase { this.win.webContents.send(message, arg); } + protected async getApplicationSettings(): Promise { + const value = await storage.get('application-settings'); + if (!value) { + return { + checkForUpdateOnStartUp: true, + everAttemptedSavingToKeyboard: false + }; + } + + return JSON.parse(value); + } + } 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..b31f22d91d6 --- /dev/null +++ b/packages/uhk-agent/src/services/tray.service.ts @@ -0,0 +1,394 @@ +import { app, BrowserWindow, ipcMain, Menu, nativeImage, nativeTheme, Tray } from 'electron'; +import path from 'node:path'; +import { IpcEvents, LogService } from 'uhk-common'; + +import { WindowState } from '../models/window-state'; +import { MainServiceBase } from './main-service-base'; + +type SavedWindowDisplayState = Pick; + +const MINIMIZE_SUPPRESS_MS = 500; + +export class TrayService extends MainServiceBase { + private tray: Tray | null = null; + private contextMenu: Menu | null = null; + private hiddenToTray = false; + private isMaximized = false; + private suppressMinimizeToTray = false; + private suppressMinimizeTimeout: NodeJS.Timeout | null = null; + private trayActive = false; + private wasFullScreenBeforeTray = false; + private wasMaximizedBeforeTray = false; + private isSubscribedToIpcEvents = false; + + private readonly minimizeToTrayChangedHandler = (_event: Electron.IpcMainEvent, args: [boolean]) => { + this.setMinimizeToTrayEnabled(args[0] ?? false); + }; + + private readonly themeChangedHandler = (): void => { + if (this.tray && !this.tray.isDestroyed()) { + this.tray.setImage(this.buildTrayIcon()); + } + }; + + constructor(protected logService: LogService, + protected win: Electron.BrowserWindow, + ) { + super(logService, win); + this.init(win); + } + + init(win: BrowserWindow): void { + this.detachWindowListeners(); + + 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(); + }); + + this.subscribeToIpcEvents(); + } + + destroy(): void { + this.clearSuppressMinimizeTimer() + ipcMain.removeListener(IpcEvents.app.minimizeToTrayChanged, this.minimizeToTrayChangedHandler); + this.destroyTrayInstance(); + } + + 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 this.getApplicationSettings(); + if (!minimizeToTray) { + return; + } + + this.ensureTray(); + } + + revealWindow(): void { + const restoreMaximized = this.wasMaximizedBeforeTray; + const restoreFullScreen = this.wasFullScreenBeforeTray; + + // Defer until after tray click / context menu handling so focus is not stolen. + this.runWindowActionImmediate((currentWin) => { + this.hiddenToTray = false; + this.runWithMinimizeSuppressed(() => { + if (process.platform === 'linux') { + currentWin.setSkipTaskbar(false); + } + + currentWin.show(); + + if (currentWin.isMinimized()) { + 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', { + restoreMaximized, + restoreFullScreen, + }); + }); + } + + startInTray(savedState?: Partial): void { + this.ensureTray(); + this.hideToTray(savedState); + } + + private clearSuppressMinimizeTimer() { + if (this.suppressMinimizeTimeout) { + clearTimeout(this.suppressMinimizeTimeout); + } + } + + private detachWindowListeners() { + if (!this.win) { + return; + } + + /* eslint-disable @typescript-eslint/no-unsafe-argument */ + this.win.removeListener('minimize', this.toggleWindow.bind(this)); + this.win.removeListener('maximize', this.toggleWindow.bind(this)); + this.win.removeListener('unmaximize', this.toggleWindow.bind(this)); + this.win.removeListener('close', this.toggleWindow.bind(this)); + /* eslint-enable @typescript-eslint/no-unsafe-argument */ + } + + private getWindow(): BrowserWindow | null { + const win = this.win; + if (!win || win.isDestroyed()) { + return null; + } + + return win; + } + + private runWithMinimizeSuppressed(action: () => void): void { + this.suppressMinimizeToTray = true; + this.clearSuppressMinimizeTimer(); + + action(); + + this.suppressMinimizeTimeout = setTimeout(() => { + this.suppressMinimizeToTray = false; + this.suppressMinimizeTimeout = null; + }, MINIMIZE_SUPPRESS_MS); + } + + private subscribeToIpcEvents(): void { + if (this.isSubscribedToIpcEvents) { + return; + } + + this.isSubscribedToIpcEvents = true; + ipcMain.on(IpcEvents.app.minimizeToTrayChanged, this.minimizeToTrayChangedHandler); + } + + private applySavedWindowDisplayState(savedState: Partial): void { + this.wasFullScreenBeforeTray = savedState.isFullScreen ?? false; + this.wasMaximizedBeforeTray = !this.wasFullScreenBeforeTray && (savedState.isMaximized ?? false); + this.isMaximized = this.wasMaximizedBeforeTray; + } + + private captureWindowStateBeforeTray(win: BrowserWindow): void { + this.wasFullScreenBeforeTray = win.isFullScreen(); + this.wasMaximizedBeforeTray = this.isMaximized || win.isMaximized(); + } + + private hideToTray(savedState?: Partial): void { + const win = this.getWindow(); + if (!win) { + return; + } + + if (savedState) { + this.applySavedWindowDisplayState(savedState); + } else { + 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', { + wasMaximized: this.wasMaximizedBeforeTray, + wasFullScreen: this.wasFullScreenBeforeTray, + }); + } + + private toggleWindow(): void { + if (!this.trayActive) { + return; + } + + if (this.hiddenToTray) { + this.revealWindow(); + return; + } + + this.runWindowActionImmediate((win) => { + 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 this.getApplicationSettings(); + if (!minimizeToTray || this.suppressMinimizeToTray || this.hiddenToTray) { + return; + } + + this.ensureTray(); + this.hideToTray(); + } + + private buildTrayIcon(): Electron.NativeImage { + const trayAssetsDir = path.join(import.meta.dirname, 'images', 'tray-icons') + + if (process.platform === 'linux') { + return nativeImage.createFromPath( + path.join(trayAssetsDir, 'trayIcon-linux.png')); // 22px, @2x auto + } + + // macOS & Windows: 16px with @2x sibling picked up automatically. + // No setTemplateImage — this is a full-color icon by design. + return nativeImage.createFromPath( + path.join(trayAssetsDir, 'trayIcon.png')); + } + + private buildContextMenu(): Menu { + return Menu.buildFromTemplate([ + { + label: 'Show UHK Agent', + click: () => this.revealWindow(), + }, + { type: 'separator' }, + { + label: 'Quit', + click: () => { + app.quit(); + }, + }, + ]); + } + + 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.removeAllListeners('click'); + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + this.tray.on('click', this.toggleWindow.bind(this)); + nativeTheme.on('updated', this.themeChangedHandler); + } + + 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 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.sendIpcToWindow(IpcEvents.app.minimizeToTrayDisabledOnLinux) + } + } + + private destroyTrayInstance(): void { + nativeTheme.removeListener('updated', this.themeChangedHandler); + + 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'); + } + + private runWindowAction(action: (win: BrowserWindow) => void): void { + const win = this.getWindow(); + if (!win) { + return; + } + + action(win); + } + + private runWindowActionImmediate(action: (win: BrowserWindow) => void): void { + setImmediate(() => { + this.runWindowAction(action); + }) + } +} 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/webpack.config.js b/packages/uhk-agent/webpack.config.js index 13e767723e0..f54af6126d7 100644 --- a/packages/uhk-agent/webpack.config.js +++ b/packages/uhk-agent/webpack.config.js @@ -36,6 +36,10 @@ module.exports = { plugins: [ new CopyWebpackPlugin({ patterns: [ + { + from: 'src/images', + to: 'images' + }, { from: 'src/manifest.json', to: 'manifest.json' diff --git a/packages/uhk-common/src/models/application-settings.ts b/packages/uhk-common/src/models/application-settings.ts index ba50a375cdf..603efe5f672 100644 --- a/packages/uhk-common/src/models/application-settings.ts +++ b/packages/uhk-common/src/models/application-settings.ts @@ -27,6 +27,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-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 1e2c1d0b728..349f5e37aea 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,22 @@

+
+
+ + +
+
+
@@ -129,3 +145,7 @@

Macro sidebar grouping

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/components/agent/settings/settings.component.ts b/packages/uhk-web/src/app/components/agent/settings/settings.component.ts index 8bb2c563ab7..ccd06a1380b 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 @@ -13,9 +13,10 @@ import { getAppTheme, getIsAdvancedSettingsMenuVisible, getMacroGroupingSettings, + getMinimizeToTray, getOperatingSystem, getSupportedThemes, - keyboardHalvesAlwaysJoined + keyboardHalvesAlwaysJoined, } from '../../../store'; import { MACRO_GROUPING_MAX_DEPTH } from '../../../util/group-macros-by-name'; import { State as UpdateSettingsState } from '../../../store/reducers/auto-update-settings'; @@ -23,7 +24,14 @@ import { CheckForUpdateNowAction, ToggleCheckForUpdateOnStartupAction } from '../../../store/actions/auto-update-settings'; -import { OpenConfigFolderAction, SetAppThemeAction, SetMacroGroupingSettingsAction, ToggleAnimationEnabledAction, ToggleKeyboardHalvesAlwaysJoinedAction } from '../../../store/actions/app'; +import { + OpenConfigFolderAction, + SetAppThemeAction, + SetMacroGroupingSettingsAction, + ToggleAnimationEnabledAction, + ToggleKeyboardHalvesAlwaysJoinedAction, + ToggleMinimizeToTrayAction, +} from '../../../store/actions/app'; import { ToggleAlwaysEnableAdvancedModeAction } from '../../../store/actions/advance-settings.action'; import { OperatingSystem } from '../../../models/operating-system'; @@ -39,6 +47,7 @@ import { OperatingSystem } from '../../../models/operating-system'; export class SettingsComponent { updateSettingsState$: Observable; animationEnabled$: Observable; + minimizeToTray$: Observable; appTheme$: Observable; themes$: Observable; isLinux$: Observable; @@ -52,6 +61,7 @@ export class SettingsComponent { constructor(private store: Store) { this.updateSettingsState$ = store.select(appUpdateSettingsState); this.animationEnabled$ = store.select(getAnimationEnabled); + this.minimizeToTray$ = store.select(getMinimizeToTray); this.appTheme$ = store.select(getAppTheme); this.themes$ = store.select(getSupportedThemes); this.isLinux$ = store.select(getOperatingSystem).pipe(map(os => os === OperatingSystem.Linux)); @@ -85,6 +95,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/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/actions/app.ts b/packages/uhk-web/src/app/store/actions/app.ts index 1ab6f0d9e10..3edf75caa69 100644 --- a/packages/uhk-web/src/app/store/actions/app.ts +++ b/packages/uhk-web/src/app/store/actions/app.ts @@ -30,6 +30,7 @@ export enum ActionTypes { ToggleAnimationEnabled = '[app] Toggle animation enabled', ToggleKeyboardHalvesAlwaysJoined = '[app] Toggle keyboard halves always joined', SetMacroGroupingSettings = '[app] Set macro grouping settings', + ToggleMinimizeToTray = '[app] Toggle minimize to tray', SetAppTheme = '[app] Set application theme', LoadAppStartInfo = '[app] Load app start info', StartKeypressCapturing = '[app] Start keypress capturing', @@ -181,6 +182,13 @@ export class SetMacroGroupingSettingsAction implements Action { } } +export class ToggleMinimizeToTrayAction implements Action { + type = ActionTypes.ToggleMinimizeToTray; + + constructor(public payload: boolean) { + } +} + export class SetAppThemeAction implements Action { type = ActionTypes.SetAppTheme; @@ -244,6 +252,7 @@ export type Actions | ToggleAnimationEnabledAction | ToggleKeyboardHalvesAlwaysJoinedAction | SetMacroGroupingSettingsAction + | 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 7e1689f2386..df97187dd0a 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'; @@ -60,6 +61,7 @@ export class ApplicationEffects { everAttemptedSavingToKeyboard: false, animationEnabled: true, keyboardHalvesAlwaysJoined: false, + minimizeToTray: false, ...appSettings }; @@ -147,6 +149,7 @@ export class ApplicationEffects { ActionTypes.SetMacroGroupingSettings, ActionTypes.ToggleAnimationEnabled, ActionTypes.ToggleKeyboardHalvesAlwaysJoined, + ActionTypes.ToggleMinimizeToTray, AdvanceSettingsActionTypes.toggleAlwaysEnableAdvancedMode, UpdateActionTypes.ToggleCheckForUpdateOnStartup, DeviceActionTypes.SaveConfiguration, @@ -177,6 +180,17 @@ export class ApplicationEffects { { dispatch: false } ); + minimizeToTrayChanged$ = createEffect(() => this.actions$ + .pipe( + ofType(ActionTypes.ToggleMinimizeToTray), + map(action => action.payload), + tap((enabled) => { + this.appRendererService.setMinimizeToTray(enabled); + }) + ), + { dispatch: false } + ); + navigateTo$ = createEffect(() => this.actions$ .pipe( ofType(ActionTypes.NavigateTo), diff --git a/packages/uhk-web/src/app/store/index.ts b/packages/uhk-web/src/app/store/index.ts index e8665e4d4ac..a95a049ef6d 100644 --- a/packages/uhk-web/src/app/store/index.ts +++ b/packages/uhk-web/src/app/store/index.ts @@ -205,6 +205,7 @@ export const getEverAttemptedSavingToKeyboard = createSelector(appState, fromApp export const getUdevFileContent = createSelector(appState, fromApp.getUdevFileContent); export const getAnimationEnabled = createSelector(appState, fromApp.getAnimationEnabled); export const getMacroGroupingSettings = createSelector(appState, fromApp.getMacroGroupingSettings); +export const getMinimizeToTray = createSelector(appState, fromApp.getMinimizeToTray); export const getAppTheme = createSelector(appState, fromApp.getAppTheme); export const getUhkThemeColors = createSelector(getAppTheme, (theme): UhkThemeColors => { return defaultUhkThemeColors(theme); @@ -861,6 +862,7 @@ export const getApplicationSettings = createSelector( appTheme: app.appTheme, backlightingColorPalette, keyboardHalvesAlwaysJoined, + minimizeToTray: app.minimizeToTray, alwaysEnableAdvancedMode, macroGrouping, 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 b55627f4f60..50954294259 100644 --- a/packages/uhk-web/src/app/store/reducers/app.reducer.ts +++ b/packages/uhk-web/src/app/store/reducers/app.reducer.ts @@ -24,6 +24,7 @@ const DEFAULT_ERROR_PANEL_HEIGHT = 10; export interface State { appTheme: AppTheme; animationEnabled: boolean; + minimizeToTray: boolean; errorPanelHeight: number; isRunningOnWayland: boolean; started: boolean; @@ -48,6 +49,7 @@ export interface State { export const initialState: State = { appTheme: AppTheme.System, animationEnabled: true, + minimizeToTray: false, errorPanelHeight: DEFAULT_ERROR_PANEL_HEIGHT, isRunningOnWayland: false, started: false, @@ -214,7 +216,8 @@ export function reducer( everAttemptedSavingToKeyboard: settings.everAttemptedSavingToKeyboard, animationEnabled: settings.animationEnabled, appTheme: settings.appTheme || AppTheme.System, - macroGrouping: normalizeMacroGroupingSettings(settings.macroGrouping) + macroGrouping: normalizeMacroGroupingSettings(settings.macroGrouping), + minimizeToTray: settings.minimizeToTray ?? false, }; } @@ -239,6 +242,12 @@ export function reducer( }) }; + case App.ActionTypes.ToggleMinimizeToTray: + return { + ...state, + minimizeToTray: (action as App.ToggleMinimizeToTrayAction).payload + }; + case App.ActionTypes.SetAppTheme: return { ...state, @@ -283,6 +292,7 @@ export const getEverAttemptedSavingToKeyboard = (state: State): boolean => state export const getUdevFileContent = (state: State): string => state.udevFileContent; export const getAnimationEnabled = (state: State): boolean => state.animationEnabled; export const getMacroGroupingSettings = (state: State): MacroGroupingSettings => state.macroGrouping; +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;